From b0475534fbf3a9ed57296e51355861c1c929b20c Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 11 Jun 2026 14:27:05 -0700 Subject: [PATCH 1/4] [Credential Cache Pr 1/3] CachedSupplier ALLOW static stability with uniform backoff (#7028) --- .../awssdk/utils/cache/CachedSupplier.java | 112 ++++++++-- .../utils/cache/CachedSupplierTest.java | 197 ++++++++++++++++-- 2 files changed, 270 insertions(+), 39 deletions(-) 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..38cd00033e25 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,57 @@ 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 + 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); + + 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(extendedPrefetchTime) + .prefetchTime(extendedPrefetchTime) + .build(); + } + return currentCachedValue; } @@ -333,6 +375,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 +398,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 +416,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 +456,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 +548,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/CachedSupplierTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java index 159e2d69b6e9..5d0c9ec4381f 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,190 @@ 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"); } } + } - supplier.close(); + @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; + + // 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_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 From 6b14305a00a9c8cdf09fade4f72421a222e94095 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 18 Jun 2026 10:32:37 -0700 Subject: [PATCH 2/4] [Credential Cache Pr 2/3] Enable static stability for STS, Container, SSO, and Login credential providers (#7031) --- .../ContainerCredentialsProvider.java | 3 + .../ContainerCredentialsProviderTest.java | 50 ++++++++ .../signin/auth/LoginCredentialsProvider.java | 44 +++++-- .../auth/LoginCredentialsProviderTest.java | 57 ++++++++- .../sso/auth/SsoCredentialsProvider.java | 8 +- .../sso/auth/SsoCredentialsProviderTest.java | 119 ++++++++++++++++++ .../sts/auth/StsCredentialsProvider.java | 3 +- .../auth/StsCredentialsProviderTestBase.java | 58 +++++++++ 8 files changed, 326 insertions(+), 16 deletions(-) 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..44cfdda33ae2 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; @@ -113,10 +114,12 @@ private ContainerCredentialsProvider(BuilderImpl builder) { 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(); } } 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/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..c2beadf7ff4f 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,6 +44,7 @@ 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; @@ -120,7 +122,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)); } @@ -205,16 +209,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.", @@ -227,6 +227,32 @@ private RefreshResult refreshFromSigninService(LoginAccessToken } } + /** + * Determines whether a given exception represents a non-recoverable refresh failure that should bypass + * static stability. For Login, this is 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) { + 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 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. diff --git a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java index 4431bf6f85be..76171a1aa59b 100644 --- a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java +++ b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java @@ -54,6 +54,7 @@ import software.amazon.awssdk.services.signin.internal.LoginAccessToken; import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; +import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.services.signin.model.SigninException; import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; @@ -182,7 +183,7 @@ public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache() @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneric500_raisesException() { - // expired + // expired - no cached value in CachedSupplier yet, so ALLOW still throws on first failure AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60)); LoginAccessToken token = buildAccessToken(creds); tokenManager.storeToken(token); @@ -195,6 +196,50 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneri assertThrows(SigninException.class, () -> 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 +248,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 +261,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 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..75c0462b707e 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sso.auth; 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,6 +31,7 @@ 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; @@ -90,7 +92,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)); } @@ -115,6 +120,7 @@ private RefreshResult updateSsoCredentials() { 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()) 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..73f71269b17e 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() { 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..5ac6881c3520 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 @@ -78,7 +78,8 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, 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)); } 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..caffab32a9aa 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(); From 74ca0f0d368226f3cdbeaa784739e0509938d307 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 24 Jun 2026 10:35:44 -0700 Subject: [PATCH 3/4] [Credential Cache Pr 3/3] Standardize refresh window configuration across all credential providers (#7053) * Unify refresh window configuration across credential providers Standardize staleTime/prefetchTime defaults and add builder configuration across all credential providers: InstanceProfileCredentialsProvider: - Change default staleTime from 1 second to 1 minute - Change prefetchTime from max(timeUntilExpiry/2, 5min) to flat 5 minutes before expiry - Add prefetchTime(Duration) builder method - Add staleTime <= prefetchTime validation ContainerCredentialsProvider: - Change prefetchTime from min(1hr, 15min-before-expiry) to flat 5 minutes before expiry - Add staleTime(Duration) and prefetchTime(Duration) builder methods - Add staleTime <= prefetchTime validation ProcessCredentialsProvider: - Add staleTime(Duration) and prefetchTime(Duration) builder methods - Change default staleTime from 0 (at expiration) to 1 minute - Change default prefetchTime from 15 seconds to 5 minutes - Deprecate credentialRefreshThreshold (now sets prefetchTime) - Add staleTime <= prefetchTime validation WebIdentityTokenFileCredentialsProvider: - Add staleTime(Duration) and prefetchTime(Duration) builder methods (pass-through to underlying STS provider) StsCredentialsProvider / SsoCredentialsProvider / LoginCredentialsProvider: - Add staleTime <= prefetchTime validation - Update Javadoc for staleTime, prefetchTime, and asyncCredentialUpdateEnabled to use consistent terminology (mandatory refresh window / advisory refresh window) HttpCredentialsProvider: - Update asyncCredentialUpdateEnabled Javadoc for clarity All providers now use consistent defaults: - staleTime: 1 minute (mandatory/blocking refresh window) - prefetchTime: 5 minutes (advisory/proactive refresh window) * Update docs to match validator --- .../ContainerCredentialsProvider.java | 80 +++++++++-- .../credentials/HttpCredentialsProvider.java | 6 +- .../InstanceProfileCredentialsProvider.java | 63 +++++++-- .../ProcessCredentialsProvider.java | 129 ++++++++++++++---- ...bIdentityTokenFileCredentialsProvider.java | 27 +++- ...nstanceProfileCredentialsProviderTest.java | 111 ++++++++++----- .../ProcessCredentialsProviderTest.java | 54 +++++++- .../signin/auth/LoginCredentialsProvider.java | 49 +++++-- .../sso/auth/SsoCredentialsProvider.java | 48 +++++-- .../sts/auth/StsCredentialsProvider.java | 47 +++++-- ...dentityCredentialsProviderFactoryTest.java | 4 +- 11 files changed, 486 insertions(+), 132 deletions(-) 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 44cfdda33ae2..89b894b0f95b 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 @@ -25,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; @@ -44,7 +45,6 @@ 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; @@ -86,6 +86,9 @@ 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 static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); + private final String endpoint; private final HttpCredentialsLoader httpCredentialsLoader; private final CachedSupplier credentialsCache; @@ -95,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() @@ -108,6 +113,10 @@ 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 = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + 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"); @@ -156,19 +165,14 @@ 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); - - return ComparableUtils.minimum(oneHourFromNow, fifteenMinutesBeforeExpiration); + return expiration.minus(prefetchTime); } @Override @@ -323,6 +327,40 @@ 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). + * + *

By default, this is 5 minutes. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration prefetchTime); } private static final class BuilderImpl implements Builder { @@ -330,6 +368,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"); @@ -340,6 +380,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 @@ -372,6 +414,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..b6098427c186 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; @@ -93,6 +92,8 @@ public final class InstanceProfileCredentialsProvider private final Duration staleTime; + private final Duration prefetchTime; + private final String sourceChain; private final String providerName; @@ -120,7 +121,10 @@ 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 = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5)); + 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 +208,12 @@ private Instant prefetchTime(Instant expiration) { return null; } - return now.plus(maximum(timeUntilExpiration.dividedBy(2), Duration.ofMinutes(5))); + // Advisory refresh window: use configured prefetchTime before expiry. + // If remaining lifetime < prefetchTime, refresh immediately. + if (timeUntilExpiration.compareTo(prefetchTime) < 0) { + return now; + } + return expiration.minus(prefetchTime); } @Override @@ -355,17 +364,39 @@ 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). + * + *

By default, this is 5 minutes. + * + * @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 +413,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 +428,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 +508,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..0b1acb2b4b28 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; @@ -44,21 +45,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 +84,10 @@ 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 static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); 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,10 @@ 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 = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + 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 +173,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 +183,20 @@ 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; + } + return expiration.minus(prefetchTime); + } + /** * Parse the output from the credentials process. */ @@ -277,10 +310,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 +326,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 +350,47 @@ 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). + * + *

