From 1927e3021f74c641d72fa541cc9b56b66f14cba4 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 10 Jun 2026 12:41:54 -0700 Subject: [PATCH 1/2] CachedSupplier ALLOW static stability with uniform backoff + CacheInvalidatingError Rewrite CachedSupplier's StaleValueBehavior.ALLOW to implement static stability semantics per the Credential Refresh SEP: - Replace exponential 100ms-10s backoff with uniform random 5-10 minute (300-600 second) backoff on refresh failure - Add CacheInvalidatingError marker interface (utils module) for exceptions that should bypass static stability and propagate immediately - Add CacheInvalidatingException (sdk-core module) as the concrete public exception type implementing CacheInvalidatingError - Handle prefetch-window failures: extend prefetchTime on failure to suppress further attempts until backoff elapses - Update handleFetchedSuccess ALLOW branch for expired-credentials case - Remove consecutiveStaleRetrievalFailures counter (no longer needed) - Deprecate maxStaleFailureJitterTest() (cannot remove due to japicmp) - Add comprehensive tests for static stability, cache-invalidating exception bypass, and backoff range validation --- .../exception/CacheInvalidatingException.java | 110 ++++++++++ .../utils/cache/CacheInvalidatingError.java | 31 +++ .../awssdk/utils/cache/CachedSupplier.java | 84 +++++--- .../utils/cache/CachedSupplierTest.java | 193 ++++++++++++++++-- 4 files changed, 379 insertions(+), 39 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/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 00000000000..c0e65192208 --- /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/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 00000000000..ba970d8a599 --- /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 e8ecc4d741d..ff593b8e7cb 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,7 +23,6 @@ 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; @@ -54,6 +53,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(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 +92,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 +233,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 +271,55 @@ 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 + long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + + jitterRandom.nextInt( + STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + Instant extendedStaleTime = now.plusSeconds(backoffSeconds); + + log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Extending cached credential expiration. A refresh of these credentials" + + " will be attempted again after " + backoffSeconds + " seconds.", e); return currentCachedValue.toBuilder() - .staleTime(newStaleTime) + .staleTime(extendedStaleTime) + .prefetchTime(extendedStaleTime) .build(); default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } } + // Not yet stale — we're in the prefetch window. Handle failure based on mode. + if (staleValueBehavior == StaleValueBehavior.ALLOW) { + if (e instanceof CacheInvalidatingError) { + throw e; + } + // During prefetch window failure: extend prefetchTime to suppress further attempts + long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + + jitterRandom.nextInt( + STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds); + + log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Extending cached credential expiration. A refresh of these credentials" + + " will be attempted again after " + backoffSeconds + " seconds.", e); + + return currentCachedValue.toBuilder() + .staleTime(extendedPrefetchTime) + .prefetchTime(extendedPrefetchTime) + .build(); + } + return currentCachedValue; } @@ -333,6 +365,12 @@ private Duration maxPrefetchJitter(RefreshResult result) { return timeBetweenPrefetchAndStale; } + private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { + long jitterRange = jitterEnd.minus(jitterStart).toMillis(); + long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); + return time.plus(jitterStart).plusMillis(jitterAmount); + } + private Duration maxStaleFailureJitter(int numFailures) { // prevent cycling back through low values if (numFailures > 63) { @@ -350,12 +388,6 @@ protected Duration maxStaleFailureJitterTest(int numFailures) { return maxStaleFailureJitter(numFailures); } - private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { - long jitterRange = jitterEnd.minus(jitterStart).toMillis(); - long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); - return time.plus(jitterStart).plusMillis(jitterAmount); - } - /** * Free any resources consumed by the prefetch strategy this supplier is using. */ @@ -488,8 +520,14 @@ public enum StaleValueBehavior { STRICT, /** - * Allow stale values to be returned from the cache. Value retrieval will never fail, as long as the cache has - * succeeded when calling the underlying supplier at least once. + * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, + * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). + * + *

If 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 159e2d69b6e..537b63bdf31 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) + [300,600]s(backoff) + // So total offset from epoch: 61 + [300,600] = [361, 661] seconds from original now + Instant minExpectedStale = now.plusSeconds(61 + 300); + Instant maxExpectedStale = now.plusSeconds(61 + 600); + + // Advance just before the minimum backoff - should still return cached (not stale yet) + clock.time = minExpectedStale.minusSeconds(1); + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // Value not stale yet so should return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past maximum possible backoff - must be stale now and will refresh + clock.time = maxExpectedStale.plusSeconds(1); + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); } } + } - supplier.close(); + @Test + public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value (not throw) and extend prefetch time + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Verify that a subsequent call shortly after does NOT attempt another refresh + // (because prefetchTime was extended) + clock.time = now.plusSeconds(62); + supplier.set(RefreshResult.builder("should-not-get-this") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // The prefetchTime was extended far into the future, so this should still return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .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 007db49d1420f0ce481f4c605334a77ef4faab67 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 11 Jun 2026 14:16:28 -0700 Subject: [PATCH 2/2] Replace cacheInvalidError marker interface with predicate. --- .../exception/CacheInvalidatingException.java | 110 ------------------ .../utils/cache/CacheInvalidatingError.java | 31 ----- .../awssdk/utils/cache/CachedSupplier.java | 52 +++++++-- .../utils/cache/CachedSupplierTest.java | 8 +- 4 files changed, 46 insertions(+), 155 deletions(-) delete mode 100644 core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java delete mode 100644 utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java 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 deleted file mode 100644 index c0e65192208..00000000000 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/CacheInvalidatingException.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java deleted file mode 100644 index ba970d8a599..00000000000 --- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheInvalidatingError.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 ff593b8e7cb..38cd00033e2 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 @@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -54,14 +55,14 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); /** - * Minimum backoff duration in seconds when a refresh fails (inclusive). + * Minimum backoff duration when a refresh fails (inclusive). */ - private static final int STATIC_STABILITY_BACKOFF_MIN_SECONDS = 300; + private static final Duration STATIC_STABILITY_BACKOFF_MIN = Duration.ofMinutes(5); /** - * Maximum backoff duration in seconds when a refresh fails (inclusive). + * Maximum backoff duration when a refresh fails (inclusive). */ - private static final int STATIC_STABILITY_BACKOFF_MAX_SECONDS = 600; + private static final Duration STATIC_STABILITY_BACKOFF_MAX = Duration.ofMinutes(10); /** @@ -112,6 +113,12 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Random jitterRandom = new Random(); + /** + * Predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability (i.e., be re-thrown immediately without extending expiration). + */ + private final Predicate cacheInvalidatingPredicate; + private CachedSupplier(Builder builder) { Validate.notNull(builder.supplier, "builder.supplier"); Validate.notNull(builder.jitterEnabled, "builder.jitterEnabled"); @@ -121,6 +128,7 @@ private CachedSupplier(Builder builder) { this.staleValueBehavior = Validate.notNull(builder.staleValueBehavior, "builder.staleValueBehavior"); this.clock = Validate.notNull(builder.clock, "builder.clock"); this.cachedValueName = Validate.notNull(builder.cachedValueName, "builder.cachedValueName"); + this.cacheInvalidatingPredicate = builder.cacheInvalidatingPredicate; } /** @@ -276,14 +284,15 @@ private RefreshResult handleFetchFailure(RuntimeException e) { throw e; case ALLOW: // Cache-invalidating errors bypass static stability - if (e instanceof CacheInvalidatingError) { + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { throw e; } // Uniform random backoff: 5-10 minutes - long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( - STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() + - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); Instant extendedStaleTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() @@ -301,13 +310,14 @@ private RefreshResult handleFetchFailure(RuntimeException e) { // Not yet stale — we're in the prefetch window. Handle failure based on mode. if (staleValueBehavior == StaleValueBehavior.ALLOW) { - if (e instanceof CacheInvalidatingError) { + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { throw e; } // During prefetch window failure: extend prefetchTime to suppress further attempts - long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN_SECONDS + long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( - STATIC_STABILITY_BACKOFF_MAX_SECONDS - STATIC_STABILITY_BACKOFF_MIN_SECONDS + 1); + (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() + - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() @@ -406,6 +416,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; + private Predicate cacheInvalidatingPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -445,6 +456,23 @@ public Builder cachedValueName(String cachedValueName) { return this; } + /** + * Configure a predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability. When the predicate returns {@code true} for a given exception, + * the exception will be re-thrown immediately without extending the cached value's expiration. + * + *

