diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 383dd5a..6bc1697 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.5.2"
+ ".": "0.5.3"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6108d7e..75a726d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.5.3 (2026-03-11)
+
+Full Changelog: [v0.5.2...v0.5.3](https://github.com/CASParser/cas-parser-java/compare/v0.5.2...v0.5.3)
+
+### Bug Fixes
+
+* **client:** incorrect `Retry-After` parsing ([60bb6b1](https://github.com/CASParser/cas-parser-java/commit/60bb6b16e2d13932c0abd7ea7be8f51baea69199))
+
## 0.5.2 (2026-03-07)
Full Changelog: [v0.5.1...v0.5.2](https://github.com/CASParser/cas-parser-java/compare/v0.5.1...v0.5.2)
diff --git a/README.md b/README.md
index ef44eb1..c9d4ecf 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/com.cas_parser.api/cas-parser-java/0.5.2)
-[](https://javadoc.io/doc/com.cas_parser.api/cas-parser-java/0.5.2)
+[](https://central.sonatype.com/artifact/com.cas_parser.api/cas-parser-java/0.5.3)
+[](https://javadoc.io/doc/com.cas_parser.api/cas-parser-java/0.5.3)
@@ -22,7 +22,7 @@ Use the Cas Parser MCP Server to enable AI assistants to interact with this API,
-The REST API documentation can be found on [casparser.in](https://casparser.in/docs). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.cas_parser.api/cas-parser-java/0.5.2).
+The REST API documentation can be found on [casparser.in](https://casparser.in/docs). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.cas_parser.api/cas-parser-java/0.5.3).
@@ -33,7 +33,7 @@ The REST API documentation can be found on [casparser.in](https://casparser.in/d
### Gradle
```kotlin
-implementation("com.cas_parser.api:cas-parser-java:0.5.2")
+implementation("com.cas_parser.api:cas-parser-java:0.5.3")
```
### Maven
@@ -42,7 +42,7 @@ implementation("com.cas_parser.api:cas-parser-java:0.5.2")
com.cas_parser.api
cas-parser-java
- 0.5.2
+ 0.5.3
```
diff --git a/build.gradle.kts b/build.gradle.kts
index ef89225..51b98a3 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,7 +9,7 @@ repositories {
allprojects {
group = "com.cas_parser.api"
- version = "0.5.2" // x-release-please-version
+ version = "0.5.3" // x-release-please-version
}
subprojects {
diff --git a/cas-parser-java-core/src/main/kotlin/com/cas_parser/api/core/http/RetryingHttpClient.kt b/cas-parser-java-core/src/main/kotlin/com/cas_parser/api/core/http/RetryingHttpClient.kt
index 795ceb0..5487fce 100644
--- a/cas-parser-java-core/src/main/kotlin/com/cas_parser/api/core/http/RetryingHttpClient.kt
+++ b/cas-parser-java-core/src/main/kotlin/com/cas_parser/api/core/http/RetryingHttpClient.kt
@@ -201,7 +201,7 @@ private constructor(
?: headers.values("Retry-After").getOrNull(0)?.let { retryAfter ->
retryAfter.toFloatOrNull()?.times(TimeUnit.SECONDS.toNanos(1))
?: try {
- ChronoUnit.MILLIS.between(
+ ChronoUnit.NANOS.between(
OffsetDateTime.now(clock),
OffsetDateTime.parse(
retryAfter,
diff --git a/cas-parser-java-core/src/test/kotlin/com/cas_parser/api/core/http/RetryingHttpClientTest.kt b/cas-parser-java-core/src/test/kotlin/com/cas_parser/api/core/http/RetryingHttpClientTest.kt
index c3f47cd..39c7156 100644
--- a/cas-parser-java-core/src/test/kotlin/com/cas_parser/api/core/http/RetryingHttpClientTest.kt
+++ b/cas-parser-java-core/src/test/kotlin/com/cas_parser/api/core/http/RetryingHttpClientTest.kt
@@ -20,7 +20,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
import com.github.tomakehurst.wiremock.junit5.WireMockTest
import com.github.tomakehurst.wiremock.stubbing.Scenario
import java.io.InputStream
+import java.time.Clock
import java.time.Duration
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
import java.util.concurrent.CompletableFuture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
@@ -36,6 +40,21 @@ internal class RetryingHttpClientTest {
private lateinit var baseUrl: String
private lateinit var httpClient: HttpClient
+ private class RecordingSleeper : Sleeper {
+ val durations = mutableListOf()
+
+ override fun sleep(duration: Duration) {
+ durations.add(duration)
+ }
+
+ override fun sleepAsync(duration: Duration): CompletableFuture {
+ durations.add(duration)
+ return CompletableFuture.completedFuture(null)
+ }
+
+ override fun close() {}
+ }
+
@BeforeEach
fun beforeEach(wmRuntimeInfo: WireMockRuntimeInfo) {
baseUrl = wmRuntimeInfo.httpBaseUrl
@@ -86,7 +105,8 @@ internal class RetryingHttpClientTest {
@ValueSource(booleans = [false, true])
fun execute(async: Boolean) {
stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))
- val retryingClient = retryingHttpClientBuilder().build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).build()
val response =
retryingClient.execute(
@@ -100,6 +120,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@@ -111,8 +132,12 @@ internal class RetryingHttpClientTest {
.withHeader("X-Some-Header", matching("stainless-java-retry-.+"))
.willReturn(ok())
)
+ val sleeper = RecordingSleeper()
val retryingClient =
- retryingHttpClientBuilder().maxRetries(2).idempotencyHeader("X-Some-Header").build()
+ retryingHttpClientBuilder(sleeper)
+ .maxRetries(2)
+ .idempotencyHeader("X-Some-Header")
+ .build()
val response =
retryingClient.execute(
@@ -126,20 +151,20 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withRetryAfterHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
// First we fail with a retry after header given as a date
.inScenario("foo")
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -158,7 +183,13 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ // Fix the clock to 5 seconds before the Retry-After date so the date-based backoff is
+ // deterministic.
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -186,19 +217,20 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("2")),
)
+ assertThat(sleeper.durations)
+ .containsExactly(Duration.ofSeconds(5), Duration.ofMillis(1234))
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withOverwrittenRetryCountHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
.inScenario("foo") // first we fail with a retry after header given as a date
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -208,7 +240,11 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -227,6 +263,7 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("42")),
)
+ assertThat(sleeper.durations).containsExactly(Duration.ofSeconds(5))
assertNoResponseLeaks()
}
@@ -247,7 +284,8 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(1).build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
val response =
retryingClient.execute(
@@ -261,6 +299,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(2, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(10))
assertNoResponseLeaks()
}
@@ -301,21 +340,12 @@ internal class RetryingHttpClientTest {
override fun close() = httpClient.close()
}
+ val sleeper = RecordingSleeper()
val retryingClient =
RetryingHttpClient.builder()
.httpClient(failingHttpClient)
.maxRetries(2)
- .sleeper(
- object : Sleeper {
-
- override fun sleep(duration: Duration) {}
-
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
-
- override fun close() {}
- }
- )
+ .sleeper(sleeper)
.build()
val response =
@@ -339,25 +369,153 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("0")),
)
+ // Exponential backoff with jitter: 0.5s * jitter where jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
assertNoResponseLeaks()
}
- private fun retryingHttpClientBuilder() =
- RetryingHttpClient.builder()
- .httpClient(httpClient)
- // Use a no-op `Sleeper` to make the test fast.
- .sleeper(
- object : Sleeper {
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoff(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(3).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
- override fun sleep(duration: Duration) {}
+ // All retries exhausted; the last 503 response is returned.
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(4, postRequestedFor(urlPathEqualTo("/something")))
+ // Exponential backoff with jitter: backoff = min(0.5 * 2^(retries-1), 8) * jitter where
+ // jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(3)
+ // retries=1: 0.5s * [0.75, 1.0]
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ // retries=2: 1.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[1]).isBetween(Duration.ofMillis(750), Duration.ofMillis(1000))
+ // retries=3: 2.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[2]).isBetween(Duration.ofMillis(1500), Duration.ofMillis(2000))
+ assertNoResponseLeaks()
+ }
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoffCap(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(6).build()
- override fun close() {}
- }
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
)
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(7, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).hasSize(6)
+ // retries=5: min(0.5 * 2^4, 8) = 8.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[4]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ // retries=6: min(0.5 * 2^5, 8) = min(16, 8) = 8.0s * [0.75, 1.0] (capped)
+ assertThat(sleeper.durations[5]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterMsPriorityOverRetryAfter(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(
+ serviceUnavailable()
+ .withHeader("Retry-After-Ms", "50")
+ .withHeader("Retry-After", "2")
+ )
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Retry-After-Ms (50ms) takes priority over Retry-After (2s).
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(50))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterUnparseable(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(serviceUnavailable().withHeader("Retry-After", "not-a-date-or-number"))
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Unparseable Retry-After falls through to exponential backoff.
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ assertNoResponseLeaks()
+ }
+
+ private fun retryingHttpClientBuilder(
+ sleeper: RecordingSleeper,
+ clock: Clock = Clock.systemUTC(),
+ ) = RetryingHttpClient.builder().httpClient(httpClient).sleeper(sleeper).clock(clock)
+
private fun HttpClient.execute(request: HttpRequest, async: Boolean): HttpResponse =
if (async) executeAsync(request).get() else execute(request)