By default, this is 5 minutes.

+ * + * @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 +418,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..53ecf64b53da 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,36 @@ 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. + * + *

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). * *

By default, this is 5 minutes. + * + * @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/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 055967055c25..46bf5faf8123 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 (5 min before expiry). + // Since prefetchTime = expiration - 5min = now + 5h55m, anything before that should not trigger refresh. + clock.time = now.plus(5, HOURS); + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); + AwsCredentials credentials5HoursLater = credentialsProvider.resolveCredentials(); + + assertThat(credentials5HoursLater).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 c2beadf7ff4f..8c2ca8be010b 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 @@ -48,6 +48,7 @@ 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.CachedSupplier; @@ -106,6 +107,8 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + 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) @@ -254,16 +257,16 @@ private static AccessDeniedException extractAccessDeniedException(RuntimeExcepti } /** - * 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; @@ -312,29 +315,49 @@ 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. + * + *

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). * *

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/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 75c0462b707e..eeefefb9983c 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,6 +15,7 @@ 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; @@ -83,6 +84,8 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + 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) @@ -133,16 +136,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; @@ -182,30 +185,49 @@ 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. * - * Prefetch 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. + * + *

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). * *

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/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 5ac6881c3520..c878bd987970 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 @@ -74,6 +74,8 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + 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 = @@ -117,16 +119,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; @@ -184,9 +186,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.

