From 1a3139bbabad507e54f42355a034db376ae9bf9c Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 5 Jun 2026 16:00:40 -0700 Subject: [PATCH 1/3] First pass POC --- .../ContainerCredentialsProvider.java | 14 +- .../InstanceProfileCredentialsProvider.java | 21 +- .../ProcessCredentialsProvider.java | 40 +++- .../ContainerCredentialsProviderTest.java | 50 +++++ ...nstanceProfileCredentialsProviderTest.java | 64 +++--- .../ProcessCredentialsProviderTest.java | 36 +++- .../exception/CacheInvalidatingException.java | 110 ++++++++++ .../signin/auth/LoginCredentialsProvider.java | 12 +- .../auth/LoginCredentialsProviderTest.java | 53 ++++- .../sso/auth/SsoCredentialsProvider.java | 33 ++- .../sso/auth/SsoCredentialsProviderTest.java | 101 +++++++++ .../sts/auth/StsCredentialsProvider.java | 3 +- .../auth/StsCredentialsProviderTestBase.java | 38 ++++ .../utils/cache/CacheInvalidatingError.java | 31 +++ .../awssdk/utils/cache/CachedSupplier.java | 107 ++++++---- .../utils/cache/CachedSupplierTest.java | 193 ++++++++++++++++-- 16 files changed, 781 insertions(+), 125 deletions(-) create mode 100644 core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java create mode 100644 utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.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 5fefed5e8d65..f4903ffee75f 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; @@ -43,7 +44,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; @@ -113,10 +113,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(); } } @@ -157,15 +159,11 @@ private Instant staleTime(Instant expiration) { } 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); + // 5 minutes before expiry + return expiration.minus(5, ChronoUnit.MINUTES); } @Override 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..856a7088b5fc 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; @@ -120,7 +119,7 @@ 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)); if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -192,19 +191,24 @@ private Instant staleTime(Instant expiration) { } private Instant prefetchTime(Instant expiration) { - Instant now = clock.instant(); - if (expiration == null) { - return now.plus(60, MINUTES); + return clock.instant().plus(60, MINUTES); } + Instant now = clock.instant(); Duration timeUntilExpiration = Duration.between(now, expiration); if (timeUntilExpiration.isNegative()) { // IMDS gave us a time in the past. We're already stale. Don't prefetch. return null; } - return now.plus(maximum(timeUntilExpiration.dividedBy(2), Duration.ofMinutes(5))); + // Advisory refresh window: 5 minutes before expiry. + // If remaining lifetime < 5 minutes, use remaining lifetime. + Duration advisoryWindow = Duration.ofMinutes(5); + if (timeUntilExpiration.compareTo(advisoryWindow) < 0) { + return now; + } + return expiration.minus(advisoryWindow); } @Override @@ -357,10 +361,7 @@ 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. + * The default is 1 minute. * * @param duration the amount of time before expiration for when to consider the credentials to be stale and need 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 08d9cf373ab2..69c6b8447495 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 @@ -54,9 +54,10 @@ * Available settings: * @@ -71,9 +72,12 @@ public final class ProcessCredentialsProvider private static final JsonNodeParser PARSER = JsonNodeParser.builder() .removeErrorLocations(true) .build(); + private static final Duration PROCESS_STALE_TIME = Duration.ofMinutes(1); + private static final Duration PROCESS_PREFETCH_TIME = Duration.ofMinutes(5); private final List executableCommand; private final Duration credentialRefreshThreshold; + private final boolean credentialRefreshThresholdExplicitlySet; private final long processOutputLimit; private final String staticAccountId; @@ -95,6 +99,7 @@ private ProcessCredentialsProvider(Builder builder) { this.executableCommand = executableCommand(builder); this.processOutputLimit = Validate.isPositive(builder.processOutputLimit, "processOutputLimit"); this.credentialRefreshThreshold = Validate.isPositive(builder.credentialRefreshThreshold, "expirationBuffer"); + this.credentialRefreshThresholdExplicitlySet = builder.credentialRefreshThresholdExplicitlySet; this.commandFromBuilder = builder.command; this.commandAsListOfStringsFromBuilder = builder.commandAsListOfStrings; this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; @@ -154,8 +159,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 +169,23 @@ private RefreshResult refreshCredentials() { } } + private Instant staleTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + return expiration.minus(PROCESS_STALE_TIME); + } + + private Instant prefetchTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + if (credentialRefreshThresholdExplicitlySet) { + return expiration.minusMillis(credentialRefreshThreshold.toMillis()); + } + return expiration.minus(PROCESS_PREFETCH_TIME); + } + /** * Parse the output from the credentials process. */ @@ -278,6 +300,7 @@ public static class Builder implements CopyableBuilder commandAsListOfStrings; private Duration credentialRefreshThreshold = Duration.ofSeconds(15); + private boolean credentialRefreshThresholdExplicitlySet = false; private long processOutputLimit = 64000; private String staticAccountId; private String sourceChain; @@ -293,6 +316,7 @@ private Builder(ProcessCredentialsProvider provider) { this.command = provider.commandFromBuilder; this.commandAsListOfStrings = provider.commandAsListOfStringsFromBuilder; this.credentialRefreshThreshold = provider.credentialRefreshThreshold; + this.credentialRefreshThresholdExplicitlySet = provider.credentialRefreshThresholdExplicitlySet; this.processOutputLimit = provider.processOutputLimit; this.staticAccountId = provider.staticAccountId; this.sourceChain = provider.sourceChain; @@ -339,9 +363,15 @@ public Builder command(List commandAsListOfStrings) { * refreshed. This allows the credentials to be refreshed *before* they are reported to expire. * *

