From 5894ea94e51105eaeacc5efb8daed34b9ccab3e5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:20:34 +0000 Subject: [PATCH 1/3] fix(client): incorrect `Retry-After` parsing --- .../arcade/core/http/RetryingHttpClient.kt | 2 +- .../core/http/RetryingHttpClientTest.kt | 224 +++++++++++++++--- 2 files changed, 192 insertions(+), 34 deletions(-) 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) From 868abbc84407c5b7a38ac6c2294cb08f290de4f8 Mon Sep 17 00:00:00 2001 From: Brian Demers Date: Mon, 16 Mar 2026 10:48:19 -0400 Subject: [PATCH 2/3] Update the project description used on Maven Central (#17) --- buildSrc/src/main/kotlin/arcade.publish.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From e3c4b10c58c3349740ac16b69a6a099aac57bc27 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:48:36 +0000 Subject: [PATCH 3/3] release: 0.1.0-alpha.5 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ README.md | 10 +++++----- build.gradle.kts | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) 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 @@ -[![Maven Central](https://img.shields.io/maven-central/v/dev.arcade/arcade-java)](https://central.sonatype.com/artifact/dev.arcade/arcade-java/0.1.0-alpha.4) -[![javadoc](https://javadoc.io/badge2/dev.arcade/arcade-java/0.1.0-alpha.4/javadoc.svg)](https://javadoc.io/doc/dev.arcade/arcade-java/0.1.0-alpha.4) +[![Maven Central](https://img.shields.io/maven-central/v/dev.arcade/arcade-java)](https://central.sonatype.com/artifact/dev.arcade/arcade-java/0.1.0-alpha.5) +[![javadoc](https://javadoc.io/badge2/dev.arcade/arcade-java/0.1.0-alpha.5/javadoc.svg)](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/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 {