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..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 @@ -27,14 +27,17 @@ 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; +import software.amazon.awssdk.utils.Logger; /** * Wrapper around the pipeline for a single request to provide retry, clockskew and request throttling functionality. @@ -42,6 +45,8 @@ @SdkInternalApi 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; @@ -135,7 +140,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 +177,37 @@ 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. + LOG.debug(() -> String.format("Unable to parse header '%s' value '%s' as integer", + X_AMZ_RETRY_AFTER_HEADER, xAmzRetryAfter), e); + 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..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 @@ -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; @@ -30,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. @@ -37,6 +39,9 @@ @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 static final Logger LOG = Logger.loggerFor(RetryableStage.class); + private final RequestPipeline> requestPipeline; private final HttpClientDependencies dependencies; @@ -87,7 +92,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 +107,83 @@ 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. + logIntParseException(X_AMZ_RETRY_AFTER_HEADER, xAmzRetryAfter, e); + 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. + logIntParseException(RETRY_AFTER_HEADER, retryAfterHeader, e); + return null; } - } - return Optional.empty(); + }); + } + + private boolean newRetries2026Enabled(RequestExecutionContext executionContext) { + return executionContext.executionAttributes() + .getOptionalAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED) + .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; - 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..fcc84e0377f --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/BaseRetryableStageTest.java @@ -0,0 +1,193 @@ +/* + * 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 int overflow") + .retryAfter(Long.toString(Long.MAX_VALUE)) + .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 int overflow") + .xAmzRetryAfter(Long.toString(Long.MAX_VALUE)) + .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()); } }