From 2cf1748e7c82875e82d876673427060b53a33812 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Singh Date: Mon, 10 Aug 2026 12:46:39 +0530 Subject: [PATCH] fix: clamp negative retry-after delays --- .../openai/core/http/RetryingHttpClient.kt | 2 +- .../core/http/RetryingHttpClientTest.kt | 113 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt index b9d7eef8f..3171afdce 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt @@ -215,7 +215,7 @@ private constructor( } ?.let { retryAfterNanos -> // If the API asks us to wait a certain amount of time, do what it says. - return Duration.ofNanos(retryAfterNanos.toLong()) + return Duration.ofNanos(retryAfterNanos.toLong().coerceAtLeast(0)) } // Apply exponential backoff, but not more than the max. diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt index 21834efab..0fd916874 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt @@ -303,6 +303,119 @@ internal class RetryingHttpClientTest { assertNoResponseLeaks() } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun execute_withNegativeRetryAfterHeader(async: Boolean) { + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("negative-retry-after") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(serviceUnavailable().withHeader("Retry-After", "-1")) + .willSetStateTo("RETRY") + ) + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("negative-retry-after") + .whenScenarioStateIs("RETRY") + .willReturn(ok()) + ) + 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) + verify(2, postRequestedFor(urlPathEqualTo("/something"))) + assertThat(sleeper.durations).containsExactly(Duration.ZERO) + assertNoResponseLeaks() + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun execute_withNegativeRetryAfterMsHeader(async: Boolean) { + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("negative-retry-after-ms") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(serviceUnavailable().withHeader("Retry-After-Ms", "-1")) + .willSetStateTo("RETRY") + ) + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("negative-retry-after-ms") + .whenScenarioStateIs("RETRY") + .willReturn(ok()) + ) + 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) + verify(2, postRequestedFor(urlPathEqualTo("/something"))) + assertThat(sleeper.durations).containsExactly(Duration.ZERO) + assertNoResponseLeaks() + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun execute_withPastRetryAfterDate(async: Boolean) { + val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT" + val clock = + Clock.fixed( + OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME) + .plusSeconds(1) + .toInstant(), + ZoneOffset.UTC, + ) + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("past-retry-after-date") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate)) + .willSetStateTo("RETRY") + ) + stubFor( + post(urlPathEqualTo("/something")) + .inScenario("past-retry-after-date") + .whenScenarioStateIs("RETRY") + .willReturn(ok()) + ) + val sleeper = RecordingSleeper() + val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(1).build() + + val response = + retryingClient.execute( + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl(baseUrl) + .addPathSegment("something") + .build(), + async, + ) + + assertThat(response.statusCode()).isEqualTo(200) + verify(2, postRequestedFor(urlPathEqualTo("/something"))) + assertThat(sleeper.durations).containsExactly(Duration.ZERO) + assertNoResponseLeaks() + } + @ParameterizedTest @ValueSource(booleans = [false, true]) fun execute_withRetryableException(async: Boolean) {