Default: 15 seconds.

+ * + * @deprecated The provider now uses a default prefetch time of 5 minutes before expiry, aligned with other + * credential providers. If this method is called, the specified value will be honored as the prefetch time for + * backward compatibility. */ + @Deprecated public Builder credentialRefreshThreshold(Duration credentialRefreshThreshold) { this.credentialRefreshThreshold = credentialRefreshThreshold; + this.credentialRefreshThresholdExplicitlySet = true; return this; } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java index 29955af439ca..028e21b77143 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java @@ -140,4 +140,54 @@ private String getSuccessfulBody() { "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + "\"Expiration\":\"3000-05-03T04:55:54Z\"}"; } + + /** + * Tests that when the cache is stale and refresh fails, the provider returns cached credentials + * instead of throwing an exception (ALLOW behavior / static stability). + */ + @Test + public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() { + // First call succeeds with credentials that are already expired (stale immediately on next get) + String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + + "\"Expiration\":\"2020-01-01T00:00:00Z\"}"; + + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(alreadyExpiredBody))); + + // First call succeeds (initial fetch always succeeds even if credentials are expired) + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + + // Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure) + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + // Second call: cache is stale (expiration is in the past), refresh fails with 500, + // but ALLOW behavior should return the cached credentials + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + } + + /** + * Tests that when no credentials are cached (initial fetch) and the endpoint fails, + * an exception is thrown. + */ + @Test + public void testInitialFetchFailure_throwsException() { + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class); + } } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 055967055c25..b3811761132b 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 @@ -575,38 +575,38 @@ void resolveCredentials_callsImdsIfCredentialsWithin5MinutesOfExpiration() { @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 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..f95366147ed2 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 @@ -225,7 +225,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(); @@ -250,6 +250,40 @@ 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 = diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java new file mode 100644 index 000000000000..c0e651922088 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java @@ -0,0 +1,110 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.exception; + +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.utils.cache.CacheInvalidatingError; + +/** + * An exception that signals a non-recoverable credential refresh failure. + * When thrown by a credential provider's refresh function, the caching layer + * will propagate this exception immediately to the caller without applying + * refresh backoff or extending cached credential 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).

+ */ +@SdkPublicApi +public final class CacheInvalidatingException extends SdkClientException implements CacheInvalidatingError { + + private CacheInvalidatingException(Builder builder) { + super(builder); + } + + public static CacheInvalidatingException create(String message) { + return builder().message(message).build(); + } + + public static CacheInvalidatingException create(String message, Throwable cause) { + return builder().message(message).cause(cause).build(); + } + + @Override + public Builder toBuilder() { + return new BuilderImpl(this); + } + + public static Builder builder() { + return new BuilderImpl(); + } + + public interface Builder extends SdkClientException.Builder { + @Override + Builder message(String message); + + @Override + Builder cause(Throwable cause); + + @Override + Builder writableStackTrace(Boolean writableStackTrace); + + @Override + Builder numAttempts(Integer numAttempts); + + @Override + CacheInvalidatingException build(); + } + + protected static final class BuilderImpl extends SdkClientException.BuilderImpl implements Builder { + + protected BuilderImpl() { + } + + protected BuilderImpl(CacheInvalidatingException ex) { + super(ex); + } + + @Override + public Builder message(String message) { + this.message = message; + return this; + } + + @Override + public Builder cause(Throwable cause) { + this.cause = cause; + return this; + } + + @Override + public Builder writableStackTrace(Boolean writableStackTrace) { + this.writableStackTrace = writableStackTrace; + return this; + } + + @Override + public Builder numAttempts(Integer numAttempts) { + this.numAttempts = numAttempts; + return this; + } + + @Override + public CacheInvalidatingException build() { + return new CacheInvalidatingException(this); + } + } +} 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..cd04cf49eb1d 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; @@ -32,6 +33,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.SdkPlugin; +import software.amazon.awssdk.core.exception.CacheInvalidatingException; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.services.signin.SigninClient; @@ -120,7 +122,8 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSigninCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -205,15 +208,14 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: - throw SdkClientException.create( + throw CacheInvalidatingException.create( "Your session has expired. Please reauthenticate.", accessDeniedException); case USER_CREDENTIALS_CHANGED: - throw SdkClientException.create( + throw CacheInvalidatingException.create( "Unable to refresh credentials because of a change in your password. " + "Please reauthenticate with your new password.", - accessDeniedException - ); + accessDeniedException); case INSUFFICIENT_PERMISSIONS: throw SdkClientException.create( "Unable to refresh credentials due to insufficient permissions. You may be missing permission " 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..0609696a5a8a 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 @@ -39,6 +39,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.exception.CacheInvalidatingException; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.interceptor.Context; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; @@ -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,7 +248,8 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); + CacheInvalidatingException e = assertThrows(CacheInvalidatingException.class, + () -> loginCredentialsProvider.resolveCredentials()); assertTrue(e.getMessage().contains("Your session has expired")); } @@ -215,7 +261,8 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); + CacheInvalidatingException e = assertThrows(CacheInvalidatingException.class, + () -> loginCredentialsProvider.resolveCredentials()); assertTrue(e.getMessage().contains("change in your password")); } 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..ee78dea2842f 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 @@ -25,11 +25,13 @@ import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.core.exception.CacheInvalidatingException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.services.sso.SsoClient; 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,8 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSsoCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -103,19 +106,31 @@ private SsoCredentialsProvider(BuilderImpl builder) { * are close to expiring. */ private RefreshResult updateSsoCredentials() { - SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); - Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); - - return RefreshResult.builder(credentials) - .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) - .build(); + try { + SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); + Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + + return RefreshResult.builder(credentials) + .staleTime(actualTokenExpiration.minus(staleTime)) + .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .build(); + } catch (ExpiredTokenException e) { + throw CacheInvalidatingException.create( + "SSO token has expired. Please run 'aws sso login' to re-authenticate.", e); + } } private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get(); notNull(request, "GetRoleCredentialsRequest can't be null."); - RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials(); + + RoleCredentials roleCredentials; + try { + roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials(); + } catch (UnauthorizedException e) { + throw CacheInvalidatingException.create( + "SSO access token is expired or invalid. Please run 'aws sso login'.", e); + } AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder() .accessKeyId(roleCredentials.accessKeyId()) .secretAccessKey(roleCredentials.secretAccessKey()) 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..e27ffd0a41b7 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,14 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.core.exception.CacheInvalidatingException; +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 +92,103 @@ 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_throwsCacheInvalidatingException() { + 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 CacheInvalidatingException + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(CacheInvalidatingException.class) + .hasMessageContaining("SSO access token is expired or invalid") + .hasCauseInstanceOf(UnauthorizedException.class); + } + } + + @Test + public void expiredTokenException_throwsCacheInvalidatingException() { + 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 CacheInvalidatingException wrapping ExpiredTokenException + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(CacheInvalidatingException.class) + .hasMessageContaining("SSO token has expired") + .hasCauseInstanceOf(ExpiredTokenException.class); + } + } + 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..20722ccfd045 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 @@ -27,7 +27,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 +103,42 @@ 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"); + } + } + public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, int numTimesInvokeCredentialsProvider, boolean overrideStaleAndPrefetchTimes) { Credentials credentials = Credentials.builder().accessKeyId("a").secretAccessKey("b").sessionToken("c").expiration(credentialsExpirationDate).build(); RequestT request = getRequest(); diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java new file mode 100644 index 000000000000..ba970d8a5995 --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java @@ -0,0 +1,31 @@ +/* + * 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 software.amazon.awssdk.annotations.SdkProtectedApi; + +/** + * Marker interface for exceptions that indicate a non-recoverable refresh failure. + * When thrown during a cache refresh, the caching layer will propagate the exception + * immediately without applying backoff or extending expiration. + * + *