This is used for errors where the credential source has definitively indicated that the current + * authentication state is invalid and requires user intervention (e.g., expired SSO tokens, + * changed user credentials).

+ * + *

By default, no exceptions are considered cache-invalidating (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured).

+ */ + public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) { + this.cacheInvalidatingPredicate = cacheInvalidatingPredicate; + return this; + } + /** * Configure the clock used for this cached supplier. Configurable for testing. */ @@ -523,8 +551,8 @@ public enum StaleValueBehavior { * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). * - *

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

+ *

If a {@link Builder#cacheInvalidatingPredicate(Predicate)} is configured and returns {@code true} + * for the exception, it is re-thrown immediately without extending the stale time.

* *

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

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 537b63bdf31..5d0c9ec4381 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 @@ -397,6 +397,8 @@ public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedExce MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) .build()) { @@ -513,6 +515,8 @@ public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) .build()) { @@ -538,9 +542,9 @@ public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() } /** - * A RuntimeException that implements CacheInvalidatingError for testing. + * A RuntimeException that represents a cache-invalidating error for testing. */ - private static class CacheInvalidatingRuntimeException extends RuntimeException implements CacheInvalidatingError { + private static class CacheInvalidatingRuntimeException extends RuntimeException { CacheInvalidatingRuntimeException(String message) { super(message); }