diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index b56c3d0..e8285b7 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.1.0-alpha.4"
+ ".": "0.1.0-alpha.5"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df9dd1c..30401cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.1.0-alpha.5 (2026-03-16)
+
+Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/ArcadeAI/arcade-java/compare/v0.1.0-alpha.4...v0.1.0-alpha.5)
+
+### Bug Fixes
+
+* **client:** incorrect `Retry-After` parsing ([5894ea9](https://github.com/ArcadeAI/arcade-java/commit/5894ea94e51105eaeacc5efb8daed34b9ccab3e5))
+
## 0.1.0-alpha.4 (2026-03-10)
Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/ArcadeAI/arcade-java/compare/v0.1.0-alpha.3...v0.1.0-alpha.4)
diff --git a/README.md b/README.md
index ea860a4..e1b0ef4 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/dev.arcade/arcade-java/0.1.0-alpha.4)
-[](https://javadoc.io/doc/dev.arcade/arcade-java/0.1.0-alpha.4)
+[](https://central.sonatype.com/artifact/dev.arcade/arcade-java/0.1.0-alpha.5)
+[](https://javadoc.io/doc/dev.arcade/arcade-java/0.1.0-alpha.5)
@@ -13,7 +13,7 @@ It is generated with [Stainless](https://www.stainless.com/).
-The REST API documentation can be found on [docs.arcade.dev](https://docs.arcade.dev). Javadocs are available on [javadoc.io](https://javadoc.io/doc/dev.arcade/arcade-java/0.1.0-alpha.4).
+The REST API documentation can be found on [docs.arcade.dev](https://docs.arcade.dev). Javadocs are available on [javadoc.io](https://javadoc.io/doc/dev.arcade/arcade-java/0.1.0-alpha.5).
@@ -24,7 +24,7 @@ The REST API documentation can be found on [docs.arcade.dev](https://docs.arcade
### Gradle
```kotlin
-implementation("dev.arcade:arcade-java:0.1.0-alpha.4")
+implementation("dev.arcade:arcade-java:0.1.0-alpha.5")
```
### Maven
@@ -33,7 +33,7 @@ implementation("dev.arcade:arcade-java:0.1.0-alpha.4")
dev.arcade
arcade-java
- 0.1.0-alpha.4
+ 0.1.0-alpha.5
```
diff --git a/arcade-java-core/src/main/kotlin/dev/arcade/core/http/RetryingHttpClient.kt b/arcade-java-core/src/main/kotlin/dev/arcade/core/http/RetryingHttpClient.kt
index d1b6f0c..4f5bbbb 100644
--- a/arcade-java-core/src/main/kotlin/dev/arcade/core/http/RetryingHttpClient.kt
+++ b/arcade-java-core/src/main/kotlin/dev/arcade/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/arcade-java-core/src/test/kotlin/dev/arcade/core/http/RetryingHttpClientTest.kt b/arcade-java-core/src/test/kotlin/dev/arcade/core/http/RetryingHttpClientTest.kt
index eb6750d..1e64d8e 100644
--- a/arcade-java-core/src/test/kotlin/dev/arcade/core/http/RetryingHttpClientTest.kt
+++ b/arcade-java-core/src/test/kotlin/dev/arcade/core/http/RetryingHttpClientTest.kt
@@ -20,7 +20,11 @@ import dev.arcade.core.RequestOptions
import dev.arcade.core.Sleeper
import dev.arcade.errors.ArcadeRetryableException
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)
diff --git a/build.gradle.kts b/build.gradle.kts
index 73f51e5..2348249 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,7 +9,7 @@ repositories {
allprojects {
group = "dev.arcade"
- version = "0.1.0-alpha.4" // x-release-please-version
+ version = "0.1.0-alpha.5" // x-release-please-version
}
subprojects {
diff --git a/buildSrc/src/main/kotlin/arcade.publish.gradle.kts b/buildSrc/src/main/kotlin/arcade.publish.gradle.kts
index 39290bc..cd70029 100644
--- a/buildSrc/src/main/kotlin/arcade.publish.gradle.kts
+++ b/buildSrc/src/main/kotlin/arcade.publish.gradle.kts
@@ -10,7 +10,7 @@ configure {
pom {
name.set("Arcade API")
- description.set("Reference Documentation for Arcade Engine API")
+ description.set("The Arcade SDK provides convenient access to the Arcade REST API from applications running on the JVM.")
url.set("https://docs.arcade.dev")
licenses {