Skip to content

Commit ecd324a

Browse files
committed
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.
1 parent 270414e commit ecd324a

12 files changed

Lines changed: 442 additions & 94 deletions

File tree

core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,13 @@ private void configureRetryPolicy(SdkClientConfiguration.Builder config) {
437437
private void configureRetryStrategy(SdkClientConfiguration.Builder config) {
438438
RetryStrategy strategy = config.option(SdkClientOption.RETRY_STRATEGY);
439439
if (strategy == null) {
440-
config.lazyOption(SdkClientOption.RETRY_STRATEGY, this::resolveAwsRetryStrategy);
440+
Boolean defaultNewRetries2026 = config.option(SdkClientOption.DEFAULT_NEW_RETRIES_2026);
441+
442+
config.lazyOption(SdkClientOption.RETRY_STRATEGY, src -> resolveAwsRetryStrategy(src, defaultNewRetries2026));
443+
444+
config.option(SdkClientOption.NEW_RETRIES_2026_ENABLED,
445+
new NewRetries2026Resolver().defaultNewRetries2026(defaultNewRetries2026).resolve());
446+
441447
return;
442448
}
443449

@@ -457,9 +463,7 @@ private void configureRetryStrategy(SdkClientConfiguration.Builder config) {
457463

458464
}
459465

460-
private RetryStrategy resolveAwsRetryStrategy(LazyValueSource config) {
461-
Boolean defaultNewRetries2026 = config.get(SdkClientOption.DEFAULT_NEW_RETRIES_2026);
462-
466+
private RetryStrategy resolveAwsRetryStrategy(LazyValueSource config, Boolean defaultNewRetries2026) {
463467
RetryMode retryMode = RetryMode.resolver()
464468
.profileFile(config.get(SdkClientOption.PROFILE_FILE_SUPPLIER))
465469
.profileName(config.get(SdkClientOption.PROFILE_NAME))

core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilder.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static software.amazon.awssdk.auth.signer.internal.util.SignerMethodResolver.resolveSigningMethodUsed;
1919
import static software.amazon.awssdk.awscore.internal.AwsServiceProtocol.SMITHY_RPC_V2_CBOR;
20+
import static software.amazon.awssdk.core.client.config.SdkClientOption.NEW_RETRIES_2026_ENABLED;
2021
import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_POLICY;
2122
import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_STRATEGY;
2223
import static software.amazon.awssdk.core.interceptor.SdkExecutionAttribute.RESOLVED_CHECKSUM_SPECS;
@@ -104,6 +105,7 @@ private AwsExecutionContextBuilder() {
104105
.putAttribute(AwsSignerExecutionAttribute.SIGNING_REGION, clientConfig.option(AwsClientOption.SIGNING_REGION))
105106
.putAttribute(SdkInternalExecutionAttribute.IS_FULL_DUPLEX, executionParams.isFullDuplex())
106107
.putAttribute(SdkInternalExecutionAttribute.IS_LONG_POLLING, executionParams.isLongPolling())
108+
.putAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED, clientConfig.option(NEW_RETRIES_2026_ENABLED))
107109
.putAttribute(SdkInternalExecutionAttribute.HAS_INITIAL_REQUEST_EVENT, executionParams.hasInitialRequestEvent())
108110
.putAttribute(SdkExecutionAttribute.CLIENT_TYPE, clientConfig.option(SdkClientOption.CLIENT_TYPE))
109111
.putAttribute(SdkExecutionAttribute.SERVICE_NAME, clientConfig.option(SdkClientOption.SERVICE_NAME))

core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/InternalDefaultsTest.java

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.mockito.Mockito.mock;
20+
import static software.amazon.awssdk.core.client.config.SdkClientOption.NEW_RETRIES_2026_ENABLED;
2021
import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_STRATEGY;
2122

2223
import java.util.stream.Stream;
@@ -60,7 +61,7 @@ static void teardown() {
6061
@ParameterizedTest(name = "system prop = {0}, env var = {1}, default cfg = {2}, expected = {3}")
6162
@MethodSource("newRetries2026Settings")
6263
void buildClient_precedenceIsCorrect(String systemProperty, String environmentVariable, Boolean defaultConfig,
63-
Class<?> retryStrategyClass) {
64+
Class<?> retryStrategyClass, boolean newRetries2026Enabled) {
6465
EnvironmentVariableHelper.run((env) -> {
6566
if (environmentVariable != null) {
6667
env.set(SdkSystemSetting.AWS_NEW_RETRIES_2026.environmentVariable(), environmentVariable);
@@ -80,23 +81,26 @@ void buildClient_precedenceIsCorrect(String systemProperty, String environmentVa
8081

8182
assertThat(sync.clientConfiguration.option(RETRY_STRATEGY)).isInstanceOf(retryStrategyClass);
8283
assertThat(async.clientConfiguration.option(RETRY_STRATEGY)).isInstanceOf(retryStrategyClass);
84+
85+
assertThat(sync.clientConfiguration.option(NEW_RETRIES_2026_ENABLED)).isEqualTo(newRetries2026Enabled);
86+
assertThat(async.clientConfiguration.option(NEW_RETRIES_2026_ENABLED)).isEqualTo(newRetries2026Enabled);
8387
});
8488
}
8589

8690
// system property, environment variable, default config, expected retry strategy
8791
static Stream<Arguments> newRetries2026Settings() {
8892
return Stream.of(
89-
Arguments.of(null, null, null, LegacyRetryStrategy.class),
93+
Arguments.of(null, null, null, LegacyRetryStrategy.class, false),
9094

91-
Arguments.of("true", null, null, StandardRetryStrategy.class),
92-
Arguments.of("false", null, null, LegacyRetryStrategy.class),
93-
Arguments.of(null, "true", null, StandardRetryStrategy.class),
94-
Arguments.of(null, "false", null, LegacyRetryStrategy.class),
95-
Arguments.of(null, null, true, StandardRetryStrategy.class),
96-
Arguments.of(null, null, false, LegacyRetryStrategy.class),
95+
Arguments.of("true", null, null, StandardRetryStrategy.class, true),
96+
Arguments.of("false", null, null, LegacyRetryStrategy.class, false),
97+
Arguments.of(null, "true", null, StandardRetryStrategy.class, true),
98+
Arguments.of(null, "false", null, LegacyRetryStrategy.class, false),
99+
Arguments.of(null, null, true, StandardRetryStrategy.class, true),
100+
Arguments.of(null, null, false, LegacyRetryStrategy.class, false),
97101

98-
Arguments.of("true", null, false, StandardRetryStrategy.class),
99-
Arguments.of(null, "true", false, StandardRetryStrategy.class)
102+
Arguments.of("true", null, false, StandardRetryStrategy.class, true),
103+
Arguments.of(null, "true", false, StandardRetryStrategy.class, true)
100104
);
101105
}
102106

core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/AwsExecutionContextBuilderTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,19 @@ public void invokeInterceptorsAndCreateExecutionContext_withLongPollingOperation
567567
assertThat(executionContext.executionAttributes().getAttribute(SdkInternalExecutionAttribute.IS_LONG_POLLING)).isTrue();
568568
}
569569

570+
@Test
571+
public void invokeInterceptorsAndCreateExecutionContext_newRetries2026EnabledConfig_setsCorrectAttributeValue() {
572+
SdkClientConfiguration clientConfig = testClientConfiguration()
573+
.option(SdkClientOption.NEW_RETRIES_2026_ENABLED, true)
574+
.build();
575+
ClientExecutionParams<SdkRequest, SdkResponse> executionParams = clientExecutionParams();
576+
ExecutionContext executionContext =
577+
AwsExecutionContextBuilder.invokeInterceptorsAndCreateExecutionContext(executionParams, clientConfig);
578+
579+
assertThat(executionContext.executionAttributes()
580+
.getAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED)).isTrue();
581+
}
582+
570583
private ClientExecutionParams<SdkRequest, SdkResponse> clientExecutionParams() {
571584
return clientExecutionParams(sdkRequest);
572585
}

core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/SdkClientOption.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ public final class SdkClientOption<T> extends ClientOption<T> {
311311
*/
312312
public static final SdkClientOption<Boolean> DEFAULT_NEW_RETRIES_2026 = new SdkClientOption<>(Boolean.class);
313313

314+
/**
315+
* Whether retries 2.1 behavior is enabled.
316+
*/
317+
public static final SdkClientOption<Boolean> NEW_RETRIES_2026_ENABLED = new SdkClientOption<>(Boolean.class);
318+
314319
/**
315320
* The {@link EndpointProvider} configured on the client.
316321
*/

core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/SdkInternalExecutionAttribute.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ public final class SdkInternalExecutionAttribute extends SdkExecutionAttribute {
217217
*/
218218
public static final ExecutionAttribute<Boolean> IS_LONG_POLLING = new ExecutionAttribute<>("IsLongPolling");
219219

220+
/**
221+
* Indicates whether retries v2.1 is enabled.
222+
*/
223+
public static final ExecutionAttribute<Boolean> NEW_RETRIES_2026_ENABLED = new ExecutionAttribute<>("NewRetries2026Enabled");
224+
220225
/**
221226
* The backing attribute for RESOLVED_CHECKSUM_SPECS.
222227
* This holds the real ChecksumSpecs value, and is used to map to the ChecksumAlgorithm signer property

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStage.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@
2727
import software.amazon.awssdk.core.async.AsyncRequestBody;
2828
import software.amazon.awssdk.core.client.config.SdkClientOption;
2929
import software.amazon.awssdk.core.exception.SdkException;
30+
import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
3031
import software.amazon.awssdk.core.internal.http.HttpClientDependencies;
3132
import software.amazon.awssdk.core.internal.http.RequestExecutionContext;
3233
import software.amazon.awssdk.core.internal.http.TransformingAsyncResponseHandler;
3334
import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline;
3435
import software.amazon.awssdk.core.internal.http.pipeline.stages.utils.RetryableStageHelper;
3536
import software.amazon.awssdk.http.SdkHttpFullRequest;
37+
import software.amazon.awssdk.http.SdkHttpResponse;
3638
import software.amazon.awssdk.utils.CompletableFutureUtils;
3739
import software.amazon.awssdk.utils.Either;
3840

@@ -42,6 +44,7 @@
4244
@SdkInternalApi
4345
public final class AsyncRetryableStage<OutputT> implements RequestPipeline<SdkHttpFullRequest,
4446
CompletableFuture<Response<OutputT>>> {
47+
private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after";
4548

4649
private final TransformingAsyncResponseHandler<Response<OutputT>> responseHandler;
4750
private final RequestPipeline<SdkHttpFullRequest, CompletableFuture<Response<OutputT>>> requestPipeline;
@@ -135,7 +138,7 @@ private void attemptExecute(CompletableFuture<Response<OutputT>> future) {
135138
}
136139

137140
public void maybeAttemptExecute(CompletableFuture<Response<OutputT>> future) {
138-
Either<Duration, Duration> backoffDelay = retryableStageHelper.tryRefreshToken(Duration.ZERO);
141+
Either<Duration, Duration> backoffDelay = retryableStageHelper.tryRefreshToken(suggestedDelay());
139142

140143
Optional<Duration> acquireFailureDelay = backoffDelay.right();
141144
if (acquireFailureDelay.isPresent()) {
@@ -172,5 +175,33 @@ private void maybeRetryExecute(CompletableFuture<Response<OutputT>> future, Exce
172175
future.completeExceptionally(t);
173176
}
174177
}
178+
179+
private Duration suggestedDelay() {
180+
if (newRetries2026Enabled(context)) {
181+
return xAmzRetryAfter(retryableStageHelper.getLastResponse()).orElse(Duration.ZERO);
182+
}
183+
// Unlike in the sync RetryableStage, we never used 'Retry-After' for suggested delay in async
184+
// 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
185+
return Duration.ZERO;
186+
}
187+
}
188+
189+
/**
190+
* Returns the suggested backoff delay based on the 'x-amz-retry-after' header value in the response.
191+
*/
192+
private Optional<Duration> xAmzRetryAfter(SdkHttpResponse response) {
193+
Optional<String> optionalXAmzRetryAfter = response.firstMatchingHeader(X_AMZ_RETRY_AFTER_HEADER);
194+
return optionalXAmzRetryAfter.map(xAmzRetryAfter -> {
195+
try {
196+
return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter));
197+
} catch (NumberFormatException e) {
198+
// Ignore and fallback to returning empty.
199+
return null;
200+
}
201+
});
202+
}
203+
204+
private boolean newRetries2026Enabled(RequestExecutionContext executionContext) {
205+
return executionContext.executionAttributes().getAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED);
175206
}
176207
}

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStage.java

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import software.amazon.awssdk.annotations.SdkInternalApi;
2323
import software.amazon.awssdk.core.Response;
2424
import software.amazon.awssdk.core.exception.SdkException;
25+
import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
2526
import software.amazon.awssdk.core.internal.http.HttpClientDependencies;
2627
import software.amazon.awssdk.core.internal.http.RequestExecutionContext;
2728
import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline;
@@ -37,6 +38,7 @@
3738
@SdkInternalApi
3839
public final class RetryableStage<OutputT> implements RequestToResponsePipeline<OutputT> {
3940
private static final String RETRY_AFTER_HEADER = "Retry-After";
41+
private static final String X_AMZ_RETRY_AFTER_HEADER = "x-amz-retry-after";
4042
private final RequestPipeline<SdkHttpFullRequest, Response<OutputT>> requestPipeline;
4143
private final HttpClientDependencies dependencies;
4244

@@ -87,7 +89,7 @@ public Response<OutputT> execute(SdkHttpFullRequest request, RequestExecutionCon
8789
private Duration suggestedDelay(Exception e) {
8890
if (e instanceof SdkExceptionWithRetryAfterHint) {
8991
SdkExceptionWithRetryAfterHint except = (SdkExceptionWithRetryAfterHint) e;
90-
return Duration.ofSeconds(except.retryAfter());
92+
return except.retryAfter();
9193
}
9294
return Duration.ZERO;
9395
}
@@ -102,52 +104,75 @@ private Response<OutputT> executeRequest(RetryableStageHelper retryableStageHelp
102104
retryableStageHelper.setLastResponse(response.httpResponse());
103105
if (!response.isSuccess()) {
104106
retryableStageHelper.adjustClockIfClockSkew(response);
105-
throw responseException(response);
107+
throw responseException(response, context);
106108
}
107109
return response;
108110
}
109111

110-
private RuntimeException responseException(Response<OutputT> response) {
111-
Optional<Integer> optionalRetryAfter = retryAfter(response.httpResponse());
112+
private RuntimeException responseException(Response<OutputT> response, RequestExecutionContext context) {
113+
Optional<Duration> optionalRetryAfter;
114+
if (newRetries2026Enabled(context)) {
115+
optionalRetryAfter = xAmzRetryAfter(response.httpResponse());
116+
} else {
117+
optionalRetryAfter = retryAfter(response.httpResponse());
118+
}
119+
112120
if (optionalRetryAfter.isPresent()) {
113121
return new SdkExceptionWithRetryAfterHint(optionalRetryAfter.get(), response.exception());
114122
}
115123
return response.exception();
116124
}
117125

118-
private Optional<Integer> retryAfter(SdkHttpFullResponse response) {
126+
/**
127+
* Returns the suggested backoff delay based on the 'x-amz-retry-after' header value in the response.
128+
*/
129+
private Optional<Duration> xAmzRetryAfter(SdkHttpFullResponse response) {
130+
Optional<String> optionalXAmzRetryAfter = response.firstMatchingHeader(X_AMZ_RETRY_AFTER_HEADER);
131+
return optionalXAmzRetryAfter.map(xAmzRetryAfter -> {
132+
try {
133+
return Duration.ofMillis(Integer.parseInt(xAmzRetryAfter));
134+
} catch (NumberFormatException e) {
135+
// Ignore and fallback to returning empty.
136+
return null;
137+
}
138+
});
139+
}
140+
141+
/**
142+
* Returns the suggested backoff delay based on the 'Retry-After' header value in the response.
143+
*/
144+
private Optional<Duration> retryAfter(SdkHttpFullResponse response) {
119145
Optional<String> optionalRetryAfterHeader = response.firstMatchingHeader(RETRY_AFTER_HEADER);
120-
if (optionalRetryAfterHeader.isPresent()) {
121-
String retryAfterHeader = optionalRetryAfterHeader.get();
146+
return optionalRetryAfterHeader.map(retryAfterHeader -> {
122147
try {
123-
return Optional.of(Integer.parseInt(retryAfterHeader));
148+
return Duration.ofSeconds(Integer.parseInt(retryAfterHeader));
124149
} catch (NumberFormatException e) {
125150
// Ignore and fallback to returning empty.
151+
return null;
126152
}
127-
}
128-
return Optional.empty();
153+
});
154+
}
155+
156+
private boolean newRetries2026Enabled(RequestExecutionContext executionContext) {
157+
return executionContext.executionAttributes().getAttribute(SdkInternalExecutionAttribute.NEW_RETRIES_2026_ENABLED);
129158
}
130159

131160
// This probably should go directly into SdkException
132161
static class SdkExceptionWithRetryAfterHint extends RuntimeException {
133162
private final SdkException cause;
134-
private final int seconds;
163+
private final Duration delay;
135164

136-
SdkExceptionWithRetryAfterHint(int seconds, SdkException cause) {
137-
this.seconds = seconds;
165+
SdkExceptionWithRetryAfterHint(Duration delay, SdkException cause) {
166+
this.delay = delay;
138167
this.cause = cause;
139168
}
140169

141-
public int retryAfter() {
142-
return seconds;
170+
public Duration retryAfter() {
171+
return delay;
143172
}
144173

145174
public SdkException cause() {
146175
return cause;
147176
}
148-
149-
public int seconds() {
150-
return seconds;
151-
}
152177
}
153178
}

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ public void setLastResponse(SdkHttpResponse lastResponse) {
259259
this.lastResponse = lastResponse;
260260
}
261261

262+
public SdkHttpResponse getLastResponse() {
263+
return lastResponse;
264+
}
265+
262266
/**
263267
* Returns true if this is the first attempt.
264268
*/

0 commit comments

Comments
 (0)