Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"type": "feature",
"category": "AWS SDK for Java v2",
"contributor": "",
"description": "Standardize cached credential stale/prefetch windows, improve credential refresh resilliancy (static-stability) and add automatic credential invalidation/refresh."
"description": "Standardize cached credential stale/prefetch windows, improve credential refresh resiliency (static-stability) and add automatic credential invalidation/refresh. Requests rejected with `ExpiredToken` or `InvalidToken` are now retried, and each retry attempt resolves credentials again."
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ public final class AwsErrorCode {
possibleClockSkewErrorCodes.add("AuthFailure");
POSSIBLE_CLOCK_SKEW_ERROR_CODES = unmodifiableSet(possibleClockSkewErrorCodes);

Set<String> retryableErrorCodes = new HashSet<>(1);
Set<String> retryableErrorCodes = new HashSet<>(6);
retryableErrorCodes.add("PriorRequestNotComplete");
retryableErrorCodes.add("RequestTimeout");
retryableErrorCodes.add("RequestTimeoutException");
retryableErrorCodes.add("InternalError");
retryableErrorCodes.add("ExpiredToken");

@joviegas joviegas Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when these two codes are retried on these paths?

  1. A legacy signer override (SdkAdvancedClientOption.SIGNER)
  2. An interceptor that sets AWS_CREDENTIALS
  3. S3 presignedUrlExtension().getObject

On paths 1 and 2, AwsRequestIdentityProviderResolver (line 68) replaces the provider with StaticCredentialsProvider, whose invalidate() is a no-op. On path 3, the credentials are in the URL. Since the retry attempt signs with the same credentials (or sends the same URL), will these calls make up to 3 extra attempts with the default retry mode and still end with ExpiredToken, where before this change they failed after 1 attempt

WDYT about skipping the retry for these two codes when the identity can't change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah these are valid - each of these cases will result in new retries that are unlikely to succeed, but thats the cross-SDK spec for them.

I don't think we can reliably detect "identities that can't change" and the retry spec requires that we retry them unconditionally....

retryableErrorCodes.add("InvalidToken");
RETRYABLE_ERROR_CODES = unmodifiableSet(retryableErrorCodes);

Set<String> authErrorCodes = new HashSet<>(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ public static AdaptiveRetryStrategy adaptiveRetryStrategy(Boolean newRetries2026
private static boolean retryOnAwsRetryableErrors(Throwable ex) {
if (ex instanceof AwsServiceException) {
AwsServiceException exception = (AwsServiceException) ex;
return AwsErrorCode.RETRYABLE_ERROR_CODES.contains(exception.awsErrorDetails().errorCode());
return AwsErrorCode.isRetryableErrorCode(exception.awsErrorDetails().errorCode());
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ public void retriesOnRetryableErrorCodes() {
assertTrue(shouldRetry(applyErrorCode("PriorRequestNotComplete")));
}

@Test
public void retriesOnAuthenticationErrorCodes() {
assertTrue(shouldRetry(applyErrorCode("ExpiredToken")));
assertTrue(shouldRetry(applyErrorCode("InvalidToken")));
}

@Test
public void doesNotRetryOnAuthorizationErrorCodes() {
assertFalse(shouldRetry(applyErrorCode("AccessDenied")));
assertFalse(shouldRetry(applyErrorCode("AccessDeniedException")));
}

@Test
public void retriesOnThrottlingExceptions() {
assertTrue(shouldRetry(applyErrorCode("ThrottlingException")));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
import org.junit.jupiter.params.provider.CsvSource;
import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.retries.StandardRetryStrategy;
import software.amazon.awssdk.retries.api.AcquireInitialTokenRequest;
import software.amazon.awssdk.retries.api.RefreshRetryTokenRequest;
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.retries.internal.DefaultRetryToken;
Expand Down Expand Up @@ -89,6 +91,45 @@ void standardRetryStrategy_retry21_throttlingBehaviorCorrect(String errorCode) {
}
}

@ParameterizedTest
@CsvSource({"STANDARD, false, ExpiredToken",
"STANDARD, false, InvalidToken",
"STANDARD, true, ExpiredToken",
"STANDARD, true, InvalidToken",
"ADAPTIVE_V2, false, ExpiredToken",
"ADAPTIVE_V2, true, InvalidToken",
"LEGACY, false, ExpiredToken",
"LEGACY, false, InvalidToken"})
void forRetryMode_authenticationErrorCode_isRetried(RetryMode mode, boolean newRetries2026Enabled, String errorCode) {
RetryStrategy strategy = AwsRetryStrategy.forRetryMode(mode, newRetries2026Enabled);

RetryToken token = strategy.acquireInitialToken(AcquireInitialTokenRequest.create("test")).token();
RefreshRetryTokenRequest refresh = RefreshRetryTokenRequest.builder()
.failure(createTestException(errorCode))
.token(token)
.build();

assertThat(strategy.refreshRetryToken(refresh).delay()).isGreaterThanOrEqualTo(Duration.ZERO);
}

@ParameterizedTest
@CsvSource({"STANDARD, AccessDenied",
"STANDARD, AccessDeniedException",
"STANDARD, ExpiredTokenException",
"LEGACY, AccessDenied"})
void forRetryMode_errorCodeThatIsNotAnAuthenticationError_isNotRetried(RetryMode mode, String errorCode) {
RetryStrategy strategy = AwsRetryStrategy.forRetryMode(mode, false);

RetryToken token = strategy.acquireInitialToken(AcquireInitialTokenRequest.create("test")).token();
RefreshRetryTokenRequest refresh = RefreshRetryTokenRequest.builder()
.failure(createTestException(errorCode))
.token(token)
.build();

assertThatThrownBy(() -> strategy.refreshRetryToken(refresh))
.isInstanceOf(TokenAcquisitionFailedException.class);
}

private static AwsServiceException createTestException(String errorCode) {
AwsErrorDetails details = AwsErrorDetails.builder()
.errorCode(errorCode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ public AuthSchemeOption authSchemeOption() {
return authSchemeOption;
}

/**
* The identity provider that resolved the {@link #identity()}, or null if it is unknown. When present, it is used to
* invalidate the identity after an authentication error, and to resolve the identity again before each retry attempt.
*/
public IdentityProvider<T> identityProvider() {
return identityProvider;
}
Expand Down Expand Up @@ -121,7 +125,11 @@ public interface Builder<T extends Identity> {
Builder<T> authSchemeOption(AuthSchemeOption authSchemeOption);

/**
* The identity provider that resolved the identity. Used for invalidation on auth errors.
* The identity provider that resolved the identity. Used for invalidation on auth errors, and to resolve the identity
* again, using the identity properties of the {@link #authSchemeOption(AuthSchemeOption)}, before each retry attempt.
*
* <p>When an identity provider is set, the {@link #identity(CompletableFuture)} must have been resolved by it. A
* retry attempt replaces the identity with one newly resolved from this provider.
*/
Builder<T> identityProvider(IdentityProvider<T> identityProvider);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

package software.amazon.awssdk.core.http.auth;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand All @@ -29,22 +28,17 @@
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
import software.amazon.awssdk.core.internal.util.MetricUtils;
import software.amazon.awssdk.core.metrics.CoreMetric;
import software.amazon.awssdk.core.internal.http.pipeline.stages.utils.IdentityResolutionHelper;
import software.amazon.awssdk.core.spi.identity.AuthSchemeOptionsResolver;
import software.amazon.awssdk.core.spi.identity.RequestIdentityProviderResolver;
import software.amazon.awssdk.http.auth.spi.scheme.AuthScheme;
import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption;
import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
import software.amazon.awssdk.http.auth.spi.signer.SignerProperty;
import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.identity.spi.Identity;
import software.amazon.awssdk.identity.spi.IdentityProvider;
import software.amazon.awssdk.identity.spi.IdentityProviders;
import software.amazon.awssdk.identity.spi.ResolveIdentityRequest;
import software.amazon.awssdk.identity.spi.TokenIdentity;
import software.amazon.awssdk.metrics.MetricCollector;
import software.amazon.awssdk.metrics.SdkMetric;
import software.amazon.awssdk.utils.Logger;

/**
Expand Down Expand Up @@ -280,11 +274,8 @@ private static <T extends Identity> SelectedAuthScheme<T> trySelectAuthScheme(
return null;
}

ResolveIdentityRequest.Builder identityRequestBuilder = ResolveIdentityRequest.builder();
authOption.forEachIdentityProperty(identityRequestBuilder::putProperty);

CompletableFuture<? extends T> identity = resolveIdentity(
identityProvider, identityRequestBuilder.build(), metricCollector);
CompletableFuture<? extends T> identity = IdentityResolutionHelper.resolveIdentity(
identityProvider, IdentityResolutionHelper.resolveIdentityRequest(authOption), metricCollector);

return SelectedAuthScheme.<T>builder()
.identity(identity)
Expand All @@ -293,27 +284,4 @@ private static <T extends Identity> SelectedAuthScheme<T> trySelectAuthScheme(
.identityProvider(identityProvider)
.build();
}

private static <T extends Identity> CompletableFuture<? extends T> resolveIdentity(
IdentityProvider<T> identityProvider,
ResolveIdentityRequest request,
MetricCollector metricCollector) {

SdkMetric<Duration> metric = getIdentityMetric(identityProvider);
if (metric == null || metricCollector == null) {
return identityProvider.resolveIdentity(request);
}
return MetricUtils.reportDuration(() -> identityProvider.resolveIdentity(request), metricCollector, metric);
}

private static SdkMetric<Duration> getIdentityMetric(IdentityProvider<?> identityProvider) {
Class<?> identityType = identityProvider.identityType();
if (identityType == AwsCredentialsIdentity.class) {
return CoreMetric.CREDENTIALS_FETCH_DURATION;
}
if (identityType == TokenIdentity.class) {
return CoreMetric.TOKEN_FETCH_DURATION;
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.core.Response;
import software.amazon.awssdk.core.async.AsyncRequestBody;
import software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption;
import software.amazon.awssdk.core.client.config.SdkClientOption;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
Expand All @@ -51,6 +54,7 @@ public final class AsyncRetryableStage<OutputT> implements RequestPipeline<SdkHt
private final TransformingAsyncResponseHandler<Response<OutputT>> responseHandler;
private final RequestPipeline<SdkHttpFullRequest, CompletableFuture<Response<OutputT>>> requestPipeline;
private final ScheduledExecutorService scheduledExecutor;
private final Executor futureCompletionExecutor;
private final HttpClientDependencies dependencies;

public AsyncRetryableStage(TransformingAsyncResponseHandler<Response<OutputT>> responseHandler,
Expand All @@ -59,6 +63,8 @@ public AsyncRetryableStage(TransformingAsyncResponseHandler<Response<OutputT>> r
this.responseHandler = responseHandler;
this.dependencies = dependencies;
this.scheduledExecutor = dependencies.clientConfiguration().option(SdkClientOption.SCHEDULED_EXECUTOR_SERVICE);
this.futureCompletionExecutor =
dependencies.clientConfiguration().option(SdkAdvancedAsyncClientOption.FUTURE_COMPLETION_EXECUTOR);
this.requestPipeline = requestPipeline;
}

Expand Down Expand Up @@ -116,6 +122,7 @@ private void attemptExecute(CompletableFuture<Response<OutputT>> future) {
CompletableFuture<Response<OutputT>> responseFuture;
try {
retryableStageHelper.startingAttempt();
retryableStageHelper.resolveIdentityForAttempt();

@joviegas joviegas Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On async clients, retries reach this line through scheduleOrFail (line 187), so every retry resolves identity on an sdk-ScheduledExecutor thread.
For the SDK credential providers, resolveIdentity calls
resolveCredentials() on the calling thread. After an ExpiredToken, invalidate() marks the CachedSupplier value stale, so the retry's get() does a blocking refresh (an STS, IMDS, or container call). Other threads can wait on the refresh lock for up to 5 seconds(BLOCKING_REFRESH_MAX_WAIT) .

The default scheduler has 5 threads. It also runs the apiCallTimeout timers, the apiCallAttemptTimeout timers, and the retry backoff timers for every request on the client. If 5 or more requests retry an ExpiredToken at the same time, will the timeouts and retries of unrelated requests fire late until the refresh completes?
Can we check this scenario: 5 async calls get ExpiredToken while a 6th call with apiCallTimeout(300ms) is in flight?
WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - this is a good callout. I was struggling with where/how to run the refresh on the async path. I went with the scheduler thread since it was already doing things like signing... but you are correct that a blocked/slow refresh would impact the api call timeout.

I've moved all of the retry execution into the futureCompletionExecutor which I think is the more correct place for all of that retry loop logic to live. That fixes the issue with the apiCallTimeout (but does cost one more thread jump, but I think its logically more correct for all of the retry loop to execute outside of the scheduler threads).

retryableStageHelper.logSendingRequest();
responseFuture = requestPipeline.execute(retryableStageHelper.requestToSend(), context);

Expand Down Expand Up @@ -183,13 +190,34 @@ public void maybeAttemptExecute(CompletableFuture<Response<OutputT>> future) {
Duration successDelay = backoffDelay.left().get();
retryableStageHelper.logBackingOff(successDelay);
long totalDelayMillis = successDelay.toMillis();
scheduleOrFail(() -> attemptExecute(future), totalDelayMillis, MILLISECONDS, future);
scheduleOrFail(() -> attemptRetryExecute(future), totalDelayMillis, MILLISECONDS, future);
} catch (Throwable t) {
future.completeExceptionally(t);
}
});
}

/**
* Runs a retry attempt on the future completion executor, off the scheduled executor that runs the client's timers.
* A retry attempt resolves the identity again, which can block while credentials are refreshed. Falls back to the
* current thread if there is no future completion executor or it rejects the attempt.
*/
private void attemptRetryExecute(CompletableFuture<Response<OutputT>> future) {
if (futureCompletionExecutor == null) {
attemptExecute(future);
return;
}
try {
futureCompletionExecutor.execute(() -> attemptExecute(future));
} catch (RejectedExecutionException e) {
LOG.debug(() -> String.format("Could not run the retry attempt on the provided FUTURE_COMPLETION_EXECUTOR. "
+ "The attempt will run on thread %s. This may be an indication that the "
+ "executor is being overwhelmed by too many requests.",
Thread.currentThread().getName()), e);
attemptExecute(future);
}
}

private void maybeRetryExecute(CompletableFuture<Response<OutputT>> future, Exception exception) {
retryableStageHelper.setLastException(exception);
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public Response<OutputT> execute(SdkHttpFullRequest request, RequestExecutionCon
while (true) {
try {
retryableStageHelper.startingAttempt();
retryableStageHelper.resolveIdentityForAttempt();
Response<OutputT> response = executeRequest(retryableStageHelper, context);
retryableStageHelper.recordAttemptSucceeded();
return response;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
* {@link SelectedAuthScheme} from the execution context and calls
* {@link IdentityProvider#invalidate} so that the provider refreshes before it vends credentials again.
*
* <p>Identity is resolved once per API call, by a stage that sits outside the retry loop, and every attempt of that
* call reuses it. Invalidation therefore does not affect the attempt currently being retried; it takes effect on the
* next API call that resolves credentials.
* <p>Each retry attempt resolves the identity again (see {@link IdentityResolutionHelper}), so invalidation takes effect on
* the retry of the rejected request, as well as on later API calls. Invalidation is best-effort and the retry path does not
* wait for it. A retry observes it when it completes before the retry attempt resolves the identity, which is always the case
* for the SDK's identity providers because they invalidate inline. If an invalidation is still in progress, the retry
* attempt may reuse the rejected identity.
*
* <p>Both the synchronous and asynchronous request paths must call
* {@link #invalidateIfAuthError(Throwable, RequestExecutionContext)} for the behavior to apply to both client types.
Expand All @@ -62,8 +64,8 @@ private AuthErrorInvalidationHelper() {
* resolved by the time a response has been received, so the invalidation completes inline.
*
* <p>The returned future never completes exceptionally. Invalidation is best-effort, and any failure is logged at
* debug level instead of being propagated. Callers are not required to await it: identity is resolved outside the
* retry loop, so a pending invalidation cannot affect the attempt currently being retried.
* debug level instead of being propagated. Callers are not required to await it, and the retry path does not, so that a
* slow or never-completing invalidation cannot delay or block the request.
*
* @param exception The exception from the failed request attempt
* @param context The request execution context containing auth scheme info
Expand Down
Loading
Loading