suggestedDelay() {
return Optional.of(suggestedDelay);
}
+ @Override
+ public boolean isLongPolling() {
+ return isLongPolling;
+ }
+
@Override
public Throwable failure() {
return failure;
@@ -68,11 +75,13 @@ public static Builder builder() {
public static final class Builder implements RefreshRetryTokenRequest.Builder {
private RetryToken token;
private Duration suggestedDelay = Duration.ZERO;
+ private boolean isLongPolling;
private Throwable failure;
Builder(RefreshRetryTokenRequestImpl refreshRetryTokenRequest) {
this.token = refreshRetryTokenRequest.token;
this.suggestedDelay = refreshRetryTokenRequest.suggestedDelay;
+ this.isLongPolling = refreshRetryTokenRequest.isLongPolling;
this.failure = refreshRetryTokenRequest.failure;
}
@@ -91,6 +100,12 @@ public Builder suggestedDelay(Duration duration) {
return this;
}
+ @Override
+ public RefreshRetryTokenRequest.Builder isLongPolling(boolean isLongPolling) {
+ this.isLongPolling = isLongPolling;
+ return this;
+ }
+
@Override
public Builder failure(Throwable throwable) {
this.failure = throwable;
diff --git a/core/retries/pom.xml b/core/retries/pom.xml
index dda02f94e835..8f114dd02068 100644
--- a/core/retries/pom.xml
+++ b/core/retries/pom.xml
@@ -21,7 +21,7 @@
core
software.amazon.awssdk
- 2.43.3-SNAPSHOT
+ 2.44.0-SNAPSHOT
4.0.0
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/AdaptiveRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/AdaptiveRetryStrategy.java
index de4e9127fd68..d9b6ce7f3c95 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/AdaptiveRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/AdaptiveRetryStrategy.java
@@ -15,6 +15,7 @@
package software.amazon.awssdk.retries;
+import java.time.Duration;
import java.util.function.Predicate;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.annotations.ThreadSafe;
@@ -64,15 +65,35 @@ public interface AdaptiveRetryStrategy extends RetryStrategy {
*
*/
static AdaptiveRetryStrategy.Builder builder() {
+ return builder(false);
+ }
+
+ /**
+ * Create a new {@link AdaptiveRetryStrategy.Builder} with v2.0 or v2.1 retry constants.
+ *
+ * @param retries2026Enabled when {@code true}, uses v2.1 constants (50ms base delay, differentiated token costs); when
+ * {@code false}, uses v2.0 constants (100ms base delay, uniform token costs)
+ */
+ static AdaptiveRetryStrategy.Builder builder(Boolean retries2026Enabled) {
+ boolean retries21 = Boolean.TRUE.equals(retries2026Enabled);
+
+ Duration baseDelay = retries21 ? DefaultRetryStrategy.Standard.BASE_DELAY_V21
+ : DefaultRetryStrategy.Standard.BASE_DELAY_V20;
+ int exceptionCost = retries21 ? DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST_V21
+ : DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST_V20;
+ // v2.0 does not treat throttling exceptions differently from others
+ int throttlingCost = retries21 ? DefaultRetryStrategy.Standard.THROTTLING_EXCEPTION_TOKEN_COST_V21
+ : exceptionCost;
return DefaultAdaptiveRetryStrategy
.builder()
.maxAttempts(DefaultRetryStrategy.Adaptive.MAX_ATTEMPTS)
.tokenBucketStore(TokenBucketStore.builder()
.tokenBucketMaxCapacity(DefaultRetryStrategy.Standard.TOKEN_BUCKET_SIZE)
.build())
- .tokenBucketExceptionCost(DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST)
+ .tokenBucketExceptionCost(exceptionCost)
+ .throttlingTokenBucketExceptionCost(throttlingCost)
.rateLimiterTokenBucketStore(RateLimiterTokenBucketStore.builder().build())
- .backoffStrategy(BackoffStrategy.exponentialDelay(DefaultRetryStrategy.Standard.BASE_DELAY,
+ .backoffStrategy(BackoffStrategy.exponentialDelay(baseDelay,
DefaultRetryStrategy.Standard.MAX_BACKOFF))
.throttlingBackoffStrategy(BackoffStrategy.exponentialDelay(
DefaultRetryStrategy.Standard.THROTTLED_BASE_DELAY,
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
index b02709c847e6..d39291ada5ce 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
@@ -38,7 +38,7 @@ public static StandardRetryStrategy doNotRetry() {
}
/**
- * Create a new builder for a {@link StandardRetryStrategy}.
+ * Create a new builder for a {@link StandardRetryStrategy}. This is equivalent to {@code standardStrategyBuilder(false)}.
*
* Example Usage
* {@snippet
@@ -50,7 +50,25 @@ public static StandardRetryStrategy doNotRetry() {
* }
*/
public static StandardRetryStrategy.Builder standardStrategyBuilder() {
- return StandardRetryStrategy.builder();
+ return standardStrategyBuilder(false);
+ }
+
+ /**
+ * Create a new builder for a {@link StandardRetryStrategy}. This is equivalent to {@code standardStrategyBuilder(false)}.
+ *
+ *
Example Usage
+ * {@snippet
+ * StandardRetryStrategy retryStrategy =
+ * DefaultRetryStrategy.standardStrategyBuilder(true)
+ * .retryOnExceptionInstanceOf(IllegalArgumentException.class)
+ * .retryOnExceptionInstanceOf(IllegalStateException.class)
+ * .build();
+ * }
+ *
+ * @param retries2026Enabled Whether retries 2.1 behavior is used.
+ */
+ public static StandardRetryStrategy.Builder standardStrategyBuilder(Boolean retries2026Enabled) {
+ return StandardRetryStrategy.builder(retries2026Enabled);
}
/**
@@ -70,7 +88,7 @@ public static LegacyRetryStrategy.Builder legacyStrategyBuilder() {
}
/**
- * Create a new builder for a {@link AdaptiveRetryStrategy}.
+ * Create a new builder for a {@link AdaptiveRetryStrategy}. This is equivalent to {@code adaptiveStrategyBuilder(false)}.
*
*
Example Usage
* {@snippet
@@ -82,16 +100,42 @@ public static LegacyRetryStrategy.Builder legacyStrategyBuilder() {
* }
*/
public static AdaptiveRetryStrategy.Builder adaptiveStrategyBuilder() {
- return AdaptiveRetryStrategy.builder();
+ return adaptiveStrategyBuilder(false);
+ }
+
+ /**
+ * Create a new builder for a {@link AdaptiveRetryStrategy}.
+ *
+ *
Example Usage
+ * {@snippet
+ * AdaptiveRetryStrategy retryStrategy =
+ * DefaultRetryStrategy.adaptiveStrategyBuilder(true)
+ * .retryOnExceptionInstanceOf(IllegalArgumentException.class)
+ * .retryOnExceptionInstanceOf(IllegalStateException.class)
+ * .build();
+ * }
+ *
+ * @param retries2026Enabled Whether retries 2.1 behavior is used.
+ */
+ public static AdaptiveRetryStrategy.Builder adaptiveStrategyBuilder(Boolean retries2026Enabled) {
+ return AdaptiveRetryStrategy.builder(retries2026Enabled);
}
static final class Standard {
static final int MAX_ATTEMPTS = 3;
- static final Duration BASE_DELAY = Duration.ofMillis(100);
+
+ // v2.1 constants
+ static final Duration BASE_DELAY_V21 = Duration.ofMillis(50);
+ static final int DEFAULT_EXCEPTION_TOKEN_COST_V21 = 14;
+ static final int THROTTLING_EXCEPTION_TOKEN_COST_V21 = 5;
+
+ // v2.0 constants
+ static final Duration BASE_DELAY_V20 = Duration.ofMillis(100);
+ static final int DEFAULT_EXCEPTION_TOKEN_COST_V20 = 5;
+
static final Duration THROTTLED_BASE_DELAY = Duration.ofSeconds(1);
static final Duration MAX_BACKOFF = Duration.ofSeconds(20);
static final int TOKEN_BUCKET_SIZE = 500;
- static final int DEFAULT_EXCEPTION_TOKEN_COST = 5;
private Standard() {
}
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java
index dff43d017333..bd5fb50c1c6e 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java
@@ -15,6 +15,7 @@
package software.amazon.awssdk.retries;
+import java.time.Duration;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.annotations.ThreadSafe;
import software.amazon.awssdk.retries.api.BackoffStrategy;
@@ -56,15 +57,36 @@ public interface StandardRetryStrategy extends RetryStrategy {
*
*/
static Builder builder() {
+ return builder(false);
+ }
+
+ /**
+ * Create a new {@link StandardRetryStrategy.Builder} with v2.0 or v2.1 retry constants.
+ *
+ * @param retries2026Enabled when {@code true}, uses v2.1 constants (50ms base delay, differentiated token costs); when
+ * {@code false}, uses v2.0 constants (100ms base delay, uniform token costs)
+ */
+ static Builder builder(Boolean retries2026Enabled) {
+ boolean retries21 = Boolean.TRUE.equals(retries2026Enabled);
+
+ Duration baseDelay = retries21 ? DefaultRetryStrategy.Standard.BASE_DELAY_V21
+ : DefaultRetryStrategy.Standard.BASE_DELAY_V20;
+ int exceptionCost = retries21 ? DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST_V21
+ : DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST_V20;
+ // v2.0 does not treat throttling exceptions differently from others
+ int throttlingCost = retries21 ? DefaultRetryStrategy.Standard.THROTTLING_EXCEPTION_TOKEN_COST_V21
+ : exceptionCost;
return DefaultStandardRetryStrategy
.builder()
+ .retries2026Enabled(retries2026Enabled)
.maxAttempts(DefaultRetryStrategy.Standard.MAX_ATTEMPTS)
.tokenBucketStore(TokenBucketStore
.builder()
.tokenBucketMaxCapacity(DefaultRetryStrategy.Standard.TOKEN_BUCKET_SIZE)
.build())
- .tokenBucketExceptionCost(DefaultRetryStrategy.Standard.DEFAULT_EXCEPTION_TOKEN_COST)
- .backoffStrategy(BackoffStrategy.exponentialDelay(DefaultRetryStrategy.Standard.BASE_DELAY,
+ .tokenBucketExceptionCost(exceptionCost)
+ .throttlingTokenBucketExceptionCost(throttlingCost)
+ .backoffStrategy(BackoffStrategy.exponentialDelay(baseDelay,
DefaultRetryStrategy.Standard.MAX_BACKOFF))
.throttlingBackoffStrategy(BackoffStrategy.exponentialDelay(DefaultRetryStrategy.Standard.THROTTLED_BASE_DELAY,
DefaultRetryStrategy.Standard.MAX_BACKOFF));
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/BaseRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/BaseRetryStrategy.java
index 89bce90f7155..dfda06093ab3 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/BaseRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/BaseRetryStrategy.java
@@ -57,6 +57,7 @@ public abstract class BaseRetryStrategy implements DefaultAwareRetryStrategy {
protected final BackoffStrategy throttlingBackoffStrategy;
protected final Predicate treatAsThrottling;
protected final int exceptionCost;
+ protected final int throttlingExceptionCost;
protected final TokenBucketStore tokenBucketStore;
protected final Set defaultsAdded;
protected final boolean useClientDefaults;
@@ -71,6 +72,8 @@ public abstract class BaseRetryStrategy implements DefaultAwareRetryStrategy {
this.throttlingBackoffStrategy = Validate.paramNotNull(builder.throttlingBackoffStrategy, "throttlingBackoffStrategy");
this.treatAsThrottling = Validate.paramNotNull(builder.treatAsThrottling, "treatAsThrottling");
this.exceptionCost = Validate.paramNotNull(builder.exceptionCost, "exceptionCost");
+ this.throttlingExceptionCost = builder.throttlingExceptionCost != null
+ ? builder.throttlingExceptionCost : this.exceptionCost;
this.tokenBucketStore = Validate.paramNotNull(builder.tokenBucketStore, "tokenBucketStore");
this.defaultsAdded = Collections.unmodifiableSet(
Validate.paramNotNull(new HashSet<>(builder.defaultsAdded), "defaultsAdded"));
@@ -155,7 +158,7 @@ public boolean useClientDefaults() {
/**
* Computes the backoff before the first attempt, by default {@link Duration#ZERO}. Extending classes can override this method
- * to compute different a different depending on their logic.
+ * to compute a different duration depending on their logic.
*/
protected Duration computeInitialBackoff(AcquireInitialTokenRequest request) {
return Duration.ZERO;
@@ -163,7 +166,7 @@ protected Duration computeInitialBackoff(AcquireInitialTokenRequest request) {
/**
* Computes the backoff before a retry using the configured backoff strategy. Extending classes can override this method to
- * compute different a different depending on their logic.
+ * compute a different duration depending on their logic.
*/
protected Duration computeBackoff(RefreshRetryTokenRequest request, DefaultRetryToken token) {
Duration backoff;
@@ -176,6 +179,17 @@ protected Duration computeBackoff(RefreshRetryTokenRequest request, DefaultRetry
return maxOf(suggested, backoff);
}
+ /**
+ * Computes the backoff before exiting the retry loop using the configured backoff strategy. Extending classes can override
+ * this method to compute a different duration depending on their logic. The default implementation returns
+ * 0 delay.
+ *
+ * @param request The refresh request that failed to acquire sufficient capacity.
+ */
+ protected Duration computeAcquireFailureBackoff(RefreshRetryTokenRequest request) {
+ return Duration.ZERO;
+ }
+
/**
* Called inside {@link #recordSuccess} to allow extending classes to update their internal state after a successful request.
*/
@@ -194,10 +208,13 @@ protected void updateStateForRetry(RefreshRetryTokenRequest request) {
* amount for the specific kind of failure.
*/
protected int exceptionCost(RefreshRetryTokenRequest request) {
- if (circuitBreakerEnabled) {
- return exceptionCost;
+ if (!circuitBreakerEnabled) {
+ return 0;
+ }
+ if (treatAsThrottling.test(request.failure())) {
+ return throttlingExceptionCost;
}
- return 0;
+ return exceptionCost;
}
/**
@@ -293,7 +310,8 @@ private void throwOnAcquisitionFailure(RefreshRetryTokenRequest request, Acquire
.build();
String message = acquisitionFailedMessage(acquireResponse);
log.debug(() -> message, failure);
- throw new TokenAcquisitionFailedException(message, refreshedToken, failure);
+ Duration delay = computeAcquireFailureBackoff(request);
+ throw new TokenAcquisitionFailedException(message, refreshedToken, failure, delay);
}
}
@@ -362,6 +380,13 @@ static Duration maxOf(Duration left, Duration right) {
return right;
}
+ static Duration minOf(Duration left, Duration right) {
+ if (left.compareTo(right) <= 0) {
+ return left;
+ }
+ return right;
+ }
+
static DefaultRetryToken asDefaultRetryToken(RetryToken token) {
return Validate.isInstanceOf(DefaultRetryToken.class, token,
"RetryToken is of unexpected class (%s), "
@@ -397,6 +422,7 @@ public String toString() {
.add("tokenBucketStore", tokenBucketStore)
.add("defaultsAdded", defaultsAdded)
.add("useClientDefaults", useClientDefaults)
+ .add("throttlingExceptionCost", throttlingExceptionCost)
.build();
}
@@ -408,6 +434,7 @@ public abstract static class Builder implements DefaultAwareRetryStrategy.Builde
private Boolean circuitBreakerEnabled;
private Boolean useClientDefaults;
private Integer exceptionCost;
+ private Integer throttlingExceptionCost;
private BackoffStrategy backoffStrategy;
private BackoffStrategy throttlingBackoffStrategy;
private Predicate treatAsThrottling = throwable -> false;
@@ -423,6 +450,7 @@ public abstract static class Builder implements DefaultAwareRetryStrategy.Builde
this.maxAttempts = strategy.maxAttempts;
this.circuitBreakerEnabled = strategy.circuitBreakerEnabled;
this.exceptionCost = strategy.exceptionCost;
+ this.throttlingExceptionCost = strategy.throttlingExceptionCost;
this.backoffStrategy = strategy.backoffStrategy;
this.throttlingBackoffStrategy = strategy.throttlingBackoffStrategy;
this.treatAsThrottling = strategy.treatAsThrottling;
@@ -463,6 +491,10 @@ void setTokenBucketExceptionCost(int exceptionCost) {
this.exceptionCost = exceptionCost;
}
+ void setThrottlingTokenBucketExceptionCost(int throttlingExceptionCost) {
+ this.throttlingExceptionCost = throttlingExceptionCost;
+ }
+
void setUseClientDefaults(Boolean useClientDefaults) {
this.useClientDefaults = useClientDefaults;
}
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java
index e5e56b602b39..c52c7859526f 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java
@@ -129,6 +129,11 @@ public Builder tokenBucketExceptionCost(int exceptionCost) {
return this;
}
+ public Builder throttlingTokenBucketExceptionCost(int throttlingExceptionCost) {
+ setThrottlingTokenBucketExceptionCost(throttlingExceptionCost);
+ return this;
+ }
+
public Builder rateLimiterTokenBucketStore(RateLimiterTokenBucketStore rateLimiterTokenBucketStore) {
this.rateLimiterTokenBucketStore = rateLimiterTokenBucketStore;
return this;
diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultStandardRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultStandardRetryStrategy.java
index 3a6a120fa398..8742bba4a194 100644
--- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultStandardRetryStrategy.java
+++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultStandardRetryStrategy.java
@@ -15,10 +15,13 @@
package software.amazon.awssdk.retries.internal;
+import java.time.Duration;
+import java.util.Optional;
import java.util.function.Predicate;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.retries.StandardRetryStrategy;
import software.amazon.awssdk.retries.api.BackoffStrategy;
+import software.amazon.awssdk.retries.api.RefreshRetryTokenRequest;
import software.amazon.awssdk.retries.internal.circuitbreaker.TokenBucketStore;
import software.amazon.awssdk.utils.Logger;
@@ -26,9 +29,12 @@
public final class DefaultStandardRetryStrategy
extends BaseRetryStrategy implements StandardRetryStrategy {
private static final Logger LOG = Logger.loggerFor(DefaultStandardRetryStrategy.class);
+ private static final Duration FIVE_SECONDS = Duration.ofSeconds(5);
+ private final Boolean retries2026Enabled;
DefaultStandardRetryStrategy(Builder builder) {
super(LOG, builder);
+ this.retries2026Enabled = builder.retries2026Enabled;
}
@Override
@@ -40,7 +46,56 @@ public static Builder builder() {
return new Builder();
}
+ @Override
+ protected Duration computeAcquireFailureBackoff(RefreshRetryTokenRequest request) {
+ if (!isRetries2026Enabled() || !request.isLongPolling()) {
+ return super.computeAcquireFailureBackoff(request);
+ }
+
+ DefaultRetryToken attemptIncremented = asDefaultRetryToken(request.token()).toBuilder()
+ .increaseAttempt()
+ .build();
+ return computeBackoff(request, attemptIncremented);
+ }
+
+ @Override
+ protected Duration computeBackoff(RefreshRetryTokenRequest request, DefaultRetryToken token) {
+ if (!isRetries2026Enabled()) {
+ return super.computeBackoff(request, token);
+ }
+
+ Duration strategyBackoff;
+ if (treatAsThrottling.test(request.failure())) {
+ strategyBackoff = throttlingBackoffStrategy.computeDelay(token.attempt());
+ } else {
+ strategyBackoff = backoffStrategy.computeDelay(token.attempt());
+ }
+
+ Optional optionalSuggested = request.suggestedDelay();
+
+ if (!optionalSuggested.isPresent()) {
+ return strategyBackoff;
+ }
+
+ // the suggested delay needs to be at least what the strategy computed, OR
+ // not greater than 5s more than what the strat computed
+ Duration minBackoff = strategyBackoff;
+ Duration maxBackoff = strategyBackoff.plus(FIVE_SECONDS);
+
+ Duration backoff = optionalSuggested.get();
+
+ backoff = maxOf(minBackoff, backoff);
+ backoff = minOf(maxBackoff, backoff);
+
+ return backoff;
+ }
+
+ private boolean isRetries2026Enabled() {
+ return Boolean.TRUE.equals(retries2026Enabled);
+ }
+
public static class Builder extends BaseRetryStrategy.Builder implements StandardRetryStrategy.Builder {
+ private Boolean retries2026Enabled;
Builder() {
}
@@ -90,6 +145,11 @@ public Builder tokenBucketExceptionCost(int exceptionCost) {
return this;
}
+ public Builder throttlingTokenBucketExceptionCost(int throttlingExceptionCost) {
+ setThrottlingTokenBucketExceptionCost(throttlingExceptionCost);
+ return this;
+ }
+
public Builder tokenBucketStore(TokenBucketStore tokenBucketStore) {
setTokenBucketStore(tokenBucketStore);
return this;
@@ -101,6 +161,14 @@ public Builder useClientDefaults(boolean useClientDefaults) {
return this;
}
+ /**
+ * Whether retries 2.1 behavior is enabled.
+ */
+ public Builder retries2026Enabled(Boolean retries2026Enabled) {
+ this.retries2026Enabled = retries2026Enabled;
+ return this;
+ }
+
@Override
public StandardRetryStrategy build() {
return new DefaultStandardRetryStrategy(this);
diff --git a/core/retries/src/test/java/software/amazon/awssdk/retries/AdaptiveRetryStrategyV21ConstantsTest.java b/core/retries/src/test/java/software/amazon/awssdk/retries/AdaptiveRetryStrategyV21ConstantsTest.java
new file mode 100644
index 000000000000..9cc5ce02dc22
--- /dev/null
+++ b/core/retries/src/test/java/software/amazon/awssdk/retries/AdaptiveRetryStrategyV21ConstantsTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.retries;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+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.internal.AcquireInitialTokenRequestImpl;
+import software.amazon.awssdk.retries.internal.DefaultRetryToken;
+
+/**
+ * Tests that {@code AdaptiveRetryStrategy.builder(boolean retries2026Enabled)} selects the correct
+ * v2.0 or v2.1 constants for base delay, exception token cost, and throttling token cost.
+ */
+class AdaptiveRetryStrategyV21ConstantsTest {
+
+ private static final int BUCKET_CAPACITY = 500;
+
+ @Test
+ void v21Enabled_nonThrottlingRetry_deducts14Tokens() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 14);
+ }
+
+ @Test
+ void v21Enabled_throttlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> true)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("throttled"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v20_nonThrottlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v20_throttlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> true)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("throttled"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v21Enabled_backoffUses50msBaseDelay() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .build();
+
+ RefreshRetryTokenResponse response = refreshToken(strategy, new RuntimeException("err"));
+ // First retry delay should include exponential backoff component in [0, 50ms]
+ assertThat(response.delay()).isBetween(Duration.ZERO, Duration.ofMillis(50));
+ }
+
+ @Test
+ void v20_backoffUses100msBaseDelay() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .build();
+
+ RefreshRetryTokenResponse response = refreshToken(strategy, new RuntimeException("err"));
+ // First retry delay should include exponential backoff component in [0, 100ms]
+ assertThat(response.delay()).isBetween(Duration.ZERO, Duration.ofMillis(100));
+ }
+
+ @Test
+ void noArgBuilder_usesV20Constants() {
+ RetryStrategy strategy = AdaptiveRetryStrategy.builder()
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ // v2.0: exception cost is 5
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ /**
+ * Acquires an initial token, triggers one retry. Returns the token after the retry (before success).
+ */
+ private DefaultRetryToken retryOnceBeforeSuccess(RetryStrategy strategy, Exception failure) {
+ AcquireInitialTokenResponse initial = strategy.acquireInitialToken(AcquireInitialTokenRequestImpl.create("test"));
+ RetryToken token = initial.token();
+
+ RefreshRetryTokenResponse refreshResponse = strategy.refreshRetryToken(
+ RefreshRetryTokenRequest.builder().token(token).failure(failure).build());
+
+ return (DefaultRetryToken) refreshResponse.token();
+ }
+
+ /**
+ * Acquires an initial token and triggers one refresh to get the backoff delay.
+ */
+ private RefreshRetryTokenResponse refreshToken(RetryStrategy strategy, Exception failure) {
+ AcquireInitialTokenResponse initial = strategy.acquireInitialToken(AcquireInitialTokenRequestImpl.create("test"));
+ return strategy.refreshRetryToken(
+ RefreshRetryTokenRequest.builder().token(initial.token()).failure(failure).build());
+ }
+}
diff --git a/core/retries/src/test/java/software/amazon/awssdk/retries/StandardRetryStrategyV21ConstantsTest.java b/core/retries/src/test/java/software/amazon/awssdk/retries/StandardRetryStrategyV21ConstantsTest.java
new file mode 100644
index 000000000000..cd772869e307
--- /dev/null
+++ b/core/retries/src/test/java/software/amazon/awssdk/retries/StandardRetryStrategyV21ConstantsTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.retries;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.retries.api.AcquireInitialTokenResponse;
+import software.amazon.awssdk.retries.api.BackoffStrategy;
+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.internal.AcquireInitialTokenRequestImpl;
+import software.amazon.awssdk.retries.internal.DefaultRetryToken;
+
+/**
+ * Tests that {@code StandardRetryStrategy.builder(boolean retries2026Enabled)} selects the correct
+ * v2.0 or v2.1 constants for base delay, exception token cost, and throttling token cost.
+ */
+class StandardRetryStrategyV21ConstantsTest {
+
+ private static final int BUCKET_CAPACITY = 500;
+
+ @Test
+ void v21Enabled_nonThrottlingRetry_deducts14Tokens() {
+ RetryStrategy strategy = StandardRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 14);
+ }
+
+ @Test
+ void v21Enabled_throttlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = StandardRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> true)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("throttled"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v20_nonThrottlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = StandardRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v20_throttlingRetry_deducts5Tokens() {
+ RetryStrategy strategy = StandardRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> true)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("throttled"));
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ @Test
+ void v21Enabled_backoffUses50msBaseDelay() {
+
+ Supplier stratSupplier = () -> StandardRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .build();
+
+ probabilisticAssertDelayBetween(stratSupplier, new RuntimeException("err"), Duration.ZERO, Duration.ofMillis(50));
+ }
+
+ @Test
+ void v21Enabled_throttlingBackoffUses1000msBaseDelay() {
+
+ Supplier stratSupplier = () -> StandardRetryStrategy.builder(true)
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> true)
+ .build();
+
+ probabilisticAssertDelayBetween(stratSupplier, new RuntimeException("err"), Duration.ZERO, Duration.ofMillis(1000));
+ }
+
+ @Test
+ void v20_backoffUses100msBaseDelay() {
+ Supplier stratSupplier = () -> StandardRetryStrategy.builder(false)
+ .retryOnException(t -> true)
+ .build();
+
+ probabilisticAssertDelayBetween(stratSupplier, new RuntimeException("err"), Duration.ZERO, Duration.ofMillis(100));
+
+ }
+
+ @Test
+ void noArgBuilder_usesV20Constants() {
+ RetryStrategy strategy = StandardRetryStrategy.builder()
+ .retryOnException(t -> true)
+ .treatAsThrottling(t -> false)
+ .build();
+
+ DefaultRetryToken token = retryOnceBeforeSuccess(strategy, new RuntimeException("transient"));
+ // v2.0: exception cost is 5
+ assertThat(token.capacityRemaining()).isEqualTo(BUCKET_CAPACITY - 5);
+ }
+
+ /**
+ * Acquires an initial token, triggers one retry. Returns the token after the retry (before success).
+ */
+ private DefaultRetryToken retryOnceBeforeSuccess(RetryStrategy strategy, Exception failure) {
+ AcquireInitialTokenResponse initial = strategy.acquireInitialToken(AcquireInitialTokenRequestImpl.create("test"));
+ RetryToken token = initial.token();
+
+ RefreshRetryTokenResponse refreshResponse = strategy.refreshRetryToken(
+ RefreshRetryTokenRequest.builder().token(token).failure(failure).build());
+
+ return (DefaultRetryToken) refreshResponse.token();
+ }
+
+ /**
+ * Acquires an initial token and triggers one refresh to get the backoff delay.
+ */
+ private RefreshRetryTokenResponse refreshToken(RetryStrategy strategy, Exception failure) {
+ AcquireInitialTokenResponse initial = strategy.acquireInitialToken(AcquireInitialTokenRequestImpl.create("test"));
+ return strategy.refreshRetryToken(
+ RefreshRetryTokenRequest.builder().token(initial.token()).failure(failure).build());
+ }
+
+ // Backoffs are jittered, so verify it by testing it many times
+ private void probabilisticAssertDelayBetween(Supplier strategy,
+ Exception failure,
+ Duration min,
+ Duration max) {
+ for (int i = 0; i < 128; ++i) {
+ RefreshRetryTokenResponse response = refreshToken(strategy.get(), failure);
+ assertThat(response.delay()).isBetween(min, max);
+ }
+ }
+}
diff --git a/core/retries/src/test/java/software/amazon/awssdk/retries/internal/StandardRetryStrategyTest.java b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/StandardRetryStrategyTest.java
new file mode 100644
index 000000000000..59497420701d
--- /dev/null
+++ b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/StandardRetryStrategyTest.java
@@ -0,0 +1,870 @@
+/*
+ * 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.retries.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.retries.DefaultRetryStrategy;
+import software.amazon.awssdk.retries.StandardRetryStrategy;
+import software.amazon.awssdk.retries.api.AcquireInitialTokenRequest;
+import software.amazon.awssdk.retries.api.AcquireInitialTokenResponse;
+import software.amazon.awssdk.retries.api.BackoffStrategy;
+import software.amazon.awssdk.retries.api.RecordSuccessRequest;
+import software.amazon.awssdk.retries.api.RecordSuccessResponse;
+import software.amazon.awssdk.retries.api.RefreshRetryTokenRequest;
+import software.amazon.awssdk.retries.api.RefreshRetryTokenResponse;
+import software.amazon.awssdk.retries.api.RetryToken;
+import software.amazon.awssdk.retries.api.TokenAcquisitionFailedException;
+import software.amazon.awssdk.retries.internal.circuitbreaker.TokenBucketStore;
+
+public class StandardRetryStrategyTest {
+ private static final Duration BASE_DELAY_V21_THROTTLING = Duration.ofMillis(1000);
+ private static final Duration BASE_DELAY_V21_NON_THROTTLING = Duration.ofMillis(50);
+ private static final Duration BASE_DELAY_V20 = Duration.ofMillis(1000);
+
+ @ParameterizedTest
+ @MethodSource("retriesV20Tests")
+ void refreshRetryToken_v2_0_behavesCorrectly(Scenario scenario) {
+ verifyScenario(scenario);
+ }
+
+ @ParameterizedTest
+ @MethodSource("retriesV21Tests")
+ void refreshRetryToken_v2_1_behavesCorrectly(Scenario scenario) {
+ verifyScenario(scenario);
+ }
+
+ void verifyScenario(Scenario scenario) {
+ DefaultStandardRetryStrategy.Builder builder =
+ (DefaultStandardRetryStrategy.Builder) DefaultRetryStrategy.standardStrategyBuilder(scenario.newRetries2026);
+
+ Given given = scenario.given;
+
+ if (given.maxAttempts != null) {
+ builder.maxAttempts(given.maxAttempts);
+ }
+
+ if (given.initialRetryTokens != null) {
+ builder.tokenBucketStore(TokenBucketStore.builder()
+ .tokenBucketMaxCapacity(given.initialRetryTokens)
+ .build());
+ }
+
+ Duration maxBackoff;
+ if (given.maxBackoff != null) {
+ maxBackoff = given.maxBackoff;
+ } else {
+ maxBackoff = Duration.ofSeconds(20);
+ }
+
+ builder.backoffStrategy(BackoffStrategy.exponentialDelayWithoutJitter(
+ scenario.newRetries2026 ? BASE_DELAY_V21_NON_THROTTLING : BASE_DELAY_V20,
+ maxBackoff));
+
+ builder.throttlingBackoffStrategy(BackoffStrategy.exponentialDelayWithoutJitter(
+ scenario.newRetries2026 ? BASE_DELAY_V21_THROTTLING : BASE_DELAY_V20,
+ maxBackoff
+ ));
+
+ StandardRetryStrategy strategy = builder.retryOnException(e -> true)
+ .treatAsThrottling(e -> ((ScenarioTestException) e).throttling)
+ .build();
+
+ AcquireInitialTokenResponse initialToken = strategy.acquireInitialToken(AcquireInitialTokenRequest.create("test"));
+
+ AtomicReference token = new AtomicReference<>(initialToken.token());
+
+ for (Response response : scenario.responses) {
+ Expected expected = response.expected;
+
+ Outcome outcome = expected.outcome;
+ switch (outcome) {
+ case RETRY_REQUEST: {
+ ScenarioTestException scenarioTestException = new ScenarioTestException(response.statusCode,
+ response.throttling);
+ RefreshRetryTokenRequest.Builder refreshRequest = RefreshRetryTokenRequest.builder();
+
+ if (response.xAmzRetryAfter != null) {
+ refreshRequest.suggestedDelay(response.xAmzRetryAfter);
+ }
+
+ refreshRequest.failure(scenarioTestException)
+ .isLongPolling(given.isLongPolling)
+ .token(token.get())
+ .build();
+ RefreshRetryTokenResponse refreshResponse = strategy.refreshRetryToken(refreshRequest.build());
+ DefaultRetryToken refreshedToken = (DefaultRetryToken) refreshResponse.token();
+ token.set(refreshedToken);
+
+ assertThat(refreshResponse.delay()).isEqualTo(expected.delay);
+ assertThat(refreshedToken.capacityRemaining()).isEqualTo(expected.retryQuota);
+ }
+ break;
+ case RETRY_QUOTA_EXCEEDED: {
+ ScenarioTestException scenarioTestException = new ScenarioTestException(response.statusCode,
+ response.throttling);
+ RefreshRetryTokenRequest.Builder refreshRequest = RefreshRetryTokenRequest.builder();
+
+ if (response.xAmzRetryAfter != null) {
+ refreshRequest.suggestedDelay(response.xAmzRetryAfter);
+ }
+
+ refreshRequest.failure(scenarioTestException)
+ .isLongPolling(given.isLongPolling)
+ .token(token.get())
+ .build();
+
+ assertThatThrownBy(() -> strategy.refreshRetryToken(refreshRequest.build()))
+ .isInstanceOf(TokenAcquisitionFailedException.class)
+ .matches(e -> {
+ TokenAcquisitionFailedException acquireException = (TokenAcquisitionFailedException) e;
+ DefaultRetryToken acquireToken = (DefaultRetryToken) acquireException.token();
+ token.set(acquireToken);
+
+ Duration acquireDelay = acquireException.delay().orElse(Duration.ZERO);
+ Duration expectedDelay = expected.delay == null ? Duration.ZERO : expected.delay;
+
+ return acquireToken.state() == DefaultRetryToken.TokenState.TOKEN_ACQUISITION_FAILED
+ && acquireToken.capacityRemaining() == expected.retryQuota
+ && acquireDelay.equals(expectedDelay);
+ },
+ "Token has TOKEN_ACQUISITION_FAILED state and capacity of "
+ + expected.retryQuota
+ + " and delay of " + expected.delay);
+ }
+ break;
+ case MAX_ATTEMPTS_EXCEEDED: {
+ ScenarioTestException scenarioTestException = new ScenarioTestException(response.statusCode,
+ response.throttling);
+ RefreshRetryTokenRequest.Builder refreshRequest = RefreshRetryTokenRequest.builder();
+
+ if (response.xAmzRetryAfter != null) {
+ refreshRequest.suggestedDelay(response.xAmzRetryAfter);
+ }
+
+ refreshRequest.failure(scenarioTestException)
+ .isLongPolling(given.isLongPolling)
+ .token(token.get())
+ .build();
+
+ assertThatThrownBy(() -> strategy.refreshRetryToken(refreshRequest.build()))
+ .isInstanceOf(TokenAcquisitionFailedException.class)
+ .matches(e -> {
+ TokenAcquisitionFailedException acquireException = (TokenAcquisitionFailedException) e;
+ DefaultRetryToken acquireToken = (DefaultRetryToken) acquireException.token();
+ token.set(acquireToken);
+
+ Duration acquireDelay = acquireException.delay().orElse(Duration.ZERO);
+ Duration expectedDelay = expected.delay == null ? Duration.ZERO : expected.delay;
+
+ return acquireToken.state() == DefaultRetryToken.TokenState.MAX_RETRIES_REACHED
+ && acquireToken.capacityRemaining() == expected.retryQuota
+ && acquireDelay.equals(expectedDelay);
+ }, "Token has MAX_RETRIES_REACHED state and has expected retry quota "
+ + expected.retryQuota
+ + " and delay of " + expected.delay);
+ }
+ break;
+ case SUCCESS: {
+ RecordSuccessRequest recordRequest = RecordSuccessRequest.create(token.get());
+ RecordSuccessResponse recordResponse = strategy.recordSuccess(recordRequest);
+
+ DefaultRetryToken successToken = (DefaultRetryToken) recordResponse.token();
+ token.set(successToken);
+ assertThat(successToken.capacityRemaining()).isEqualTo(expected.retryQuota);
+ }
+ break;
+ default:
+ throw new RuntimeException("unknown outcome");
+ }
+
+ // If the last outcome was a terminal state, get a new token so that state is consistent with a new request
+ if (outcome == Outcome.SUCCESS
+ || outcome == Outcome.MAX_ATTEMPTS_EXCEEDED
+ || outcome == Outcome.RETRY_QUOTA_EXCEEDED) {
+ AcquireInitialTokenRequest acquireInitialTokenRequest = AcquireInitialTokenRequest.create("test");
+ token.set(strategy.acquireInitialToken(acquireInitialTokenRequest).token());
+ }
+ }
+ }
+
+ private static Stream retriesV20Tests() {
+ return Stream.of(
+ aScenario("Retry eventually succeeds.")
+ .given(g ->
+ g.maxAttempts(3).initialRetryTokens(500).maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(495)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(490)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(495))),
+
+ aScenario("Fail due to max attempts reached.")
+ .given(g ->
+ g.maxAttempts(3)
+ .initialRetryTokens(500)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(
+ e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(495)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(
+ e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(490)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(
+ e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(490))),
+
+ aScenario("Retry Quota reached after a single retry.")
+ .given(g ->
+ g.maxAttempts(3)
+ .initialRetryTokens(5)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofSeconds(1))
+ .retryQuota(0)))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(0))),
+
+ aScenario("No retries at all if retry quota is 0.")
+ .given(g ->
+ g.maxAttempts(3)
+ .initialRetryTokens(0)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(0))),
+
+ aScenario("Verifying exponential backoff timing.")
+ .given(g ->
+ g.maxAttempts(5)
+ .initialRetryTokens(500)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(495)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(490)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(485)
+ .delay(Duration.ofSeconds(4))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(480)
+ .delay(Duration.ofSeconds(8))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(480))),
+
+ aScenario("Verify max backoff time.")
+ .given(g ->
+ g.maxAttempts(5)
+ .initialRetryTokens(500)
+ .maxBackoff(Duration.ofSeconds(3)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(495)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(490)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(485)
+ .delay(Duration.ofSeconds(3))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(480)
+ .delay(Duration.ofSeconds(3))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(480))),
+
+ aScenario("Retry Stops After Retry Quota Exhaustion")
+ .given(g ->
+ g.maxAttempts(5)
+ .initialRetryTokens(10)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(5)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(0)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(503)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(0))),
+
+ aScenario("Retry quota Recovery After Successful Responses")
+ .given(g ->
+ g.maxAttempts(5)
+ .initialRetryTokens(15)
+ .maxBackoff(Duration.ofSeconds(20)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(10)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(5)
+ .delay(Duration.ofSeconds(2))))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(10)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(5)
+ .delay(Duration.ofSeconds(1))))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(10)
+ .delay(Duration.ofSeconds(1)))),
+
+ // taken from v2.1 tests, adjusted with all 0 delay for acquire failures
+ aScenario("Long-Polling Backoff After Transient Error When Token Bucket Empty")
+ .given(g -> g.isLongPolling(true)
+ .initialRetryTokens(0))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .delay(Duration.ZERO)
+ .retryQuota(0))),
+
+ aScenario("Long-Polling Backoff After Throttling Error When Token Bucket Empty")
+ .given(g -> g.isLongPolling(true)
+ .initialRetryTokens(0))
+ .addResponse(r ->
+ r.statusCode(400)
+ .isThrottling(true)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .delay(Duration.ZERO)
+ .retryQuota(0))),
+
+ aScenario("Long-Polling Max Attempts Exceeded Must NOT Delay")
+ .given(g ->
+ g.isLongPolling(true)
+ .maxAttempts(2))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofSeconds(1))
+ .retryQuota(495)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .delay(Duration.ZERO)
+ .retryQuota(495)))
+ );
+ }
+
+ private static Stream retriesV21Tests() {
+ return Stream.of(
+ aScenario("Retry eventually succeeds.")
+ .given(g -> {
+ })
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(486)
+ .delay(Duration.ofMillis(50))))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(472)
+ .delay(Duration.ofMillis(100))))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(486))),
+
+ aScenario("Fail due to max attempts reached.")
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(486)
+ .delay(Duration.ofMillis(50))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(472)
+ .delay(Duration.ofMillis(100))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(472))),
+ aScenario("Retry Quota reached after a single retry.")
+ .newRetries2026(true)
+ .given(g -> g.initialRetryTokens(14))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .retryQuota(0)
+ .delay(Duration.ofMillis(50))))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(0))),
+ aScenario("No retries at all if retry quota is 0.")
+ .newRetries2026(true)
+ .given(g -> g.initialRetryTokens(0))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(0))),
+ aScenario("Verifying exponential backoff timing.")
+ .newRetries2026(true)
+ .given(g -> g.maxAttempts(5))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(100))
+ .retryQuota(472)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(200))
+ .retryQuota(458)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(400))
+ .retryQuota(444)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(444))),
+
+ aScenario("Verify max backoff time.")
+ .newRetries2026(true)
+ .given(g -> g.maxAttempts(5).maxBackoff(Duration.ofMillis(200)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(100))
+ .retryQuota(472)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(200))
+ .retryQuota(458)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(200))
+ .retryQuota(444)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .retryQuota(444))),
+
+ aScenario("Retry Stops After Retry Quota Exhaustion")
+ .newRetries2026(true)
+ .given(g -> g.maxAttempts(5).initialRetryTokens(20))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(6)))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .retryQuota(6))),
+
+ aScenario("Retry quota Recovery After Successful Responses")
+ .newRetries2026(true)
+ .given(g -> g.maxAttempts(5).initialRetryTokens(30))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(16)))
+ .addResponse(r ->
+ r.statusCode(502)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(100))
+ .retryQuota(2)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(16)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(2)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(16))),
+
+ aScenario("Throttling Error Token Bucket Drain (5 tokens) and Backoff Duration (1000ms)")
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(400)
+ .isThrottling(true)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(1000))
+ .retryQuota(495)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(500))),
+
+ aScenario("Long-Polling Backoff After Transient Error When Token Bucket Empty")
+ .newRetries2026(true)
+ .given(g -> g.isLongPolling(true)
+ .initialRetryTokens(0))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(0))),
+
+ aScenario("Long-Polling Backoff After Throttling Error When Token Bucket Empty")
+ .newRetries2026(true)
+ .given(g -> g.isLongPolling(true)
+ .initialRetryTokens(0))
+ .addResponse(r ->
+ r.statusCode(400)
+ .isThrottling(true)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_QUOTA_EXCEEDED)
+ .delay(Duration.ofMillis(1000))
+ .retryQuota(0))),
+
+ aScenario("Long-Polling Max Attempts Exceeded Must NOT Delay")
+ .newRetries2026(true)
+ .given(g ->
+ g.isLongPolling(true)
+ .maxAttempts(2))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(500)
+ .expected(e ->
+ e.outcome(Outcome.MAX_ATTEMPTS_EXCEEDED)
+ .delay(Duration.ZERO)
+ .retryQuota(486))),
+
+ aScenario("Honor x-amz-retry-after Header")
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(500)
+ .xAmzRetryAfter(Duration.ofMillis(1500))
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(1500))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(500))),
+
+ aScenario("x-amz-retry-after minimum is exponential backoff duration")
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(500)
+ .xAmzRetryAfter(Duration.ofMillis(0))
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(50))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(500))),
+
+ aScenario("x-amz-retry-after maximum is 5+exponential backoff duration")
+ .newRetries2026(true)
+ .addResponse(r ->
+ r.statusCode(500)
+ .xAmzRetryAfter(Duration.ofMillis(10000))
+ .expected(e ->
+ e.outcome(Outcome.RETRY_REQUEST)
+ .delay(Duration.ofMillis(5050))
+ .retryQuota(486)))
+ .addResponse(r ->
+ r.statusCode(200)
+ .expected(e ->
+ e.outcome(Outcome.SUCCESS)
+ .retryQuota(500)))
+ );
+ }
+
+ private static Scenario aScenario(String description) {
+ return new Scenario(description);
+ }
+
+ private static class ScenarioTestException extends RuntimeException {
+ private final int statusCode;
+ private final boolean throttling;
+
+ public ScenarioTestException(int statusCode, boolean throttling) {
+ this.statusCode = statusCode;
+ this.throttling = throttling;
+ }
+ }
+
+ private static class Given {
+ private Integer maxAttempts;
+ private Integer initialRetryTokens;
+ private boolean isLongPolling;
+ private Duration maxBackoff;
+
+ public Given maxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ return this;
+ }
+
+ public Given initialRetryTokens(int initialRetryTokens) {
+ this.initialRetryTokens = initialRetryTokens;
+ return this;
+ }
+
+ public Given isLongPolling(boolean isLongPolling) {
+ this.isLongPolling = isLongPolling;
+ return this;
+ }
+
+ public Given maxBackoff(Duration maxBackoff) {
+ this.maxBackoff = maxBackoff;
+ return this;
+ }
+ }
+
+ private static class Response {
+ private int statusCode;
+ private boolean throttling;
+ private Duration xAmzRetryAfter;
+ private Expected expected;
+
+ public Response statusCode(int statusCode) {
+ this.statusCode = statusCode;
+ return this;
+ }
+
+ public Response isThrottling(boolean throttling) {
+ this.throttling = throttling;
+ return this;
+ }
+
+ public Response xAmzRetryAfter(Duration xAmzRetryAfter) {
+ this.xAmzRetryAfter = xAmzRetryAfter;
+ return this;
+ }
+
+ public Response expected(Consumer acceptor) {
+ this.expected = new Expected();
+ acceptor.accept(this.expected);
+ return this;
+ }
+ }
+
+ private static class Expected {
+ private Outcome outcome;
+ private int retryQuota;
+ private Duration delay;
+
+ public Expected outcome(Outcome outcome) {
+ this.outcome = outcome;
+ return this;
+ }
+
+ public Expected retryQuota(int retryQuota) {
+ this.retryQuota = retryQuota;
+ return this;
+ }
+
+ public Expected delay(Duration delay) {
+ this.delay = delay;
+ return this;
+ }
+ }
+
+ private enum Outcome {
+ RETRY_REQUEST,
+ RETRY_QUOTA_EXCEEDED,
+ MAX_ATTEMPTS_EXCEEDED,
+ SUCCESS
+ }
+
+ private static class Scenario {
+ private String description;
+ private boolean newRetries2026;
+ private Given given = new Given();
+ private List responses = new ArrayList<>();
+
+ public Scenario(String description) {
+ this.description = description;
+ }
+
+ public Scenario newRetries2026(boolean newRetries2026) {
+ this.newRetries2026 = newRetries2026;
+ return this;
+ }
+
+ public Scenario given(Consumer acceptor) {
+ this.given = new Given();
+ acceptor.accept(this.given);
+ return this;
+ }
+
+ public Scenario addResponse(Consumer acceptor) {
+ Response response = new Response();
+ acceptor.accept(response);
+ responses.add(response);
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ }
+}
diff --git a/core/sdk-core/pom.xml b/core/sdk-core/pom.xml
index 11b88cdc08a9..767757332f33 100644
--- a/core/sdk-core/pom.xml
+++ b/core/sdk-core/pom.xml
@@ -21,7 +21,7 @@
software.amazon.awssdk
core
- 2.43.3-SNAPSHOT
+ 2.44.0-SNAPSHOT
sdk-core
AWS Java SDK :: SDK Core
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java
index 65889c2d08fd..db0ee4d67f8f 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java
@@ -263,7 +263,14 @@ public enum SdkSystemSetting implements SystemSetting {
* Configure the preferred auth scheme to use.
* This is a comma-delimited list of AWS auth scheme names used during signing.
*/
- AWS_AUTH_SCHEME_PREFERENCE("aws.authSchemePreference", null);
+ AWS_AUTH_SCHEME_PREFERENCE("aws.authSchemePreference", null),
+
+ /**
+ * Configure whether v2.1 retry behavior is enabled. When {@code true}, the SDK uses updated retry
+ * defaults including STANDARD as the default retry mode, reduced base backoff delays, differentiated token bucket
+ * costs, and other v2.1 retry specification changes. When {@code false} (the default), the SDK uses v2.0 retry behavior.
+ */
+ AWS_NEW_RETRIES_2026("aws.newRetries2026", null);
private final String systemProperty;
private final String defaultValue;
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 53dfcc52d6bb..20ea0d01c3b9 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
@@ -306,6 +306,16 @@ public final class SdkClientOption extends ClientOption {
*/
public static final SdkClientOption DEFAULT_RETRY_MODE = new SdkClientOption<>(RetryMode.class);
+ /**
+ * Option to specify the default for the {@code AWS_NEW_RETRIES_2026} feature gate for the SDK client.
+ */
+ 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/client/handler/ClientExecutionParams.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/handler/ClientExecutionParams.java
index e307f5857ce7..ef31aba69ecb 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/handler/ClientExecutionParams.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/client/handler/ClientExecutionParams.java
@@ -55,6 +55,7 @@ public final class ClientExecutionParams {
private AsyncResponseTransformer asyncResponseTransformer;
private boolean fullDuplex;
private boolean hasInitialRequestEvent;
+ private boolean longPolling;
private String hostPrefixExpression;
private String operationName;
private SdkProtocolMetadata protocolMetadata;
@@ -168,6 +169,19 @@ public ClientExecutionParams withFullDuplex(boolean fullDuplex)
return this;
}
+ /**
+ * Whether this is a long polling operation, i.e. a request where the service can wait extended period of time before
+ * sending a response back to the client.
+ */
+ public boolean isLongPolling() {
+ return longPolling;
+ }
+
+ public ClientExecutionParams withLongPolling(boolean longPolling) {
+ this.longPolling = longPolling;
+ return this;
+ }
+
public boolean hasInitialRequestEvent() {
return hasInitialRequestEvent;
}
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 3fe0f69d3ab8..529e129e18cd 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
@@ -212,6 +212,16 @@ public final class SdkInternalExecutionAttribute extends SdkExecutionAttribute {
public static final ExecutionAttribute CHECKSUM_STORE =
new ExecutionAttribute<>("ChecksumStore");
+ /**
+ * Indicates whether this is a long polling operation.
+ */
+ 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 3e3d79a68524..20d94a006663 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,13 +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.
@@ -41,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;
@@ -134,9 +140,20 @@ private void attemptExecute(CompletableFuture> future) {
}
public void maybeAttemptExecute(CompletableFuture> future) {
- Optional delay = retryableStageHelper.tryRefreshToken(Duration.ZERO);
- if (!delay.isPresent()) {
- future.completeExceptionally(retryableStageHelper.retryPolicyDisallowedRetryException());
+ Either backoffDelay = retryableStageHelper.tryRefreshToken(suggestedDelay());
+
+ Optional acquireFailureDelay = backoffDelay.right();
+ if (acquireFailureDelay.isPresent()) {
+ Duration delay = acquireFailureDelay.get();
+ retryableStageHelper.logAcquireFailureBackingOff(delay);
+ SdkException disallowedException = retryableStageHelper.retryPolicyDisallowedRetryException();
+ // Avoid needless scheduling if we won't wait
+ if (delay.isZero()) {
+ future.completeExceptionally(disallowedException);
+ } else {
+ scheduledExecutor.schedule(() -> future.completeExceptionally(disallowedException),
+ delay.toMillis(), MILLISECONDS);
+ }
return;
}
// We failed the last attempt, but will retry. The response handler wants to know when that happens.
@@ -145,9 +162,10 @@ public void maybeAttemptExecute(CompletableFuture> future) {
// Reset the request provider to the original one before retries, in case it was modified downstream.
context.requestProvider(originalRequestBody);
- Duration backoffDelay = delay.get();
- retryableStageHelper.logBackingOff(backoffDelay);
- long totalDelayMillis = backoffDelay.toMillis();
+ // get() is safe, Either requires left OR right to be present
+ Duration successDelay = backoffDelay.left().get();
+ retryableStageHelper.logBackingOff(successDelay);
+ long totalDelayMillis = successDelay.toMillis();
scheduledExecutor.schedule(() -> attemptExecute(future), totalDelayMillis, MILLISECONDS);
}
@@ -159,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 a545cb4b088e..3407429f7455 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;
@@ -29,6 +30,8 @@
import software.amazon.awssdk.core.internal.http.pipeline.stages.utils.RetryableStageHelper;
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.
@@ -36,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;
@@ -64,12 +70,19 @@ public Response execute(SdkHttpFullRequest request, RequestExecutionCon
}
retryableStageHelper.setLastException(throwable);
Duration suggestedDelay = suggestedDelay(e);
- Optional backoffDelay = retryableStageHelper.tryRefreshToken(suggestedDelay);
- if (backoffDelay.isPresent()) {
- Duration delay = backoffDelay.get();
+ Either backoffDelay = retryableStageHelper.tryRefreshToken(suggestedDelay);
+ Optional successDelay = backoffDelay.left();
+ if (successDelay.isPresent()) {
+ Duration delay = successDelay.get();
retryableStageHelper.logBackingOff(delay);
TimeUnit.MILLISECONDS.sleep(delay.toMillis());
} else {
+ Optional failureDelay = backoffDelay.right();
+ if (failureDelay.isPresent()) {
+ Duration delay = failureDelay.get();
+ retryableStageHelper.logAcquireFailureBackingOff(delay);
+ TimeUnit.MILLISECONDS.sleep(delay.toMillis());
+ }
throw retryableStageHelper.retryPolicyDisallowedRetryException();
}
}
@@ -79,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;
}
@@ -94,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 b73dd34e112b..840df78367fd 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
@@ -31,6 +31,7 @@
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.interceptor.ExecutionAttribute;
+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.retry.ClockSkewAdjuster;
@@ -48,6 +49,7 @@
import software.amazon.awssdk.retries.api.RetryStrategy;
import software.amazon.awssdk.retries.api.RetryToken;
import software.amazon.awssdk.retries.api.TokenAcquisitionFailedException;
+import software.amazon.awssdk.utils.Either;
/**
* Contains the logic shared by {@link RetryableStage} and {@link AsyncRetryableStage} when querying and interacting with a
@@ -60,6 +62,7 @@ public final class RetryableStageHelper {
new ExecutionAttribute<>("LastBackoffDuration");
private final SdkHttpFullRequest request;
+ private final boolean isLongPollingOperation;
private final RequestExecutionContext context;
private RetryPolicyAdapter retryPolicyAdapter;
private final RetryStrategy retryStrategy;
@@ -74,6 +77,7 @@ public RetryableStageHelper(SdkHttpFullRequest request,
HttpClientDependencies dependencies) {
this.request = request;
this.context = context;
+ this.isLongPollingOperation = isLongPollingOperation(this.context);
RetryPolicy retryPolicy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_POLICY);
RetryStrategy retryStrategy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_STRATEGY);
if (retryPolicy != null) {
@@ -123,33 +127,41 @@ public void recordAttemptSucceeded() {
}
/**
- * Invoked after a failed attempt and before retrying. The returned optional will be non-empty if the client can retry or
- * empty if the retry-strategy disallows the retry. The calling code is expected to wait the delay represented in the duration
- * if present before retrying the request.
+ * Invoked after a failed attempt and before retrying. The returned {@link Either} will have its left be populated
+ * if the refresh is successful. The right is populated if the refresh is unsuccessful. In either case, the calling
+ * code is expected to wait the delay represented in the duration before retrying the request or exiting the retry loop.
*
* @param suggestedDelay A suggested delay, presumably coming from the server response. The response when present will be at
* least this amount.
- * @return An optional time to wait. If the value is not present the retry strategy disallowed the retry and the calling code
- * should not retry.
+ * @return An optional time to wait, regardless of whether the refresh is successful. If the left value is present, the
+ * retry strategy allowed the retry. If the right value is present the retry strategy disallowed the retry and the calling
+ * code should not retry.
*/
- public Optional tryRefreshToken(Duration suggestedDelay) {
+ public Either tryRefreshToken(Duration suggestedDelay) {
RetryToken retryToken = context.executionAttributes().getAttribute(RETRY_TOKEN);
RefreshRetryTokenResponse refreshResponse;
try {
RefreshRetryTokenRequest refreshRequest = RefreshRetryTokenRequest.builder()
.failure(this.lastException)
.token(retryToken)
+ .isLongPolling(isLongPollingOperation)
.suggestedDelay(suggestedDelay)
.build();
refreshResponse = retryStrategy().refreshRetryToken(refreshRequest);
} catch (TokenAcquisitionFailedException e) {
context.executionAttributes().putAttribute(RETRY_TOKEN, e.token());
- return Optional.empty();
+ Optional acquireFailureDelay = e.delay();
+ if (acquireFailureDelay.isPresent()) {
+ Duration acquireDelay = acquireFailureDelay.get();
+ context.executionAttributes().putAttribute(LAST_BACKOFF_DELAY_DURATION, acquireDelay);
+ return Either.right(acquireDelay);
+ }
+ return Either.right(Duration.ZERO);
}
- Duration delay = refreshResponse.delay();
+ Duration acquireSuccessDelay = refreshResponse.delay();
context.executionAttributes().putAttribute(RETRY_TOKEN, refreshResponse.token());
- context.executionAttributes().putAttribute(LAST_BACKOFF_DELAY_DURATION, delay);
- return Optional.of(delay);
+ context.executionAttributes().putAttribute(LAST_BACKOFF_DELAY_DURATION, acquireSuccessDelay);
+ return Either.left(acquireSuccessDelay);
}
/**
@@ -181,6 +193,12 @@ public void logBackingOff(Duration backoffDelay) {
attemptNumber, lastException);
}
+ public void logAcquireFailureBackingOff(Duration acquireFailureBackoffDelay) {
+ SdkStandardLogger.REQUEST_LOGGER.debug(() -> "Unable to acquire sufficient retry quota to retry. Will cease retrying in "
+ + acquireFailureBackoffDelay.toMillis() + "ms. Request attempt number " +
+ attemptNumber, lastException);
+ }
+
/**
* Retrieve the request to send to the service, including any detailed retry information headers.
*/
@@ -241,6 +259,10 @@ public void setLastResponse(SdkHttpResponse lastResponse) {
this.lastResponse = lastResponse;
}
+ public SdkHttpResponse getLastResponse() {
+ return lastResponse;
+ }
+
/**
* Returns true if this is the first attempt.
*/
@@ -296,4 +318,10 @@ private RetryPolicyContext retryPolicyContext() {
.httpStatusCode(lastResponse == null ? null : lastResponse.statusCode())
.build();
}
+
+ private boolean isLongPollingOperation(RequestExecutionContext context) {
+ return context.executionAttributes()
+ .getOptionalAttribute(SdkInternalExecutionAttribute.IS_LONG_POLLING)
+ .orElse(false);
+ }
}
\ No newline at end of file
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/MaxAttemptsResolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/MaxAttemptsResolver.java
new file mode 100644
index 000000000000..accb4192b995
--- /dev/null
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/MaxAttemptsResolver.java
@@ -0,0 +1,73 @@
+/*
+ * 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.retry;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.profiles.ProfileFile;
+import software.amazon.awssdk.profiles.ProfileFileSystemSetting;
+import software.amazon.awssdk.profiles.ProfileProperty;
+import software.amazon.awssdk.utils.OptionalUtils;
+
+/**
+ * Resolves the retry max attempts from {@link SdkSystemSetting#AWS_MAX_ATTEMPTS} and {@link ProfileProperty#MAX_ATTEMPTS}.
+ */
+@SdkInternalApi
+public class MaxAttemptsResolver {
+ private Supplier profileFile;
+ private String profileName;
+
+ /**
+ * Configure the profile file that should be used when determining the max attempts. The supplier is only consulted
+ * if a higher-priority determinant (e.g. environment variables) does not find the setting.
+ */
+ public MaxAttemptsResolver profileFile(Supplier profileFile) {
+ this.profileFile = profileFile;
+ return this;
+ }
+
+ /**
+ * Configure the profile file name should be used when determining the max attempts.
+ */
+ public MaxAttemptsResolver profileName(String profileName) {
+ this.profileName = profileName;
+ return this;
+ }
+
+ /**
+ * Resolve the max attempts based on the configured values. If not configured, returns {@code null}.
+ */
+ public Integer resolve() {
+ return OptionalUtils.firstPresent(fromSystemSettings(), () -> fromProfileFile(profileFile, profileName))
+ .orElse(null);
+ }
+
+
+ private static Optional fromSystemSettings() {
+ return SdkSystemSetting.AWS_MAX_ATTEMPTS.getIntegerValue();
+ }
+
+ private static Optional fromProfileFile(Supplier profileFile, String profileName) {
+ profileFile = profileFile != null ? profileFile : ProfileFile::defaultProfileFile;
+ profileName = profileName != null ? profileName : ProfileFileSystemSetting.AWS_PROFILE.getStringValueOrThrow();
+ return profileFile.get()
+ .profile(profileName)
+ .flatMap(p -> p.property(ProfileProperty.MAX_ATTEMPTS))
+ .map(Integer::parseInt);
+ }
+}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java
index bc7685b6126c..c31de0a7d4db 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java
@@ -70,9 +70,19 @@ public static RetryStrategy defaultRetryStrategy() {
* @return the appropriate retry strategy for the retry mode with AWS-specific conditions added.
*/
public static RetryStrategy forRetryMode(RetryMode mode) {
+ return forRetryMode(mode, false);
+ }
+
+ /**
+ * Retrieve the appropriate retry strategy for the retry mode with AWS-specific conditions added.
+ *
+ * @param mode The retry mode for which we want the retry strategy
+ * @return the appropriate retry strategy for the retry mode with AWS-specific conditions added.
+ */
+ public static RetryStrategy forRetryMode(RetryMode mode, boolean newRetries2026Enabled) {
switch (mode) {
case STANDARD:
- return standardRetryStrategy();
+ return standardRetryStrategy(newRetries2026Enabled);
case ADAPTIVE:
return legacyAdaptiveRetryStrategy();
case ADAPTIVE_V2:
@@ -115,6 +125,10 @@ public static StandardRetryStrategy standardRetryStrategy() {
return standardRetryStrategyBuilder().build();
}
+ public static StandardRetryStrategy standardRetryStrategy(boolean newRetries2026Enabled) {
+ return standardRetryStrategyBuilder(newRetries2026Enabled).build();
+ }
+
/**
* Returns a {@link LegacyRetryStrategy} with generic SDK retry conditions.
*
@@ -139,10 +153,20 @@ public static AdaptiveRetryStrategy adaptiveRetryStrategy() {
* @return a {@link StandardRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
*/
public static StandardRetryStrategy.Builder standardRetryStrategyBuilder() {
- StandardRetryStrategy.Builder builder = DefaultRetryStrategy.standardStrategyBuilder();
- return configure(builder);
+ return standardRetryStrategyBuilder(false);
+ }
+
+ /**
+ * Returns a {@link StandardRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
+ *
+ * @return a {@link StandardRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
+ */
+ public static StandardRetryStrategy.Builder standardRetryStrategyBuilder(boolean newRetries2026Enabled) {
+ StandardRetryStrategy.Builder builder = DefaultRetryStrategy.standardStrategyBuilder(newRetries2026Enabled);
+ return configure(builder, newRetries2026Enabled);
}
+
/**
* Returns a {@link LegacyRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
*
@@ -159,8 +183,17 @@ public static LegacyRetryStrategy.Builder legacyRetryStrategyBuilder() {
* @return an {@link AdaptiveRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
*/
public static AdaptiveRetryStrategy.Builder adaptiveRetryStrategyBuilder() {
- AdaptiveRetryStrategy.Builder builder = DefaultRetryStrategy.adaptiveStrategyBuilder();
- return configure(builder);
+ return adaptiveRetryStrategyBuilder(false);
+ }
+
+ /**
+ * Returns an {@link AdaptiveRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
+ *
+ * @return an {@link AdaptiveRetryStrategy.Builder} with preconfigured generic SDK retry conditions.
+ */
+ public static AdaptiveRetryStrategy.Builder adaptiveRetryStrategyBuilder(boolean newRetries2026Enabled) {
+ AdaptiveRetryStrategy.Builder builder = DefaultRetryStrategy.adaptiveStrategyBuilder(newRetries2026Enabled);
+ return configure(builder, newRetries2026Enabled);
}
/**
@@ -171,13 +204,17 @@ public static AdaptiveRetryStrategy.Builder adaptiveRetryStrategyBuilder() {
* @return The given builder
*/
public static > T configure(T builder) {
+ return configure(builder, false);
+ }
+
+ private static > T configure(T builder, boolean newRetries2026Enabled) {
builder.retryOnException(SdkDefaultRetryStrategy::retryOnRetryableException)
.retryOnException(SdkDefaultRetryStrategy::retryOnStatusCodes)
.retryOnException(SdkDefaultRetryStrategy::retryOnClockSkewException)
.retryOnException(SdkDefaultRetryStrategy::retryOnThrottlingCondition);
SdkDefaultRetrySetting.RETRYABLE_EXCEPTIONS.forEach(builder::retryOnExceptionOrCauseInstanceOf);
builder.treatAsThrottling(SdkDefaultRetryStrategy::treatAsThrottling);
- Integer maxAttempts = SdkSystemSetting.AWS_MAX_ATTEMPTS.getIntegerValue().orElse(null);
+ Integer maxAttempts = resolveMaxAttempts(newRetries2026Enabled);
if (maxAttempts != null) {
builder.maxAttempts(maxAttempts);
}
@@ -262,5 +299,13 @@ private static void markDefaultsAdded(RetryStrategy.Builder, ?> builder) {
}
}
+ static Integer resolveMaxAttempts(boolean newRetries2026Enabled) {
+ if (newRetries2026Enabled) {
+ return new MaxAttemptsResolver().resolve();
+ }
+
+ // pre 2.1 changes, we never looked at the profile file
+ return SdkSystemSetting.AWS_MAX_ATTEMPTS.getIntegerValue().orElse(null);
+ }
}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/NewRetries2026Resolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/NewRetries2026Resolver.java
new file mode 100644
index 000000000000..142583b15c2c
--- /dev/null
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/NewRetries2026Resolver.java
@@ -0,0 +1,56 @@
+/*
+ * 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.retry;
+
+import java.util.Optional;
+import software.amazon.awssdk.annotations.SdkProtectedApi;
+import software.amazon.awssdk.core.SdkSystemSetting;
+
+/**
+ * Resolver for the {@link SdkSystemSetting#AWS_NEW_RETRIES_2026} that supports setting a fallback value if not defined in the
+ * environment or system properties.
+ */
+@SdkProtectedApi
+public final class NewRetries2026Resolver {
+ private Boolean defaultNewRetries2026;
+
+ /**
+ * The default value for {@code AWS_NEW_RETRIES_2026} if not configured via {@link SdkSystemSetting#AWS_NEW_RETRIES_2026}.
+ *
+ * @return This resolver for method chaining.
+ */
+ public NewRetries2026Resolver defaultNewRetries2026(Boolean defaultNewRetries2026) {
+ this.defaultNewRetries2026 = defaultNewRetries2026;
+ return this;
+ }
+
+ /**
+ * Resolve whether retries v2.1 is used.
+ */
+ public boolean resolve() {
+ Optional envConfig = SdkSystemSetting.AWS_NEW_RETRIES_2026.getBooleanValue();
+
+ if (envConfig.isPresent()) {
+ return envConfig.get();
+ }
+
+ if (defaultNewRetries2026 != null) {
+ return defaultNewRetries2026;
+ }
+
+ return false;
+ }
+}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java
index fdfe4fa68a82..3d616446eafb 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java
@@ -130,11 +130,10 @@ public static Resolver resolver() {
* Allows customizing the variables used during determination of a {@link RetryMode}. Created via {@link #resolver()}.
*/
public static class Resolver {
- private static final RetryMode SDK_DEFAULT_RETRY_MODE = LEGACY;
-
private Supplier profileFile;
private String profileName;
private RetryMode defaultRetryMode;
+ private Boolean defaultNewRetries2026;
private Resolver() {
}
@@ -164,6 +163,15 @@ public Resolver defaultRetryMode(RetryMode defaultRetryMode) {
return this;
}
+ /**
+ * Configure whether retry 2.1 behavior is enabled by default if not specified anywhere else (i.e. via
+ * {@link SdkSystemSetting#AWS_NEW_RETRIES_2026}).
+ */
+ public Resolver defaultNewRetries2026(Boolean defaultNewRetries2026) {
+ this.defaultNewRetries2026 = defaultNewRetries2026;
+ return this;
+ }
+
/**
* Resolve which retry mode should be used, based on the configured values.
*/
@@ -204,7 +212,14 @@ private static Optional fromString(String string) {
}
private RetryMode fromDefaultMode() {
- return defaultRetryMode != null ? defaultRetryMode : SDK_DEFAULT_RETRY_MODE;
+ return defaultRetryMode != null ? defaultRetryMode : sdkDefaultRetryMode();
+ }
+
+ /**
+ * Resolves the SDK default retry mode dynamically based on the {@code AWS_NEW_RETRIES_2026} gate.
+ */
+ private RetryMode sdkDefaultRetryMode() {
+ return new NewRetries2026Resolver().defaultNewRetries2026(defaultNewRetries2026).resolve() ? STANDARD : LEGACY;
}
}
}
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/SdkSystemSettingNewRetriesTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/SdkSystemSettingNewRetriesTest.java
new file mode 100644
index 000000000000..0a32d18f0129
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/SdkSystemSettingNewRetriesTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import software.amazon.awssdk.testutils.EnvironmentVariableHelper;
+
+/**
+ * Tests for the {@link SdkSystemSetting#AWS_NEW_RETRIES_2026} system setting.
+ */
+class SdkSystemSettingNewRetriesTest {
+
+ @AfterEach
+ void cleanup() {
+ System.clearProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ }
+
+ @Test
+ void defaultsToEmpty_whenUnset() {
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.getBooleanValue()).isEmpty();
+ }
+
+ @ParameterizedTest(name = "systemProperty=\"{0}\" -> {1}")
+ @CsvSource({
+ "false, false",
+ "true, true"
+ })
+ void getBooleanValue_reflectsSystemProperty(String propertyValue, boolean expected) {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), propertyValue);
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.getBooleanValue()).hasValue(expected);
+ }
+
+ @ParameterizedTest(name = "envVar=\"{0}\" -> {1}")
+ @CsvSource({
+ "false, false",
+ "true, true"
+ })
+ void getBooleanValue_reflectsEnvVar(String envVarValue, boolean expected) {
+ EnvironmentVariableHelper.run(helper -> {
+ helper.set(SdkSystemSetting.AWS_NEW_RETRIES_2026, envVarValue);
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.getBooleanValue()).hasValue(expected);
+ });
+ }
+
+ @Test
+ void systemPropertyTakesPrecedenceOverEnvVar() {
+ EnvironmentVariableHelper.run(helper -> {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), "false");
+ helper.set(SdkSystemSetting.AWS_NEW_RETRIES_2026, "true");
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.getBooleanValue()).hasValue(false);
+ });
+ }
+
+ @Test
+ void environmentVariable_isCorrectName() {
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.environmentVariable())
+ .isEqualTo("AWS_NEW_RETRIES_2026");
+ }
+
+ @Test
+ void systemProperty_isCorrectName() {
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.property())
+ .isEqualTo("aws.newRetries2026");
+ }
+
+ @Test
+ void defaultValue_isNull() {
+ assertThat(SdkSystemSetting.AWS_NEW_RETRIES_2026.defaultValue())
+ .isNull();
+ }
+}
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
new file mode 100644
index 000000000000..5f0b03ac346f
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AsyncRetryableStageTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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 static org.assertj.core.api.Assertions.assertThat;
+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.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+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;
+import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientOption;
+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;
+import software.amazon.awssdk.core.internal.http.pipeline.RequestPipeline;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpFullResponse;
+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 extends BaseRetryableStageTest {
+ private RetryStrategy mockRetryStrategy;
+ private AcquireInitialTokenResponse mockAcquireInitialTokenResponse;
+ private RetryToken mockRetryToken;
+
+ private RequestPipeline>> mockDelegatePipeline;
+
+ private static ScheduledExecutorService executorService;
+
+ @BeforeAll
+ static void setup() {
+ executorService = Executors.newScheduledThreadPool(1);
+ }
+
+ @AfterAll
+ static void teardown() {
+ executorService.shutdownNow();
+ }
+
+ @BeforeEach
+ void methodSetup() {
+ mockRetryStrategy = mock(RetryStrategy.class);
+ mockAcquireInitialTokenResponse = mock(AcquireInitialTokenResponse.class);
+ mockRetryToken = mock(RetryToken.class);
+
+ when(mockAcquireInitialTokenResponse.token()).thenReturn(mockRetryToken);
+ when(mockAcquireInitialTokenResponse.delay()).thenReturn(Duration.ZERO);
+
+ when(mockRetryStrategy.acquireInitialToken(any())).thenReturn(mockAcquireInitialTokenResponse);
+
+ mockDelegatePipeline = mock(RequestPipeline.class);
+ }
+
+ @ParameterizedTest
+ @MethodSource("acquireDelayTestCases")
+ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws Exception {
+ SdkClientConfiguration clientConfig = SdkClientConfiguration.builder()
+ .option(SdkClientOption.RETRY_STRATEGY, mockRetryStrategy)
+ .option(SdkClientOption.SCHEDULED_EXECUTOR_SERVICE,
+ executorService)
+ .build();
+
+ HttpClientDependencies deps = HttpClientDependencies.builder()
+ .clientConfiguration(clientConfig)
+ .build();
+
+ 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, true)
+ .build();
+
+ ExecutionContext execCtx = ExecutionContext.builder()
+ .metricCollector(NoOpMetricCollector.create())
+ .executionAttributes(execAttrs)
+ .build();
+
+ RequestExecutionContext ctx = RequestExecutionContext.builder()
+ .originalRequest(mock(SdkRequest.class))
+ .executionContext(execCtx)
+ .build();
+
+ SdkHttpFullResponse.Builder httpResponse = SdkHttpFullResponse.builder()
+ .statusCode(502);
+
+ Response response = Response.builder()
+ .httpResponse(httpResponse.build())
+ .isSuccess(false)
+ .exception(SdkException.builder().build())
+ .build();
+
+ when(mockDelegatePipeline.execute(any(), any())).thenReturn(CompletableFuture.completedFuture(response));
+
+ if (testCase.isFailure()) {
+ when(mockRetryStrategy.refreshRetryToken(any())).thenThrow(
+ 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());
+ }
+ throw new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, Duration.ZERO);
+ });
+ }
+
+ long start = System.nanoTime();
+ CompletableFuture> execute = retryableStage.execute(httpRequest, ctx);
+ // exception thrown doesn't matter, just results in exception because we mock just enough...
+ assertThatThrownBy(execute::join);
+ long end = System.nanoTime();
+
+ Duration lowerBound = testCase.expectedDelay();
+ assertThat(Duration.ofNanos(end - start)).isBetween(lowerBound, lowerBound.plusMillis(250));
+ }
+
+
+ @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());
+
+ SdkClientConfiguration clientConfig = SdkClientConfiguration.builder()
+ .option(SdkClientOption.RETRY_STRATEGY, mockRetryStrategy)
+ .option(SdkClientOption.SCHEDULED_EXECUTOR_SERVICE,
+ executorService)
+ .build();
+
+ HttpClientDependencies deps = HttpClientDependencies.builder()
+ .clientConfiguration(clientConfig)
+ .build();
+
+ 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();
+
+ 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());
+ }
+
+ 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 000000000000..fcc84e0377fe
--- /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
new file mode 100644
index 000000000000..d5dff64c4746
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/RetryableStageTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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 static org.assertj.core.api.Assertions.assertThat;
+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 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;
+import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientOption;
+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;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+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 extends BaseRetryableStageTest {
+ private RetryStrategy mockRetryStrategy;
+ private AcquireInitialTokenResponse mockAcquireInitialTokenResponse;
+ private RetryToken mockRetryToken;
+
+ private RequestPipeline> mockDelegatePipeline;
+
+ @BeforeEach
+ void setup() {
+ mockRetryStrategy = mock(RetryStrategy.class);
+ mockAcquireInitialTokenResponse = mock(AcquireInitialTokenResponse.class);
+ mockRetryToken = mock(RetryToken.class);
+
+ when(mockAcquireInitialTokenResponse.token()).thenReturn(mockRetryToken);
+ when(mockAcquireInitialTokenResponse.delay()).thenReturn(Duration.ZERO);
+
+ when(mockRetryStrategy.acquireInitialToken(any())).thenReturn(mockAcquireInitialTokenResponse);
+
+ mockDelegatePipeline = mock(RequestPipeline.class);
+ }
+
+ @ParameterizedTest
+ @MethodSource("acquireDelayTestCases")
+ void execute_acquireDelay_behavesCorrectly(AcquireDelayTestCase testCase) throws Exception {
+ SdkClientConfiguration clientConfig = SdkClientConfiguration.builder()
+ .option(SdkClientOption.RETRY_STRATEGY, mockRetryStrategy)
+ .build();
+
+ HttpClientDependencies deps = HttpClientDependencies.builder()
+ .clientConfiguration(clientConfig)
+ .build();
+
+ RetryableStage retryableStage = new RetryableStage<>(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, true)
+ .build();
+
+ ExecutionContext execCtx = ExecutionContext.builder()
+ .executionAttributes(execAttrs)
+ .build();
+
+ RequestExecutionContext ctx = RequestExecutionContext.builder()
+ .originalRequest(mock(SdkRequest.class))
+ .executionContext(execCtx)
+ .build();
+
+ SdkHttpFullResponse.Builder httpResponse = SdkHttpFullResponse.builder()
+ .statusCode(502);
+
+ Response response = Response.builder()
+ .httpResponse(httpResponse.build())
+ .isSuccess(false)
+ .exception(SdkException.builder().build())
+ .build();
+
+ when(mockDelegatePipeline.execute(any(), any())).thenReturn(response);
+
+ if (testCase.isFailure()) {
+ when(mockRetryStrategy.refreshRetryToken(any())).thenThrow(
+ 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());
+ }
+ throw new TokenAcquisitionFailedException("Acquire failed", mockRetryToken, null, Duration.ZERO);
+ });
+ }
+
+ long start = System.nanoTime();
+ // exception thrown doesn't matter, just results in exception because we mock just enough...
+ assertThatThrownBy(() -> retryableStage.execute(httpRequest, ctx));
+ long end = System.nanoTime();
+
+ Duration lowerBound = testCase.expectedDelay();
+ assertThat(Duration.ofNanos(end - start)).isBetween(lowerBound, lowerBound.plusMillis(250));
+ }
+
+ @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();
+
+ RetryableStage retryableStage = new RetryableStage<>(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()
+ .executionAttributes(execAttrs)
+ .build();
+
+ 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());
+ }
+
+ 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());
+ }
+}
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelperTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelperTest.java
new file mode 100644
index 000000000000..1febd989ebd3
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelperTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.utils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.core.SdkRequest;
+import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientOption;
+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.http.SdkHttpFullRequest;
+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;
+import software.amazon.awssdk.utils.Either;
+
+public class RetryableStageHelperTest {
+
+ @ParameterizedTest(name = "IS_LONG_POLLING = {0}, expected = {1}")
+ @MethodSource("longPollingValueTestParams")
+ void tryRefreshToken_forwardsLongPollingAttrValue(Boolean attribute, boolean expected) {
+ SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder()
+ .method(SdkHttpMethod.GET)
+ .uri(URI.create("https://my-service.amazonaws.com"))
+ .build();
+
+ ExecutionAttributes.Builder attributes = ExecutionAttributes.builder();
+ if (attribute != null) {
+ attributes.put(SdkInternalExecutionAttribute.IS_LONG_POLLING, attribute);
+ }
+
+ ExecutionContext executionContext = ExecutionContext.builder().executionAttributes(attributes.build()).build();
+
+ RequestExecutionContext requestExecutionContext = RequestExecutionContext.builder()
+ .originalRequest(mock(SdkRequest.class))
+ .executionContext(executionContext)
+ .build();
+
+ RetryStrategy retryStrategy = mock(RetryStrategy.class);
+
+ SdkClientConfiguration clientConfig = SdkClientConfiguration.builder()
+ .option(SdkClientOption.RETRY_STRATEGY, retryStrategy)
+ .build();
+
+ HttpClientDependencies dependencies = HttpClientDependencies.builder()
+ .clientConfiguration(clientConfig)
+ .build();
+
+ RetryableStageHelper helper = new RetryableStageHelper(httpRequest,
+ requestExecutionContext,
+ dependencies);
+
+ AcquireInitialTokenResponse mockAcquireResponse = mock(AcquireInitialTokenResponse.class);
+ RetryToken token = mock(RetryToken.class);
+ when(mockAcquireResponse.token()).thenReturn(token);
+ when(retryStrategy.acquireInitialToken(any())).thenReturn(mockAcquireResponse);
+
+ RefreshRetryTokenResponse mockRefreshResponse = mock(RefreshRetryTokenResponse.class);
+ when(mockRefreshResponse.delay()).thenReturn(Duration.ZERO);
+ ArgumentCaptor refreshRequestCaptor = ArgumentCaptor.forClass(RefreshRetryTokenRequest.class);
+
+ when(retryStrategy.refreshRetryToken(any())).thenReturn(mockRefreshResponse);
+
+ helper.acquireInitialToken();
+
+ helper.setLastException(new RuntimeException());
+ helper.tryRefreshToken(Duration.ZERO);
+
+ verify(retryStrategy).refreshRetryToken(refreshRequestCaptor.capture());
+
+ assertThat(refreshRequestCaptor.getValue().isLongPolling()).isEqualTo(expected);
+ }
+
+ @ParameterizedTest(name = "delay on successful refresh = {0}, delay on failed refresh = {1}")
+ @MethodSource("refreshBackoffTestParams")
+ void tryRefreshToken_returnsCorrectBackoff(Duration successDelay, Duration failureDelay) {
+ SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder()
+ .method(SdkHttpMethod.GET)
+ .uri(URI.create("https://my-service.amazonaws.com"))
+ .build();
+
+ ExecutionAttributes.Builder attributes = ExecutionAttributes.builder();
+
+ ExecutionContext executionContext = ExecutionContext.builder().executionAttributes(attributes.build()).build();
+
+ RequestExecutionContext requestExecutionContext = RequestExecutionContext.builder()
+ .originalRequest(mock(SdkRequest.class))
+ .executionContext(executionContext)
+ .build();
+
+ RetryStrategy retryStrategy = mock(RetryStrategy.class);
+
+ SdkClientConfiguration clientConfig = SdkClientConfiguration.builder()
+ .option(SdkClientOption.RETRY_STRATEGY, retryStrategy)
+ .build();
+
+ HttpClientDependencies dependencies = HttpClientDependencies.builder()
+ .clientConfiguration(clientConfig)
+ .build();
+
+ RetryableStageHelper helper = new RetryableStageHelper(httpRequest,
+ requestExecutionContext,
+ dependencies);
+
+ AcquireInitialTokenResponse mockAcquireResponse = mock(AcquireInitialTokenResponse.class);
+ RetryToken token = mock(RetryToken.class);
+ when(mockAcquireResponse.token()).thenReturn(token);
+ when(retryStrategy.acquireInitialToken(any())).thenReturn(mockAcquireResponse);
+
+ if (successDelay != null) {
+ RefreshRetryTokenResponse mockRefreshResponse = mock(RefreshRetryTokenResponse.class);
+ when(mockRefreshResponse.delay()).thenReturn(successDelay);
+ when(retryStrategy.refreshRetryToken(any())).thenReturn(mockRefreshResponse);
+ } else {
+ when(retryStrategy.refreshRetryToken(any())).thenThrow(
+ new TokenAcquisitionFailedException("failed", token, null, failureDelay)
+ );
+ }
+
+ helper.acquireInitialToken();
+
+ helper.setLastException(new RuntimeException());
+ Either backoff = helper.tryRefreshToken(Duration.ZERO);
+
+ if (successDelay != null) {
+ assertThat(backoff.left().get()).isEqualTo(successDelay);
+ } else {
+ assertThat(backoff.right().get()).isEqualTo(failureDelay);
+ }
+ }
+
+ private static Stream longPollingValueTestParams() {
+ return Stream.of(
+ // Absent should default to false
+ Arguments.of(null, false),
+ Arguments.of(true, true),
+ Arguments.of(false, false)
+ );
+ }
+
+ private static Stream refreshBackoffTestParams() {
+ return Stream.of(
+ Arguments.of(null, Duration.ofSeconds(1)),
+ Arguments.of(Duration.ofSeconds(1), null)
+ );
+ }
+}
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/NewRetries2026ResolverTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/NewRetries2026ResolverTest.java
new file mode 100644
index 000000000000..573d0fff22f0
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/NewRetries2026ResolverTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.retry;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.stream.Stream;
+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 software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.testutils.EnvironmentVariableHelper;
+
+public class NewRetries2026ResolverTest {
+ private static String newRetries2026Save;
+
+ @BeforeAll
+ static void setup() {
+ newRetries2026Save = System.getProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ }
+
+ @AfterAll
+ static void teardown() {
+ if (newRetries2026Save != null) {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), newRetries2026Save);
+ } else {
+ System.clearProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ }
+ }
+
+ @BeforeEach
+ void methodSetup() {
+ System.clearProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ }
+
+ @ParameterizedTest
+ @MethodSource("params")
+ void resolve_behavesCorrectly(TestParams params) {
+ EnvironmentVariableHelper.run((env) -> {
+ if (params.systemProperty != null) {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), params.systemProperty);
+ }
+
+ if (params.envVar != null) {
+ env.set(SdkSystemSetting.AWS_NEW_RETRIES_2026.environmentVariable(), params.envVar);
+ }
+
+ NewRetries2026Resolver resolver = new NewRetries2026Resolver().defaultNewRetries2026(params.defaultNewRetries2026);
+
+ assertThat(resolver.resolve()).isEqualTo(params.expected);
+ });
+ }
+
+ private static Stream params() {
+ return Stream.of(
+ // default
+ new TestParams().expected(false),
+
+ // precedence testing
+ new TestParams().systemProperty("true").defaultNewRetries2026(true).expected(true),
+ new TestParams().systemProperty("false").defaultNewRetries2026(true).expected(false),
+ new TestParams().envVar("true").defaultNewRetries2026(true).expected(true),
+ new TestParams().envVar("false").defaultNewRetries2026(true).expected(false),
+ new TestParams().defaultNewRetries2026(true).expected(true),
+ new TestParams().defaultNewRetries2026(false).expected(false)
+ );
+ }
+
+ private static class TestParams {
+ private String systemProperty;
+ private String envVar;
+ private Boolean defaultNewRetries2026;
+ private boolean expected;
+
+ public TestParams systemProperty(String systemProperty) {
+ this.systemProperty = systemProperty;
+ return this;
+ }
+
+ public TestParams envVar(String envVar) {
+ this.envVar = envVar;
+ return this;
+ }
+
+ public TestParams defaultNewRetries2026(Boolean defaultNewRetries2026) {
+ this.defaultNewRetries2026 = defaultNewRetries2026;
+ return this;
+ }
+
+ public TestParams expected(boolean expected) {
+ this.expected = expected;
+ return this;
+ }
+ }
+}
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeGatedDefaultTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeGatedDefaultTest.java
new file mode 100644
index 000000000000..f14cad1efd9a
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeGatedDefaultTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.retry;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.testutils.EnvironmentVariableHelper;
+
+/**
+ * Tests for the gated default {@link RetryMode} behavior controlled by
+ * {@link SdkSystemSetting#AWS_NEW_RETRIES_2026}.
+ */
+class RetryModeGatedDefaultTest {
+
+ @BeforeEach
+ @AfterEach
+ void cleanup() {
+ System.clearProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ System.clearProperty(SdkSystemSetting.AWS_RETRY_MODE.property());
+ }
+
+ @Test
+ void defaultRetryMode_returnsLegacy_whenGateIsUnset() {
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(RetryMode.LEGACY);
+ }
+
+ @ParameterizedTest(name = "gate=\"{0}\" -> {1}")
+ @CsvSource({
+ "false, LEGACY",
+ "true, STANDARD"
+ })
+ void defaultRetryMode_reflectsGate_whenSetViaSystemProperty(String gateValue, RetryMode expected) {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), gateValue);
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(expected);
+ }
+
+ @ParameterizedTest(name = "gate=\"{0}\" -> {1}")
+ @CsvSource({
+ "false, LEGACY",
+ "true, STANDARD"
+ })
+ void defaultRetryMode_reflectsGate_whenSetViaEnvVar(String gateValue, RetryMode expected) {
+ EnvironmentVariableHelper.run(helper -> {
+ helper.set(SdkSystemSetting.AWS_NEW_RETRIES_2026, gateValue);
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(expected);
+ });
+ }
+
+ @Test
+ void defaultRetryMode_changesDynamically_whenGateSystemPropertyChangesAtRuntime() {
+ // Initially unset — should be LEGACY
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(RetryMode.LEGACY);
+
+ // Enable gate — should switch to STANDARD
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), "true");
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(RetryMode.STANDARD);
+
+ // Disable gate — should revert to LEGACY
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), "false");
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(RetryMode.LEGACY);
+
+ // Clear gate — should fall back to default (LEGACY)
+ System.clearProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property());
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(RetryMode.LEGACY);
+ }
+
+ @ParameterizedTest(name = "gate=\"{0}\" retryMode=\"{1}\" -> {2}")
+ @CsvSource({
+ "true, legacy, LEGACY",
+ "false, standard, STANDARD",
+ "false, adaptive, ADAPTIVE_V2"
+ })
+ void resolve_honorsExplicitRetryMode_regardlessOfGate(String gateValue, String retryModeValue, RetryMode expected) {
+ System.setProperty(SdkSystemSetting.AWS_NEW_RETRIES_2026.property(), gateValue);
+ System.setProperty(SdkSystemSetting.AWS_RETRY_MODE.property(), retryModeValue);
+ assertThat(RetryMode.defaultRetryMode()).isEqualTo(expected);
+ }
+
+ @Test
+ void resolve_throwsIllegalStateException_whenInvalidRetryModeConfigured() {
+ System.setProperty(SdkSystemSetting.AWS_RETRY_MODE.property(), "invalid_mode");
+ assertThatThrownBy(RetryMode::defaultRetryMode).isInstanceOf(IllegalStateException.class);
+ }
+}
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeTest.java
index b5fb23c37ed4..40fca37c82e3 100644
--- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeTest.java
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/retry/RetryModeTest.java
@@ -44,34 +44,57 @@ public class RetryModeTest {
public static Collection