Exceptions implementing this interface bypass cache static stability behavior, + * ensuring that actionable errors (such as expired tokens or changed credentials) + * are never suppressed by the caching layer.

+ */ +@SdkProtectedApi +public interface CacheInvalidatingError { +} 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..284b85f264b4 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,13 +23,11 @@ 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.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; -import software.amazon.awssdk.utils.ComparableUtils; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.Validate; @@ -54,6 +52,21 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); + /** + * The advisory refresh window added to the current time before applying backoff on refresh failure. + */ + private static final Duration STATIC_STABILITY_ADVISORY_WINDOW = Duration.ofMinutes(5); + + /** + * Minimum backoff duration in seconds when a refresh fails (inclusive). + */ + private static final int STATIC_STABILITY_BACKOFF_MIN_SECONDS = 300; + + /** + * Maximum backoff duration in seconds when a refresh fails (inclusive). + */ + private static final int STATIC_STABILITY_BACKOFF_MAX_SECONDS = 600; + /** * 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 +96,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. */ @@ -229,8 +237,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 +275,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 (e instanceof CacheInvalidatingError) { + throw e; + } + + // Uniform random backoff: 5-10 minutes + int backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + + jitterRandom.nextInt( + STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + Instant extendedStaleTime = now.plus(STATIC_STABILITY_ADVISORY_WINDOW) + .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 (e instanceof CacheInvalidatingError) { + throw e; + } + // During prefetch window failure: extend prefetchTime to suppress further attempts + int backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + + jitterRandom.nextInt( + STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + Instant extendedPrefetchTime = now.plus(STATIC_STABILITY_ADVISORY_WINDOW) + .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,29 +371,21 @@ private Duration maxPrefetchJitter(RefreshResult result) { return timeBetweenPrefetchAndStale; } - private Duration maxStaleFailureJitter(int numFailures) { - // prevent cycling back through low values - if (numFailures > 63) { - return Duration.ofSeconds(10); - } - long exponentialBackoffMillis = (1L << numFailures - 1) * 100; - if (exponentialBackoffMillis <= 0) { - exponentialBackoffMillis = Long.MAX_VALUE; - } - return ComparableUtils.minimum(Duration.ofMillis(exponentialBackoffMillis), Duration.ofSeconds(10)); - } - - @SdkTestInternalApi - 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); } + /** + * @deprecated This method is no longer used internally. It will be removed in a future release. + */ + @Deprecated + @SdkTestInternalApi + protected Duration maxStaleFailureJitterTest(int numFailures) { + return Duration.ofSeconds(10); + } + /** * Free any resources consumed by the prefetch strategy this supplier is using. */ @@ -488,8 +518,15 @@ 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 the advisory refresh window (5 minutes) plus a uniformly random backoff + * between 5 and 10 minutes (300-600 seconds). + * + *