*/ @@ -197,10 +203,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) { @@ -209,13 +222,21 @@ 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. * - * Prefetch 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. + * + *

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). * *

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/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") From 25c91a15f066dd45f0949d7db475992a48ca3cbc Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 10:02:44 -0700 Subject: [PATCH 4/4] [Credentials Cache PR4]Add dynamic advisory refresh window and update prefetch behavior (#7093) Add dynamic advisory refresh window and fix prefetch failure stale time Implement two changes from the updated cross-SDK credential refresh spec: 1. Dynamic advisory refresh window: When the user has NOT explicitly configured a prefetchTime, compute the advisory window dynamically based on the credential's remaining lifetime: - remaining < 20 min -> 5 min window - 20 min <= remaining < 90 min -> 15 min window - remaining >= 90 min -> 60 min window This is recomputed on each successful refresh. If the user HAS explicitly configured a value, it is always honored unchanged. 2. Prefetch failure stale time preservation: When a credential refresh fails during the advisory (prefetch) window, extend the prefetch time by backoff but preserve the existing stale time if it is later than the new prefetch time. Previously both were set to the same backoff value, which could move the mandatory refresh boundary closer than intended. Affected providers: STS (all), IMDS, Container, SSO, Login, Process. New utility: CacheRefreshUtils.computeDynamicPrefetchWindow() * Update approach to dynamic prefetch window --- .../ContainerCredentialsProvider.java | 21 ++- .../InstanceProfileCredentialsProvider.java | 24 ++-- .../ProcessCredentialsProvider.java | 19 ++- ...bIdentityTokenFileCredentialsProvider.java | 5 +- ...nstanceProfileCredentialsProviderTest.java | 10 +- .../signin/auth/LoginCredentialsProvider.java | 55 ++++++-- .../auth/LoginCredentialsProviderTest.java | 129 ++++++++++++++++++ .../sso/auth/SsoCredentialsProvider.java | 20 ++- .../sso/auth/SsoCredentialsProviderTest.java | 2 +- .../sts/auth/StsCredentialsProvider.java | 20 ++- .../auth/StsCredentialsProviderTestBase.java | 2 +- ...ebIdentityTokenCredentialProviderTest.java | 2 +- .../awssdk/utils/cache/CacheRefreshUtils.java | 72 ++++++++++ .../awssdk/utils/cache/CachedSupplier.java | 11 +- .../utils/cache/CacheRefreshUtilsTest.java | 119 ++++++++++++++++ .../utils/cache/CachedSupplierTest.java | 43 ++++++ 16 files changed, 496 insertions(+), 58 deletions(-) create mode 100644 utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java create mode 100644 utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java 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 89b894b0f95b..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 @@ -50,6 +50,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; @@ -87,7 +88,6 @@ public final class ContainerCredentialsProvider 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 static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private final String endpoint; private final HttpCredentialsLoader httpCredentialsLoader; @@ -114,9 +114,11 @@ private ContainerCredentialsProvider(BuilderImpl builder) { : builder.sourceChain + "," + PROVIDER_NAME; this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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"); @@ -172,7 +174,11 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null) { return Instant.now().plus(1, ChronoUnit.HOURS); } - return expiration.minus(prefetchTime); + + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + + return expiration.minus(effectivePrefetchWindow); } @Override @@ -356,7 +362,10 @@ public interface Builder extends HttpCredentialsProvider.BuilderThis 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). * - *

