diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java index 5fefed5e8d65..94744da5da6c 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.auth.credentials; import static java.nio.charset.StandardCharsets.UTF_8; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.io.IOException; import java.net.InetAddress; @@ -24,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -43,12 +45,12 @@ import software.amazon.awssdk.core.util.SdkUserAgent; import software.amazon.awssdk.regions.util.ResourcesEndpointProvider; import software.amazon.awssdk.regions.util.ResourcesEndpointRetryPolicy; -import software.amazon.awssdk.utils.ComparableUtils; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.ToString; 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; @@ -85,6 +87,8 @@ public final class ContainerCredentialsProvider private static final List VALID_LOOP_BACK_IPV4 = Arrays.asList(ECS_CONTAINER_HOST, EKS_CONTAINER_HOST_IPV4); private static final List VALID_LOOP_BACK_IPV6 = Arrays.asList(EKS_CONTAINER_HOST_IPV6); + private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); + private final String endpoint; private final HttpCredentialsLoader httpCredentialsLoader; private final CachedSupplier credentialsCache; @@ -94,6 +98,8 @@ public final class ContainerCredentialsProvider private final String asyncThreadName; private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -107,16 +113,24 @@ private ContainerCredentialsProvider(BuilderImpl builder) { ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName); + this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) .prefetchStrategy(new NonBlocking(builder.asyncThreadName)) + .staleValueBehavior(ALLOW) .build(); } else { this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) + .staleValueBehavior(ALLOW) .build(); } } @@ -153,19 +167,18 @@ private Instant staleTime(Instant expiration) { return null; } - return expiration.minus(1, ChronoUnit.MINUTES); + return expiration.minus(staleTime); } private Instant prefetchTime(Instant expiration) { - Instant oneHourFromNow = Instant.now().plus(1, ChronoUnit.HOURS); - if (expiration == null) { - return oneHourFromNow; + return Instant.now().plus(1, ChronoUnit.HOURS); } - Instant fifteenMinutesBeforeExpiration = expiration.minus(15, ChronoUnit.MINUTES); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); - return ComparableUtils.minimum(oneHourFromNow, fifteenMinutesBeforeExpiration); + return expiration.minus(effectivePrefetchWindow); } @Override @@ -320,6 +333,43 @@ public boolean isMetadataServiceEndpoint(String host) { */ public interface Builder extends HttpCredentialsProvider.Builder, CopyableBuilder { + + /** + * 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 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 less than 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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration prefetchTime); } private static final class BuilderImpl implements Builder { @@ -327,6 +377,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"); @@ -337,6 +389,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 @@ -369,6 +423,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/HttpCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java index 29239b34908d..218dc977f1dd 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java @@ -28,9 +28,9 @@ public interface HttpCredentialsProvider extends AwsCredentialsProvider, SdkAutoCloseable { interface Builder> { /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window, so that callers are less + * likely to block waiting for credentials. Additional resources (a thread) are used to maintain the provider. * *

By default, this is disabled.

*/ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java index 364cd4401529..713bc6cc1cf1 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java @@ -16,7 +16,6 @@ package software.amazon.awssdk.auth.credentials; import static java.time.temporal.ChronoUnit.MINUTES; -import static software.amazon.awssdk.utils.ComparableUtils.maximum; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; @@ -50,6 +49,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; @@ -93,6 +93,8 @@ public final class InstanceProfileCredentialsProvider private final Duration staleTime; + private final Duration prefetchTime; + private final String sourceChain; private final String providerName; @@ -120,7 +122,12 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .profileName(profileName) .build(); - this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofSeconds(1)); + this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -204,7 +211,14 @@ private Instant prefetchTime(Instant expiration) { return null; } - return now.plus(maximum(timeUntilExpiration.dividedBy(2), Duration.ofMinutes(5))); + // Use dynamic window when user has not explicitly configured prefetchTime + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + + // If remaining lifetime < the advisory window, refresh immediately. + if (timeUntilExpiration.compareTo(effectivePrefetchWindow) < 0) { + return now; + } + return expiration.minus(effectivePrefetchWindow); } @Override @@ -355,17 +369,42 @@ public interface Builder extends HttpCredentialsProvider.BuilderIncreasing this value to a higher value (10s or more) may help with situations where a higher load on the instance - * metadata service causes it to return 503s error, for which the SDK may not be able to recover fast enough and - * returns expired credentials. + * 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). * - * @param duration the amount of time before expiration for when to consider the credentials to be stale and need refresh + *

By default, this is 1 minute. + * + * @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 less than 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. + * + * @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 +421,7 @@ static final class BuilderImpl implements Builder { private Supplier profileFile; private String profileName; private Duration staleTime; + private Duration prefetchTime; private String sourceChain; private BuilderImpl() { @@ -396,6 +436,7 @@ private BuilderImpl(InstanceProfileCredentialsProvider provider) { this.profileFile = provider.profileFile; this.profileName = provider.profileName; this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; this.sourceChain = provider.sourceChain; } @@ -475,6 +516,16 @@ public void setStaleTime(Duration duration) { staleTime(duration); } + @Override + public Builder prefetchTime(Duration duration) { + this.prefetchTime = duration; + return this; + } + + public void setPrefetchTime(Duration duration) { + prefetchTime(duration); + } + /** * 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/ProcessCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java index 08d9cf373ab2..17be7f789d8f 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,6 +24,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.protocols.jsoncore.JsonNode; @@ -37,6 +38,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 +46,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()}. + * + *

Available settings:

*
    - *
  • Command - The command that should be executed to retrieve credentials.
  • - *
  • CredentialRefreshThreshold - The amount of time between when the credentials expire and when the credentials should - * start to be refreshed. This allows the credentials to be refreshed *before* they are reported to expire. Default: 15 - * seconds.
  • - *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an exception is - * raised. Default: 64000 bytes (64KB).
  • + *
  • Command - The command that should be executed to retrieve credentials. Can be specified as a single string + * (deprecated) or as a list of strings.
  • + *
  • StaleTime - The amount of time before credential expiration that defines the mandatory refresh window. When + * credentials are within this window, all callers block until a refresh attempt completes. If the refresh fails, an + * exception is raised. Default: 1 minute.
  • + *
  • PrefetchTime - The amount of time before credential expiration that defines the advisory refresh window. When + * credentials are within this window, the provider proactively attempts to refresh them. If the refresh fails during the + * advisory window, the existing cached credentials are returned without error. This replaces the deprecated + * {@code credentialRefreshThreshold} setting; if that setting was explicitly configured, its value is honored as the + * prefetch time for backward compatibility. Default: 5 minutes.
  • + *
  • AsyncCredentialUpdateEnabled - Whether to refresh credentials asynchronously in a background thread during + * the advisory refresh window, so that callers are less likely to block. Default: disabled.
  • + *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an + * exception is raised. Default: 64000 bytes (64KB).
  • + *
  • CredentialRefreshThreshold - Deprecated. Use {@code prefetchTime} instead. If explicitly set, the + * value is honored as the prefetch time for backward compatibility.
  • *
*/ @SdkPublicApi @@ -71,9 +85,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 executableCommand; - private final Duration credentialRefreshThreshold; private final long processOutputLimit; private final String staticAccountId; @@ -87,6 +101,8 @@ public final class ProcessCredentialsProvider private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -94,7 +110,6 @@ public final class ProcessCredentialsProvider private ProcessCredentialsProvider(Builder builder) { this.executableCommand = executableCommand(builder); this.processOutputLimit = Validate.isPositive(builder.processOutputLimit, "processOutputLimit"); - this.credentialRefreshThreshold = Validate.isPositive(builder.credentialRefreshThreshold, "expirationBuffer"); this.commandFromBuilder = builder.command; this.commandAsListOfStringsFromBuilder = builder.commandAsListOfStrings; this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; @@ -103,6 +118,12 @@ private ProcessCredentialsProvider(Builder builder) { this.providerName = StringUtils.isEmpty(builder.sourceChain) ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; + this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()); @@ -154,8 +175,8 @@ private RefreshResult refreshCredentials() { Instant credentialExpirationTime = credentialExpirationTime(credentialsJson); return RefreshResult.builder(credentials) - .staleTime(credentialExpirationTime) - .prefetchTime(credentialExpirationTime.minusMillis(credentialRefreshThreshold.toMillis())) + .staleTime(staleTime(credentialExpirationTime)) + .prefetchTime(prefetchTime(credentialExpirationTime)) .build(); } catch (InterruptedException e) { throw new IllegalStateException("Process-based credential refreshing has been interrupted.", e); @@ -164,6 +185,22 @@ private RefreshResult refreshCredentials() { } } + private Instant staleTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + return expiration.minus(staleTime); + } + + private Instant prefetchTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + return expiration.minus(dynamicWindow); + } + /** * Parse the output from the credentials process. */ @@ -277,10 +314,11 @@ public static class Builder implements CopyableBuilder commandAsListOfStrings; - private Duration credentialRefreshThreshold = Duration.ofSeconds(15); private long processOutputLimit = 64000; private String staticAccountId; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; /** * @see #builder() @@ -292,16 +330,21 @@ private Builder(ProcessCredentialsProvider provider) { this.asyncCredentialUpdateEnabled = provider.asyncCredentialUpdateEnabled; this.command = provider.commandFromBuilder; this.commandAsListOfStrings = provider.commandAsListOfStringsFromBuilder; - this.credentialRefreshThreshold = provider.credentialRefreshThreshold; this.processOutputLimit = provider.processOutputLimit; this.staticAccountId = provider.staticAccountId; this.sourceChain = provider.sourceChain; + this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -311,6 +354,50 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled return this; } + /** + * 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 + * raises an exception to the caller. + * + *

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: 5 minutes for credentials with less than 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.

+ * + * @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 +425,13 @@ public Builder command(List commandAsListOfStrings) { * Configure the amount of time between when the credentials expire and when the credentials should start to be * refreshed. This allows the credentials to be refreshed *before* they are reported to expire. * - *

Default: 15 seconds.

+ * @deprecated Use {@link #prefetchTime(Duration)} instead. This method has been deprecated for consistency + * with other credential providers. Calls to this method are equivalent to calling + * {@code prefetchTime(credentialRefreshThreshold)}. */ + @Deprecated public Builder credentialRefreshThreshold(Duration credentialRefreshThreshold) { - this.credentialRefreshThreshold = credentialRefreshThreshold; + this.prefetchTime = credentialRefreshThreshold; return this; } diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java index 6ce5217dc909..e108482b0490 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java @@ -189,21 +189,39 @@ public interface Builder extends CopyableBuilderPrefetch updates will occur between the specified time and the stale time of the provider. Prefetch - * updates may be asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

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 less than 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. + * + * @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/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java index 29955af439ca..028e21b77143 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java @@ -140,4 +140,54 @@ private String getSuccessfulBody() { "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + "\"Expiration\":\"3000-05-03T04:55:54Z\"}"; } + + /** + * Tests that when the cache is stale and refresh fails, the provider returns cached credentials + * instead of throwing an exception (ALLOW behavior / static stability). + */ + @Test + public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() { + // First call succeeds with credentials that are already expired (stale immediately on next get) + String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + + "\"Expiration\":\"2020-01-01T00:00:00Z\"}"; + + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(alreadyExpiredBody))); + + // First call succeeds (initial fetch always succeeds even if credentials are expired) + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + + // Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure) + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + // Second call: cache is stale (expiration is in the past), refresh fails with 500, + // but ALLOW behavior should return the cached credentials + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + } + + /** + * Tests that when no credentials are cached (initial fetch) and the endpoint fails, + * an exception is thrown. + */ + @Test + public void testInitialFetchFailure_throwsException() { + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class); + } } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 055967055c25..9a5ad4079e65 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java @@ -573,40 +573,80 @@ void resolveCredentials_callsImdsIfCredentialsWithin5MinutesOfExpiration() { assertThat(credentials10SecondsAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); } + @Test + void resolveCredentials_immediateRefreshWhenRemainingLifetimeLessThan5Minutes() { + // When IMDS returns credentials with less than 5 minutes of remaining lifetime, + // the prefetchTime should be set to 'now', triggering a refresh soon after. + // Advance the clock by 2 minutes to account for jitter on the prefetch time. + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + // Credentials that expire in 3 minutes (less than the 5 minute advisory window) + Instant shortExpiration = now.plus(3, MINUTES); + String shortLivedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(shortExpiration) + '"' + + "}"; + + String refreshedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' + + "}"; + + // Prime cache with short-lived credentials + clock.time = now; + stubSecureCredentialsResponse(aResponse().withBody(shortLivedCredentialsResponse)); + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Advance past any jitter on the prefetch time (which was set to 'now'). + // The staleTime is shortExpiration - 1min = now + 2min. + // The jitter window is at most 1 minute (between prefetchTime and 1min before staleTime). + // So advancing by 2 minutes guarantees we are past the jittered prefetch time, triggering refresh. + clock.time = now.plus(2, MINUTES); + stubSecureCredentialsResponse(aResponse().withBody(refreshedCredentialsResponse)); + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); + } + @Test void imdsCallFrequencyIsLimited() { - // Requires running the test multiple times to account for refresh jitter - for (int i = 0; i < 10; i++) { - AdjustableClock clock = new AdjustableClock(); - AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); - Instant now = Instant.now(); - String successfulCredentialsResponse1 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now) + '"' - + "}"; - - String successfulCredentialsResponse2 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' - + "}"; - - // Set the time to 5 minutes before expiration and call IMDS - clock.time = now.minus(5, MINUTES); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); - AwsCredentials credentials5MinutesAgo = credentialsProvider.resolveCredentials(); - - // Set the time to 2 seconds before expiration, and verify that do not call IMDS because it hasn't been 5 minutes yet - clock.time = now.minus(2, SECONDS); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); - AwsCredentials credentials2SecondsAgo = credentialsProvider.resolveCredentials(); - - assertThat(credentials2SecondsAgo).isEqualTo(credentials5MinutesAgo); - assertThat(credentials5MinutesAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); - } + // Verify that IMDS is not called again if we haven't reached the prefetch window + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + Instant expiration = now.plus(6, HOURS); + String successfulCredentialsResponse1 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration) + '"' + + "}"; + + String successfulCredentialsResponse2 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration.plus(6, HOURS)) + '"' + + "}"; + + // Prime the cache at the current time + clock.time = now; + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); + AwsCredentials credentialsAtStart = credentialsProvider.resolveCredentials(); + + // Move time forward but still before the prefetch window (60 min before expiry for 6h credentials). + // Since dynamic prefetchTime = expiration - 60min = now + 5h, anything before that should not trigger refresh. + clock.time = now.plus(4, HOURS); + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); + AwsCredentials credentialsLater = credentialsProvider.resolveCredentials(); + + assertThat(credentialsLater).isEqualTo(credentialsAtStart); + assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); } @Test @@ -632,7 +672,7 @@ void testErrorWhileCacheIsStale_shouldRecover() { Duration staleTime = Duration.ofMinutes(5); - AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime); + AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(10)); // cache expiration with expiration = 6 hours clock.time = now; @@ -704,10 +744,15 @@ private AwsCredentialsProvider credentialsProviderWithClock(Clock clock) { } private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime) { + return credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(5)); + } + + private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime, Duration prefetchTime) { InstanceProfileCredentialsProvider.BuilderImpl builder = (InstanceProfileCredentialsProvider.BuilderImpl) InstanceProfileCredentialsProvider.builder(); builder.clock(clock); builder.staleTime(staleTime); + builder.prefetchTime(prefetchTime); return builder.build(); } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java index 5b77907c59fa..e4b3ba321482 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java @@ -161,7 +161,8 @@ void sessionCredentialsCanBeLoaded() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -176,7 +177,8 @@ void sessionCredentialsWithAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s acctid=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration, ACCOUNT_ID)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -191,7 +193,8 @@ void sessionCredentialsWithStaticAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .staticAccountId("staticAccountId") .sourceChain("v") .build(); @@ -225,7 +228,7 @@ void resultsAreCached() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, - DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -241,7 +244,8 @@ void expirationBufferOverrideIsApplied() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -250,12 +254,47 @@ void expirationBufferOverrideIsApplied() { assertThat(request1).isNotEqualTo(request2); } + @Test + void defaultPrefetchTime_credentialsWithinFiveMinuteWindow_areRefreshed() { + // Credentials that expire in 30 seconds: staleTime = now+30s - 1min = now-30s (in the past, stale!) + // In STRICT mode, stale credentials force a synchronous refresh on every call + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plusSeconds(30)))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isNotEqualTo(request2); + } + + @Test + void defaultPrefetchTime_credentialsFarFromExpiry_areCached() { + // Credentials that expire in 30 minutes: prefetchTime = now+30min - 5min = now+25min (in the future) + // So the cache should NOT refresh + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isEqualTo(request2); + } + @Test void processFailed_shouldContainErrorMessage() { ProcessCredentialsProvider credentialsProvider = ProcessCredentialsProvider.builder() .command(errorScriptLocation) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); assertThatThrownBy(credentialsProvider::resolveCredentials) @@ -269,7 +308,8 @@ void lackOfExpirationIsCachedForever() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN)) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); 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 95f8e51bb153..be2b61486860 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 @@ -18,6 +18,7 @@ import static software.amazon.awssdk.utils.UserHomeDirectoryUtils.userHomeDirectory; import static software.amazon.awssdk.utils.Validate.notNull; import static software.amazon.awssdk.utils.Validate.paramNotBlank; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.nio.file.Path; import java.nio.file.Paths; @@ -43,11 +44,14 @@ import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse; +import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; +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; @@ -75,7 +79,6 @@ public final class LoginCredentialsProvider implements private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_LOGIN.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final Path DEFAULT_TOKEN_LOCATION = Paths.get(userHomeDirectory(), ".aws", "login", "cache"); private static final String ASYNC_THREAD_NAME = "sdk-login-credentials-provider"; @@ -103,7 +106,11 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.loginSession = paramNotBlank(builder.loginSession, "LoginSession"); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -120,7 +127,9 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSigninCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -129,21 +138,22 @@ private LoginCredentialsProvider(BuilderImpl builder) { } /** - * Update the expiring session SSO credentials by calling SSO. Invoked by {@link CachedSupplier} when the credentials are - * close to expiring. + * Update the expiring session Login credentials by calling the Signin Service. Invoked by {@link CachedSupplier} when the + * credentials are close to expiring. */ private RefreshResult updateSigninCredentials() { // always re-load token from the disk in case it has been updated elsewhere LoginAccessToken tokenFromDisc = onDiskTokenManager.loadToken().orElseThrow( - () -> SdkClientException.create("Token cache file for login_session `" + loginSession + "` not found. " + () -> new InvalidTokenException("Token cache file for login_session `" + loginSession + "` not found. " + "You must re-authenticate.")); Instant currentExpirationTime = tokenFromDisc.getAccessToken().expirationTime().orElseThrow( - () -> SdkClientException.create("Invalid token expiration time. You must re-authenticate.") + () -> new InvalidTokenException("Invalid token expiration time. You must re-authenticate.") ); + Duration effectivePrefetch = CacheRefreshUtils.computePrefetchWindow(currentExpirationTime, prefetchTime, Instant.now()); if (shouldNotRefresh(currentExpirationTime, staleTime) - && shouldNotRefresh(currentExpirationTime, prefetchTime)) { + && shouldNotRefresh(currentExpirationTime, effectivePrefetch)) { log.debug(() -> "Using access token from disk, current expiration time is : " + currentExpirationTime); AwsCredentials credentials = tokenFromDisc.getAccessToken() .toBuilder() @@ -152,7 +162,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetch)) .build(); } @@ -196,7 +206,8 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTime)) + .prefetchTime(newExpiration.minus( + CacheRefreshUtils.computePrefetchWindow(newExpiration, prefetchTime, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -205,16 +216,12 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: - throw SdkClientException.create( - "Your session has expired. Please reauthenticate.", - accessDeniedException); case USER_CREDENTIALS_CHANGED: - throw SdkClientException.create( - "Unable to refresh credentials because of a change in your password. " - + "Please reauthenticate with your new password.", - accessDeniedException - ); + // Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate + // 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. throw SdkClientException.create( "Unable to refresh credentials due to insufficient permissions. You may be missing permission " + "for the 'CreateOAuth2Token' action.", @@ -228,16 +235,49 @@ private RefreshResult refreshFromSigninService(LoginAccessToken } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no - * longer be used. All threads will block until the value is updated. + * Determines whether a given exception represents a non-recoverable refresh failure that should bypass + * static stability. For Login, this includes: + *

    + *
  • Missing or invalid token cache on disk (requires re-authentication)
  • + *
  • An {@link AccessDeniedException} with error code {@link OAuth2ErrorCode#TOKEN_EXPIRED}, + * {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}
  • + *
+ */ + private static boolean isCacheInvalidating(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; @@ -286,29 +326,52 @@ public interface Builder extends CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is enabled. */ 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 less than 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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); @@ -337,6 +400,17 @@ public interface Builder extends CopyableBuilder loginCredentialsProvider.resolveCredentials()); } + @Test + public void resolveCredentials_transientFailureAfterSuccessfulCache_returnsCachedCredentials() { + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + // staleTime will be now+30s - 1min = now-30s (already stale), so next get() will refresh again + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + // Second response: transient 500 error + HttpExecuteResponse failureResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(500).build()) + .build(); + + mockHttpClient.stubResponses(successResponse, failureResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Second call: the cached value is already stale (30s expiry - 1min staleTime < now), + // so CachedSupplier tries to refresh, gets 500, and with ALLOW behavior returns cached value + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenExpired_raisesException() { // expired @@ -203,8 +251,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("Your session has expired")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test @@ -215,8 +264,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("change in your password")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test @@ -231,6 +281,132 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithInsuff assertTrue(e.getMessage().contains("insufficient permissions")); } + @Test + public void resolveCredentials_tokenCacheMissingAfterSuccessfulCache_throwsAndBypassesStaticStability() throws Exception { + // Build a provider without async updates so refresh is synchronous + LoginCredentialsProvider syncProvider = LoginCredentialsProvider + .builder() + .loginSession(LOGIN_SESSION_ID) + .signinClient(signinClient) + .tokenCacheLocation(tempDir) + .asyncCredentialUpdateEnabled(false) + .build(); + + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + mockHttpClient.stubResponses(successResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = syncProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Now delete all token cache files from the temp directory to simulate token being removed + Files.list(tempDir) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale (handleFetchedSuccess extended it with jitter, but + // we wait for it to expire). Since the stale time was extended 1-10 minutes into the future, + // we instead rely on the prefetch window triggering a synchronous OneCallerBlocks refresh. + // With OneCallerBlocks, the refresh happens inline and the InvalidTokenException propagates. + // Give a brief pause and then call again - the prefetch time should already be in the past + // since the credentials expired in 30s and handleFetchedSuccess sets prefetch = stale time. + // Actually, with the extended stale time, the value enters prefetch window immediately. + // With OneCallerBlocks, the failing refresh throws through to the caller. + SdkClientException e = assertThrows(SdkClientException.class, + () -> syncProvider.resolveCredentials()); + assertTrue(e.getMessage().contains("not found")); + syncProvider.close(); + } + + @Test + public void resolveCredentials_tokenMalformedAfterSuccessfulCache_staticStabilityReturnsCachedCredentials() + throws Exception { + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + mockHttpClient.stubResponses(successResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Now overwrite the token file with one that is missing the expiresAt field. + // This simulates a corrupted or malformed token file on disk. + String malformedTokenJson = + "{\"accessToken\":" + + "{\"accessKeyId\":\"akid\"," + + "\"secretAccessKey\":\"skid\"," + + "\"sessionToken\":\"sessionToken\"," + + "\"accountId\":\"123456789012\"}," + + "\"clientId\":\"client-123\"," + + "\"dpopKey\":\"" + VALID_TEST_PEM.replace("\n", "\\n") + "\"," + + "\"refreshToken\":\"refresh-token\"," + + "\"tokenType\":\"aws_sigv4\"," + + "\"identityToken\":\"id-token\"}"; + Files.list(tempDir) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.write(p, malformedTokenJson.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale, CachedSupplier refreshes, but token has no expiresAt. + // OnDiskTokenManager throws a plain SdkClientException (not InvalidTokenException), so static + // stability kicks in and returns the cached credentials rather than throwing. + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + private static void verifyResolvedCredentialsAreUpdated(AwsCredentials resolvedCredentials) { assertEquals("new-akid", resolvedCredentials.accessKeyId()); assertEquals("new-skid", resolvedCredentials.secretAccessKey()); 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 42465940b7ca..d81ae3dba549 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 @@ -15,7 +15,9 @@ package software.amazon.awssdk.services.sso.auth; +import static software.amazon.awssdk.utils.Validate.isTrue; import static software.amazon.awssdk.utils.Validate.notNull; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.time.Duration; import java.time.Instant; @@ -30,10 +32,12 @@ import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; 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; @@ -56,7 +60,6 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_SSO.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final String ASYNC_THREAD_NAME = "sdk-sso-credentials-provider"; @@ -80,7 +83,11 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.getRoleCredentialsRequestSupplier = builder.getRoleCredentialsRequestSupplier; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -90,7 +97,10 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSsoCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -106,15 +116,19 @@ private RefreshResult updateSsoCredentials() { SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get(); notNull(request, "GetRoleCredentialsRequest can't be null."); + RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials(); AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder() .accessKeyId(roleCredentials.accessKeyId()) @@ -127,16 +141,16 @@ private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * 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; @@ -176,30 +190,52 @@ public interface Builder extends CopyableBuilderRegardless 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 SSO session 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 SSO session token expiration, that the cached credentials are considered - * close to stale and should be updated. + * 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). * - * 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 less than 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.

* - *

By default, this is 5 minutes.

+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); 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 d7be6cdd852c..fc0668a10811 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sso.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -27,11 +28,13 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; 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.services.sso.SsoClient; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; /** * Validates the functionality of {@link SsoCredentialsProvider}. @@ -88,6 +91,122 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta callClient(verify(ssoClient, times(2)), Mockito.any()); } + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + // First call succeeds, second call fails with transient error + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials because ALLOW is set + AwsSessionCredentials secondResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(secondResult.accessKeyId()).isEqualTo("a"); + assertThat(secondResult.secretAccessKey()).isEqualTo("b"); + assertThat(secondResult.sessionToken()).isEqualTo("c"); + } + } + + @Test + public void unauthorizedException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + UnauthorizedException unauthorizedException = (UnauthorizedException) UnauthorizedException.builder() + .message("Token is expired") + .build(); + + // First call succeeds, second call fails with UnauthorizedException + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(unauthorizedException); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should throw UnauthorizedException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(UnauthorizedException.class) + .hasMessageContaining("Token is expired"); + } + } + + @Test + public void expiredTokenException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + + ExpiredTokenException expiredTokenException = (ExpiredTokenException) ExpiredTokenException.builder() + .message("The SSO session associated with this profile has expired") + .build(); + + // Request supplier throws ExpiredTokenException (client-side token expiry) + Supplier expiredSupplier = () -> { + throw expiredTokenException; + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(expiredSupplier) + .ssoClient(ssoClient) + .build()) { + // Should throw ExpiredTokenException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("The SSO session associated with this profile has expired"); + } + } + + @Test + public void noCachedCredentials_anyFailure_throwsImmediately() { + ssoClient = mock(SsoClient.class); + + Supplier supplier = getRequestSupplier(); + + // First call fails with a transient error — no cached credentials exist + when(ssoClient.getRoleCredentials(supplier.get())) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // Should throw immediately since no cached credentials exist + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("SSO service unavailable"); + } + } + private GetRoleCredentialsRequestSupplier getRequestSupplier() { @@ -129,7 +248,7 @@ private void callClientWithCredentialsProvider(Instant credentialsExpirationDate assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(4)); } else { assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { 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 74ebc39c664e..431ffbac43bb 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 @@ -32,6 +32,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; @@ -53,7 +54,6 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private static final Logger log = Logger.loggerFor(StsCredentialsProvider.class); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); /** * The STS client that should be used for periodically updating the session credentials. @@ -73,12 +73,17 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, this.stsClient = Validate.notNull(builder.stsClient, "STS client must not be null."); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSessionCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(asyncThreadName)); } @@ -95,9 +100,12 @@ private RefreshResult updateSessionCredentials() { credentials.expirationTime() .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -116,16 +124,16 @@ public void close() { } /** - * The amount of time, relative to STS token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * 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 STS 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; @@ -183,9 +191,13 @@ public B stsClient(StsClient stsClient) { } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -196,10 +208,17 @@ public B asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled) { } /** - * 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 */ @SuppressWarnings("unchecked") public B staleTime(Duration staleTime) { @@ -208,13 +227,24 @@ public B staleTime(Duration staleTime) { } /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered - * close to stale and should be updated. + * 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). * - * 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 less than 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.

* - *

By default, this is 5 minutes.

+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ @SuppressWarnings("unchecked") public B prefetchTime(Duration prefetchTime) { 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 8c054aa97e1a..10014ddbf7c9 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sts.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,7 +28,9 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +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.services.sts.StsClient; import software.amazon.awssdk.services.sts.endpoints.internal.Arn; import software.amazon.awssdk.services.sts.model.Credentials; @@ -101,6 +104,61 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta protected abstract String providerName(); + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + // First call returns valid credentials that are already expired (to force a refresh on next call) + Credentials validCredentials = Credentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5))) + .build(); + RequestT request = getRequest(); + ResponseT response = getResponse(validCredentials); + + // First call succeeds, second call fails + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // First call should succeed and cache credentials + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(firstResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials instead of throwing + // because StaleValueBehavior.ALLOW is now set + AwsCredentials secondResult = credentialsProvider.resolveCredentials(); + assertThat(secondResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) secondResult).accessKeyId()).isEqualTo("a"); + assertThat(((AwsSessionCredentials) secondResult).secretAccessKey()).isEqualTo("b"); + assertThat(((AwsSessionCredentials) secondResult).sessionToken()).isEqualTo("c"); + } + } + + @Test + public void initialFetchFailureThrowsException_noCachedCredentials() { + RequestT request = getRequest(); + + // The very first call to STS fails — no credentials have ever been cached + when(callClient(stsClient, request)) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // Should throw because there are no cached credentials to fall back on + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("STS service unavailable"); + } + } + 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(); @@ -123,7 +181,7 @@ public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, } else { //validate that the default values are used assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java index 058eff163eff..82673b870363 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java @@ -183,7 +183,7 @@ void defaultTiming_usesStandardValues() { .build(); try { - assertThat(provider.prefetchTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(provider.prefetchTime()).isNull(); assertThat(provider.staleTime()).isEqualTo(Duration.ofMinutes(1)); } finally { provider.close(); diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java index 904a2ff2c0ff..ee5174919a4b 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java @@ -39,8 +39,8 @@ void stsWebIdentityCredentialsProviderFactory_withWebIdentityTokenCredentialProp AwsCredentialsProvider provider = factory.create( WebIdentityTokenCredentialProperties.builder() .asyncCredentialUpdateEnabled(true) - .prefetchTime(Duration.ofMinutes(5)) - .staleTime(Duration.ofMinutes(15)) + .prefetchTime(Duration.ofMinutes(15)) + .staleTime(Duration.ofMinutes(5)) .roleArn("role-arn") .webIdentityTokenFile(Paths.get("/path/to/file")) .roleSessionName("session-name") 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 new file mode 100644 index 000000000000..998648a6ca02 --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -0,0 +1,72 @@ +/* + * 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 java.time.Duration; +import java.time.Instant; +import software.amazon.awssdk.annotations.SdkProtectedApi; + +/** + * Utility methods for credential cache refresh timing computation. + */ +@SdkProtectedApi +public final class CacheRefreshUtils { + + private static final Duration WINDOW_SHORT = Duration.ofMinutes(5); + private static final Duration WINDOW_MEDIUM = Duration.ofMinutes(15); + private static final Duration WINDOW_LONG = Duration.ofMinutes(60); + + private static final long THRESHOLD_MEDIUM_MINUTES = 20; + private static final long THRESHOLD_LONG_MINUTES = 90; + + private CacheRefreshUtils() { + } + + /** + * Compute the advisory refresh window (prefetch time) for a credential. If {@code prefetchTime} is non-null + * (i.e., explicitly configured by the user), it is returned directly. Otherwise, the window is computed + * 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:

+ *
    + *
  • remaining lifetime < 20 minutes → 5 minute window
  • + *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • + *
  • remaining lifetime ≥ 90 minutes → 60 minute window
  • + *
+ * + * @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); + long remainingMinutes = remainingLifetime.toMinutes(); + + if (remainingMinutes < THRESHOLD_MEDIUM_MINUTES) { + return WINDOW_SHORT; + } else if (remainingMinutes < THRESHOLD_LONG_MINUTES) { + 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..ab978bb1a7dc 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 implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); + /** + * Minimum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MIN = Duration.ofMinutes(5); + + /** + * Maximum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MAX = Duration.ofMinutes(10); + /** * Used as a primitive form of rate limiting for the speed of our refreshes. This will make sure that the backing supplier has @@ -83,11 +93,6 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Clock clock; - /** - * The number of consecutive failures encountered when updating a stale value. - */ - private final AtomicInteger consecutiveStaleRetrievalFailures = new AtomicInteger(0); - /** * The name to include with each log message, to differentiate caches. */ @@ -108,6 +113,12 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Random jitterRandom = new Random(); + /** + * 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 CachedSupplier(Builder builder) { Validate.notNull(builder.supplier, "builder.supplier"); Validate.notNull(builder.jitterEnabled, "builder.jitterEnabled"); @@ -117,6 +128,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; } /** @@ -229,8 +241,6 @@ 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) { - consecutiveStaleRetrievalFailures.set(0); - Instant now = clock.instant(); if (now.isBefore(fetch.staleTime())) { @@ -269,25 +279,64 @@ private RefreshResult handleFetchFailure(RuntimeException e) { Instant now = clock.instant(); if (!now.isBefore(currentCachedValue.staleTime())) { - int numFailures = consecutiveStaleRetrievalFailures.incrementAndGet(); - switch (staleValueBehavior) { case STRICT: throw e; case ALLOW: - Instant newStaleTime = jitterTime(now, Duration.ofMillis(1), maxStaleFailureJitter(numFailures)); - log.warn(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + - newStaleTime + " because calling the downstream service failed (consecutive failures: " + - numFailures + ").", e); + // Cache-invalidating errors bypass static stability + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + throw e; + } + + // Uniform random backoff: 5-10 minutes + 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); + + 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); return currentCachedValue.toBuilder() - .staleTime(newStaleTime) + .staleTime(extendedStaleTime) + .prefetchTime(extendedStaleTime) .build(); default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } } + // Not yet stale — we're in the prefetch window. Handle failure based on mode. + if (staleValueBehavior == StaleValueBehavior.ALLOW) { + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.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. + 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; + + 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); + + return currentCachedValue.toBuilder() + .staleTime(newStaleTime) + .prefetchTime(extendedPrefetchTime) + .build(); + } + return currentCachedValue; } @@ -333,6 +382,12 @@ private Duration maxPrefetchJitter(RefreshResult result) { return timeBetweenPrefetchAndStale; } + private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { + long jitterRange = jitterEnd.minus(jitterStart).toMillis(); + long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); + return time.plus(jitterStart).plusMillis(jitterAmount); + } + private Duration maxStaleFailureJitter(int numFailures) { // prevent cycling back through low values if (numFailures > 63) { @@ -350,12 +405,6 @@ protected Duration maxStaleFailureJitterTest(int numFailures) { return maxStaleFailureJitter(numFailures); } - private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { - long jitterRange = jitterEnd.minus(jitterStart).toMillis(); - long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); - return time.plus(jitterStart).plusMillis(jitterAmount); - } - /** * Free any resources consumed by the prefetch strategy this supplier is using. */ @@ -374,6 +423,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; + private Predicate cacheInvalidatingPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -413,6 +463,23 @@ public Builder cachedValueName(String cachedValueName) { return this; } + /** + * Configure a predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability. When the predicate returns {@code true} for a given exception, + * the exception will be re-thrown immediately without extending the cached value's expiration. + * + *

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 cache-invalidating (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured).

+ */ + public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) { + this.cacheInvalidatingPredicate = cacheInvalidatingPredicate; + return this; + } + /** * Configure the clock used for this cached supplier. Configurable for testing. */ @@ -488,8 +555,14 @@ public enum StaleValueBehavior { STRICT, /** - * Allow stale values to be returned from the cache. Value retrieval will never fail, as long as the cache has - * succeeded when calling the underlying supplier at least once. + * 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.

+ * + *

Value retrieval will never fail as long as the cache has succeeded at least once, + * unless the error is cache-invalidating.

*/ 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..adb6ae4bb98e --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -0,0 +1,119 @@ +/* + * 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)); + } + + @Test + public void remainingLifetime5Minutes_returns5MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly20Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(20)); + 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)); + } +} 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..60a55265fba6 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 @@ -364,25 +364,233 @@ public void throwIsHiddenIfValueIsStaleInAllowMode() throws InterruptedException } @Test - public void maxStaleFailureJitter_shouldNotReturnNegativeOrCycleLowValues() { - CachedSupplier supplier = CachedSupplier.builder(() -> RefreshResult.builder("v") - .staleTime(Instant.MAX) - .build()) - .build(); - - for (int i = 1; i <= 70; i++) { - Duration jitter = supplier.maxStaleFailureJitterTest(i); - assertThat(jitter) - .as("numFailures=%d: jitter must be positive", i) - .isPositive(); - - if (i > 64) { - assertThat(jitter) - .isEqualTo(Duration.ofSeconds(10)); + public void allowMode_returnsCachedValueOnNonCacheInvalidatingFailure() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value instead of throwing + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time and throw cache-invalidating error + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw even though cached value exists + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + @Test + public void allowMode_backoffIsInExpectedRange() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + + // Run multiple iterations to verify backoff range + for (int i = 0; i < 50; i++) { + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + cachedSupplier.get(); + + // Advance past stale time and trigger failure + clock.time = now.plusSeconds(61); + 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); + + // Advance just before the minimum backoff - should still return cached (not stale yet) + clock.time = minExpectedStale.minusSeconds(1); + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // Value not stale yet so should return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past maximum possible backoff - must be stale now and will refresh + clock.time = maxExpectedStale.plusSeconds(1); + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); } } + } + + @Test + public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; - supplier.close(); + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value (not throw) and extend prefetch time + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Verify that a subsequent call shortly after does NOT attempt another refresh + // (because prefetchTime was extended) + 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 + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_preservesStaleTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch: stale at +3600s (1 hour), prefetch at +60s + Instant originalStaleTime = now.plusSeconds(3600); + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(originalStaleTime) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but well before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // 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); + 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. + assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw cache-invalidating error even in prefetch window + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + /** + * A RuntimeException that represents a cache-invalidating error for testing. + */ + private static class CacheInvalidatingRuntimeException extends RuntimeException { + CacheInvalidatingRuntimeException(String message) { + super(message); + } } @Test