If the failure is a {@link CacheInvalidatingError}, the exception 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..0b1c2e97c9ae 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,186 @@ 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) + .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) + 300s(advisory) + [300,600]s(backoff) + // So total offset from epoch: 61 + 300 + [300,600] = [661, 961] seconds from original now + Instant minExpectedStale = now.plusSeconds(61 + 300 + 300); + Instant maxExpectedStale = now.plusSeconds(61 + 300 + 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) + .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 implements CacheInvalidatingError for testing. + */ + private static class CacheInvalidatingRuntimeException extends RuntimeException implements CacheInvalidatingError { + CacheInvalidatingRuntimeException(String message) { + super(message); + } } @Test From 97d32064dbacfd46ce3d7a6bc1fca0a53813aa4a Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 9 Jun 2026 09:21:45 -0700 Subject: [PATCH 2/3] Added consinstent configuration for stale/prefetch times --- .../ContainerCredentialsProvider.java | 71 ++++++++++++++++++- .../credentials/HttpCredentialsProvider.java | 6 +- .../InstanceProfileCredentialsProvider.java | 57 ++++++++++++--- .../ProcessCredentialsProvider.java | 64 +++++++++++++++-- ...bIdentityTokenFileCredentialsProvider.java | 25 +++++-- ...nstanceProfileCredentialsProviderTest.java | 47 +++++++++++- .../signin/auth/LoginCredentialsProvider.java | 47 ++++++++---- .../sso/auth/SsoCredentialsProvider.java | 46 ++++++++---- .../sso/auth/SsoCredentialsProviderTest.java | 21 ++++++ .../sts/auth/StsCredentialsProvider.java | 45 ++++++++---- .../auth/StsCredentialsProviderTestBase.java | 20 ++++++ ...dentityCredentialsProviderFactoryTest.java | 4 +- 12 files changed, 385 insertions(+), 68 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 f4903ffee75f..88a0f5a48c93 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; @@ -85,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; @@ -94,6 +98,8 @@ public final class ContainerCredentialsProvider private final String asyncThreadName; private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -107,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 prefetchTime (%s).", this.staleTime, this.prefetchTime); if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -155,15 +165,14 @@ private Instant staleTime(Instant expiration) { return null; } - return expiration.minus(1, ChronoUnit.MINUTES); + return expiration.minus(staleTime); } private Instant prefetchTime(Instant expiration) { if (expiration == null) { return Instant.now().plus(1, ChronoUnit.HOURS); } - // 5 minutes before expiry - return expiration.minus(5, ChronoUnit.MINUTES); + return expiration.minus(prefetchTime); } @Override @@ -318,6 +327,38 @@ 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 {@link #prefetchTime(Duration)}. + * + *

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 {@link #staleTime(Duration)}. + * + *

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 { @@ -325,6 +366,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"); @@ -335,6 +378,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 @@ -367,6 +412,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 856a7088b5fc..8b561db438da 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 @@ -92,6 +92,8 @@ public final class InstanceProfileCredentialsProvider private final Duration staleTime; + private final Duration prefetchTime; + private final String sourceChain; private final String providerName; @@ -120,6 +122,9 @@ 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 prefetchTime (%s).", this.staleTime, this.prefetchTime); if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -202,13 +207,12 @@ private Instant prefetchTime(Instant expiration) { return null; } - // Advisory refresh window: 5 minutes before expiry. - // If remaining lifetime < 5 minutes, use remaining lifetime. - Duration advisoryWindow = Duration.ofMinutes(5); - if (timeUntilExpiration.compareTo(advisoryWindow) < 0) { + // Advisory refresh window: use configured prefetchTime before expiry. + // If remaining lifetime < prefetchTime, refresh immediately. + if (timeUntilExpiration.compareTo(prefetchTime) < 0) { return now; } - return expiration.minus(advisoryWindow); + return expiration.minus(prefetchTime); } @Override @@ -359,14 +363,37 @@ public interface Builder extends HttpCredentialsProvider.BuilderThis value must be less than {@link #prefetchTime(Duration)}. * - * @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 {@link #staleTime(Duration)}. + * + *

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. */ @@ -383,6 +410,7 @@ static final class BuilderImpl implements Builder { private Supplier profileFile; private String profileName; private Duration staleTime; + private Duration prefetchTime; private String sourceChain; private BuilderImpl() { @@ -397,6 +425,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; } @@ -476,6 +505,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 69c6b8447495..a6d7882146a5 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; @@ -91,6 +92,8 @@ public final class ProcessCredentialsProvider private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -108,6 +111,10 @@ private ProcessCredentialsProvider(Builder builder) { this.providerName = StringUtils.isEmpty(builder.sourceChain) ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; + this.staleTime = Optional.ofNullable(builder.staleTime).orElse(PROCESS_STALE_TIME); + this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(PROCESS_PREFETCH_TIME); + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) < 0, + "staleTime (%s) must be less than prefetchTime (%s).", this.staleTime, this.prefetchTime); CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()); @@ -173,7 +180,7 @@ private Instant staleTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - return expiration.minus(PROCESS_STALE_TIME); + return expiration.minus(staleTime); } private Instant prefetchTime(Instant expiration) { @@ -183,7 +190,7 @@ private Instant prefetchTime(Instant expiration) { if (credentialRefreshThresholdExplicitlySet) { return expiration.minusMillis(credentialRefreshThreshold.toMillis()); } - return expiration.minus(PROCESS_PREFETCH_TIME); + return expiration.minus(prefetchTime); } /** @@ -304,6 +311,8 @@ public static class Builder implements CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -335,6 +350,45 @@ 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 {@link #prefetchTime(Duration)}. + * + *

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 {@link #staleTime(Duration)}. + * + *

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. 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..6aa78a9e6b63 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,34 @@ 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 {@link #staleTime(Duration)}. * *

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 {@link #prefetchTime(Duration)}. * *

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 b3811761132b..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,6 +573,46 @@ 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() { // Verify that IMDS is not called again if we haven't reached the prefetch window @@ -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/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 cd04cf49eb1d..0f42833a3e63 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 prefetchTime (%s).", this.staleTime, this.prefetchTime); this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -230,16 +233,16 @@ private RefreshResult refreshFromSigninService(LoginAccessToken } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no - * longer be used. All threads will block until the value is updated. + * 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; @@ -288,29 +291,47 @@ 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 {@link #prefetchTime(Duration)}. * *

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 {@link #staleTime(Duration)}. * *

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 ee78dea2842f..c505b872e2e8 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 java.time.Duration; @@ -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 prefetchTime (%s).", this.staleTime, this.prefetchTime); this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -142,16 +145,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; @@ -191,30 +194,47 @@ 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 {@link #prefetchTime(Duration)}. * *

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 {@link #staleTime(Duration)}. * *

By default, this is 5 minutes.

+ * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java index e27ffd0a41b7..e4bb390397f0 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 @@ -189,6 +189,27 @@ public void expiredTokenException_throwsCacheInvalidatingException() { } } + @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 5ac6881c3520..db7854ed5aa4 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 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,16 @@ 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 {@link #prefetchTime(Duration)}. * *

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 +221,20 @@ 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 {@link #staleTime(Duration)}. * *

By default, this is 5 minutes.

+ * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ @SuppressWarnings("unchecked") public B prefetchTime(Duration prefetchTime) { diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java index 20722ccfd045..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; @@ -139,6 +140,25 @@ public void refreshFailureReturnsCachedCredentials_staticStability() { } } + @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(); 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 9511ec176de07b9257a23b46401cd8e6533c341a Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 9 Jun 2026 14:34:44 -0700 Subject: [PATCH 3/3] Minor cleanups --- .../InstanceProfileCredentialsProvider.java | 5 +- .../ProcessCredentialsProvider.java | 67 +++++++++---------- .../ProcessCredentialsProviderTest.java | 18 +++-- .../awssdk/utils/cache/CachedSupplier.java | 14 +--- .../utils/cache/CachedSupplierTest.java | 8 +-- 5 files changed, 54 insertions(+), 58 deletions(-) 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 8b561db438da..17cef6c92815 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 @@ -196,11 +196,12 @@ private Instant staleTime(Instant expiration) { } private Instant prefetchTime(Instant expiration) { + Instant now = clock.instant(); + if (expiration == null) { - return clock.instant().plus(60, MINUTES); + return now.plus(60, MINUTES); } - Instant now = clock.instant(); Duration timeUntilExpiration = Duration.between(now, expiration); if (timeUntilExpiration.isNegative()) { // IMDS gave us a time in the past. We're already stale. Don't prefetch. 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 a6d7882146a5..d9fcb6b07a3e 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 @@ -45,22 +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 - Deprecated. Previously configured the amount of time between when the - * credentials expire and when the credentials should start to be refreshed. The provider now uses a default prefetch time - * of 5 minutes before expiry and a stale time of 1 minute before expiry. If explicitly set, the value is honored as the - * prefetch time for backward compatibility.
  • - *
  • 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 @@ -73,12 +84,10 @@ public final class ProcessCredentialsProvider private static final JsonNodeParser PARSER = JsonNodeParser.builder() .removeErrorLocations(true) .build(); - private static final Duration PROCESS_STALE_TIME = Duration.ofMinutes(1); - private static final Duration PROCESS_PREFETCH_TIME = Duration.ofMinutes(5); + 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 boolean credentialRefreshThresholdExplicitlySet; private final long processOutputLimit; private final String staticAccountId; @@ -101,8 +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.credentialRefreshThresholdExplicitlySet = builder.credentialRefreshThresholdExplicitlySet; this.commandFromBuilder = builder.command; this.commandAsListOfStringsFromBuilder = builder.commandAsListOfStrings; this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; @@ -111,8 +118,8 @@ private ProcessCredentialsProvider(Builder builder) { this.providerName = StringUtils.isEmpty(builder.sourceChain) ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; - this.staleTime = Optional.ofNullable(builder.staleTime).orElse(PROCESS_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(PROCESS_PREFETCH_TIME); + 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 prefetchTime (%s).", this.staleTime, this.prefetchTime); @@ -187,9 +194,6 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - if (credentialRefreshThresholdExplicitlySet) { - return expiration.minusMillis(credentialRefreshThreshold.toMillis()); - } return expiration.minus(prefetchTime); } @@ -306,8 +310,6 @@ public static class Builder implements CopyableBuilder commandAsListOfStrings; - private Duration credentialRefreshThreshold = Duration.ofSeconds(15); - private boolean credentialRefreshThresholdExplicitlySet = false; private long processOutputLimit = 64000; private String staticAccountId; private String sourceChain; @@ -324,8 +326,6 @@ private Builder(ProcessCredentialsProvider provider) { this.asyncCredentialUpdateEnabled = provider.asyncCredentialUpdateEnabled; this.command = provider.commandFromBuilder; this.commandAsListOfStrings = provider.commandAsListOfStringsFromBuilder; - this.credentialRefreshThreshold = provider.credentialRefreshThreshold; - this.credentialRefreshThresholdExplicitlySet = provider.credentialRefreshThresholdExplicitlySet; this.processOutputLimit = provider.processOutputLimit; this.staticAccountId = provider.staticAccountId; this.sourceChain = provider.sourceChain; @@ -416,16 +416,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 The provider now uses a default prefetch time of 5 minutes before expiry, aligned with other - * credential providers. If this method is called, the specified value will be honored as the prefetch time for - * backward compatibility. + * @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.credentialRefreshThresholdExplicitlySet = true; + this.prefetchTime = credentialRefreshThreshold; return this; } 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 f95366147ed2..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(); @@ -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(); @@ -289,7 +293,8 @@ 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) @@ -303,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/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java index 284b85f264b4..c10970c1230e 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 @@ -52,11 +52,6 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); - /** - * The advisory refresh window added to the current time before applying backoff on refresh failure. - */ - private static final Duration STATIC_STABILITY_ADVISORY_WINDOW = Duration.ofMinutes(5); - /** * Minimum backoff duration in seconds when a refresh fails (inclusive). */ @@ -288,8 +283,7 @@ private RefreshResult handleFetchFailure(RuntimeException e) { int backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + jitterRandom.nextInt( STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); - Instant extendedStaleTime = now.plus(STATIC_STABILITY_ADVISORY_WINDOW) - .plusSeconds(backoffSeconds); + Instant extendedStaleTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + ". Extending cached credential expiration. A refresh of these credentials" @@ -313,8 +307,7 @@ private RefreshResult handleFetchFailure(RuntimeException e) { int backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + jitterRandom.nextInt( STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); - Instant extendedPrefetchTime = now.plus(STATIC_STABILITY_ADVISORY_WINDOW) - .plusSeconds(backoffSeconds); + Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + ". Extending cached credential expiration. A refresh of these credentials" @@ -519,8 +512,7 @@ public enum StaleValueBehavior { /** * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, - * extends the stale time by the advisory refresh window (5 minutes) plus a uniformly random backoff - * between 5 and 10 minutes (300-600 seconds). + * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). * *

If the failure is a {@link CacheInvalidatingError}, the exception is re-thrown immediately * without extending the stale time.

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 0b1c2e97c9ae..537b63bdf31a 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 @@ -448,10 +448,10 @@ public void allowMode_backoffIsInExpectedRange() throws InterruptedException { cachedSupplier.get(); // Advance well past the extended time to test that the backoff was applied - // The extended stale time should be: now(61) + 300s(advisory) + [300,600]s(backoff) - // So total offset from epoch: 61 + 300 + [300,600] = [661, 961] seconds from original now - Instant minExpectedStale = now.plusSeconds(61 + 300 + 300); - Instant maxExpectedStale = now.plusSeconds(61 + 300 + 600); + // 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);