By default, this is 5 minutes. + *

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 */ 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 b6098427c186..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 @@ -49,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; @@ -122,9 +123,11 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .build(); this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); - this.prefetchTime = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5)); - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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"); @@ -208,12 +211,14 @@ private Instant prefetchTime(Instant expiration) { return null; } - // Advisory refresh window: use configured prefetchTime before expiry. - // If remaining lifetime < prefetchTime, refresh immediately. - if (timeUntilExpiration.compareTo(prefetchTime) < 0) { + // 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(prefetchTime); + return expiration.minus(effectivePrefetchWindow); } @Override @@ -391,7 +396,10 @@ public interface Builder extends HttpCredentialsProvider.BuilderThis 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). * - *

By default, this is 5 minutes. + *

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 */ 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 0b1acb2b4b28..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 @@ -38,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; @@ -85,7 +86,6 @@ public final class ProcessCredentialsProvider .removeErrorLocations(true) .build(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private final List executableCommand; private final long processOutputLimit; @@ -119,9 +119,11 @@ private ProcessCredentialsProvider(Builder builder) { ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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()); @@ -194,7 +196,9 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - return expiration.minus(prefetchTime); + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + return expiration.minus(dynamicWindow); } /** @@ -382,7 +386,10 @@ public Builder staleTime(Duration staleTime) { *

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). * - *

By default, this is 5 minutes.

+ *

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 */ 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 53ecf64b53da..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 @@ -201,7 +201,10 @@ public interface Builder extends CopyableBuilderThis 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). * - *

By default, this is 5 minutes. + *

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 */ 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 46bf5faf8123..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 @@ -639,13 +639,13 @@ void imdsCallFrequencyIsLimited() { stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); AwsCredentials credentialsAtStart = credentialsProvider.resolveCredentials(); - // Move time forward but still before the prefetch window (5 min before expiry). - // Since prefetchTime = expiration - 5min = now + 5h55m, anything before that should not trigger refresh. - clock.time = now.plus(5, HOURS); + // 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 credentials5HoursLater = credentialsProvider.resolveCredentials(); + AwsCredentials credentialsLater = credentialsProvider.resolveCredentials(); - assertThat(credentials5HoursLater).isEqualTo(credentialsAtStart); + assertThat(credentialsLater).isEqualTo(credentialsAtStart); assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); } 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 8c2ca8be010b..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 @@ -51,6 +51,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; @@ -78,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"; @@ -106,9 +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); - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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) @@ -136,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() @@ -159,7 +162,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetch)) .build(); } @@ -203,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) { @@ -232,11 +236,18 @@ private RefreshResult refreshFromSigninService(LoginAccessToken /** * Determines whether a given exception represents a non-recoverable refresh failure that should bypass - * static stability. For Login, this is an {@link AccessDeniedException} with error code - * {@link OAuth2ErrorCode#TOKEN_EXPIRED}, {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, - * or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}. + * 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; @@ -355,7 +366,10 @@ public interface Builder extends CopyableBuilderThis 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). * - *

By default, this is 5 minutes. + *

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 */ @@ -386,6 +400,17 @@ public interface Builder extends CopyableBuilder 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 eeefefb9983c..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 @@ -37,6 +37,7 @@ 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; @@ -59,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"; @@ -83,9 +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); - isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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) @@ -114,9 +116,12 @@ 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(); } @@ -225,7 +230,10 @@ public interface Builder extends CopyableBuilderThis 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). * - *

By default, this is 5 minutes.

+ *

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 */ 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 73f71269b17e..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 @@ -248,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 c878bd987970..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,9 +73,11 @@ 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); - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + 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 = @@ -98,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(); } @@ -234,7 +239,10 @@ public B staleTime(Duration staleTime) { *

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). * - *

By default, this is 5 minutes.

+ *

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 */ 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 caffab32a9aa..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 @@ -181,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/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 38cd00033e25..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 @@ -313,19 +313,26 @@ private RefreshResult handleFetchFailure(RuntimeException e) { if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { throw e; } - // During prefetch window failure: extend prefetchTime to suppress further attempts + // 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(extendedPrefetchTime) + .staleTime(newStaleTime) .prefetchTime(extendedPrefetchTime) .build(); } 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 5d0c9ec4381f..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 @@ -509,6 +509,49 @@ public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { } } + @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();