From 575b7cea5ae4c154d3127796ab29f66b844c1353 Mon Sep 17 00:00:00 2001 From: Dongie Agnir Date: Mon, 27 Apr 2026 11:26:09 -0700 Subject: [PATCH 1/2] Use x-amz-retry-after for suggested delay In retries 2.1, 'x-amz-retry-after' must be parsed from the last response and if present, should be used as the suggested delay (i.e. suggested backoff) when attempting another retry. This changes makes updates to RetryableStage and AsyncRetryableStage to parse the header and pass it to the RetryStrategy. As part of this change, we also need to plumb whether retries 2.1 is enabled from the SDK client. --- .../builder/AwsDefaultClientBuilder.java | 12 +- .../internal/AwsExecutionContextBuilder.java | 2 + .../client/builder/InternalDefaultsTest.java | 24 ++- .../AwsExecutionContextBuilderTest.java | 13 ++ .../core/client/config/SdkClientOption.java | 5 + .../SdkInternalExecutionAttribute.java | 5 + .../pipeline/stages/AsyncRetryableStage.java | 35 +++- .../http/pipeline/stages/RetryableStage.java | 65 +++++-- .../stages/utils/RetryableStageHelper.java | 4 + .../stages/AsyncRetryableStageTest.java | 101 +++++++--- .../stages/BaseRetryableStageTest.java | 182 ++++++++++++++++++ .../pipeline/stages/RetryableStageTest.java | 92 ++++++--- 12 files changed, 446 insertions(+), 94 deletions(-) create mode 100644 core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java index d97261c3c44..cf74d712bc2 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java @@ -437,7 +437,13 @@ private void configureRetryPolicy(SdkClientConfiguration.Builder config) { private void configureRetryStrategy(SdkClientConfiguration.Builder config) { RetryStrategy strategy = config.option(SdkClientOption.RETRY_STRATEGY); if (strategy == null) { - config.lazyOption(SdkClientOption.RETRY_STRATEGY, this::resolveAwsRetryStrategy); + Boolean defaultNewRetries2026 = config.option(SdkClientOption.DEFAULT_NEW_RETRIES_2026); + + config.lazyOption(SdkClientOption.RETRY_STRATEGY, src -> resolveAwsRetryStrategy(src, defaultNewRetries2026)); + + config.option(SdkClientOption.NEW_RETRIES_2026_ENABLED, + new NewRetries2026Resolver().defaultNewRetries2026(defaultNewRetries2026).resolve()); + return; } @@ -457,9 +463,7 @@ private void configureRetryStrategy(SdkClientConfiguration.Builder config) { } - private RetryStrategy resolveAwsRetryStrategy(LazyValueSource config) { - Boolean defaultNewRetries2026 = config.get(SdkClientOption.DEFAULT_NEW_RETRIES_2026); - + private RetryStrategy resolveAwsRetryStrategy(LazyValueSource config, Boolean defaultNewRetries2026) { RetryMode retryMode = RetryMode.resolver() .profileFile(config.get(SdkClientOption.PROFILE_FILE_SUPPLIER)) .profileName(config.get(SdkClientOption.PROFILE_NAME)) diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilder.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilder.java index 0bf89b3754e..2a7f417a72a 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilder.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilder.java @@ -17,6 +17,7 @@ import static software.amazon.awssdk.auth.signer.internal.util.SignerMethodResolver.resolveSigningMethodUsed; import static software.amazon.awssdk.awscore.internal.AwsServiceProtocol.SMITHY_RPC_V2_CBOR; +import static software.amazon.awssdk.core.client.config.SdkClientOption.NEW_RETRIES_2026_ENABLED; import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_POLICY; import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_STRATEGY; import static software.amazon.awssdk.core.interceptor.SdkExecutionAttribute.RESOLVED_CHECKSUM_SPECS; @@ -104,6 +105,7 @@ private AwsExecutionContextBuilder() { .putAttribute(AwsSignerExecutionAttribute.SIGNING_REGION, clientConfig.option(AwsClientOption.SIGNING_REGION)) .putAttribute(SdkInternalExecutionAttribute.IS_FULL_DUPLEX, executionParams.isFullDuplex()) .putAttribute(SdkInternalExecutionAttribute.IS_LONG_POLLING, executionParams.isLongPolling()) + .putAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, clientConfig.option(NEW_RETRIES_2026_ENABLED)) .putAttribute(SdkInternalExecutionAttribute.HAS_INITIAL_REQUEST_EVENT, executionParams.hasInitialRequestEvent()) .putAttribute(SdkExecutionAttribute.CLIENT_TYPE, clientConfig.option(SdkClientOption.CLIENT_TYPE)) .putAttribute(SdkExecutionAttribute.SERVICE_NAME, clientConfig.option(SdkClientOption.SERVICE_NAME)) diff --git a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/InternalDefaultsTest.java b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/InternalDefaultsTest.java index f397e0bc53a..5c411276e92 100644 --- a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/InternalDefaultsTest.java +++ b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/InternalDefaultsTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static software.amazon.awssdk.core.client.config.SdkClientOption.NEW_RETRIES_2026_ENABLED; import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_STRATEGY; import java.util.stream.Stream; @@ -60,7 +61,7 @@ static void teardown() { @ParameterizedTest(name = "system prop = {0}, env var = {1}, default cfg = {2}, expected = {3}") @MethodSource("newRetries2026Settings") void buildClient_precedenceIsCorrect(String systemProperty, String environmentVariable, Boolean defaultConfig, - Class retryStrategyClass) { + Class retryStrategyClass, boolean newRetries2026Enabled) { EnvironmentVariableHelper.run((env) -> { if (environmentVariable != null) { env.set(SdkSystemSetting.AWS_NEW_RETRIES_2026.environmentVariable(), environmentVariable); @@ -80,23 +81,26 @@ void buildClient_precedenceIsCorrect(String systemProperty, String environmentVa assertThat(sync.clientConfiguration.option(RETRY_STRATEGY)).isInstanceOf(retryStrategyClass); assertThat(async.clientConfiguration.option(RETRY_STRATEGY)).isInstanceOf(retryStrategyClass); + + assertThat(sync.clientConfiguration.option(NEW_RETRIES_2026_ENABLED)).isEqualTo(newRetries2026Enabled); + assertThat(async.clientConfiguration.option(NEW_RETRIES_2026_ENABLED)).isEqualTo(newRetries2026Enabled); }); } // system property, environment variable, default config, expected retry strategy static Stream newRetries2026Settings() { return Stream.of( - Arguments.of(null, null, null, LegacyRetryStrategy.class), + Arguments.of(null, null, null, LegacyRetryStrategy.class, false), - Arguments.of("true", null, null, StandardRetryStrategy.class), - Arguments.of("false", null, null, LegacyRetryStrategy.class), - Arguments.of(null, "true", null, StandardRetryStrategy.class), - Arguments.of(null, "false", null, LegacyRetryStrategy.class), - Arguments.of(null, null, true, StandardRetryStrategy.class), - Arguments.of(null, null, false, LegacyRetryStrategy.class), + Arguments.of("true", null, null, StandardRetryStrategy.class, true), + Arguments.of("false", null, null, LegacyRetryStrategy.class, false), + Arguments.of(null, "true", null, StandardRetryStrategy.class, true), + Arguments.of(null, "false", null, LegacyRetryStrategy.class, false), + Arguments.of(null, null, true, StandardRetryStrategy.class, true), + Arguments.of(null, null, false, LegacyRetryStrategy.class, false), - Arguments.of("true", null, false, StandardRetryStrategy.class), - Arguments.of(null, "true", false, StandardRetryStrategy.class) + Arguments.of("true", null, false, StandardRetryStrategy.class, true), + Arguments.of(null, "true", false, StandardRetryStrategy.class, true) ); } diff --git a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilderTest.java b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilderTest.java index f59310eaeac..65a568289a0 100644 --- a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilderTest.java +++ b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilderTest.java @@ -567,6 +567,19 @@ public void invokeInterceptorsAndCreateExecutionContext_withLongPollingOperation assertThat(executionContext.executionAttributes().getAttribute(SdkInternalExecutionAttribute.IS_LONG_POLLING)).isTrue(); } + @Test + public void invokeInterceptorsAndCreateExecutionContext_newRetries2026EnabledConfig_setsCorrectAttributeValue() { + SdkClientConfiguration clientConfig = testClientConfiguration() + .option(SdkClientOption.NEW_RETRIES_2026_ENABLED, true) + .build(); + ClientExecutionParams executionParams = clientExecutionParams(); + ExecutionContext executionContext = + AwsExecutionContextBuilder.invokeInterceptorsAndCreateExecutionContext(executionParams, clientConfig); + + assertThat(executionContext.executionAttributes() + .getAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED)).isTrue(); + } + private ClientExecutionParams clientExecutionParams() { return clientExecutionParams(sdkRequest); } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/SdkClientOption.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/SdkClientOption.java index 0e20334e9ec..20ea0d01c3b 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/SdkClientOption.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/SdkClientOption.java @@ -311,6 +311,11 @@ public final class SdkClientOption extends ClientOption { */ public static final SdkClientOption DEFAULT_NEW_RETRIES_2026 = new SdkClientOption<>(Boolean.class); + /** + * Whether retries 2.1 behavior is enabled. + */ + public static final SdkClientOption NEW_RETRIES_2026_ENABLED = new SdkClientOption<>(Boolean.class); + /** * The {@link EndpointProvider} configured on the client. */ diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/SdkInternalExecutionAttribute.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/SdkInternalExecutionAttribute.java index 910c6659410..529e129e18c 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/SdkInternalExecutionAttribute.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/SdkInternalExecutionAttribute.java @@ -217,6 +217,11 @@ public final class SdkInternalExecutionAttribute extends SdkExecutionAttribute { */ public static final ExecutionAttribute IS_LONG_POLLING = new ExecutionAttribute<>("IsLongPolling"); + /** + * Indicates whether retries v2.1 is enabled. + */ + public static final ExecutionAttribute NEW_RETRIES_2026_ENABLED = new ExecutionAttribute<>("NewRetries2026Enabled"); + /** * The backing attribute for RESOLVED_CHECKSUM_SPECS. * This holds the real ChecksumSpecs value, and is used to map to the ChecksumAlgorithm signer property diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java index 7a6147bc478..c0a7d1f9197 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java @@ -27,12 +27,14 @@ import software.amazon.awssdk.core.async.AsyncRequestBody; import software.amazon.awssdk.core.client.config.SdkClientOption; import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; import software.amazon.awssdk.core.internal.http.HttpClientDependencies; import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.core.internal.http.TransformingAsyncResponseHandler; import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline; import software.amazon.awssdk.core.internal.http.pipeline.stages.utils.RetryableStageHelper; import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpResponse; import software.amazon.awssdk.utils.CompletableFutureUtils; import software.amazon.awssdk.utils.Either; @@ -42,6 +44,7 @@ @SdkInternalApi public final class AsyncRetryableStage implements RequestPipeline>> { + private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after"; private final TransformingAsyncResponseHandler> responseHandler; private final RequestPipeline>> requestPipeline; @@ -135,7 +138,7 @@ private void attemptExecute(CompletableFuture> future) { } public void maybeAttemptExecute(CompletableFuture> future) { - Either backoffDelay = retryableStageHelper.tryRefreshToken(Duration.ZERO); + Either backoffDelay = retryableStageHelper.tryRefreshToken(suggestedDelay()); Optional acquireFailureDelay = backoffDelay.right(); if (acquireFailureDelay.isPresent()) { @@ -172,5 +175,35 @@ private void maybeRetryExecute(CompletableFuture> future, Exce future.completeExceptionally(t); } } + + private Duration suggestedDelay() { + if (newRetries2026Enabled(context)) { + return xAmzRetryAfter(retryableStageHelper.getLastResponse()).orElse(Duration.ZERO); + } + // Unlike in the sync RetryableStage, we never used 'Retry-After' for suggested delay in async + // https://github.com/aws/aws-sdk-java-v2/blob/1483d30d071716ead3dc1fa6571441658013d5c1/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java#L137 + return Duration.ZERO; + } + } + + /** + * Returns the suggested backoff delay based on the 'x-amz-retry-after' header value in the response. + */ + private Optional xAmzRetryAfter(SdkHttpResponse response) { + Optional optionalXAmzRetryAfter = response.firstMatchingHeader(X_AMZ_RETRY_AFTER_HEADER); + return optionalXAmzRetryAfter.map(xAmzRetryAfter -> { + try { + return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter)); + } catch (NumberFormatException e) { + // Ignore and fallback to returning empty. + return null; + } + }); + } + + private boolean newRetries2026Enabled(RequestExecutionContext executionContext) { + return executionContext.executionAttributes() + .getOptionalAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED) + .orElse(false); } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java index 6b04e688ff7..cef3f90e876 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java @@ -22,6 +22,7 @@ import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.Response; import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; import software.amazon.awssdk.core.internal.http.HttpClientDependencies; import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline; @@ -37,6 +38,7 @@ @SdkInternalApi public final class RetryableStage implements RequestToResponsePipeline { private static final String RETRY_AFTER_HEADER = "Retry-After"; + private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after"; private final RequestPipeline> requestPipeline; private final HttpClientDependencies dependencies; @@ -87,7 +89,7 @@ public Response execute(SdkHttpFullRequest request, RequestExecutionCon private Duration suggestedDelay(Exception e) { if (e instanceof SdkExceptionWithRetryAfterHint) { SdkExceptionWithRetryAfterHint except = (SdkExceptionWithRetryAfterHint) e; - return Duration.ofSeconds(except.retryAfter()); + return except.retryAfter(); } return Duration.ZERO; } @@ -102,52 +104,77 @@ private Response executeRequest(RetryableStageHelper retryableStageHelp retryableStageHelper.setLastResponse(response.httpResponse()); if (!response.isSuccess()) { retryableStageHelper.adjustClockIfClockSkew(response); - throw responseException(response); + throw responseException(response, context); } return response; } - private RuntimeException responseException(Response response) { - Optional optionalRetryAfter = retryAfter(response.httpResponse()); + private RuntimeException responseException(Response response, RequestExecutionContext context) { + Optional optionalRetryAfter; + if (newRetries2026Enabled(context)) { + optionalRetryAfter = xAmzRetryAfter(response.httpResponse()); + } else { + optionalRetryAfter = retryAfter(response.httpResponse()); + } + if (optionalRetryAfter.isPresent()) { return new SdkExceptionWithRetryAfterHint(optionalRetryAfter.get(), response.exception()); } return response.exception(); } - private Optional retryAfter(SdkHttpFullResponse response) { + /** + * Returns the suggested backoff delay based on the 'x-amz-retry-after' header value in the response. + */ + private Optional xAmzRetryAfter(SdkHttpFullResponse response) { + Optional optionalXAmzRetryAfter = response.firstMatchingHeader(X_AMZ_RETRY_AFTER_HEADER); + return optionalXAmzRetryAfter.map(xAmzRetryAfter -> { + try { + return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter)); + } catch (NumberFormatException e) { + // Ignore and fallback to returning empty. + return null; + } + }); + } + + /** + * Returns the suggested backoff delay based on the 'Retry-After' header value in the response. + */ + private Optional retryAfter(SdkHttpFullResponse response) { Optional optionalRetryAfterHeader = response.firstMatchingHeader(RETRY_AFTER_HEADER); - if (optionalRetryAfterHeader.isPresent()) { - String retryAfterHeader = optionalRetryAfterHeader.get(); + return optionalRetryAfterHeader.map(retryAfterHeader -> { try { - return Optional.of(Integer.parseInt(retryAfterHeader)); + return Duration.ofSeconds(Integer.parseInt(retryAfterHeader)); } catch (NumberFormatException e) { // Ignore and fallback to returning empty. + return null; } - } - return Optional.empty(); + }); + } + + private boolean newRetries2026Enabled(RequestExecutionContext executionContext) { + return executionContext.executionAttributes() + .getOptionalAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED) + .orElse(false); } // This probably should go directly into SdkException static class SdkExceptionWithRetryAfterHint extends RuntimeException { private final SdkException cause; - private final int seconds; + private final Duration delay; - SdkExceptionWithRetryAfterHint(int seconds, SdkException cause) { - this.seconds = seconds; + SdkExceptionWithRetryAfterHint(Duration delay, SdkException cause) { + this.delay = delay; this.cause = cause; } - public int retryAfter() { - return seconds; + public Duration retryAfter() { + return delay; } public SdkException cause() { return cause; } - - public int seconds() { - return seconds; - } } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java index b78d1a2fba9..840df78367f 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java @@ -259,6 +259,10 @@ public void setLastResponse(SdkHttpResponse lastResponse) { this.lastResponse = lastResponse; } + public SdkHttpResponse getLastResponse() { + return lastResponse; + } + /** * Returns true if this is the first attempt. */ diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStageTest.java index 862c1446501..5f0b03ac346 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStageTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; @@ -27,12 +28,13 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Stream; +import org.junit.Assume; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import software.amazon.awssdk.core.Response; import software.amazon.awssdk.core.SdkRequest; import software.amazon.awssdk.core.SdkResponse; @@ -41,6 +43,7 @@ import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.http.ExecutionContext; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; import software.amazon.awssdk.core.internal.http.HttpClientDependencies; import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.core.internal.http.TransformingAsyncResponseHandler; @@ -50,12 +53,13 @@ import software.amazon.awssdk.http.SdkHttpMethod; import software.amazon.awssdk.metrics.NoOpMetricCollector; import software.amazon.awssdk.retries.api.AcquireInitialTokenResponse; +import software.amazon.awssdk.retries.api.RefreshRetryTokenRequest; import software.amazon.awssdk.retries.api.RefreshRetryTokenResponse; import software.amazon.awssdk.retries.api.RetryStrategy; import software.amazon.awssdk.retries.api.RetryToken; import software.amazon.awssdk.retries.api.TokenAcquisitionFailedException; -public class AsyncRetryableStageTest { +public class AsyncRetryableStageTest extends BaseRetryableStageTest { private RetryStrategy mockRetryStrategy; private AcquireInitialTokenResponse mockAcquireInitialTokenResponse; private RetryToken mockRetryToken; @@ -110,6 +114,7 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws .build(); ExecutionAttributes execAttrs = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, true) .build(); ExecutionContext execCtx = ExecutionContext.builder() @@ -133,16 +138,16 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws when(mockDelegatePipeline.execute(any(), any())).thenReturn(CompletableFuture.completedFuture(response)); - if (testCase.failure) { + if (testCase.isFailure()) { when(mockRetryStrategy.refreshRetryToken(any())).thenThrow( - new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, testCase.failureDelay) + new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, testCase.failureDelay()) ); } else { // only retry once, otherwise we'll get into an infinite loop AtomicBoolean first = new AtomicBoolean(); when(mockRetryStrategy.refreshRetryToken(any())).thenAnswer(i -> { if (first.compareAndSet(false, true)) { - return RefreshRetryTokenResponse.create(mockRetryToken, testCase.successDelay); + return RefreshRetryTokenResponse.create(mockRetryToken, testCase.successDelay()); } throw new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, Duration.ZERO); }); @@ -158,38 +163,74 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws assertThat(Duration.ofNanos(end - start)).isBetween(lowerBound, lowerBound.plusMillis(250)); } - private static Stream acquireDelayTestCases() { - return Stream.of( - new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ZERO), - new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ofMillis(100)), + @ParameterizedTest + @MethodSource("retryAfterTestCases") + void execute_retryableException_treatsRetryAfterCorrectly(RetryAfterTestCase testCase) throws Exception { + Assume.assumeTrue("Async v2.0 behavior doesn't look at Retry-After", testCase.isNewRetries2026Enabled()); - new AcquireDelayTestCase(false, Duration.ZERO, Duration.ofDays(1)), - new AcquireDelayTestCase(false, Duration.ofMillis(100), Duration.ofDays(1)) - ); - } + SdkClientConfiguration clientConfig = SdkClientConfiguration.builder() + .option(SdkClientOption.RETRY_STRATEGY, mockRetryStrategy) + .option(SdkClientOption.SCHEDULED_EXECUTOR_SERVICE, + executorService) + .build(); - private static class AcquireDelayTestCase { - private boolean failure; - private Duration successDelay; - private Duration failureDelay; + HttpClientDependencies deps = HttpClientDependencies.builder() + .clientConfiguration(clientConfig) + .build(); - public AcquireDelayTestCase(boolean failure, Duration successDelay, Duration failureDelay) { - this.failure = failure; - this.successDelay = successDelay; - this.failureDelay = failureDelay; - } + AsyncRetryableStage retryableStage = new AsyncRetryableStage<>(mock(TransformingAsyncResponseHandler.class), + deps, mockDelegatePipeline); + + SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .uri(URI.create("https://my-service.amazonaws.com")) + .build(); + + ExecutionAttributes execAttrs = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, + testCase.isNewRetries2026Enabled()) + .build(); + + ExecutionContext execCtx = ExecutionContext.builder() + .metricCollector(NoOpMetricCollector.create()) + .executionAttributes(execAttrs) + .build(); - public Duration expectedDelay() { - if (failure) { - return failureDelay; - } - return successDelay; + RequestExecutionContext ctx = RequestExecutionContext.builder() + .originalRequest(mock(SdkRequest.class)) + .executionContext(execCtx) + .build(); + + SdkHttpFullResponse.Builder httpResponse = SdkHttpFullResponse.builder() + .statusCode(502); + + if (testCase.retryAfter() != null) { + httpResponse.putHeader(RETRY_AFTER_HEADER, testCase.retryAfter()); } - @Override - public String toString() { - return (failure ? "Failure" : "Success") + " with delay " + expectedDelay(); + if (testCase.xAmzRetryAfter() != null) { + httpResponse.putHeader(X_AMZ_RETRY_AFTER_HEADER, testCase.xAmzRetryAfter()); } + + Response response = Response.builder() + .httpResponse(httpResponse.build()) + .isSuccess(false) + .exception(SdkException.builder().build()) + .build(); + + when(mockDelegatePipeline.execute(any(), any())).thenReturn(CompletableFuture.completedFuture(response)); + + CompletableFuture> execute = retryableStage.execute(httpRequest, ctx); + // exception thrown doesn't matter, just results in exception because we mock just enough... + assertThatThrownBy(execute::join); + + ArgumentCaptor refreshRequestCaptor = ArgumentCaptor.forClass(RefreshRetryTokenRequest.class); + + verify(mockRetryStrategy).refreshRetryToken(refreshRequestCaptor.capture()); + + RefreshRetryTokenRequest refreshRequest = refreshRequestCaptor.getValue(); + + assertThat(refreshRequest.suggestedDelay().get()).isEqualTo(testCase.expectedDelay()); } } diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java new file mode 100644 index 00000000000..4fcd54931a5 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java @@ -0,0 +1,182 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.internal.http.pipeline.stages; + +import java.time.Duration; +import java.util.stream.Stream; + +class BaseRetryableStageTest { + // note: values are in seconds + protected static final String RETRY_AFTER_HEADER = "Retry-After"; + // note: values are in ms + protected static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after"; + + + protected static Stream acquireDelayTestCases() { + return Stream.of( + new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ZERO), + new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ofMillis(100)), + + new AcquireDelayTestCase(false, Duration.ZERO, Duration.ofDays(1)), + new AcquireDelayTestCase(false, Duration.ofMillis(100), Duration.ofDays(1)) + ); + } + + protected static Stream retryAfterTestCases() { + return Stream.of( + // v2.0 + new RetryAfterTestCase() + .description("Parses Retry-After correctly") + .retryAfter("1") + .expectedDelay(Duration.ofSeconds(1)), + + new RetryAfterTestCase() + .description("Ignores format error") + .retryAfter("one second") + .expectedDelay(Duration.ZERO), + + new RetryAfterTestCase() + .description("Ignores x-amz-retry-after") + .retryAfter("1") + .xAmzRetryAfter("50") + .expectedDelay(Duration.ofSeconds(1)), + + new RetryAfterTestCase() + .description("No header, no delay") + .expectedDelay(Duration.ZERO), + + // v2.1 + new RetryAfterTestCase() + .newRetries2026Enabled(true) + .description("Parses x-amz-retry-after correctly") + .xAmzRetryAfter("1") + .expectedDelay(Duration.ofMillis(1)), + + new RetryAfterTestCase() + .newRetries2026Enabled(true) + .description("Ignores format error") + .xAmzRetryAfter("one second") + .expectedDelay(Duration.ZERO), + + new RetryAfterTestCase() + .newRetries2026Enabled(true) + .description("Ignores Retry-After") + .retryAfter("1") + .xAmzRetryAfter("50") + .expectedDelay(Duration.ofMillis(50)), + + new RetryAfterTestCase() + .newRetries2026Enabled(true) + .description("No header, no delay") + .expectedDelay(Duration.ZERO) + ); + } + + + protected static class AcquireDelayTestCase { + private boolean failure; + private Duration successDelay; + private Duration failureDelay; + + public AcquireDelayTestCase(boolean failure, Duration successDelay, Duration failureDelay) { + this.failure = failure; + this.successDelay = successDelay; + this.failureDelay = failureDelay; + } + + public boolean isFailure() { + return failure; + } + + public Duration failureDelay() { + return failureDelay; + } + + public Duration successDelay() { + return successDelay; + } + + public Duration expectedDelay() { + if (failure) { + return failureDelay; + } + return successDelay; + } + + @Override + public String toString() { + return (failure ? "Failure" : "Success") + " with delay " + expectedDelay(); + } + } + + + protected static class RetryAfterTestCase { + private String description; + private String retryAfter; + private String xAmzRetryAfter; + private boolean newRetries2026Enabled; + private Duration expectedDelay; + + public RetryAfterTestCase description(String description) { + this.description = description; + return this; + } + + public RetryAfterTestCase retryAfter(String retryAfter) { + this.retryAfter = retryAfter; + return this; + } + + public String retryAfter() { + return retryAfter; + } + + public RetryAfterTestCase xAmzRetryAfter(String xAmzRetryAfter) { + this.xAmzRetryAfter = xAmzRetryAfter; + return this; + } + + public String xAmzRetryAfter() { + return xAmzRetryAfter; + } + + public RetryAfterTestCase newRetries2026Enabled(boolean newRetries2026Enabled) { + this.newRetries2026Enabled = newRetries2026Enabled; + return this; + } + + public boolean isNewRetries2026Enabled() { + return newRetries2026Enabled; + } + + public RetryAfterTestCase expectedDelay(Duration expectedDelay) { + this.expectedDelay = expectedDelay; + return this; + } + + public Duration expectedDelay() { + return expectedDelay; + } + + @Override + public String toString() { + if (newRetries2026Enabled) { + return "[v2.1] " + description; + } + return description; + } + } +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStageTest.java index e645f34ea1a..d5dff64c474 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStageTest.java @@ -19,15 +19,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.URI; import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import software.amazon.awssdk.core.Response; import software.amazon.awssdk.core.SdkRequest; import software.amazon.awssdk.core.SdkResponse; @@ -36,6 +37,7 @@ import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.http.ExecutionContext; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; import software.amazon.awssdk.core.internal.http.HttpClientDependencies; import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline; @@ -43,12 +45,13 @@ import software.amazon.awssdk.http.SdkHttpFullResponse; import software.amazon.awssdk.http.SdkHttpMethod; import software.amazon.awssdk.retries.api.AcquireInitialTokenResponse; +import software.amazon.awssdk.retries.api.RefreshRetryTokenRequest; import software.amazon.awssdk.retries.api.RefreshRetryTokenResponse; import software.amazon.awssdk.retries.api.RetryStrategy; import software.amazon.awssdk.retries.api.RetryToken; import software.amazon.awssdk.retries.api.TokenAcquisitionFailedException; -public class RetryableStageTest { +public class RetryableStageTest extends BaseRetryableStageTest { private RetryStrategy mockRetryStrategy; private AcquireInitialTokenResponse mockAcquireInitialTokenResponse; private RetryToken mockRetryToken; @@ -88,6 +91,7 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws .build(); ExecutionAttributes execAttrs = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, true) .build(); ExecutionContext execCtx = ExecutionContext.builder() @@ -110,15 +114,15 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws when(mockDelegatePipeline.execute(any(), any())).thenReturn(response); - if (testCase.failure) { + if (testCase.isFailure()) { when(mockRetryStrategy.refreshRetryToken(any())).thenThrow( - new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, testCase.failureDelay)); + new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, testCase.failureDelay())); } else { // only retry once, otherwise we'll get into an infinite loop AtomicBoolean first = new AtomicBoolean(); when(mockRetryStrategy.refreshRetryToken(any())).thenAnswer(i -> { if (first.compareAndSet(false, true)) { - return RefreshRetryTokenResponse.create(mockRetryToken, testCase.successDelay); + return RefreshRetryTokenResponse.create(mockRetryToken, testCase.successDelay()); } throw new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, Duration.ZERO); }); @@ -133,38 +137,66 @@ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws assertThat(Duration.ofNanos(end - start)).isBetween(lowerBound, lowerBound.plusMillis(250)); } - private static Stream acquireDelayTestCases() { - return Stream.of( - new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ZERO), - new AcquireDelayTestCase(true, Duration.ofDays(1), Duration.ofMillis(100)), + @ParameterizedTest + @MethodSource("retryAfterTestCases") + void execute_retryableException_treatsRetryAfterCorrectly(RetryAfterTestCase testCase) throws Exception { + SdkClientConfiguration clientConfig = SdkClientConfiguration.builder() + .option(SdkClientOption.RETRY_STRATEGY, mockRetryStrategy) + .build(); + HttpClientDependencies deps = HttpClientDependencies.builder() + .clientConfiguration(clientConfig) + .build(); - new AcquireDelayTestCase(false, Duration.ZERO, Duration.ofDays(1)), - new AcquireDelayTestCase(false, Duration.ofMillis(100), Duration.ofDays(1)) - ); - } + RetryableStage retryableStage = new RetryableStage<>(deps, mockDelegatePipeline); + + SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .uri(URI.create("https://my-service.amazonaws.com")) + .build(); - private static class AcquireDelayTestCase { - private boolean failure; - private Duration successDelay; - private Duration failureDelay; + ExecutionAttributes execAttrs = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, + testCase.isNewRetries2026Enabled()) + .build(); - public AcquireDelayTestCase(boolean failure, Duration successDelay, Duration failureDelay) { - this.failure = failure; - this.successDelay = successDelay; - this.failureDelay = failureDelay; - } + ExecutionContext execCtx = ExecutionContext.builder() + .executionAttributes(execAttrs) + .build(); - public Duration expectedDelay() { - if (failure) { - return failureDelay; - } - return successDelay; + RequestExecutionContext ctx = RequestExecutionContext.builder() + .originalRequest(mock(SdkRequest.class)) + .executionContext(execCtx) + .build(); + + SdkHttpFullResponse.Builder httpResponse = SdkHttpFullResponse.builder() + .statusCode(502); + + if (testCase.retryAfter() != null) { + httpResponse.putHeader(RETRY_AFTER_HEADER, testCase.retryAfter()); } - @Override - public String toString() { - return (failure ? "Failure" : "Success") + " with delay " + expectedDelay(); + if (testCase.xAmzRetryAfter() != null) { + httpResponse.putHeader(X_AMZ_RETRY_AFTER_HEADER, testCase.xAmzRetryAfter()); } + + Response response = Response.builder() + .httpResponse(httpResponse.build()) + .isSuccess(false) + .exception(SdkException.builder().build()) + .build(); + + when(mockDelegatePipeline.execute(any(), any())).thenReturn(response); + + // exception thrown doesn't matter, just results in exception because we mock just enough... + assertThatThrownBy(() -> retryableStage.execute(httpRequest, ctx)); + + ArgumentCaptor refreshRequestCaptor = ArgumentCaptor.forClass(RefreshRetryTokenRequest.class); + + verify(mockRetryStrategy).refreshRetryToken(refreshRequestCaptor.capture()); + + RefreshRetryTokenRequest refreshRequest = refreshRequestCaptor.getValue(); + + assertThat(refreshRequest.suggestedDelay().get()).isEqualTo(testCase.expectedDelay()); } } From a857106f06316f39dd4860e6ffeda05a8a0aeb7b Mon Sep 17 00:00:00 2001 From: Dongie Agnir Date: Tue, 28 Apr 2026 20:48:01 -0700 Subject: [PATCH 2/2] Review comments --- .../http/pipeline/stages/AsyncRetryableStage.java | 4 ++++ .../internal/http/pipeline/stages/RetryableStage.java | 9 +++++++++ .../http/pipeline/stages/BaseRetryableStageTest.java | 11 +++++++++++ 3 files changed, 24 insertions(+) diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java index c0a7d1f9197..20d94a00666 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java @@ -37,6 +37,7 @@ import software.amazon.awssdk.http.SdkHttpResponse; import software.amazon.awssdk.utils.CompletableFutureUtils; import software.amazon.awssdk.utils.Either; +import software.amazon.awssdk.utils.Logger; /** * Wrapper around the pipeline for a single request to provide retry, clockskew and request throttling functionality. @@ -45,6 +46,7 @@ public final class AsyncRetryableStage implements RequestPipeline>> { private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after"; + private static final Logger LOG = Logger.loggerFor(AsyncRetryableStage.class); private final TransformingAsyncResponseHandler> responseHandler; private final RequestPipeline>> requestPipeline; @@ -196,6 +198,8 @@ private Optional xAmzRetryAfter(SdkHttpResponse response) { return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter)); } catch (NumberFormatException e) { // Ignore and fallback to returning empty. + LOG.debug(() -> String.format("Unable to parse header '%s' value '%s' as integer", + X_AMZ_RETRY_AFTER_HEADER, xAmzRetryAfter), e); return null; } }); diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java index cef3f90e876..3407429f745 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java @@ -31,6 +31,7 @@ import software.amazon.awssdk.http.SdkHttpFullRequest; import software.amazon.awssdk.http.SdkHttpFullResponse; import software.amazon.awssdk.utils.Either; +import software.amazon.awssdk.utils.Logger; /** * Wrapper around the pipeline for a single request to provide retry, clock-skew and request throttling functionality. @@ -39,6 +40,8 @@ public final class RetryableStage implements RequestToResponsePipeline { private static final String RETRY_AFTER_HEADER = "Retry-After"; private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after"; + private static final Logger LOG = Logger.loggerFor(RetryableStage.class); + private final RequestPipeline> requestPipeline; private final HttpClientDependencies dependencies; @@ -133,6 +136,7 @@ private Optional xAmzRetryAfter(SdkHttpFullResponse response) { return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter)); } catch (NumberFormatException e) { // Ignore and fallback to returning empty. + logIntParseException(X_AMZ_RETRY_AFTER_HEADER, xAmzRetryAfter, e); return null; } }); @@ -148,6 +152,7 @@ private Optional retryAfter(SdkHttpFullResponse response) { return Duration.ofSeconds(Integer.parseInt(retryAfterHeader)); } catch (NumberFormatException e) { // Ignore and fallback to returning empty. + logIntParseException(RETRY_AFTER_HEADER, retryAfterHeader, e); return null; } }); @@ -159,6 +164,10 @@ private boolean newRetries2026Enabled(RequestExecutionContext executionContext) .orElse(false); } + private static void logIntParseException(String headerName, String headerValue, Throwable t) { + LOG.debug(() -> String.format("Unable to parse header '%s' value '%s' as integer", headerName, headerValue), t); + } + // This probably should go directly into SdkException static class SdkExceptionWithRetryAfterHint extends RuntimeException { private final SdkException cause; diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java index 4fcd54931a5..fcc84e0377f 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java @@ -48,6 +48,11 @@ protected static Stream retryAfterTestCases() { .retryAfter("one second") .expectedDelay(Duration.ZERO), + new RetryAfterTestCase() + .description("Ignores int overflow") + .retryAfter(Long.toString(Long.MAX_VALUE)) + .expectedDelay(Duration.ZERO), + new RetryAfterTestCase() .description("Ignores x-amz-retry-after") .retryAfter("1") @@ -71,6 +76,12 @@ protected static Stream retryAfterTestCases() { .xAmzRetryAfter("one second") .expectedDelay(Duration.ZERO), + new RetryAfterTestCase() + .newRetries2026Enabled(true) + .description("Ignores int overflow") + .xAmzRetryAfter(Long.toString(Long.MAX_VALUE)) + .expectedDelay(Duration.ZERO), + new RetryAfterTestCase() .newRetries2026Enabled(true) .description("Ignores Retry-After")