diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java
index f16416305f..d1715aa7f0 100644
--- a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java
+++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java
@@ -9,79 +9,90 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
-import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.BiPredicate;
import software.amazon.smithy.java.context.Context;
-/** * An {@link IdentityResolver} that caches the result of a delegate resolver and refreshes it asynchronously in the
- * background before expiration.
+/**
+ * An {@link IdentityResolver} that caches identities and refreshes them using advisory and mandatory refresh
+ * windows.
*
- *
Behavior:
- *
- *
On first call (cold start), blocks until the delegate returns a result.
- *
On subsequent calls, returns the cached identity immediately. A background task refreshes the identity when
- * it enters the prefetch window ({@code expiration - prefetchBuffer}).
- *
If the background refresh fails and {@link Builder#allowExpiredCredentials(boolean)} is {@code true}
- * (static stability), the expired cached value continues to be returned and refresh is retried after a
- * jittered 5-10 minute delay.
- *
If the background refresh fails and {@code allowExpiredCredentials} is {@code false} (default), the next
- * caller that finds the cache expired will block for one synchronous retry.
- *
If the delegate returns an identity with no expiration, it is cached indefinitely until
- * {@link #invalidate()} is called.
- *
+ *
Only one refresh can run at a time. Advisory refreshes run in the background while callers return cached
+ * identities. Mandatory refreshes block callers and share the result of the in-flight refresh. When static stability
+ * is enabled, failed refreshes retain and return cached identities, including expired identities, and rate-limit
+ * subsequent attempts with a jittered backoff.
*
- *
This class is thread-safe. At most one refresh runs at a time (enforced by an {@link AtomicBoolean}).
- * Callers never block except on cold start.
- *
- * @param the identity type.
+ * @param identity type.
*/
public final class CachingIdentityResolver implements IdentityResolver, AutoCloseable {
private static final System.Logger LOGGER = System.getLogger(CachingIdentityResolver.class.getName());
+ private static final Duration DEFAULT_MANDATORY_WINDOW = Duration.ofMinutes(1);
+ private static final Duration DEFAULT_BACKOFF_MIN = Duration.ofMinutes(5);
+ private static final Duration DEFAULT_BACKOFF_MAX = Duration.ofMinutes(10);
private final IdentityResolver delegate;
- private final Duration prefetchBuffer;
+ private final Duration configuredAdvisoryWindow;
+ private final Duration mandatoryRefreshWindow;
private final boolean allowExpiredCredentials;
- private final Duration staleRefreshDelay;
+ private final Duration refreshBackoffMin;
+ private final Duration refreshBackoffMax;
private final Clock clock;
private final ScheduledExecutorService executor;
private final boolean ownsExecutor;
- private final AtomicBoolean refreshing = new AtomicBoolean(false);
- private volatile CountDownLatch coldStartLatch = new CountDownLatch(1);
-
- private volatile CachedValue cached;
- private volatile ScheduledFuture> scheduledRefresh;
+ private final boolean closeDelegate;
+ private final boolean proactiveRefresh;
+ private final BiPredicate identityMatcher;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private CachedValue cached;
+ private CompletableFuture> inFlight;
+ private ScheduledFuture> scheduledRefresh;
+ private Instant nextRefreshAllowedAt;
+ private Instant nextRefreshAfterSuccessAt;
+ private RefreshOutcome lastFailure;
+ private boolean refreshRequired;
+ private boolean closed;
private CachingIdentityResolver(Builder builder) {
this.delegate = Objects.requireNonNull(builder.delegate, "delegate");
- this.prefetchBuffer = builder.prefetchBuffer;
+ this.configuredAdvisoryWindow = builder.advisoryRefreshWindow;
+ this.mandatoryRefreshWindow = builder.mandatoryRefreshWindow;
this.allowExpiredCredentials = builder.allowExpiredCredentials;
- this.staleRefreshDelay = builder.staleRefreshDelay;
+ this.refreshBackoffMin = builder.refreshBackoffMin;
+ this.refreshBackoffMax = builder.refreshBackoffMax;
this.clock = builder.clock;
-
- if (builder.executor != null) {
- this.executor = builder.executor;
- this.ownsExecutor = false;
- } else {
- this.executor = Executors.newSingleThreadScheduledExecutor(r -> {
- Thread t = new Thread(r, "smithy-identity-cache-refresh");
- t.setDaemon(true);
- return t;
+ this.closeDelegate = builder.closeDelegate;
+ this.proactiveRefresh = builder.proactiveRefresh;
+ this.identityMatcher = builder.identityMatcher;
+
+ if (builder.executor == null) {
+ this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "smithy-identity-cache-refresh");
+ thread.setDaemon(true);
+ return thread;
});
this.ownsExecutor = true;
+ } else {
+ this.executor = builder.executor;
+ this.ownsExecutor = false;
}
}
/**
- * Create a builder.
+ * Creates a caching resolver builder.
*
- * @param delegate the underlying resolver to cache.
- * @param identity type.
- * @return a new builder.
+ * @param delegate resolver that obtains identities from the source.
+ * @param identity type.
+ * @return builder.
*/
public static Builder builder(IdentityResolver delegate) {
return new Builder<>(delegate);
@@ -89,27 +100,53 @@ public static Builder builder(IdentityResolver delega
@Override
public IdentityResult resolveIdentity(Context requestProperties) {
- CachedValue current = cached;
-
- // Cold start: first caller triggers refresh, others wait.
- if (current == null) {
- return coldStart(requestProperties);
- }
+ CompletableFuture> refresh;
+ boolean performRefresh = false;
+ IdentityResult advisoryResult = null;
- // Cache is fresh — return immediately.
- if (!isInPrefetchWindow(current) && !isExpired(current)) {
- return current.result;
+ lock.lock();
+ try {
+ Instant now = clock.instant();
+ if (cached == null) {
+ refresh = inFlight;
+ if (refresh == null) {
+ refresh = new CompletableFuture<>();
+ inFlight = refresh;
+ performRefresh = true;
+ }
+ } else if (!refreshNeeded(cached, now)) {
+ return cached.result;
+ } else if (refreshRateLimited(now)) {
+ return resolveOutcome(fallbackOutcome(cached, lastFailure, now));
+ } else if (!mandatoryRefreshNeeded(cached, now)) {
+ refresh = inFlight;
+ if (refresh != null) {
+ return cached.result;
+ }
+ refresh = new CompletableFuture<>();
+ inFlight = refresh;
+ performRefresh = true;
+ advisoryResult = cached.result;
+ } else {
+ refresh = inFlight;
+ if (refresh == null) {
+ refresh = new CompletableFuture<>();
+ inFlight = refresh;
+ performRefresh = true;
+ }
+ }
+ } finally {
+ lock.unlock();
}
- // Cache is in prefetch window or expired. Kick off async refresh if not already running.
- triggerAsyncRefresh(requestProperties);
-
- // If expired and strict mode, we can't return stale — block for the refresh.
- if (isExpired(current) && !allowExpiredCredentials) {
- return blockForRefresh(current, requestProperties);
+ if (performRefresh) {
+ if (advisoryResult != null) {
+ executeAdvisoryRefresh(requestProperties, refresh, advisoryResult);
+ return advisoryResult;
+ }
+ executeRefresh(requestProperties, refresh);
}
-
- return current.result;
+ return awaitRefresh(refresh);
}
@Override
@@ -118,244 +155,547 @@ public Class identityType() {
}
@Override
- public void invalidate() {
- cached = null;
- coldStartLatch = new CountDownLatch(1);
- cancelScheduledRefresh();
+ public void invalidate(I rejectedIdentity) {
+ // Invalidation must not make the request path wait for refresh lifecycle state.
+ if (!lock.tryLock()) {
+ return;
+ }
+ try {
+ // An active refresh supersedes this rejection and will determine the next cached identity.
+ if (inFlight != null) {
+ return;
+ }
+ if (cached == null || !identityMatcher.test(cached.identity, rejectedIdentity)) {
+ return;
+ }
+ refreshRequired = true;
+ nextRefreshAfterSuccessAt = null;
+ cancelScheduledRefreshLocked();
+ if (nextRefreshAllowedAt != null && clock.instant().isBefore(nextRefreshAllowedAt)) {
+ scheduleRefreshLocked(nextRefreshAllowedAt);
+ }
+ } finally {
+ lock.unlock();
+ }
}
@Override
public void close() {
- cancelScheduledRefresh();
+ lock.lock();
+ try {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ cancelScheduledRefreshLocked();
+ } finally {
+ lock.unlock();
+ }
+ if (closeDelegate && delegate instanceof AutoCloseable closeable) {
+ try {
+ closeable.close();
+ } catch (Exception error) {
+ LOGGER.log(System.Logger.Level.WARNING, "Failed to close identity resolver", error);
+ }
+ }
if (ownsExecutor) {
executor.shutdownNow();
}
}
- private IdentityResult coldStart(Context requestProperties) {
- if (refreshing.compareAndSet(false, true)) {
+ private void executeRefresh(Context requestProperties, CompletableFuture> refresh) {
+ try {
+ RefreshAttempt attempt = callCredentialSource(requestProperties);
+ RefreshOutcome callerOutcome;
+
+ lock.lock();
try {
- return doRefresh(requestProperties);
+ Instant now = clock.instant();
+ if (attempt.identity != null && isFresh(attempt.identity, now)) {
+ cached = createCachedValue(attempt.identity, now, requestProperties);
+ nextRefreshAllowedAt = null;
+ lastFailure = null;
+ refreshRequired = false;
+ nextRefreshAfterSuccessAt = firstRefreshAt(cached, now);
+ scheduleRefreshLocked(nextRefreshAfterSuccessAt);
+ callerOutcome = RefreshOutcome.result(cached.result);
+ } else {
+ RefreshOutcome failure = attempt.failureOutcome(this);
+ nextRefreshAfterSuccessAt = null;
+ lastFailure = failure;
+ if (attempt.nonRecoverable) {
+ // Non-recoverable failures are not backed off; cached state forces the next call to retry.
+ nextRefreshAllowedAt = null;
+ refreshRequired = cached != null;
+ cancelScheduledRefreshLocked();
+ callerOutcome = failure;
+ } else if (cached == null) {
+ nextRefreshAllowedAt = null;
+ cancelScheduledRefreshLocked();
+ callerOutcome = failure;
+ } else {
+ Duration backoff = refreshBackoff();
+ nextRefreshAllowedAt = now.plus(backoff);
+ boolean useCachedCredentials = allowExpiredCredentials || !isExpired(cached, now);
+ logRefreshFailure(attempt.failureDescription(), backoff, useCachedCredentials);
+ callerOutcome = fallbackOutcome(cached, failure, now);
+ scheduleRefreshLocked(nextRefreshAllowedAt);
+ }
+ }
+ if (inFlight == refresh) {
+ inFlight = null;
+ }
} finally {
- refreshing.set(false);
- coldStartLatch.countDown();
+ lock.unlock();
}
- }
- // Another thread is doing the cold start — wait for it.
- try {
- coldStartLatch.await();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return IdentityResult.ofError(getClass(), "Interrupted waiting for initial credential resolution");
+ refresh.complete(callerOutcome);
+ } catch (RuntimeException | Error failure) {
+ failRefresh(refresh, failure);
+ throw failure;
}
-
- CachedValue result = cached;
- return result != null ? result.result : IdentityResult.ofError(getClass(), "Failed to resolve credentials");
}
- private void triggerAsyncRefresh(Context requestProperties) {
- if (refreshing.compareAndSet(false, true)) {
- executor.submit(() -> {
- try {
- doRefresh(requestProperties);
- } finally {
- refreshing.set(false);
- }
- });
+ private void failRefresh(CompletableFuture> refresh, Throwable failure) {
+ lock.lock();
+ try {
+ if (inFlight == refresh) {
+ inFlight = null;
+ }
+ } finally {
+ lock.unlock();
}
+ refresh.completeExceptionally(failure);
}
- private IdentityResult blockForRefresh(CachedValue current, Context requestProperties) {
- // Strict mode: cache is expired. Try one synchronous refresh.
- if (refreshing.compareAndSet(false, true)) {
+ private void executeAdvisoryRefresh(
+ Context requestProperties,
+ CompletableFuture> refresh,
+ IdentityResult cachedResult
+ ) {
+ Context refreshProperties = Context.unmodifiableCopy(requestProperties);
+ try {
+ executor.execute(() -> executeRefresh(refreshProperties, refresh));
+ } catch (RejectedExecutionException error) {
+ lock.lock();
try {
- IdentityResult result = doRefresh(requestProperties);
- // If doRefresh returned the stale cached value (shouldn't in strict mode), check again.
- CachedValue latest = cached;
- if (latest != current) {
- return latest.result;
+ if (inFlight == refresh) {
+ inFlight = null;
}
- return result;
} finally {
- refreshing.set(false);
+ lock.unlock();
}
+ refresh.complete(RefreshOutcome.result(cachedResult));
}
+ }
- // Another thread is refreshing — wait briefly then check.
+ private RefreshAttempt callCredentialSource(Context requestProperties) {
try {
- Thread.sleep(50);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ IdentityResult result = delegate.resolveIdentity(requestProperties);
+ if (result == null) {
+ return RefreshAttempt.resultFailure(
+ IdentityResult.ofError(getClass(), "Credential source returned no result"));
+ }
+ if (result.identity() != null) {
+ return RefreshAttempt.success(result.identity());
+ }
+ return RefreshAttempt.resultFailure(result);
+ } catch (NonRecoverableIdentityException error) {
+ return RefreshAttempt.nonRecoverable(error);
+ } catch (RuntimeException error) {
+ return RefreshAttempt.exceptionFailure(error);
}
+ }
- CachedValue latest = cached;
- if (latest != current && latest != null && !isExpired(latest)) {
- return latest.result;
+ private CachedValue createCachedValue(I identity, Instant obtainedAt, Context requestProperties) {
+ Context refreshProperties = Context.unmodifiableCopy(requestProperties);
+ Instant expiration = identity.expirationTime();
+ if (expiration == null) {
+ return new CachedValue<>(identity, null, null, refreshProperties);
}
- // Still expired — return error.
- return IdentityResult.ofError(getClass(), "Credentials are expired and refresh failed");
+ Duration lifetime = Duration.between(obtainedAt, expiration);
+ Duration advisoryWindow = configuredAdvisoryWindow == null
+ ? defaultAdvisoryRefreshWindow(lifetime)
+ : configuredAdvisoryWindow;
+ Instant advisoryAt = expiration.minus(advisoryWindow);
+ Duration effectiveMandatoryWindow = mandatoryRefreshWindow.compareTo(advisoryWindow) > 0
+ ? advisoryWindow
+ : mandatoryRefreshWindow;
+ return new CachedValue<>(
+ identity,
+ advisoryAt,
+ expiration.minus(effectiveMandatoryWindow),
+ refreshProperties);
}
- private IdentityResult doRefresh(Context requestProperties) {
- CachedValue current = cached;
+ private boolean refreshNeeded(CachedValue value, Instant now) {
+ boolean advisoryRefreshNeeded = reached(now, value.advisoryRefreshAt)
+ && (nextRefreshAfterSuccessAt == null || reached(now, nextRefreshAfterSuccessAt));
+ return refreshRequired
+ || advisoryRefreshNeeded
+ || reached(now, value.mandatoryRefreshAt)
+ || isExpired(value, now);
+ }
+
+ private boolean mandatoryRefreshNeeded(CachedValue value, Instant now) {
+ return refreshRequired || reached(now, value.mandatoryRefreshAt) || isExpired(value, now);
+ }
+
+ private boolean refreshRateLimited(Instant now) {
+ return nextRefreshAllowedAt != null && now.isBefore(nextRefreshAllowedAt);
+ }
+
+ private RefreshOutcome fallbackOutcome(
+ CachedValue value,
+ RefreshOutcome failure,
+ Instant now
+ ) {
+ if (allowExpiredCredentials || !isExpired(value, now)) {
+ return RefreshOutcome.result(value.result);
+ }
+ if (failure != null) {
+ return failure;
+ }
+ return RefreshOutcome.result(IdentityResult.ofError(
+ getClass(),
+ "Credentials are expired and credential refresh is rate limited"));
+ }
- // Stale delay: don't hammer the source (only in static stability mode).
- if (allowExpiredCredentials && current != null
- && current.nextRefreshAfter != null
- && clock.instant().isBefore(current.nextRefreshAfter)) {
- return current.result;
+ private IdentityResult resolveOutcome(RefreshOutcome outcome) {
+ if (outcome.exception != null) {
+ throw outcome.exception;
}
+ return outcome.result;
+ }
- IdentityResult result;
+ private IdentityResult awaitRefresh(CompletableFuture> refresh) {
try {
- result = delegate.resolveIdentity(requestProperties);
- } catch (RuntimeException e) {
- LOGGER.log(System.Logger.Level.WARNING, "Credential refresh failed", e);
- if (current != null && allowExpiredCredentials) {
- current.nextRefreshAfter = clock.instant().plus(jitteredStaleDelay());
- return current.result;
+ return resolveOutcome(refresh.get());
+ } catch (InterruptedException error) {
+ Thread.currentThread().interrupt();
+ return IdentityResult.ofError(getClass(), "Interrupted waiting for credential refresh");
+ } catch (ExecutionException error) {
+ Throwable cause = error.getCause();
+ if (cause instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
+ if (cause instanceof Error executionError) {
+ throw executionError;
}
- throw e;
+ throw new IllegalStateException("Unexpected credential refresh failure", cause);
}
+ }
- if (result.identity() != null) {
- CachedValue newCached = new CachedValue<>(result.identity());
- cached = newCached;
- scheduleNextRefresh(newCached, requestProperties);
- return newCached.result;
+ private void scheduleRefreshLocked(Instant refreshAt) {
+ cancelScheduledRefreshLocked();
+ if (!proactiveRefresh || closed || cached == null || refreshAt == null) {
+ return;
}
- // Delegate returned an error.
- if (current != null && allowExpiredCredentials) {
- current.nextRefreshAfter = clock.instant().plus(jitteredStaleDelay());
- return current.result;
+ long delayMillis = Math.max(0, Duration.between(clock.instant(), refreshAt).toMillis());
+ try {
+ scheduledRefresh = executor.schedule(this::runScheduledRefresh, delayMillis, TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException ignored) {
+ scheduledRefresh = null;
}
-
- return result;
}
- private void scheduleNextRefresh(CachedValue value, Context requestProperties) {
- cancelScheduledRefresh();
- Instant expiration = value.identity.expirationTime();
- if (expiration == null) {
- return;
+ private void runScheduledRefresh() {
+ CompletableFuture> refresh = null;
+ Context refreshProperties = null;
+ lock.lock();
+ try {
+ scheduledRefresh = null;
+ if (closed || cached == null || inFlight != null) {
+ return;
+ }
+
+ Instant now = clock.instant();
+ if (refreshRateLimited(now)) {
+ scheduleRefreshLocked(nextRefreshAllowedAt);
+ return;
+ }
+ if (!refreshNeeded(cached, now)) {
+ scheduleRefreshLocked(firstRefreshAt(cached, now));
+ return;
+ }
+
+ refresh = new CompletableFuture<>();
+ inFlight = refresh;
+ refreshProperties = cached.refreshProperties;
+ } finally {
+ lock.unlock();
}
- Instant refreshAt = expiration.minus(prefetchBuffer);
- long delayMillis = Duration.between(clock.instant(), refreshAt).toMillis();
- if (delayMillis <= 0) {
- // Already in prefetch window; refresh was just done.
- return;
+ if (refresh != null) {
+ executeRefresh(refreshProperties, refresh);
}
+ }
- scheduledRefresh = executor.schedule(() -> {
- if (refreshing.compareAndSet(false, true)) {
- try {
- doRefresh(requestProperties);
- } finally {
- refreshing.set(false);
- }
- }
- }, delayMillis, TimeUnit.MILLISECONDS);
+ private Instant firstRefreshAt(CachedValue value, Instant now) {
+ if (value.advisoryRefreshAt != null && now.isBefore(value.advisoryRefreshAt)) {
+ return value.advisoryRefreshAt;
+ }
+ if (value.mandatoryRefreshAt != null && now.isBefore(value.mandatoryRefreshAt)) {
+ return value.mandatoryRefreshAt;
+ }
+ return null;
}
- private void cancelScheduledRefresh() {
- ScheduledFuture> f = scheduledRefresh;
- if (f != null) {
- f.cancel(false);
+ private void cancelScheduledRefreshLocked() {
+ if (scheduledRefresh != null) {
+ scheduledRefresh.cancel(false);
scheduledRefresh = null;
}
}
- private boolean isInPrefetchWindow(CachedValue value) {
+ private Duration refreshBackoff() {
+ long minMillis = refreshBackoffMin.toMillis();
+ long maxMillis = refreshBackoffMax.toMillis();
+ if (minMillis == maxMillis) {
+ return refreshBackoffMin;
+ }
+ return Duration.ofMillis(ThreadLocalRandom.current().nextLong(minMillis, maxMillis + 1));
+ }
+
+ private void logRefreshFailure(String error, Duration backoff, boolean useCachedCredentials) {
+ LOGGER.log(
+ System.Logger.Level.WARNING,
+ refreshFailureMessage(error, backoff, useCachedCredentials));
+ }
+
+ static String refreshFailureMessage(String error, Duration backoff, boolean useCachedCredentials) {
+ String cachedCredentialsMessage = useCachedCredentials
+ ? "The SDK will continue using cached credentials."
+ : "The SDK will not use the expired cached credentials.";
+ return "Credential refresh failed: " + error + ". " + cachedCredentialsMessage
+ + " A refresh of these credentials will be attempted again after "
+ + backoff.toSeconds() + " seconds.";
+ }
+
+ private boolean isFresh(I identity, Instant now) {
+ Instant expiration = identity.expirationTime();
+ return expiration == null || expiration.isAfter(now);
+ }
+
+ private static boolean isExpired(CachedValue> value, Instant now) {
Instant expiration = value.identity.expirationTime();
- return expiration != null && clock.instant().isAfter(expiration.minus(prefetchBuffer));
+ return expiration != null && !now.isBefore(expiration);
}
- private boolean isExpired(CachedValue value) {
- Instant exp = value.identity.expirationTime();
- return exp != null && clock.instant().isAfter(exp);
+ private static boolean reached(Instant now, Instant threshold) {
+ return threshold != null && !now.isBefore(threshold);
}
- private Duration jitteredStaleDelay() {
- long baseMillis = staleRefreshDelay.toMillis();
- long jitter = (long) (Math.random() * baseMillis);
- return Duration.ofMillis(baseMillis + jitter);
+ static Duration defaultAdvisoryRefreshWindow(Duration lifetime) {
+ if (lifetime.compareTo(Duration.ofMinutes(20)) <= 0) {
+ return Duration.ofMinutes(5);
+ }
+ if (lifetime.compareTo(Duration.ofMinutes(90)) < 0) {
+ return Duration.ofMinutes(15);
+ }
+ return Duration.ofMinutes(60);
}
private static final class CachedValue {
final I identity;
final IdentityResult result;
- volatile Instant nextRefreshAfter;
-
- CachedValue(I identity) {
+ final Instant advisoryRefreshAt;
+ final Instant mandatoryRefreshAt;
+ final Context refreshProperties;
+
+ CachedValue(
+ I identity,
+ Instant advisoryRefreshAt,
+ Instant mandatoryRefreshAt,
+ Context refreshProperties
+ ) {
this.identity = identity;
this.result = IdentityResult.of(identity);
+ this.advisoryRefreshAt = advisoryRefreshAt;
+ this.mandatoryRefreshAt = mandatoryRefreshAt;
+ this.refreshProperties = refreshProperties;
}
}
- /**
- * Builder for {@link CachingIdentityResolver}.
- */
+ private static final class RefreshOutcome {
+ final IdentityResult result;
+ final RuntimeException exception;
+
+ private RefreshOutcome(IdentityResult result, RuntimeException exception) {
+ this.result = result;
+ this.exception = exception;
+ }
+
+ static RefreshOutcome result(IdentityResult result) {
+ return new RefreshOutcome<>(Objects.requireNonNull(result), null);
+ }
+
+ static RefreshOutcome exception(RuntimeException exception) {
+ return new RefreshOutcome<>(null, Objects.requireNonNull(exception));
+ }
+ }
+
+ private static final class RefreshAttempt {
+ final I identity;
+ final IdentityResult failureResult;
+ final RuntimeException failureException;
+ final boolean nonRecoverable;
+
+ private RefreshAttempt(
+ I identity,
+ IdentityResult failureResult,
+ RuntimeException failureException,
+ boolean nonRecoverable
+ ) {
+ this.identity = identity;
+ this.failureResult = failureResult;
+ this.failureException = failureException;
+ this.nonRecoverable = nonRecoverable;
+ }
+
+ static RefreshAttempt success(I identity) {
+ return new RefreshAttempt<>(Objects.requireNonNull(identity), null, null, false);
+ }
+
+ static RefreshAttempt resultFailure(IdentityResult result) {
+ return new RefreshAttempt<>(null, Objects.requireNonNull(result), null, false);
+ }
+
+ static RefreshAttempt exceptionFailure(RuntimeException exception) {
+ return new RefreshAttempt<>(null, null, Objects.requireNonNull(exception), false);
+ }
+
+ static RefreshAttempt nonRecoverable(NonRecoverableIdentityException exception) {
+ return new RefreshAttempt<>(null, null, exception, true);
+ }
+
+ RefreshOutcome failureOutcome(CachingIdentityResolver owner) {
+ if (identity != null) {
+ return RefreshOutcome.result(IdentityResult.ofError(
+ owner.getClass(),
+ "Credential source returned credentials that are already expired"));
+ }
+ if (failureException != null) {
+ return RefreshOutcome.exception(failureException);
+ }
+ return RefreshOutcome.result(failureResult);
+ }
+
+ String failureDescription() {
+ if (identity != null) {
+ return "credential source returned credentials that are already expired";
+ }
+ if (failureException != null) {
+ return failureException.toString();
+ }
+ return failureResult.error();
+ }
+ }
+
+ /** Builder for {@link CachingIdentityResolver}. */
public static final class Builder {
private final IdentityResolver delegate;
- private Duration prefetchBuffer = Duration.ofMinutes(5);
- private boolean allowExpiredCredentials = false;
- private Duration staleRefreshDelay = Duration.ofMinutes(5);
+ private Duration advisoryRefreshWindow;
+ private Duration mandatoryRefreshWindow = DEFAULT_MANDATORY_WINDOW;
+ private boolean allowExpiredCredentials;
+ private Duration refreshBackoffMin = DEFAULT_BACKOFF_MIN;
+ private Duration refreshBackoffMax = DEFAULT_BACKOFF_MAX;
private Clock clock = Clock.systemUTC();
private ScheduledExecutorService executor;
+ private boolean closeDelegate;
+ private boolean proactiveRefresh = true;
+ private BiPredicate identityMatcher = Objects::equals;
private Builder(IdentityResolver delegate) {
- this.delegate = delegate;
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
}
/**
- * How far before expiration to trigger a background refresh. Default: 5 minutes.
+ * Sets an explicit advisory refresh window. By default the window is derived from credential lifetime.
*/
- public Builder prefetchBuffer(Duration prefetchBuffer) {
- this.prefetchBuffer = Objects.requireNonNull(prefetchBuffer);
+ public Builder prefetchBuffer(Duration advisoryRefreshWindow) {
+ this.advisoryRefreshWindow = requirePositive(advisoryRefreshWindow, "advisoryRefreshWindow");
return this;
}
- /**
- * When {@code true}, expired credentials are returned instead of failing. Enables
- * AWS Static Stability behavior. Default: {@code false}.
- */
+ /** Sets the mandatory blocking refresh window. Default: 1 minute. */
+ public Builder mandatoryRefreshWindow(Duration mandatoryRefreshWindow) {
+ this.mandatoryRefreshWindow = requirePositive(mandatoryRefreshWindow, "mandatoryRefreshWindow");
+ return this;
+ }
+
+ /** Enables returning cached expired identities after refresh failure. */
public Builder allowExpiredCredentials(boolean allowExpiredCredentials) {
this.allowExpiredCredentials = allowExpiredCredentials;
return this;
}
/**
- * Base delay before retrying refresh when credentials are expired and refresh failed.
- * Actual delay is jittered up to 2x this value. Default: 5 minutes.
+ * Sets the base stale refresh delay. The actual delay is uniformly selected from this value to twice this
+ * value. The default is 5-10 minutes.
*/
public Builder staleRefreshDelay(Duration staleRefreshDelay) {
- this.staleRefreshDelay = Objects.requireNonNull(staleRefreshDelay);
+ this.refreshBackoffMin = requirePositive(staleRefreshDelay, "staleRefreshDelay");
+ this.refreshBackoffMax = staleRefreshDelay.multipliedBy(2);
return this;
}
- /**
- * Clock for time comparisons. Default: {@link Clock#systemUTC()}.
- */
+ /** Sets the clock used for refresh decisions. */
public Builder clock(Clock clock) {
- this.clock = Objects.requireNonNull(clock);
+ this.clock = Objects.requireNonNull(clock, "clock");
+ return this;
+ }
+
+ /** Sets the executor used for proactive and advisory refresh work. */
+ public Builder executor(ScheduledExecutorService executor) {
+ this.executor = Objects.requireNonNull(executor, "executor");
return this;
}
/**
- * Executor for background refresh tasks. If not set, a single daemon thread is created
- * and owned by this resolver (shut down on {@link CachingIdentityResolver#close()}).
+ * Sets whether closing the cache also closes an {@link AutoCloseable} delegate. Disabled by default.
+ *
+ *
Enable this only when the cache owns the delegate rather than wrapping a caller-supplied resolver.
*/
- public Builder executor(ScheduledExecutorService executor) {
- this.executor = Objects.requireNonNull(executor);
+ public Builder closeDelegate(boolean closeDelegate) {
+ this.closeDelegate = closeDelegate;
return this;
}
+ /** Enables or disables proactive scheduled refresh. Enabled by default. */
+ public Builder proactiveRefresh(boolean proactiveRefresh) {
+ this.proactiveRefresh = proactiveRefresh;
+ return this;
+ }
+
+ /** Sets how an invalidated identity is matched against the currently cached identity. */
+ public Builder identityMatcher(BiPredicate identityMatcher) {
+ this.identityMatcher = Objects.requireNonNull(identityMatcher, "identityMatcher");
+ return this;
+ }
+
+ Builder refreshBackoff(Duration minimum, Duration maximum) {
+ this.refreshBackoffMin = requirePositive(minimum, "refreshBackoffMin");
+ this.refreshBackoffMax = requirePositive(maximum, "refreshBackoffMax");
+ return this;
+ }
+
+ /** Builds the caching resolver. */
public CachingIdentityResolver build() {
+ if (refreshBackoffMin.compareTo(refreshBackoffMax) > 0) {
+ throw new IllegalArgumentException("refreshBackoffMin must not exceed refreshBackoffMax");
+ }
return new CachingIdentityResolver<>(this);
}
+
+ private static Duration requirePositive(Duration duration, String name) {
+ Objects.requireNonNull(duration, name);
+ if (duration.isZero() || duration.isNegative()) {
+ throw new IllegalArgumentException(name + " must be positive");
+ }
+ return duration;
+ }
}
}
diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java
index 11e22ae63e..b9d381219a 100644
--- a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java
+++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java
@@ -31,16 +31,17 @@ public interface IdentityResolver {
Class identityType();
/**
- * Invalidate any cached identity, forcing the next call to {@link #resolveIdentity(Context)} to fetch fresh
- * credentials from the underlying source.
+ * Signals that a rejected identity should be invalidated if it is still current.
*
*
This is typically called by an interceptor when a service returns an expired- or invalid-credential error
- * (e.g., {@code ExpiredTokenException}), indicating that the currently cached identity is no longer valid.
+ * (e.g., {@code ExpiredTokenException}), indicating that the identity used for the request is no longer valid.
*
- *
The default implementation is a no-op. Caching resolvers (such as {@link CachingIdentityResolver}) override
- * this to clear their cache.
+ *
The default implementation is a no-op. Refreshable caching resolvers such as
+ * {@link CachingIdentityResolver} can require a refresh while retaining the cached identity as a fallback.
+ *
+ * @param rejectedIdentity identity used to sign the rejected request.
*/
- default void invalidate() {}
+ default void invalidate(IdentityT rejectedIdentity) {}
/**
* Combines multiple identity resolvers with the same identity type into a single resolver.
diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolverChain.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolverChain.java
index 2d737d9d05..4991caa099 100644
--- a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolverChain.java
+++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolverChain.java
@@ -39,4 +39,11 @@ public IdentityResult resolveIdentity(Context requestProperties) {
}
return IdentityResult.ofError(IdentityResolverChain.class, "Attempted resolvers: " + errors);
}
+
+ @Override
+ public void invalidate(IdentityT rejectedIdentity) {
+ for (var resolver : resolvers) {
+ resolver.invalidate(rejectedIdentity);
+ }
+ }
}
diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/NonRecoverableIdentityException.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/NonRecoverableIdentityException.java
new file mode 100644
index 0000000000..8ee915e52e
--- /dev/null
+++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/NonRecoverableIdentityException.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.java.auth.api.identity;
+
+/**
+ * Signals that refreshing an identity is not expected to succeed without external action.
+ *
+ *