resolveIdentity() {
return resolveIdentity(ResolveIdentityRequest.builder().build());
}
+
+ /**
+ * Invalidate cached credentials associated with the given rejected identity.
+ *
+ * When a target service rejects credentials with an authentication error,
+ * the SDK calls this method so the provider can mark its cache for refresh.
+ * The next call to {@link #resolveIdentity} will attempt to fetch fresh credentials.
+ *
+ *
Implementations MUST only invalidate if the currently-cached identity matches
+ * the rejected identity (e.g., same access key ID).
+ *
+ *
The default implementation is a no-op, suitable for providers that do not cache.
+ *
+ * @param identity The identity that was rejected by the service.
+ */
+ default CompletableFuture invalidate(IdentityT identity) {
+ return CompletableFuture.completedFuture(null);
+ }
}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java
index 8772260e3042..26f1645692d0 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java
@@ -20,6 +20,7 @@
import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption;
import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
import software.amazon.awssdk.identity.spi.Identity;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
import software.amazon.awssdk.utils.Validate;
@@ -42,19 +43,33 @@
/// - identity: CompletableFuture ← the resolved identity!
/// - signer: HttpSigner
/// - authSchemeOption: AuthSchemeOption
+/// - identityProvider: IdentityProvider ← the provider that resolved the identity
/// ```
@SdkProtectedApi
public final class SelectedAuthScheme {
private final CompletableFuture extends T> identity;
private final HttpSigner signer;
private final AuthSchemeOption authSchemeOption;
+ private final IdentityProvider identityProvider;
+ /**
+ * @deprecated Use {@link #builder()} instead.
+ */
+ @Deprecated
public SelectedAuthScheme(CompletableFuture extends T> identity,
HttpSigner signer,
AuthSchemeOption authSchemeOption) {
this.identity = Validate.paramNotNull(identity, "identity");
this.signer = Validate.paramNotNull(signer, "signer");
this.authSchemeOption = Validate.paramNotNull(authSchemeOption, "authSchemeOption");
+ this.identityProvider = null;
+ }
+
+ private SelectedAuthScheme(BuilderImpl builder) {
+ this.identity = Validate.paramNotNull(builder.identity, "identity");
+ this.signer = Validate.paramNotNull(builder.signer, "signer");
+ this.authSchemeOption = Validate.paramNotNull(builder.authSchemeOption, "authSchemeOption");
+ this.identityProvider = builder.identityProvider;
}
public CompletableFuture extends T> identity() {
@@ -68,4 +83,84 @@ public HttpSigner signer() {
public AuthSchemeOption authSchemeOption() {
return authSchemeOption;
}
+
+ public IdentityProvider identityProvider() {
+ return identityProvider;
+ }
+
+ /**
+ * Returns a builder initialized with the values from this instance, allowing modification
+ * of individual fields while preserving the rest.
+ */
+ public Builder toBuilder() {
+ return new BuilderImpl()
+ .identity(this.identity)
+ .signer(this.signer)
+ .authSchemeOption(this.authSchemeOption)
+ .identityProvider(this.identityProvider);
+ }
+
+ public static Builder builder() {
+ return new BuilderImpl<>();
+ }
+
+ public interface Builder {
+ /**
+ * The resolved identity future for this auth scheme.
+ */
+ Builder identity(CompletableFuture extends T> identity);
+
+ /**
+ * The signer to use for this auth scheme.
+ */
+ Builder signer(HttpSigner signer);
+
+ /**
+ * The auth scheme option containing signer and identity properties.
+ */
+ Builder authSchemeOption(AuthSchemeOption authSchemeOption);
+
+ /**
+ * The identity provider that resolved the identity. Used for invalidation on auth errors.
+ */
+ Builder identityProvider(IdentityProvider identityProvider);
+
+ SelectedAuthScheme build();
+ }
+
+ private static final class BuilderImpl implements Builder {
+ private CompletableFuture extends T> identity;
+ private HttpSigner signer;
+ private AuthSchemeOption authSchemeOption;
+ private IdentityProvider identityProvider;
+
+ @Override
+ public Builder identity(CompletableFuture extends T> identity) {
+ this.identity = identity;
+ return this;
+ }
+
+ @Override
+ public Builder signer(HttpSigner signer) {
+ this.signer = signer;
+ return this;
+ }
+
+ @Override
+ public Builder authSchemeOption(AuthSchemeOption authSchemeOption) {
+ this.authSchemeOption = authSchemeOption;
+ return this;
+ }
+
+ @Override
+ public Builder identityProvider(IdentityProvider identityProvider) {
+ this.identityProvider = identityProvider;
+ return this;
+ }
+
+ @Override
+ public SelectedAuthScheme build() {
+ return new SelectedAuthScheme<>(this);
+ }
+ }
}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java
index 6403c4e74fdc..46138fa0fd79 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java
@@ -102,6 +102,20 @@ public boolean isRetryableException() {
return false;
}
+ /**
+ * Specifies whether an exception indicates an authentication error where the credentials used for the request
+ * were rejected because they are invalid or expired. This method must return {@code false} for authorization
+ * errors such as {@code AccessDenied}, where the credentials are valid but lack permission for the requested action.
+ *
+ * When this returns {@code true}, the SDK may invalidate cached credentials so that the next attempt
+ * resolves fresh ones.
+ *
+ * @return true if the exception is classified as an authentication error, otherwise false.
+ */
+ public boolean isAuthenticationError() {
+ return false;
+ }
+
/**
* @return {@link Builder} instance to construct a new {@link SdkServiceException}.
*/
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java
index ac795a34c494..51f46a5ffb1b 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java
@@ -155,11 +155,12 @@ public static SelectedAuthScheme mergePreExistingAuthSch
AuthSchemeOption.Builder mergedOption = selectedAuthScheme.authSchemeOption().toBuilder();
existingAuthScheme.authSchemeOption().forEachSignerProperty(mergedOption::putSignerPropertyIfAbsent);
existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent);
- return new SelectedAuthScheme<>(
- selectedAuthScheme.identity(),
- selectedAuthScheme.signer(),
- mergedOption.build()
- );
+ return SelectedAuthScheme.builder()
+ .identity(selectedAuthScheme.identity())
+ .signer(selectedAuthScheme.signer())
+ .authSchemeOption(mergedOption.build())
+ .identityProvider(selectedAuthScheme.identityProvider())
+ .build();
}
// Start with the freshly resolved auth scheme as the base.
@@ -181,11 +182,12 @@ public void accept(SignerProperty key, S value) {
existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent);
- return new SelectedAuthScheme<>(
- selectedAuthScheme.identity(),
- selectedAuthScheme.signer(),
- mergedOption.build()
- );
+ return SelectedAuthScheme.builder()
+ .identity(selectedAuthScheme.identity())
+ .signer(selectedAuthScheme.signer())
+ .authSchemeOption(mergedOption.build())
+ .identityProvider(selectedAuthScheme.identityProvider())
+ .build();
}
/**
@@ -241,9 +243,12 @@ public void accept(SignerProperty key, S value) {
// Only update SELECTED_AUTH_SCHEME if at least one property was re-applied.
if (changed[0]) {
attrs.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME,
- new SelectedAuthScheme<>(currentScheme.identity(),
- currentScheme.signer(),
- mergedOption.build()));
+ SelectedAuthScheme.builder()
+ .identity(currentScheme.identity())
+ .signer(currentScheme.signer())
+ .authSchemeOption(mergedOption.build())
+ .identityProvider(currentScheme.identityProvider())
+ .build());
}
}
@@ -281,7 +286,12 @@ private static SelectedAuthScheme trySelectAuthScheme(
CompletableFuture extends T> identity = resolveIdentity(
identityProvider, identityRequestBuilder.build(), metricCollector);
- return new SelectedAuthScheme<>(identity, signer, authOption);
+ return SelectedAuthScheme.builder()
+ .identity(identity)
+ .signer(signer)
+ .authSchemeOption(authOption)
+ .identityProvider(identityProvider)
+ .build();
}
private static CompletableFuture extends T> resolveIdentity(
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java
index 831340b29d16..ae1a2a941c74 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java
@@ -151,7 +151,12 @@ private void updateIdentityOnExistingScheme(SelectedAuthSch
}
CompletableFuture extends T> identity = identityProvider.resolveIdentity(ResolveIdentityRequest.builder().build());
executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME,
- new SelectedAuthScheme<>(identity, existing.signer(), existing.authSchemeOption()));
+ SelectedAuthScheme.builder()
+ .identity(identity)
+ .signer(existing.signer())
+ .authSchemeOption(existing.authSchemeOption())
+ .identityProvider(identityProvider)
+ .build());
}
private void recordBusinessMetrics(SelectedAuthScheme extends Identity> selectedAuthScheme,
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java
new file mode 100644
index 000000000000..90e20203ee06
--- /dev/null
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java
@@ -0,0 +1,90 @@
+/*
+ * 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 software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.core.SelectedAuthScheme;
+import software.amazon.awssdk.core.exception.SdkServiceException;
+import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
+import software.amazon.awssdk.core.internal.http.RequestExecutionContext;
+import software.amazon.awssdk.identity.spi.Identity;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
+import software.amazon.awssdk.utils.CompletableFutureUtils;
+import software.amazon.awssdk.utils.Logger;
+
+/**
+ * Utility that detects authentication error responses and triggers credential invalidation
+ * on the identity provider that produced the rejected credentials.
+ *
+ * When a service returns an authentication error (as determined by
+ * {@link SdkServiceException#isAuthenticationError()}), this helper retrieves the
+ * {@link SelectedAuthScheme} from the execution context and calls
+ * {@link IdentityProvider#invalidate} so the next retry attempt resolves fresh credentials.
+ *
+ *
All exceptions from the invalidation path are caught and logged at debug level.
+ * Invalidation failures never disrupt the normal request/retry flow.
+ */
+@SdkInternalApi
+public final class AuthErrorInvalidationHelper {
+
+ private static final Logger LOG = Logger.loggerFor(AuthErrorInvalidationHelper.class);
+
+ private AuthErrorInvalidationHelper() {
+ }
+
+ /**
+ * Checks whether the given exception is an auth error that should trigger
+ * credential invalidation. If so, retrieves the identity provider from the
+ * {@link SelectedAuthScheme} and calls invalidate() on it.
+ *
+ * @param exception The exception from the failed request attempt
+ * @param context The request execution context containing auth scheme info
+ */
+ public static void invalidateIfAuthError(Throwable exception, RequestExecutionContext context) {
+ if (!(exception instanceof SdkServiceException)) {
+ return;
+ }
+
+ SdkServiceException serviceException = (SdkServiceException) exception;
+ if (!serviceException.isAuthenticationError()) {
+ return;
+ }
+
+ SelectedAuthScheme> selectedAuthScheme =
+ context.executionAttributes().getAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME);
+
+ if (selectedAuthScheme == null || selectedAuthScheme.identityProvider() == null) {
+ return;
+ }
+
+ try {
+ doInvalidate(selectedAuthScheme);
+ } catch (Exception e) {
+ LOG.debug(() -> "Failed to invalidate identity provider after auth error: " + e.getMessage(), e);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) {
+ T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity());
+ IdentityProvider provider = selectedAuthScheme.identityProvider();
+ try {
+ CompletableFutureUtils.joinLikeSync(provider.invalidate(resolvedIdentity));
+ } catch (Exception e) {
+ LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e);
+ }
+ }
+}
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 840df78367fd..35d4dab90b8a 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
@@ -138,6 +138,9 @@ public void recordAttemptSucceeded() {
* code should not retry.
*/
public Either tryRefreshToken(Duration suggestedDelay) {
+ // Invalidate cached credentials if this failure is an auth error, before the retry strategy evaluates.
+ AuthErrorInvalidationHelper.invalidateIfAuthError(this.lastException, context);
+
RetryToken retryToken = context.executionAttributes().getAttribute(RETRY_TOKEN);
RefreshRetryTokenResponse refreshResponse;
try {
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java
new file mode 100644
index 000000000000..62b8b6e4aca9
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java
@@ -0,0 +1,293 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import software.amazon.awssdk.core.SdkRequest;
+import software.amazon.awssdk.core.SelectedAuthScheme;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.core.exception.SdkServiceException;
+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.RequestExecutionContext;
+import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption;
+import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
+import software.amazon.awssdk.identity.spi.Identity;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
+import software.amazon.awssdk.identity.spi.ResolveIdentityRequest;
+
+/**
+ * Unit tests for {@link AuthErrorInvalidationHelper}.
+ */
+public class AuthErrorInvalidationHelperTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ExpiredToken", "InvalidToken"})
+ void invalidateIfAuthError_whenAuthErrorCode_triggersInvalidation(String errorCode) {
+ TrackingIdentityProvider provider = new TrackingIdentityProvider();
+ TestIdentity identity = new TestIdentity();
+ RequestExecutionContext context = contextWithProvider(provider, identity);
+ Throwable exception = serviceExceptionWithErrorCode(errorCode);
+
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context);
+
+ assertThat(provider.invalidateCalled()).isTrue();
+ assertThat(provider.lastInvalidatedIdentity()).isSameAs(identity);
+ }
+
+ @Test
+ void invalidateIfAuthError_whenAccessDenied_doesNotTriggerInvalidation() {
+ TrackingIdentityProvider provider = new TrackingIdentityProvider();
+ TestIdentity identity = new TestIdentity();
+ RequestExecutionContext context = contextWithProvider(provider, identity);
+ Throwable exception = serviceExceptionWithErrorCode("AccessDenied");
+
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context);
+
+ assertThat(provider.invalidateCalled()).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException", "AuthFailure"})
+ void invalidateIfAuthError_whenUnknownErrorCode_doesNotTriggerInvalidation(String errorCode) {
+ TrackingIdentityProvider provider = new TrackingIdentityProvider();
+ TestIdentity identity = new TestIdentity();
+ RequestExecutionContext context = contextWithProvider(provider, identity);
+ Throwable exception = serviceExceptionWithErrorCode(errorCode);
+
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context);
+
+ assertThat(provider.invalidateCalled()).isFalse();
+ }
+
+ @ParameterizedTest
+ @MethodSource("nonServiceExceptions")
+ void invalidateIfAuthError_whenNonSdkServiceException_doesNotTriggerInvalidation(Throwable exception) {
+ TrackingIdentityProvider provider = new TrackingIdentityProvider();
+ TestIdentity identity = new TestIdentity();
+ RequestExecutionContext context = contextWithProvider(provider, identity);
+
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context);
+
+ assertThat(provider.invalidateCalled()).isFalse();
+ }
+
+ static Stream nonServiceExceptions() {
+ return Stream.of(
+ new RuntimeException("something went wrong"),
+ SdkClientException.create("client error"),
+ new IOException("io error")
+ );
+ }
+
+ @Test
+ void invalidateIfAuthError_whenSelectedAuthSchemeIsNull_doesNotThrow() {
+ RequestExecutionContext context = contextWithNoAuthScheme();
+ Throwable exception = serviceExceptionWithErrorCode("ExpiredToken");
+
+ assertThatNoException().isThrownBy(() ->
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context)
+ );
+ }
+
+ @Test
+ void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() {
+ TestIdentity identity = new TestIdentity();
+ SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>(
+ CompletableFuture.completedFuture(identity),
+ mockSigner(),
+ AuthSchemeOption.builder().schemeId("test").build()
+ );
+
+ RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme);
+ Throwable exception = serviceExceptionWithErrorCode("ExpiredToken");
+
+ assertThatNoException().isThrownBy(() ->
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context)
+ );
+ }
+
+ @Test
+ void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() {
+ ThrowingIdentityProvider provider = new ThrowingIdentityProvider();
+ TestIdentity identity = new TestIdentity();
+ SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder()
+ .identity(CompletableFuture.completedFuture(identity))
+ .signer(mockSigner())
+ .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build())
+ .identityProvider(provider)
+ .build();
+
+ RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme);
+ Throwable exception = serviceExceptionWithErrorCode("ExpiredToken");
+
+ assertThatNoException().isThrownBy(() ->
+ AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context)
+ );
+ }
+
+ // --- Helper methods ---
+
+ private RequestExecutionContext contextWithProvider(TrackingIdentityProvider provider, TestIdentity identity) {
+ SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder()
+ .identity(CompletableFuture.completedFuture(identity))
+ .signer(mockSigner())
+ .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build())
+ .identityProvider(provider)
+ .build();
+ return contextWithSelectedAuthScheme(selectedAuthScheme);
+ }
+
+ private RequestExecutionContext contextWithSelectedAuthScheme(SelectedAuthScheme> selectedAuthScheme) {
+ ExecutionAttributes executionAttributes = ExecutionAttributes.builder()
+ .put(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme)
+ .build();
+
+ ExecutionContext executionContext = ExecutionContext.builder()
+ .executionAttributes(executionAttributes)
+ .build();
+
+ return RequestExecutionContext.builder()
+ .executionContext(executionContext)
+ .originalRequest(mock(SdkRequest.class))
+ .build();
+ }
+
+ private RequestExecutionContext contextWithNoAuthScheme() {
+ ExecutionAttributes executionAttributes = ExecutionAttributes.builder().build();
+
+ ExecutionContext executionContext = ExecutionContext.builder()
+ .executionAttributes(executionAttributes)
+ .build();
+
+ return RequestExecutionContext.builder()
+ .executionContext(executionContext)
+ .originalRequest(mock(SdkRequest.class))
+ .build();
+ }
+
+ /**
+ * Creates an SdkServiceException subclass that overrides {@code isAuthenticationError()} to
+ * return true for the known auth error codes.
+ */
+ private Throwable serviceExceptionWithErrorCode(String errorCode) {
+ return new TestAwsServiceException(errorCode);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpSigner mockSigner() {
+ return (HttpSigner) mock(HttpSigner.class);
+ }
+
+ // --- Test doubles ---
+
+ /**
+ * A simple identity implementation for testing.
+ */
+ private static class TestIdentity implements Identity {
+ }
+
+ /**
+ * An identity provider that tracks whether invalidate() was called.
+ */
+ private static class TrackingIdentityProvider implements IdentityProvider {
+ private final AtomicBoolean invalidateCalled = new AtomicBoolean(false);
+ private final AtomicReference lastInvalidatedIdentity = new AtomicReference<>();
+
+ @Override
+ public Class identityType() {
+ return TestIdentity.class;
+ }
+
+ @Override
+ public CompletableFuture resolveIdentity(ResolveIdentityRequest request) {
+ return CompletableFuture.completedFuture(new TestIdentity());
+ }
+
+ @Override
+ public CompletableFuture invalidate(TestIdentity identity) {
+ invalidateCalled.set(true);
+ lastInvalidatedIdentity.set(identity);
+ return CompletableFuture.completedFuture(null);
+ }
+
+ boolean invalidateCalled() {
+ return invalidateCalled.get();
+ }
+
+ TestIdentity lastInvalidatedIdentity() {
+ return lastInvalidatedIdentity.get();
+ }
+ }
+
+ /**
+ * An identity provider that throws on invalidate() — used to test exception isolation.
+ */
+ private static class ThrowingIdentityProvider implements IdentityProvider {
+ @Override
+ public Class identityType() {
+ return TestIdentity.class;
+ }
+
+ @Override
+ public CompletableFuture resolveIdentity(ResolveIdentityRequest request) {
+ return CompletableFuture.completedFuture(new TestIdentity());
+ }
+
+ @Override
+ public CompletableFuture invalidate(TestIdentity identity) {
+ throw new RuntimeException("Simulated invalidation failure");
+ }
+ }
+
+ /**
+ * Simulates an AwsServiceException that reports authentication errors via the
+ * {@link SdkServiceException#isAuthenticationError()} virtual method.
+ */
+ private static class TestAwsServiceException extends SdkServiceException {
+ private static final Set AUTH_ERROR_CODES = new HashSet<>(Arrays.asList(
+ "ExpiredToken", "InvalidToken"
+ ));
+
+ private final String errorCode;
+
+ TestAwsServiceException(String errorCode) {
+ super(SdkServiceException.builder().message("test exception").statusCode(401));
+ this.errorCode = errorCode;
+ }
+
+ @Override
+ public boolean isAuthenticationError() {
+ return AUTH_ERROR_CODES.contains(errorCode);
+ }
+ }
+}
diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java
index be2b61486860..8515b4900b79 100644
--- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java
+++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java
@@ -25,6 +25,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import software.amazon.awssdk.annotations.NotThreadSafe;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.annotations.SdkPublicApi;
@@ -35,6 +36,7 @@
import software.amazon.awssdk.core.SdkPlugin;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.signin.SigninClient;
import software.amazon.awssdk.services.signin.internal.AccessTokenManager;
import software.amazon.awssdk.services.signin.internal.DpopAuthPlugin;
@@ -129,7 +131,7 @@ private LoginCredentialsProvider(BuilderImpl builder) {
CachedSupplier.builder(this::updateSigninCredentials)
.cachedValueName(toString())
.staleValueBehavior(ALLOW)
- .cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating);
+ .nonRecoverableErrorPredicate(LoginCredentialsProvider::isNonRecoverableError);
if (builder.asyncCredentialUpdateEnabled) {
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
}
@@ -217,11 +219,11 @@ private RefreshResult refreshFromSigninService(LoginAccessToken
switch (accessDeniedException.error()) {
case TOKEN_EXPIRED:
case USER_CREDENTIALS_CHANGED:
- // Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate
+ // Let the original AccessDeniedException propagate — the nonRecoverableErrorPredicate
// on CachedSupplier will identify it and bypass static stability.
throw accessDeniedException;
case INSUFFICIENT_PERMISSIONS:
- // Wrap with a helpful message, but still cache-invalidating — the predicate checks the cause.
+ // Wrap with a helpful message, but still non-recoverable — the predicate checks the cause.
throw SdkClientException.create(
"Unable to refresh credentials due to insufficient permissions. You may be missing permission "
+ "for the 'CreateOAuth2Token' action.",
@@ -243,7 +245,7 @@ private RefreshResult refreshFromSigninService(LoginAccessToken
* {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}
*
*/
- private static boolean isCacheInvalidating(RuntimeException e) {
+ private static boolean isNonRecoverableError(RuntimeException e) {
if (e instanceof InvalidTokenException) {
return true;
}
@@ -295,6 +297,13 @@ public AwsCredentials resolveCredentials() {
return credentialCache.get();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ String rejectedAccessKeyId = identity.accessKeyId();
+ credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId()));
+ return CompletableFuture.completedFuture(null);
+ }
+
@Override
public void close() {
credentialCache.close();
diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java
index ae5fa97e8589..b1c65e09928d 100644
--- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java
+++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java
@@ -15,11 +15,13 @@
package software.amazon.awssdk.services.signin.auth;
+import java.util.concurrent.CompletableFuture;
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.ProfileCredentialsProviderFactory;
import software.amazon.awssdk.auth.credentials.ProfileProviderCredentialsContext;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.profiles.Profile;
import software.amazon.awssdk.profiles.ProfileProperty;
import software.amazon.awssdk.services.signin.SigninClient;
@@ -66,6 +68,11 @@ public AwsCredentials resolveCredentials() {
return this.credentialsProvider.resolveCredentials();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ return this.credentialsProvider.invalidate(identity);
+ }
+
@Override
public void close() {
IoUtils.closeQuietly(credentialsProvider, null);
diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java
index d81ae3dba549..7b341cc0367d 100644
--- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java
+++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java
@@ -22,12 +22,14 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.sso.SsoClient;
import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder;
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest;
@@ -99,7 +101,7 @@ private SsoCredentialsProvider(BuilderImpl builder) {
CachedSupplier.builder(this::updateSsoCredentials)
.cachedValueName(toString())
.staleValueBehavior(ALLOW)
- .cacheInvalidatingPredicate(
+ .nonRecoverableErrorPredicate(
e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException);
if (builder.asyncCredentialUpdateEnabled) {
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
@@ -168,6 +170,13 @@ public AwsCredentials resolveCredentials() {
return credentialCache.get().sessionCredentials();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ String rejectedAccessKeyId = identity.accessKeyId();
+ credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId()));
+ return CompletableFuture.completedFuture(null);
+ }
+
@Override
public void close() {
credentialCache.close();
diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java
index 0e7f93219c43..428e2a4fb3f8 100644
--- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java
+++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java
@@ -20,6 +20,7 @@
import java.nio.file.Paths;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.annotations.SdkTestInternalApi;
@@ -33,6 +34,7 @@
import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider;
import software.amazon.awssdk.auth.token.internal.LazyTokenProvider;
import software.amazon.awssdk.core.exception.SdkServiceException;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.profiles.Profile;
import software.amazon.awssdk.profiles.ProfileFile;
import software.amazon.awssdk.profiles.ProfileProperty;
@@ -137,6 +139,11 @@ public AwsCredentials resolveCredentials() {
return this.credentialsProvider.resolveCredentials();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ return this.credentialsProvider.invalidate(identity);
+ }
+
@Override
public void close() {
IoUtils.closeQuietly(credentialsProvider, null);
diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java
index fc0668a10811..dc0f54193e33 100644
--- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java
+++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java
@@ -27,9 +27,11 @@
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.sso.SsoClient;
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest;
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse;
@@ -209,6 +211,73 @@ public void noCachedCredentials_anyFailure_throwsImmediately() {
+ @Test
+ public void invalidate_matchingAccessKeyId_causesRefresh() {
+ ssoClient = mock(SsoClient.class);
+ RoleCredentials credentials = RoleCredentials.builder()
+ .accessKeyId("a")
+ .secretAccessKey("b")
+ .sessionToken("c")
+ .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli())
+ .build();
+ RoleCredentials credentials2 = RoleCredentials.builder()
+ .accessKeyId("x")
+ .secretAccessKey("y")
+ .sessionToken("z")
+ .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli())
+ .build();
+
+ Supplier supplier = getRequestSupplier();
+ when(ssoClient.getRoleCredentials(supplier.get()))
+ .thenReturn(getResponse(credentials))
+ .thenReturn(getResponse(credentials2));
+
+ try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder()
+ .refreshRequest(supplier)
+ .ssoClient(ssoClient)
+ .build()) {
+ AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials();
+ assertThat(first.accessKeyId()).isEqualTo("a");
+
+ AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b");
+ credentialsProvider.invalidate(identity).join();
+
+ AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials();
+ assertThat(second.accessKeyId()).isEqualTo("x");
+ }
+ }
+
+ @Test
+ public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() {
+ ssoClient = mock(SsoClient.class);
+ RoleCredentials credentials = RoleCredentials.builder()
+ .accessKeyId("a")
+ .secretAccessKey("b")
+ .sessionToken("c")
+ .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli())
+ .build();
+
+ Supplier supplier = getRequestSupplier();
+ when(ssoClient.getRoleCredentials(supplier.get())).thenReturn(getResponse(credentials));
+
+ try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder()
+ .refreshRequest(supplier)
+ .ssoClient(ssoClient)
+ .build()) {
+ AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials();
+ assertThat(first.accessKeyId()).isEqualTo("a");
+
+ AwsCredentialsIdentity identity = AwsBasicCredentials.create("different-key", "b");
+ credentialsProvider.invalidate(identity).join();
+
+ AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials();
+ assertThat(second.accessKeyId()).isEqualTo("a");
+ callClient(verify(ssoClient, times(1)), Mockito.any());
+ }
+ }
+
+
+
private GetRoleCredentialsRequestSupplier getRequestSupplier() {
return new GetRoleCredentialsRequestSupplier(GetRoleCredentialsRequest.builder()
.accountId("123456789")
diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java
index d6956ac87022..e40017020be5 100644
--- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java
+++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java
@@ -46,6 +46,7 @@
import software.amazon.awssdk.auth.credentials.ProfileProviderCredentialsContext;
import software.amazon.awssdk.auth.token.credentials.SdkToken;
import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider;
+import software.amazon.awssdk.core.exception.SdkServiceException;
import software.amazon.awssdk.profiles.ProfileFile;
import software.amazon.awssdk.services.sso.SsoClient;
import software.amazon.awssdk.services.sso.auth.ExpiredTokenException;
@@ -561,7 +562,10 @@ public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() {
when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class)))
.thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build());
- RuntimeException secondCallError = new RuntimeException("Token refresh failed on second attempt");
+ RuntimeException secondCallError = SdkServiceException.builder()
+ .message("SSO service unavailable")
+ .statusCode(500)
+ .build();
when(sdkTokenProvider.resolveToken())
.thenReturn(SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build())
.thenThrow(secondCallError);
diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java
index 431ffbac43bb..e02c125e7d39 100644
--- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java
+++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java
@@ -18,6 +18,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import software.amazon.awssdk.annotations.NotThreadSafe;
import software.amazon.awssdk.annotations.SdkPublicApi;
@@ -25,6 +26,7 @@
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.utils.Logger;
import software.amazon.awssdk.utils.SdkAutoCloseable;
@@ -123,6 +125,13 @@ public void close() {
sessionCache.close();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ String rejectedAccessKeyId = identity.accessKeyId();
+ sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId()));
+ return CompletableFuture.completedFuture(null);
+ }
+
/**
* The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are
* within this window, all threads will block until the credentials are updated.
diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java
index 389f4415bdd9..0fbb775306d0 100644
--- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java
+++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java
@@ -23,6 +23,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkPublicApi;
@@ -32,6 +33,7 @@
import software.amazon.awssdk.auth.credentials.internal.WebIdentityTokenCredentialProperties;
import software.amazon.awssdk.core.SdkSystemSetting;
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.internal.AssumeRoleWithWebIdentityRequestSupplier;
import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest;
@@ -155,6 +157,14 @@ public AwsCredentials resolveCredentials() {
return awsCredentials;
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ if (credentialsProvider != null) {
+ return credentialsProvider.invalidate(identity);
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+
@Override
protected AwsSessionCredentials getUpdatedCredentials(StsClient stsClient) {
AssumeRoleWithWebIdentityRequest request =
diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java
index e20236a8652d..7813739f40f9 100644
--- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java
+++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java
@@ -16,10 +16,12 @@
package software.amazon.awssdk.services.sts.internal;
import java.net.URI;
+import java.util.concurrent.CompletableFuture;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.ChildProfileCredentialsProviderFactory;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.profiles.Profile;
import software.amazon.awssdk.profiles.ProfileProperty;
import software.amazon.awssdk.regions.Region;
@@ -117,6 +119,11 @@ public AwsCredentials resolveCredentials() {
return this.credentialsProvider.resolveCredentials();
}
+ @Override
+ public CompletableFuture invalidate(AwsCredentialsIdentity identity) {
+ return this.credentialsProvider.invalidate(identity);
+ }
+
@Override
public void close() {
IoUtils.closeIfCloseable(parentCredentialsProvider, null);
diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java
index 10014ddbf7c9..d6849d804dc4 100644
--- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java
+++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java
@@ -28,9 +28,11 @@
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.endpoints.internal.Arn;
import software.amazon.awssdk.services.sts.model.Credentials;
@@ -159,6 +161,55 @@ public void initialFetchFailureThrowsException_noCachedCredentials() {
}
}
+ @Test
+ public void invalidate_matchingAccessKeyId_causesRefresh() {
+ Credentials credentials = Credentials.builder()
+ .accessKeyId("a").secretAccessKey("b").sessionToken("c")
+ .expiration(Instant.now().plus(Duration.ofHours(5)))
+ .build();
+ Credentials credentials2 = Credentials.builder()
+ .accessKeyId("x").secretAccessKey("y").sessionToken("z")
+ .expiration(Instant.now().plus(Duration.ofHours(5)))
+ .build();
+ RequestT request = getRequest();
+ when(callClient(stsClient, request))
+ .thenReturn(getResponse(credentials))
+ .thenReturn(getResponse(credentials2));
+
+ try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) {
+ AwsCredentials first = credentialsProvider.resolveCredentials();
+ assertThat(first.accessKeyId()).isEqualTo("a");
+
+ AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b");
+ credentialsProvider.invalidate(identity).join();
+
+ AwsCredentials second = credentialsProvider.resolveCredentials();
+ assertThat(second.accessKeyId()).isEqualTo("x");
+ }
+ }
+
+ @Test
+ public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() {
+ Credentials credentials = Credentials.builder()
+ .accessKeyId("a").secretAccessKey("b").sessionToken("c")
+ .expiration(Instant.now().plus(Duration.ofHours(5)))
+ .build();
+ RequestT request = getRequest();
+ when(callClient(stsClient, request)).thenReturn(getResponse(credentials));
+
+ try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) {
+ AwsCredentials first = credentialsProvider.resolveCredentials();
+ assertThat(first.accessKeyId()).isEqualTo("a");
+
+ AwsCredentialsIdentity identity = AwsBasicCredentials.create("different", "b");
+ credentialsProvider.invalidate(identity).join();
+
+ AwsCredentials second = credentialsProvider.resolveCredentials();
+ assertThat(second.accessKeyId()).isEqualTo("a");
+ callClient(verify(stsClient, times(1)), Mockito.any());
+ }
+ }
+
public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, int numTimesInvokeCredentialsProvider, boolean overrideStaleAndPrefetchTimes) {
Credentials credentials = Credentials.builder().accessKeyId("a").secretAccessKey("b").sessionToken("c").expiration(credentialsExpirationDate).build();
RequestT request = getRequest();
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java
index 998648a6ca02..f7921f7a2ad2 100644
--- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java
+++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java
@@ -41,7 +41,7 @@ private CacheRefreshUtils() {
* dynamically based on the credential's remaining lifetime so that longer-lived credentials begin refreshing
* earlier and shorter-lived credentials do not attempt a refresh the moment they are issued.
*
- * Dynamic window selection:
+ * Dynamic window selection:
*
* - remaining lifetime < 20 minutes → 5 minute window
* - 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java
index ab978bb1a7dc..8ce75cf3751e 100644
--- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java
+++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java
@@ -117,7 +117,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable {
* Predicate that determines whether an exception represents a non-recoverable refresh failure
* that should bypass static stability (i.e., be re-thrown immediately without extending expiration).
*/
- private final Predicate cacheInvalidatingPredicate;
+ private final Predicate nonRecoverableErrorPredicate;
+
+ /**
+ * Tracks when the next refresh attempt is allowed after a failure. This is set under {@link #refreshLock}
+ * and read without the lock (volatile). When non-null and in the future, {@link #get()} returns the cached
+ * value without contacting the source.
+ *
+ * This field is NEVER modified by {@code invalidate()} — backoff remains independently tracked.
+ */
+ private volatile Instant nextAllowedRefreshTime;
private CachedSupplier(Builder builder) {
Validate.notNull(builder.supplier, "builder.supplier");
@@ -128,7 +137,7 @@ private CachedSupplier(Builder builder) {
this.staleValueBehavior = Validate.notNull(builder.staleValueBehavior, "builder.staleValueBehavior");
this.clock = Validate.notNull(builder.clock, "builder.clock");
this.cachedValueName = Validate.notNull(builder.cachedValueName, "builder.cachedValueName");
- this.cacheInvalidatingPredicate = builder.cacheInvalidatingPredicate;
+ this.nonRecoverableErrorPredicate = builder.nonRecoverableErrorPredicate;
}
/**
@@ -143,9 +152,15 @@ public static CachedSupplier.Builder builder(Supplier> v
@Override
public T get() {
if (cacheIsStale()) {
+ if (refreshRateLimited()) {
+ return this.cachedValue.value();
+ }
log.debug(() -> "(" + cachedValueName + ") Cached value is stale and will be refreshed.");
refreshCache();
} else if (shouldInitiateCachePrefetch()) {
+ if (refreshRateLimited()) {
+ return this.cachedValue.value();
+ }
log.debug(() -> "(" + cachedValueName + ") Cached value has reached prefetch time and will be refreshed.");
prefetchCache();
}
@@ -153,6 +168,53 @@ public T get() {
return this.cachedValue.value();
}
+ /**
+ * Marks the cached value for mandatory refresh if the predicate matches.
+ * Sets staleTime to now() so the next get() triggers a refresh, subject to
+ * the refresh backoff gate ({@code nextAllowedRefreshTime}).
+ *
+ * This method MUST NOT discard the cached value.
+ * This method MUST NOT clear or modify {@code nextAllowedRefreshTime}.
+ *
+ *
If there is no cached value, this method is a no-op — the predicate will not be called.
+ *
+ * @param matchesCachedValue A predicate that returns true if the cached value
+ * is the one that should be invalidated. The value passed
+ * to the predicate is guaranteed to be non-null.
+ */
+ public void invalidate(Predicate matchesCachedValue) {
+ try {
+ boolean lockAcquired = refreshLock.tryLock(BLOCKING_REFRESH_MAX_WAIT.getSeconds(), TimeUnit.SECONDS);
+ if (!lockAcquired) {
+ log.debug(() -> "(" + cachedValueName + ") Unable to acquire lock for invalidation; skipping.");
+ return;
+ }
+ try {
+ RefreshResult currentCachedValue = this.cachedValue;
+ if (currentCachedValue == null || currentCachedValue.value() == null) {
+ return;
+ }
+ if (!matchesCachedValue.test(currentCachedValue.value())) {
+ return;
+ }
+ // Set staleTime = now, routing next get() through mandatory refresh
+ // (subject to nextAllowedRefreshTime gate)
+ Instant now = clock.instant();
+ this.cachedValue = currentCachedValue.toBuilder()
+ .staleTime(now)
+ .prefetchTime(now)
+ .build();
+ log.debug(() -> "(" + cachedValueName + ") Cached value invalidated. "
+ + "Next get() will attempt mandatory refresh.");
+ } finally {
+ refreshLock.unlock();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.debug(() -> "(" + cachedValueName + ") Interrupted while invalidating cached value.");
+ }
+ }
+
/**
* Determines whether the value in this cache is stale, and all threads should block and wait for an updated value.
*/
@@ -189,6 +251,16 @@ private boolean shouldInitiateCachePrefetch() {
return !clock.instant().isBefore(currentCachedValue.prefetchTime());
}
+ /**
+ * Checks whether a refresh backoff is currently active. When a refresh fails, a backoff gate
+ * ({@link #nextAllowedRefreshTime}) is set. While the gate is in the future, the cached value
+ * is returned without contacting the credential source.
+ */
+ private boolean refreshRateLimited() {
+ Instant nextAllowed = this.nextAllowedRefreshTime;
+ return nextAllowed != null && clock.instant().isBefore(nextAllowed);
+ }
+
/**
* Initiate a pre-fetch of the data using the configured {@link #prefetchStrategy}.
*/
@@ -241,6 +313,7 @@ private void refreshCache() {
* Perform necessary transformations of the successfully-fetched value based on the stale value behavior of this supplier.
*/
private RefreshResult handleFetchedSuccess(RefreshResult fetch) {
+ this.nextAllowedRefreshTime = null; // Clear backoff gate on success
Instant now = clock.instant();
if (now.isBefore(fetch.staleTime())) {
@@ -283,26 +356,22 @@ private RefreshResult handleFetchFailure(RuntimeException e) {
case STRICT:
throw e;
case ALLOW:
- // Cache-invalidating errors bypass static stability
- if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) {
+ // Non-recoverable errors bypass static stability
+ if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) {
throw e;
}
- // Uniform random backoff: 5-10 minutes
+ // Set backoff gate — do NOT modify staleTime/prefetchTime
long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds()
+ jitterRandom.nextInt(
(int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds()
- STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1));
- Instant extendedStaleTime = now.plusSeconds(backoffSeconds);
+ this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds);
log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage()
- + ". Extending cached credential expiration. A refresh of these credentials"
- + " will be attempted again after " + backoffSeconds + " seconds.", e);
+ + ". Will retry after " + backoffSeconds + " seconds.", e);
- return currentCachedValue.toBuilder()
- .staleTime(extendedStaleTime)
- .prefetchTime(extendedStaleTime)
- .build();
+ return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched
default:
throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior);
}
@@ -310,31 +379,21 @@ private RefreshResult handleFetchFailure(RuntimeException e) {
// Not yet stale — we're in the prefetch window. Handle failure based on mode.
if (staleValueBehavior == StaleValueBehavior.ALLOW) {
- if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) {
+ if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) {
throw e;
}
- // During prefetch window failure: extend prefetchTime to suppress further attempts.
- // Preserve existing staleTime if it is later than the new prefetch time.
+
+ // Set backoff gate — do NOT modify staleTime/prefetchTime
long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds()
+ jitterRandom.nextInt(
(int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds()
- STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1));
- Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds);
-
- // Do not move stale time closer — keep the existing stale time if it's later than the extended prefetch time
- Instant currentStaleTime = currentCachedValue.staleTime();
- Instant newStaleTime = (currentStaleTime != null && currentStaleTime.isAfter(extendedPrefetchTime))
- ? currentStaleTime
- : extendedPrefetchTime;
+ this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds);
log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage()
- + ". Extending cached credential expiration. A refresh of these credentials"
- + " will be attempted again after " + backoffSeconds + " seconds.", e);
+ + ". Will retry after " + backoffSeconds + " seconds.", e);
- return currentCachedValue.toBuilder()
- .staleTime(newStaleTime)
- .prefetchTime(extendedPrefetchTime)
- .build();
+ return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched
}
return currentCachedValue;
@@ -423,7 +482,7 @@ public static final class Builder {
private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT;
private Clock clock = Clock.systemUTC();
private String cachedValueName = "unknown";
- private Predicate cacheInvalidatingPredicate;
+ private Predicate nonRecoverableErrorPredicate;
private Builder(Supplier> supplier) {
this.supplier = supplier;
@@ -470,13 +529,13 @@ public Builder cachedValueName(String cachedValueName) {
*
* This is used for errors where the credential source has definitively indicated that the current
* authentication state is invalid and requires user intervention (e.g., expired SSO tokens,
- * changed user credentials).
+ * changed user credentials).
*
- * By default, no exceptions are considered cache-invalidating (all failures trigger static stability
- * backoff when {@link StaleValueBehavior#ALLOW} is configured).
+ * By default, no exceptions are considered non-recoverable (all failures trigger static stability
+ * backoff when {@link StaleValueBehavior#ALLOW} is configured).
*/
- public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) {
- this.cacheInvalidatingPredicate = cacheInvalidatingPredicate;
+ public Builder nonRecoverableErrorPredicate(Predicate nonRecoverableErrorPredicate) {
+ this.nonRecoverableErrorPredicate = nonRecoverableErrorPredicate;
return this;
}
@@ -558,11 +617,11 @@ public enum StaleValueBehavior {
* Allow stale values to be returned from the cache with static stability semantics. On refresh failure,
* extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds).
*
- * If a {@link Builder#cacheInvalidatingPredicate(Predicate)} is configured and returns {@code true}
- * for the exception, it is re-thrown immediately without extending the stale time.
+ * If a {@link Builder#nonRecoverableErrorPredicate(Predicate)} is configured and returns {@code true}
+ * for the exception, it is re-thrown immediately without extending the stale time.
*
*
Value retrieval will never fail as long as the cache has succeeded at least once,
- * unless the error is cache-invalidating.
+ * unless the error is non-recoverable.
*/
ALLOW
}
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java
index 60a55265fba6..e6bb3bbf1bdc 100644
--- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java
+++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java
@@ -35,11 +35,13 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -397,7 +399,7 @@ public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedExce
MutableSupplier supplier = new MutableSupplier();
try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier)
.staleValueBehavior(ALLOW)
- .cacheInvalidatingPredicate(
+ .nonRecoverableErrorPredicate(
e -> e instanceof CacheInvalidatingRuntimeException)
.clock(clock)
.jitterEnabled(false)
@@ -449,30 +451,29 @@ public void allowMode_backoffIsInExpectedRange() throws InterruptedException {
supplier.set(new RuntimeException("service unavailable"));
cachedSupplier.get();
- // Advance well past the extended time to test that the backoff was applied
- // The extended stale time should be: now(61) + [300,600]s(backoff)
- // So total offset from epoch: 61 + [300,600] = [361, 661] seconds from original now
- Instant minExpectedStale = now.plusSeconds(61 + 300);
- Instant maxExpectedStale = now.plusSeconds(61 + 600);
+ // Now nextAllowedRefreshTime is set to now(61) + [300,600]s
+ // The cached value should be returned while rate limited
+ Instant minBackoffEnd = now.plusSeconds(61 + 300);
+ Instant maxBackoffEnd = now.plusSeconds(61 + 600);
- // Advance just before the minimum backoff - should still return cached (not stale yet)
- clock.time = minExpectedStale.minusSeconds(1);
+ // Advance just before the minimum backoff end - should still be rate limited
+ clock.time = minBackoffEnd.minusSeconds(1);
supplier.set(RefreshResult.builder("new-creds")
.staleTime(Instant.MAX)
.prefetchTime(Instant.MAX)
.build());
- // Value not stale yet so should return cached
+ // Rate limited: returns cached value without contacting source
assertThat(cachedSupplier.get()).isEqualTo("cached-creds");
- // Advance past maximum possible backoff - must be stale now and will refresh
- clock.time = maxExpectedStale.plusSeconds(1);
+ // Advance past maximum possible backoff - rate limit expired, will refresh
+ clock.time = maxBackoffEnd.plusSeconds(1);
assertThat(cachedSupplier.get()).isEqualTo("new-creds");
}
}
}
@Test
- public void allowMode_prefetchWindowFailure_extendsPrefetchTime() {
+ public void allowMode_prefetchWindowFailure_setsBackoffGate() {
AdjustableClock clock = new AdjustableClock();
MutableSupplier supplier = new MutableSupplier();
try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier)
@@ -494,17 +495,17 @@ public void allowMode_prefetchWindowFailure_extendsPrefetchTime() {
clock.time = now.plusSeconds(61);
supplier.set(new RuntimeException("service unavailable"));
- // Should return cached value (not throw) and extend prefetch time
+ // Should return cached value (not throw) and set nextAllowedRefreshTime
assertThat(cachedSupplier.get()).isEqualTo("cached-creds");
// Verify that a subsequent call shortly after does NOT attempt another refresh
- // (because prefetchTime was extended)
+ // (because nextAllowedRefreshTime was set as a backoff gate)
clock.time = now.plusSeconds(62);
supplier.set(RefreshResult.builder("should-not-get-this")
.staleTime(Instant.MAX)
.prefetchTime(Instant.MAX)
.build());
- // The prefetchTime was extended far into the future, so this should still return cached
+ // The rate limit is active, so this should still return cached
assertThat(cachedSupplier.get()).isEqualTo("cached-creds");
}
}
@@ -536,18 +537,14 @@ public void allowMode_prefetchWindowFailure_preservesStaleTime() {
// Trigger failure during prefetch window
assertThat(cachedSupplier.get()).isEqualTo("cached-creds");
- // Verify the stale time was preserved (NOT moved to the backoff time).
- // The original stale time (now + 3600s) should still be in effect.
- // Advance to a time just before original stale time — should NOT be stale.
- // The extended prefetchTime was ~now+61+[300,600] = [361,661] from epoch.
- // At time 3599, we are past the extended prefetchTime, so a prefetch is triggered.
- clock.time = originalStaleTime.minusSeconds(1);
+ // Advance past the maximum possible backoff (61 + 600 = 661s from now) but still before stale time (3600s).
+ // The nextAllowedRefreshTime backoff will have elapsed, so a prefetch refresh will be attempted.
+ clock.time = now.plusSeconds(700);
supplier.set(RefreshResult.builder("refreshed-creds")
.staleTime(Instant.MAX)
.prefetchTime(Instant.MAX)
.build());
- // Since stale time is preserved at originalStaleTime (3600), we are NOT stale at 3599.
- // The prefetch backoff has long elapsed, so a prefetch refresh will succeed.
+ // Backoff elapsed, prefetchTime (60s) is in the past, so prefetch is triggered and succeeds
assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds");
}
}
@@ -558,7 +555,7 @@ public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown()
MutableSupplier supplier = new MutableSupplier();
try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier)
.staleValueBehavior(ALLOW)
- .cacheInvalidatingPredicate(
+ .nonRecoverableErrorPredicate(
e -> e instanceof CacheInvalidatingRuntimeException)
.clock(clock)
.jitterEnabled(false)
@@ -873,4 +870,147 @@ public Instant instant() {
return time;
}
}
+
+ // --- invalidate() tests ---
+
+ @Test
+ public void invalidate_predicateMatches_triggersRefresh() {
+ AdjustableClock clock = new AdjustableClock();
+ MutableSupplier supplier = new MutableSupplier();
+ Instant now = Instant.parse("2024-01-01T00:00:00Z");
+ clock.time = now;
+
+ try (CachedSupplier cache = CachedSupplier.builder(supplier)
+ .staleValueBehavior(ALLOW)
+ .clock(clock)
+ .jitterEnabled(false)
+ .build()) {
+ supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build());
+ assertThat(cache.get()).isEqualTo("value-1");
+
+ clock.time = now.plusSeconds(10);
+ cache.invalidate(v -> v.equals("value-1"));
+
+ supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build());
+ assertThat(cache.get()).isEqualTo("value-2");
+ }
+ }
+
+ @Test
+ public void invalidate_predicateDoesNotMatch_doesNotTriggerRefresh() {
+ AdjustableClock clock = new AdjustableClock();
+ MutableSupplier supplier = new MutableSupplier();
+ Instant now = Instant.parse("2024-01-01T00:00:00Z");
+ clock.time = now;
+
+ try (CachedSupplier cache = CachedSupplier.builder(supplier)
+ .staleValueBehavior(ALLOW)
+ .clock(clock)
+ .jitterEnabled(false)
+ .build()) {
+ supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build());
+ assertThat(cache.get()).isEqualTo("value-1");
+
+ cache.invalidate(v -> v.equals("different-value"));
+
+ supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build());
+ assertThat(cache.get()).isEqualTo("value-1");
+ }
+ }
+
+ @Test
+ public void invalidate_beforeFirstGet_isNoOp() {
+ AdjustableClock clock = new AdjustableClock();
+ MutableSupplier supplier = new MutableSupplier();
+ clock.time = Instant.parse("2024-01-01T00:00:00Z");
+
+ try (CachedSupplier cache = CachedSupplier.builder(supplier)
+ .staleValueBehavior(ALLOW)
+ .clock(clock)
+ .jitterEnabled(false)
+ .build()) {
+ cache.invalidate(v -> true); // should not throw
+
+ supplier.set(RefreshResult.builder("value-1").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build());
+ assertThat(cache.get()).isEqualTo("value-1");
+ }
+ }
+
+ @Test
+ public void invalidate_doesNotBypassRefreshBackoff() {
+ AdjustableClock clock = new AdjustableClock();
+ MutableSupplier supplier = new MutableSupplier();
+ Instant now = Instant.parse("2024-01-01T00:00:00Z");
+ clock.time = now;
+
+ try (CachedSupplier cache = CachedSupplier.builder(supplier)
+ .staleValueBehavior(ALLOW)
+ .clock(clock)
+ .jitterEnabled(false)
+ .build()) {
+ supplier.set(RefreshResult.builder("old").staleTime(now.plusSeconds(60)).prefetchTime(now.plusSeconds(30)).build());
+ assertThat(cache.get()).isEqualTo("old");
+
+ // Trigger failure to set backoff
+ clock.time = now.plusSeconds(61);
+ supplier.set(new RuntimeException("unavailable"));
+ assertThat(cache.get()).isEqualTo("old");
+
+ // Invalidate — marks stale but doesn't clear backoff
+ clock.time = now.plusSeconds(62);
+ cache.invalidate(v -> v.equals("old"));
+ supplier.set(RefreshResult.builder("new").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build());
+
+ // Still within backoff — returns stale
+ assertThat(cache.get()).isEqualTo("old");
+
+ // Past backoff — returns fresh
+ clock.time = now.plusSeconds(700);
+ assertThat(cache.get()).isEqualTo("new");
+ }
+ }
+
+ @Test
+ public void invalidate_concurrentWithGet_doesNotCorrupt() throws Exception {
+ AdjustableClock clock = new AdjustableClock();
+ clock.time = Instant.parse("2024-01-01T00:00:00Z");
+ AtomicInteger counter = new AtomicInteger(0);
+
+ try (CachedSupplier cache = CachedSupplier.builder(() ->
+ RefreshResult.builder("v-" + counter.incrementAndGet())
+ .staleTime(Instant.MAX)
+ .prefetchTime(Instant.MAX)
+ .build())
+ .staleValueBehavior(ALLOW)
+ .clock(clock)
+ .jitterEnabled(false)
+ .build()) {
+
+ cache.get(); // prime
+
+ ExecutorService executor = Executors.newFixedThreadPool(10);
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+
+ for (int i = 0; i < 10; i++) {
+ int idx = i;
+ futures.add(executor.submit(() -> {
+ try { start.await(); } catch (InterruptedException e) { return; }
+ for (int j = 0; j < 50; j++) {
+ if (idx % 2 == 0) {
+ cache.invalidate(v -> true);
+ } else {
+ assertThat(cache.get()).isNotNull();
+ }
+ }
+ }));
+ }
+
+ start.countDown();
+ for (Future> f : futures) { f.get(30, TimeUnit.SECONDS); }
+ executor.shutdown();
+
+ assertThat(cache.get()).isNotNull();
+ }
+ }
}