diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
index c4a837ae3f05..fb37121a02e5 100644
--- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
+++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
@@ -346,7 +346,6 @@
This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *
By default, this is 1 minute. + *
By default, this is zero, so the mandatory refresh window opens when the credentials expire. * * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ @@ -398,14 +392,7 @@ public Builder staleTime(Duration staleTime) { *
This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *
If not explicitly set, the advisory refresh window is computed dynamically based on the credential's - * remaining lifetime: half the remaining lifetime (but never less than 1 minute) for credentials with 10 minutes - * or less remaining, 5 minutes for 10-20 minutes remaining, 15 minutes for 20-90 minutes remaining, and 60 - * minutes for 90+ minutes remaining. This dynamic window is recomputed on each successful refresh. - * - *
The halved window for short-lived credentials is specific to this provider. Because the process decides its - * own expiration, it may emit credentials that are shorter-lived than the smallest standard window, which would - * otherwise place them inside their advisory refresh window as soon as they are produced. + *
By default, this is 15 seconds. * * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ 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 fc5df6543696..ec9f08c5aa60 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 @@ -228,7 +228,7 @@ void resultsAreCached() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, - DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) + DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -279,41 +279,18 @@ void resolveCredentials_advisoryWindowIsNotJittered() { assertThat(request1).isNotEqualTo(request2); } - /** - * The process decides its own expiration and may produce credentials shorter-lived than the smallest standard advisory - * refresh window. This provider therefore halves the lifetime instead of using that window, so freshly produced - * credentials are served from the cache rather than re-running the process on every call. This differs from the - * AWS-service-backed providers, which can rely on a 15 minute minimum session duration. - */ - @Test - void shortLivedCredentials_areNotRefreshedOnTheCallFollowingIssuance() { - // A 3 minute lifetime gives a 90 second advisory window, which opens 90 seconds after the process runs. - 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(3))))) - .build(); - - // The process emits a random session token on each run, so equal credentials mean it only ran once. - AwsCredentials request1 = credentialsProvider.resolveCredentials(); - AwsCredentials request2 = credentialsProvider.resolveCredentials(); - - assertThat(request1).isEqualTo(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 + void defaultPrefetchTime_credentialsWithinFifteenSecondsOfExpiry_areRefreshed() { + // Credentials that expire in 10 seconds: prefetchTime = now+10s - 15s = now-5s (in the past), so the advisory + // refresh window is already open when the credentials are produced and the next call re-runs the process. 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)))) + DateUtils.formatIso8601Date(Instant.now().plusSeconds(10)))) .build(); + // The process emits a random session token on each run, so unequal credentials mean it ran twice. AwsCredentials request1 = credentialsProvider.resolveCredentials(); AwsCredentials request2 = credentialsProvider.resolveCredentials(); @@ -322,8 +299,8 @@ void defaultPrefetchTime_credentialsWithinFiveMinuteWindow_areRefreshed() { @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 + // Credentials that expire in 30 minutes: prefetchTime = now+30min - 15s (in the future), so the cache should + // NOT refresh. ProcessCredentialsProvider credentialsProvider = ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s exp=%s", diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java index e27c1f7176fd..10320bbf5c7e 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java @@ -15,6 +15,7 @@ package software.amazon.awssdk.core.internal.http.pipeline.stages.utils; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.SelectedAuthScheme; import software.amazon.awssdk.core.exception.SdkServiceException; @@ -22,7 +23,6 @@ import software.amazon.awssdk.core.internal.http.RequestExecutionContext; import software.amazon.awssdk.identity.spi.Identity; import software.amazon.awssdk.identity.spi.IdentityProvider; -import software.amazon.awssdk.utils.CompletableFutureUtils; import software.amazon.awssdk.utils.Logger; /** @@ -32,7 +32,14 @@ *
When a service returns an authentication error (as determined by * {@link SdkServiceException#isAuthenticationError()}), this helper retrieves the * {@link SelectedAuthScheme} from the execution context and calls - * {@link IdentityProvider#invalidate} so the next retry attempt resolves fresh credentials. + * {@link IdentityProvider#invalidate} so that the provider refreshes before it vends credentials again. + * + *
Identity is resolved once per API call, by a stage that sits outside the retry loop, and every attempt of that + * call reuses it. Invalidation therefore does not affect the attempt currently being retried; it takes effect on the + * next API call that resolves credentials. + * + *
Both the synchronous and asynchronous request paths must call + * {@link #invalidateIfAuthError(Throwable, RequestExecutionContext)} for the behavior to apply to both client types. * *
All exceptions from the invalidation path are caught and logged at debug level. * Invalidation failures never disrupt the normal request/retry flow. @@ -50,49 +57,67 @@ private AuthErrorInvalidationHelper() { * credential invalidation. If so, retrieves the identity provider from the * {@link SelectedAuthScheme} and calls invalidate() on it. * + *
This never blocks the calling thread: it composes on the resolved identity future rather than joining it, so + * it is safe to call from the async request path, which runs on I/O threads. In practice the identity is already + * resolved by the time a response has been received, so the invalidation completes inline. + * + *
The returned future never completes exceptionally. Invalidation is best-effort, and any failure is logged at
+ * debug level instead of being propagated. Callers are not required to await it: identity is resolved outside the
+ * retry loop, so a pending invalidation cannot affect the attempt currently being retried.
+ *
* @param exception The exception from the failed request attempt
* @param context The request execution context containing auth scheme info
+ * @return A future completing when the invalidation attempt has finished. Never completes exceptionally.
+ */
+ public static CompletableFuture This assumes the credential source does not vend credentials with a lifetime shorter than the smallest window
- * above. That holds for AWS credential services, which have a minimum session duration of 15 minutes. Callers whose
- * credential source offers no such guarantee should use
- * {@link #computePrefetchWindowForArbitraryLifetime(Instant, Duration, Instant)} instead.
+ * above. That holds for AWS credential services, which have a minimum session duration of 15 minutes. A credential
+ * shorter-lived than its window is inside its advisory refresh window from the moment it is issued.
*
* @param expiration the credential's expiration time
* @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically
@@ -77,58 +68,8 @@ public static Duration computePrefetchWindow(Instant expiration, Duration prefet
return WINDOW_SHORT;
}
- return windowForLifetime(remainingLifetime);
- }
-
- /**
- * As {@link #computePrefetchWindow(Instant, Duration, Instant)}, but also handles credentials whose lifetime is shorter
- * than the smallest window that method would assign. For those, the window is half the remaining lifetime, floored at 1
- * minute to match the default mandatory refresh window:
- *
- * Halving keeps the window inside the credential's lifetime, and joins the 5 minute window continuously at a lifetime
- * of 10 minutes. Without it, a credential shorter-lived than its window is inside its advisory refresh window from the
- * moment it is issued, so every subsequent request for it contacts the credential source.
- *
- * This is for credential sources whose expiration the SDK cannot make assumptions about, such as an external process.
- * Callers backed by AWS credential services should use {@link #computePrefetchWindow(Instant, Duration, Instant)}, whose
- * windows follow the credential refresh specification exactly.
- *
- * @param expiration the credential's expiration time
- * @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically
- * @param now the current time
- * @return the Duration to use as the advisory refresh window
- */
- public static Duration computePrefetchWindowForArbitraryLifetime(Instant expiration, Duration prefetchTime, Instant now) {
- if (prefetchTime != null) {
- return prefetchTime;
- }
-
- Duration remainingLifetime = Duration.between(now, expiration);
- if (remainingLifetime.isNegative() || remainingLifetime.isZero()) {
- // Already expired. Any window puts the prefetch time in the past, which refreshes on the next request.
- return WINDOW_SHORT;
- }
-
- if (remainingLifetime.compareTo(THRESHOLD_HALVED) <= 0) {
- return ComparableUtils.maximum(remainingLifetime.dividedBy(2), WINDOW_MIN);
- }
-
- return windowForLifetime(remainingLifetime);
- }
-
- /**
- * The window tiers defined by the credential refresh specification. Thresholds are compared as durations rather than
- * whole minutes, so that a lifetime just over a boundary selects the larger window instead of being truncated down into
- * the smaller one.
- */
- private static Duration windowForLifetime(Duration remainingLifetime) {
+ // Thresholds are compared as durations rather than whole minutes, so that a lifetime just over a boundary selects
+ // the larger window instead of being truncated down into the smaller one.
if (remainingLifetime.compareTo(THRESHOLD_MEDIUM) <= 0) {
return WINDOW_SHORT;
} else if (remainingLifetime.compareTo(THRESHOLD_LONG) < 0) {
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java
index 6d1a62781098..a9c5b86d2958 100644
--- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java
+++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java
@@ -45,8 +45,7 @@ public void remainingLifetimeExactly0_returns5MinuteWindow() {
/**
* AWS credential services do not issue sessions shorter than 15 minutes, so the specification's smallest window can
- * exceed the lifetime only when the credential source is not an AWS service (or the host clock is skewed). Callers that
- * have to tolerate that use {@link CacheRefreshUtils#computePrefetchWindowForArbitraryLifetime}.
+ * exceed the lifetime only when the credential source is not an AWS service (or the host clock is skewed).
*/
@Test
public void remainingLifetimeShorterThanSmallestWindow_stillReturns5MinuteWindow() {
@@ -151,94 +150,4 @@ public void explicitPrefetchTime_ignoresRemainingLifetime() {
Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW);
assertThat(window).isEqualTo(Duration.ofMinutes(60));
}
-
- // computePrefetchWindowForArbitraryLifetime: adds the halved tier for credential sources that may vend credentials
- // shorter-lived than the smallest specification window.
-
- @Test
- public void arbitraryLifetime_remainingLifetime3Minutes_returnsHalfOfLifetime() {
- Instant expiration = NOW.plus(Duration.ofMinutes(3));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofSeconds(90));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetime5Minutes_returnsHalfOfLifetime() {
- Instant expiration = NOW.plus(Duration.ofMinutes(5));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofSeconds(150));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetimeExactly10Minutes_returnsHalfOfLifetime() {
- // The halved tier joins the 5 minute tier continuously here.
- Instant expiration = NOW.plus(Duration.ofMinutes(10));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(5));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetimeJustOver10Minutes_returns5MinuteWindow() {
- Instant expiration = NOW.plus(Duration.ofMinutes(10)).plusSeconds(1);
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(5));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetime2Minutes_returnsMandatoryWindowFloor() {
- // Half of 2 minutes is exactly the 1 minute floor.
- Instant expiration = NOW.plus(Duration.ofMinutes(2));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(1));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetimeUnder2Minutes_isFlooredAtMandatoryWindow() {
- // Half of 90 seconds is 45 seconds, which is narrower than the 1 minute mandatory refresh window.
- Instant expiration = NOW.plus(Duration.ofSeconds(90));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(1));
- }
-
- @Test
- public void arbitraryLifetime_remainingLifetimeNegative_returns5MinuteWindow() {
- Instant expiration = NOW.minus(Duration.ofMinutes(5));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(5));
- }
-
- @Test
- public void arbitraryLifetime_explicitPrefetchTime_returnsExplicitValue() {
- Instant expiration = NOW.plus(Duration.ofMinutes(3));
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, Duration.ofMinutes(2), NOW);
- assertThat(window).isEqualTo(Duration.ofMinutes(2));
- }
-
- /**
- * The halved tier is the only difference between the two methods. Above 10 minutes they must agree, so that the
- * specification's windows apply to every credential long-lived enough to have one.
- */
- @Test
- public void arbitraryLifetime_above10Minutes_matchesSpecificationWindows() {
- for (long lifetimeSeconds = 601; lifetimeSeconds <= 12 * 60 * 60; lifetimeSeconds++) {
- Instant expiration = NOW.plus(Duration.ofSeconds(lifetimeSeconds));
- assertThat(CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, null, NOW))
- .as("lifetime %s seconds", lifetimeSeconds)
- .isEqualTo(CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW));
- }
- }
-
- /**
- * The advisory refresh window must land strictly inside the credential's lifetime, otherwise the credential is inside its
- * advisory window the moment it is issued and every subsequent request contacts the credential source. The 1 minute floor
- * means this can only be guaranteed for credentials that outlive the mandatory refresh window.
- */
- @Test
- public void arbitraryLifetime_anyLifetimeLongerThanMandatoryWindow_windowIsShorterThanLifetime() {
- for (long lifetimeSeconds = 61; lifetimeSeconds <= 12 * 60 * 60; lifetimeSeconds++) {
- Duration lifetime = Duration.ofSeconds(lifetimeSeconds);
- Duration window = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(NOW.plus(lifetime), null, NOW);
- assertThat(window).as("lifetime %s", lifetime).isLessThan(lifetime);
- }
- }
}
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java
index ec4c0760f71a..bc0d34cf8259 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
@@ -62,12 +62,23 @@ public class CachedSupplierTest {
/** Maximum static stability backoff duration in seconds (10 minutes). */
private static final long BACKOFF_MAX_SECONDS = 600;
+ /** Minimum duration (seconds) that a non-recoverable error stays cached. */
+ private static final long NON_RECOVERABLE_ERROR_CACHE_MIN_SECONDS = 1;
+
/** Maximum duration (seconds) that a non-recoverable error stays cached. */
private static final long NON_RECOVERABLE_ERROR_CACHE_MAX_SECONDS = 5;
/** A duration safely past the non-recoverable error cache max, guaranteeing the cache has expired. */
private static final long PAST_NON_RECOVERABLE_ERROR_CACHE = NON_RECOVERABLE_ERROR_CACHE_MAX_SECONDS + 1;
+ /**
+ * A duration guaranteed to fall strictly inside the non-recoverable error cache window, whichever duration the
+ * jitter picked. This must stay below the cache minimum: the cache expires exactly at its expiry instant, so
+ * advancing by the minimum itself lands on an already-expired cache whenever the jitter picks that minimum.
+ */
+ private static final long WITHIN_NON_RECOVERABLE_ERROR_CACHE_MILLIS =
+ NON_RECOVERABLE_ERROR_CACHE_MIN_SECONDS * 1000 - 100;
+
/** A duration safely past the maximum backoff, guaranteeing the backoff has elapsed. */
private static final long PAST_MAX_BACKOFF = BACKOFF_MAX_SECONDS + 1;
@@ -1233,8 +1244,8 @@ public void allowMode_nonRecoverableErrorCached_withinCacheWindow_reRaisesWithou
assertThatThrownBy(cachedSupplier::get).isEqualTo(nonRecoverableError);
assertThat(supplierCallCount.get()).isEqualTo(1);
- // Second call within the cache window (< 5 seconds) — should re-raise without calling source
- clock.time = now.plusSeconds(1);
+ // Second call within the cache window — should re-raise without calling source
+ clock.time = now.plusMillis(WITHIN_NON_RECOVERABLE_ERROR_CACHE_MILLIS);
assertThatThrownBy(cachedSupplier::get).isEqualTo(nonRecoverableError);
assertThat(supplierCallCount.get()).isEqualTo(1); // Still 1 — source was NOT contacted
}
@@ -1345,7 +1356,7 @@ public void allowMode_nonRecoverableErrorCached_staleWindow_reRaisesWithoutCalli
assertThatThrownBy(cachedSupplier::get).isEqualTo(error);
// Immediately retry (within cache window) — should re-raise the same error without calling source
- clock.time = now.plusSeconds(62);
+ clock.time = now.plusSeconds(61).plusMillis(WITHIN_NON_RECOVERABLE_ERROR_CACHE_MILLIS);
// Swap supplier to something that would succeed — if called, we'd get "new-creds" not an exception
supplier.set(RefreshResult.builder("new-creds")
.staleTime(Instant.MAX)
@@ -1385,7 +1396,7 @@ public void allowMode_nonRecoverableErrorCached_prefetchWindow_reRaisesWithoutCa
assertThatThrownBy(cachedSupplier::get).isEqualTo(error);
// Immediately retry (within cache window) — should re-raise without calling source
- clock.time = now.plusSeconds(62);
+ clock.time = now.plusSeconds(61).plusMillis(WITHIN_NON_RECOVERABLE_ERROR_CACHE_MILLIS);
supplier.set(RefreshResult.builder("new-creds")
.staleTime(Instant.MAX)
.prefetchTime(Instant.MAX)
@@ -1419,8 +1430,8 @@ public void allowMode_nonRecoverableErrorCached_cacheWindowIsJitteredBetween1And
assertThatThrownBy(cachedSupplier::get).isEqualTo(error);
assertThat(callCount.get()).isEqualTo(1);
- // At 0.9s — should still be cached (cache min is 1s)
- clock.time = now.plusMillis(900);
+ // Just under the cache minimum — should still be cached whatever the jitter picked
+ clock.time = now.plusMillis(WITHIN_NON_RECOVERABLE_ERROR_CACHE_MILLIS);
assertThatThrownBy(cachedSupplier::get).isEqualTo(error);
assertThat(callCount.get()).isEqualTo(1);
- *
- *
- *