This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to + * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). + * + *
By default, this is 1 minute. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh + */ + Builder staleTime(Duration staleTime); + + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *
When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *
This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to + * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). + * + *
If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration prefetchTime); } private static final class BuilderImpl implements Builder { @@ -323,6 +384,8 @@ private static final class BuilderImpl implements Builder { private Boolean asyncCredentialUpdateEnabled; private String asyncThreadName; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; private BuilderImpl() { asyncThreadName("container-credentials-provider"); @@ -333,6 +396,8 @@ private BuilderImpl(ContainerCredentialsProvider credentialsProvider) { this.asyncCredentialUpdateEnabled = credentialsProvider.asyncCredentialUpdateEnabled; this.asyncThreadName = credentialsProvider.asyncThreadName; this.sourceChain = credentialsProvider.sourceChain; + this.staleTime = credentialsProvider.staleTime; + this.prefetchTime = credentialsProvider.prefetchTime; } @Override @@ -365,6 +430,26 @@ public void setAsyncThreadName(String asyncThreadName) { asyncThreadName(asyncThreadName); } + @Override + public Builder staleTime(Duration staleTime) { + this.staleTime = staleTime; + return this; + } + + public void setStaleTime(Duration staleTime) { + staleTime(staleTime); + } + + @Override + public Builder prefetchTime(Duration prefetchTime) { + this.prefetchTime = prefetchTime; + return this; + } + + public void setPrefetchTime(Duration prefetchTime) { + prefetchTime(prefetchTime); + } + /** * An optional string denoting previous credentials providers that are chained with this one. *
Note: This method is primarily intended for use by AWS SDK internal components
diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java
index d9059aa0298e..2a2cd6a7b9fa 100644
--- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java
+++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java
@@ -16,9 +16,11 @@
package software.amazon.awssdk.auth.credentials;
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.internal.LazyAwsCredentialsProvider;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.profiles.ProfileFile;
import software.amazon.awssdk.profiles.ProfileFileSupplier;
import software.amazon.awssdk.utils.SdkAutoCloseable;
@@ -134,6 +136,11 @@ public AwsCredentials resolveCredentials() {
return providerChain.resolveCredentials();
}
+ @Override
+ public CompletableFuture By default, this is disabled. This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * By default, this is 1 minute.
*
- * @param duration the amount of time before expiration for when to consider the credentials to be stale and need refresh
+ * @param duration the duration before expiration that triggers mandatory (blocking) refresh
*/
Builder staleTime(Duration duration);
+ /**
+ * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When
+ * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the
+ * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached
+ * credentials without error and will not attempt another refresh until a backoff period has elapsed.
+ *
+ * When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
+ *
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90
+ * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
+ * successful refresh.
+ *
+ * @param duration the duration before expiration that triggers advisory (proactive) refresh
+ */
+ Builder prefetchTime(Duration duration);
+
/**
* Build a {@link InstanceProfileCredentialsProvider} from the provided configuration.
*/
@@ -382,6 +432,7 @@ static final class BuilderImpl implements Builder {
private Supplier Note: This method is primarily intended for use by AWS SDK internal components
diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java
index 08d9cf373ab2..f365594291bc 100644
--- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java
+++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java
@@ -24,8 +24,11 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.protocols.jsoncore.JsonNode;
import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser;
import software.amazon.awssdk.utils.DateUtils;
@@ -37,6 +40,7 @@
import software.amazon.awssdk.utils.Validate;
import software.amazon.awssdk.utils.builder.CopyableBuilder;
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
+import software.amazon.awssdk.utils.cache.CacheRefreshUtils;
import software.amazon.awssdk.utils.cache.CachedSupplier;
import software.amazon.awssdk.utils.cache.NonBlocking;
import software.amazon.awssdk.utils.cache.RefreshResult;
@@ -44,21 +48,33 @@
/**
* A credentials provider that can load credentials from an external process. This is used to support the credential_process
* setting in the profile credentials file. See
- * sourcing credentials
- * from external processes for more information.
+ * sourcing
+ * credentials from external processes for more information.
*
- *
- * This class can be initialized using {@link #builder()}.
+ * This provider caches credentials returned by the external process and refreshes them before they expire. If a refresh
+ * attempt fails after credentials have entered the mandatory refresh window, the provider raises an exception to the caller
+ * (unlike other credential providers that return cached credentials on failure).
*
- *
- * Available settings:
+ * This class can be initialized using {@link #builder()}.
+ *
+ * Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by
+ * {@link #staleTime(Duration)}).
*
* By default, this is disabled. This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * By default, this is 1 minute.
+ *
+ * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh
+ */
+ public Builder staleTime(Duration staleTime) {
+ this.staleTime = staleTime;
+ return this;
+ }
+
+ /**
+ * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When
+ * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the
+ * provider will attempt to refresh them proactively. If the refresh fails during the advisory window, the provider
+ * returns the existing cached credentials. If the refresh fails after credentials have entered the mandatory refresh
+ * window (defined by {@link #staleTime(Duration)}), the provider raises an exception.
+ *
+ * When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
+ *
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: half the remaining lifetime (but never less than 1 minute) for credentials with 10 minutes
+ * or less remaining, 5 minutes for 10-20 minutes remaining, 15 minutes for 20-90 minutes remaining, and 60
+ * minutes for 90+ minutes remaining. This dynamic window is recomputed on each successful refresh.
+ *
+ * The halved window for short-lived credentials is specific to this provider. Because the process decides its
+ * own expiration, it may emit credentials that are shorter-lived than the smallest standard window, which would
+ * otherwise place them inside their advisory refresh window as soon as they are produced.
+ *
+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
+ */
+ public Builder prefetchTime(Duration prefetchTime) {
+ this.prefetchTime = prefetchTime;
+ return this;
+ }
+
/**
* Configure the command that should be executed to retrieve credentials.
* See {@link ProcessBuilder} for details on how this command is used.
@@ -338,10 +441,13 @@ public Builder command(List Default: 15 seconds. When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
*
- * By default, this is 5 minutes.
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90
+ * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
+ * successful refresh.
+ *
+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
*/
Builder prefetchTime(Duration prefetchTime);
/**
- * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered stale and
- * must be updated. All threads will block until the value is updated.
+ * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When
+ * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the
+ * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider
+ * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed.
+ *
+ * This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
* By default, this is 1 minute.
+ *
+ * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh
*/
Builder staleTime(Duration staleTime);
diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java
index 6999e25af250..46068658eb7c 100644
--- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java
+++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java
@@ -15,10 +15,12 @@
package software.amazon.awssdk.auth.credentials.internal;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.utils.IoUtils;
import software.amazon.awssdk.utils.Lazy;
import software.amazon.awssdk.utils.SdkAutoCloseable;
@@ -45,6 +47,15 @@ public AwsCredentials resolveCredentials() {
return delegate.getValue().resolveCredentials();
}
+ @Override
+ public CompletableFuture 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 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 1b5d64324c61..befba9595790 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 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);
+ }
+ }
+
+ private static Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by
+ * {@link #staleTime(Duration)}).
+ *
+ * By default, this is disabled.
*/
Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled);
/**
- * Configure the amount of time, relative to login token expiration, that the cached credentials are considered stale and
- * should no longer be used. All threads will block until the value is updated.
+ * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When
+ * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the
+ * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider
+ * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed.
+ *
+ * This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
* By default, this is 1 minute.
+ *
+ * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh
*/
Builder staleTime(Duration staleTime);
/**
- * Configure the amount of time, relative to signin token expiration, that the cached credentials are considered close to
- * stale and should be updated.
- *
- * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be
- * asynchronous. See {@link #asyncCredentialUpdateEnabled}.
+ * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When
+ * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the
+ * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached
+ * credentials without error and will not attempt another refresh until a backoff period has elapsed.
+ *
+ * When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
*
- * By default, this is 5 minutes.
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
+ *
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90
+ * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
+ * successful refresh.
+ *
+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
*/
Builder prefetchTime(Duration prefetchTime);
@@ -337,8 +409,19 @@ public interface Builder extends CopyableBuilder By default, this is disabled. This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
* By default, this is 1 minute. When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
+ *
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
- * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be
- * asynchronous. See {@link #asyncCredentialUpdateEnabled}.
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90
+ * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
+ * successful refresh. By default, this is 5 minutes. Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by
+ * {@link #staleTime(Duration)}).
*
* By default, this is disabled. This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to
+ * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
* By default, this is 1 minute. When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread
+ * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform
+ * the refresh while other callers receive the current cached credentials.
+ *
+ * This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
+ * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
*
- * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be
- * asynchronous. See {@link #asyncCredentialUpdateEnabled}.
+ * If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
+ * remaining lifetime: 5 minutes for credentials with 20 minutes or less remaining, 15 minutes for 20-90
+ * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
+ * successful refresh. By default, this is 5 minutes. Dynamic window selection:
+ * This assumes the credential source does not vend credentials with a lifetime shorter than the smallest window
+ * above. That holds for AWS credential services, which have a minimum session duration of 15 minutes. Callers whose
+ * credential source offers no such guarantee should use
+ * {@link #computePrefetchWindowForArbitraryLifetime(Instant, Duration, Instant)} instead.
+ *
+ * @param expiration the credential's expiration time
+ * @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically
+ * @param now the current time
+ * @return the Duration to use as the advisory refresh window
+ */
+ public static Duration computePrefetchWindow(Instant expiration, Duration prefetchTime, Instant now) {
+ if (prefetchTime != null) {
+ return prefetchTime;
+ }
+
+ Duration remainingLifetime = Duration.between(now, expiration);
+ if (remainingLifetime.isNegative() || remainingLifetime.isZero()) {
+ // Already expired. Any window puts the prefetch time in the past, which refreshes on the next request.
+ return WINDOW_SHORT;
+ }
+
+ return windowForLifetime(remainingLifetime);
+ }
+
+ /**
+ * As {@link #computePrefetchWindow(Instant, Duration, Instant)}, but also handles credentials whose lifetime is shorter
+ * than the smallest window that method would assign. For those, the window is half the remaining lifetime, floored at 1
+ * minute to match the default mandatory refresh window:
+ *
+ * Halving keeps the window inside the credential's lifetime, and joins the 5 minute window continuously at a lifetime
+ * of 10 minutes. Without it, a credential shorter-lived than its window is inside its advisory refresh window from the
+ * moment it is issued, so every subsequent request for it contacts the credential source.
+ *
+ * This is for credential sources whose expiration the SDK cannot make assumptions about, such as an external process.
+ * Callers backed by AWS credential services should use {@link #computePrefetchWindow(Instant, Duration, Instant)}, whose
+ * windows follow the credential refresh specification exactly.
+ *
+ * @param expiration the credential's expiration time
+ * @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically
+ * @param now the current time
+ * @return the Duration to use as the advisory refresh window
+ */
+ public static Duration computePrefetchWindowForArbitraryLifetime(Instant expiration, Duration prefetchTime, Instant now) {
+ if (prefetchTime != null) {
+ return prefetchTime;
+ }
+
+ Duration remainingLifetime = Duration.between(now, expiration);
+ if (remainingLifetime.isNegative() || remainingLifetime.isZero()) {
+ // Already expired. Any window puts the prefetch time in the past, which refreshes on the next request.
+ return WINDOW_SHORT;
+ }
+
+ if (remainingLifetime.compareTo(THRESHOLD_HALVED) <= 0) {
+ return ComparableUtils.maximum(remainingLifetime.dividedBy(2), WINDOW_MIN);
+ }
+
+ return windowForLifetime(remainingLifetime);
+ }
+
+ /**
+ * The window tiers defined by the credential refresh specification. Thresholds are compared as durations rather than
+ * whole minutes, so that a lifetime just over a boundary selects the larger window instead of being truncated down into
+ * the smaller one.
+ */
+ private static Duration windowForLifetime(Duration remainingLifetime) {
+ if (remainingLifetime.compareTo(THRESHOLD_MEDIUM) <= 0) {
+ return WINDOW_SHORT;
+ } else if (remainingLifetime.compareTo(THRESHOLD_LONG) < 0) {
+ return WINDOW_MEDIUM;
+ } else {
+ return WINDOW_LONG;
+ }
+ }
+}
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 e8ecc4d741d1..97a32e194eb9 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
@@ -23,9 +23,9 @@
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Predicate;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.annotations.SdkTestInternalApi;
@@ -54,6 +54,16 @@ public class CachedSupplier This field is NEVER modified by {@code invalidate()} — backoff remains independently tracked.
+ */
+ private volatile Instant nextAllowedRefreshTime;
+
private CachedSupplier(Builder 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.
+ *
+ * If the lock is not immediately available, this method returns without invalidating.
+ * A held lock means either a refresh or another invalidation is already in progress.
+ * In either case it is safe to skip: a refresh will replace the cached value shortly,
+ * and a concurrent invalidation will mark it stale.
+ *
+ * @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 Note that this makes the effective prefetch time non-deterministic. Callers that require the prefetch time they
+ * requested to be honored exactly must disable it via {@link Builder#prefetchJitterEnabled(Boolean)}.
*/
private Supplier 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).
+ *
+ * By default, no exceptions are considered non-recoverable (all failures trigger static stability
+ * backoff when {@link StaleValueBehavior#ALLOW} is configured).
+ */
+ public Builder When enabled (the default), the prefetch time is moved later by a uniformly random amount, up to one minute
+ * before the {@link RefreshResult#staleTime()}. This spreads refreshes out so that many suppliers sharing the same
+ * refresh schedule do not all contact the underlying source at the same instant.
+ *
+ * Jitter makes the effective prefetch time non-deterministic, so it must be disabled by callers whose prefetch
+ * time carries meaning of its own. Credential providers disable it because the prefetch time is the advisory refresh
+ * window, which is required to be a fixed, known distance from credential expiration.
*/
- @SdkTestInternalApi
- Builder 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 non-recoverable.
*/
ALLOW
}
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java
new file mode 100644
index 000000000000..6d1a62781098
--- /dev/null
+++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.utils.cache;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link CacheRefreshUtils}.
+ */
+public class CacheRefreshUtilsTest {
+
+ private static final Instant NOW = Instant.parse("2024-01-01T00:00:00Z");
+
+ @Test
+ public void remainingLifetimeUnder20Minutes_returns5MinuteWindow() {
+ // 19 minutes remaining
+ Instant expiration = NOW.plus(Duration.ofMinutes(19));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void remainingLifetimeExactly0_returns5MinuteWindow() {
+ // 0 minutes remaining (already expired)
+ Duration window = CacheRefreshUtils.computePrefetchWindow(NOW, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ /**
+ * AWS credential services do not issue sessions shorter than 15 minutes, so the specification's smallest window can
+ * exceed the lifetime only when the credential source is not an AWS service (or the host clock is skewed). Callers that
+ * have to tolerate that use {@link CacheRefreshUtils#computePrefetchWindowForArbitraryLifetime}.
+ */
+ @Test
+ public void remainingLifetimeShorterThanSmallestWindow_stillReturns5MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(3));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void remainingLifetimeExactly10Minutes_returns5MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(10));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void remainingLifetimeExactly20Minutes_returns5MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(20));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void remainingLifetimeJustOver20Minutes_returns15MinuteWindow() {
+ // 21 minutes — above the 20 minute boundary, enters the medium tier
+ Instant expiration = NOW.plus(Duration.ofMinutes(21));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ public void remainingLifetimeSecondsOver20Minutes_returns15MinuteWindow() {
+ // Boundaries are not truncated to whole minutes.
+ Instant expiration = NOW.plus(Duration.ofMinutes(20)).plusSeconds(1);
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ public void remainingLifetimeMillisOver20Minutes_returns15MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(20)).plusMillis(1);
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ public void remainingLifetime45Minutes_returns15MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(45));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ public void remainingLifetime89Minutes_returns15MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(89));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ public void remainingLifetimeExactly90Minutes_returns60MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(90));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(60));
+ }
+
+ @Test
+ public void remainingLifetime6Hours_returns60MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofHours(6));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(60));
+ }
+
+ @Test
+ public void remainingLifetime12Hours_returns60MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofHours(12));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(60));
+ }
+
+ @Test
+ public void remainingLifetimeNegative_returns5MinuteWindow() {
+ // Expiration is in the past
+ Instant expiration = NOW.minus(Duration.ofMinutes(5));
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void explicitPrefetchTime_returnsExplicitValue() {
+ Instant expiration = NOW.plus(Duration.ofHours(6));
+ Duration explicitPrefetch = Duration.ofMinutes(30);
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(30));
+ }
+
+ @Test
+ public void explicitPrefetchTime_ignoresRemainingLifetime() {
+ // Even with short remaining lifetime, explicit value is used
+ Instant expiration = NOW.plus(Duration.ofMinutes(10));
+ Duration explicitPrefetch = Duration.ofMinutes(60);
+ Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(60));
+ }
+
+ // computePrefetchWindowForArbitraryLifetime: adds the halved tier for credential sources that may vend credentials
+ // shorter-lived than the smallest specification window.
+
+ @Test
+ public void arbitraryLifetime_remainingLifetime3Minutes_returnsHalfOfLifetime() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(3));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofSeconds(90));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetime5Minutes_returnsHalfOfLifetime() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(5));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofSeconds(150));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetimeExactly10Minutes_returnsHalfOfLifetime() {
+ // The halved tier joins the 5 minute tier continuously here.
+ Instant expiration = NOW.plus(Duration.ofMinutes(10));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetimeJustOver10Minutes_returns5MinuteWindow() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(10)).plusSeconds(1);
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetime2Minutes_returnsMandatoryWindowFloor() {
+ // Half of 2 minutes is exactly the 1 minute floor.
+ Instant expiration = NOW.plus(Duration.ofMinutes(2));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(1));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetimeUnder2Minutes_isFlooredAtMandatoryWindow() {
+ // Half of 90 seconds is 45 seconds, which is narrower than the 1 minute mandatory refresh window.
+ Instant expiration = NOW.plus(Duration.ofSeconds(90));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(1));
+ }
+
+ @Test
+ public void arbitraryLifetime_remainingLifetimeNegative_returns5MinuteWindow() {
+ Instant expiration = NOW.minus(Duration.ofMinutes(5));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(5));
+ }
+
+ @Test
+ public void arbitraryLifetime_explicitPrefetchTime_returnsExplicitValue() {
+ Instant expiration = NOW.plus(Duration.ofMinutes(3));
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, Duration.ofMinutes(2), NOW);
+ assertThat(window).isEqualTo(Duration.ofMinutes(2));
+ }
+
+ /**
+ * The halved tier is the only difference between the two methods. Above 10 minutes they must agree, so that the
+ * specification's windows apply to every credential long-lived enough to have one.
+ */
+ @Test
+ public void arbitraryLifetime_above10Minutes_matchesSpecificationWindows() {
+ for (long lifetimeSeconds = 601; lifetimeSeconds <= 12 * 60 * 60; lifetimeSeconds++) {
+ Instant expiration = NOW.plus(Duration.ofSeconds(lifetimeSeconds));
+ assertThat(CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW))
+ .as("lifetime %s seconds", lifetimeSeconds)
+ .isEqualTo(CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW));
+ }
+ }
+
+ /**
+ * The advisory refresh window must land strictly inside the credential's lifetime, otherwise the credential is inside its
+ * advisory window the moment it is issued and every subsequent request contacts the credential source. The 1 minute floor
+ * means this can only be guaranteed for credentials that outlive the mandatory refresh window.
+ */
+ @Test
+ public void arbitraryLifetime_anyLifetimeLongerThanMandatoryWindow_windowIsShorterThanLifetime() {
+ for (long lifetimeSeconds = 61; lifetimeSeconds <= 12 * 60 * 60; lifetimeSeconds++) {
+ Duration lifetime = Duration.ofSeconds(lifetimeSeconds);
+ Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(NOW.plus(lifetime), null, NOW);
+ assertThat(window).as("lifetime %s", lifetime).isLessThan(lifetime);
+ }
+ }
+}
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 159e2d69b6e9..5edb1cea8320 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
@@ -34,12 +34,15 @@
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
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;
@@ -364,25 +367,228 @@ public void throwIsHiddenIfValueIsStaleInAllowMode() throws InterruptedException
}
@Test
- public void maxStaleFailureJitter_shouldNotReturnNegativeOrCycleLowValues() {
- CachedSupplierAvailable settings:
*
- *
*/
@SdkPublicApi
@@ -71,9 +87,9 @@ public final class ProcessCredentialsProvider
private static final JsonNodeParser PARSER = JsonNodeParser.builder()
.removeErrorLocations(true)
.build();
+ private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1);
private final List void accept(SignerProperty key, S value) {
existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent);
- return new SelectedAuthScheme<>(
- selectedAuthScheme.identity(),
- selectedAuthScheme.signer(),
- mergedOption.build()
- );
+ return SelectedAuthScheme. 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.
+ *
+ */
+ private static boolean isNonRecoverableError(RuntimeException e) {
+ if (e instanceof InvalidTokenException) {
+ return true;
+ }
+
+ AccessDeniedException ade = extractAccessDeniedException(e);
+ if (ade == null) {
+ return false;
+ }
+ return ade.error() == OAuth2ErrorCode.TOKEN_EXPIRED
+ || ade.error() == OAuth2ErrorCode.USER_CREDENTIALS_CHANGED
+ || ade.error() == OAuth2ErrorCode.INSUFFICIENT_PERMISSIONS;
+ }
+
+ private static AccessDeniedException extractAccessDeniedException(RuntimeException e) {
+ if (e instanceof AccessDeniedException) {
+ return (AccessDeniedException) e;
+ }
+ if (e.getCause() instanceof AccessDeniedException) {
+ return (AccessDeniedException) e.getCause();
+ }
+ return 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.
*/
public Duration staleTime() {
return staleTime;
}
/**
- * The amount of time, relative to session token expiration, that the cached credentials are considered close to stale and
- * should be updated.
+ * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are
+ * within this window, the provider proactively attempts to refresh them.
*/
public Duration prefetchTime() {
return prefetchTime;
@@ -255,6 +297,13 @@ public AwsCredentials resolveCredentials() {
return credentialCache.get();
}
+ @Override
+ public CompletableFuture
+ *
+ *
+ *
+ *
+ *
+ *