From 64d7385dca516f0f4c7f79360694f80690d53fa7 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 30 Jun 2026 10:12:15 -0700 Subject: [PATCH] feat(auth): 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() --- .../ContainerCredentialsProvider.java | 16 ++- .../InstanceProfileCredentialsProvider.java | 21 +++- .../ProcessCredentialsProvider.java | 15 ++- ...bIdentityTokenFileCredentialsProvider.java | 5 +- ...nstanceProfileCredentialsProviderTest.java | 10 +- .../signin/auth/LoginCredentialsProvider.java | 19 +++- .../sso/auth/SsoCredentialsProvider.java | 15 ++- .../sts/auth/StsCredentialsProvider.java | 15 ++- .../awssdk/utils/cache/CacheRefreshUtils.java | 65 +++++++++++ .../awssdk/utils/cache/CachedSupplier.java | 11 +- .../utils/cache/CacheRefreshUtilsTest.java | 102 ++++++++++++++++++ .../utils/cache/CachedSupplierTest.java | 43 ++++++++ 12 files changed, 313 insertions(+), 24 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..e4f4a7a8d4bc 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; @@ -100,6 +101,7 @@ public final class ContainerCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -115,6 +117,7 @@ private ContainerCredentialsProvider(BuilderImpl builder) { 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); + this.prefetchTimeExplicitlySet = builder.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); @@ -172,7 +175,13 @@ 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 = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + + return expiration.minus(effectivePrefetchWindow); } @Override @@ -356,7 +365,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..eb2ef26c97d9 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; @@ -94,6 +95,8 @@ public final class InstanceProfileCredentialsProvider private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; + private final String sourceChain; private final String providerName; @@ -123,6 +126,7 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); this.prefetchTime = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5)); + this.prefetchTimeExplicitlySet = builder.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); @@ -208,12 +212,16 @@ 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 = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, 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 +399,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..00c85c93197b 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; @@ -103,6 +104,7 @@ public final class ProcessCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -120,6 +122,7 @@ private ProcessCredentialsProvider(Builder builder) { : builder.sourceChain + "," + PROVIDER_NAME; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.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); @@ -194,7 +197,12 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - return expiration.minus(prefetchTime); + if (prefetchTimeExplicitlySet) { + return expiration.minus(prefetchTime); + } + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + return expiration.minus(dynamicWindow); } /** @@ -382,7 +390,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..97260856b2b2 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; @@ -90,6 +91,7 @@ public final class LoginCredentialsProvider implements private final SigninClient signinClient; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final Path tokenCacheLocation; private final CachedSupplier credentialCache; @@ -107,6 +109,7 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.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; @@ -157,9 +160,14 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { .providerName(this.providerName) .build(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(currentExpirationTime, now); + return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetchWindow)) .build(); } @@ -203,7 +211,9 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTime)) + .prefetchTime(newExpiration.minus(prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(newExpiration, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -355,7 +365,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/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..82b64bb4e7a4 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; @@ -70,6 +71,7 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private final SsoClient ssoClient; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final CachedSupplier credentialCache; @@ -84,6 +86,7 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.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; @@ -114,9 +117,14 @@ private RefreshResult updateSsoCredentials() { SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -225,7 +233,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/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..a29e99b08a1a 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; @@ -67,6 +68,7 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final Boolean asyncCredentialUpdateEnabled; StsCredentialsProvider(BaseBuilder builder, String asyncThreadName) { @@ -74,6 +76,7 @@ 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); + this.prefetchTimeExplicitlySet = builder.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); @@ -98,9 +101,14 @@ private RefreshResult updateSessionCredentials() { credentials.expirationTime() .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -234,7 +242,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/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..4a7b28d7e84d --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -0,0 +1,65 @@ +/* + * 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 dynamic advisory refresh window (prefetch time) based on the credential's remaining lifetime. + * The window scales with the credential's time-to-expiry so that longer-lived credentials begin refreshing + * earlier and shorter-lived credentials do not attempt a refresh the moment they are issued. + * + *
    + *
  • 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 now the current time + * @return the Duration to use as the advisory refresh window + */ + public static Duration computeDynamicPrefetchWindow(Instant expiration, Instant now) { + 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..ac91940504d8 --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -0,0 +1,102 @@ +/* + * 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.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly0_returns5MinuteWindow() { + // 0 minutes remaining (already expired) + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(NOW, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetime5Minutes_returns5MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly20Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(20)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime45Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(45)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime89Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(89)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetimeExactly90Minutes_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(90)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime6Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(6)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime12Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(12)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, 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.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } +} 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();