Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,58 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithInsuff
assertTrue(e.getMessage().contains("insufficient permissions"));
}

@Test
public void resolveCredentials_tokenExpired_cachedBriefly_immediateRetryDoesNotCallService() {
// Expired credentials force a refresh on every call
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60));
LoginAccessToken token = buildAccessToken(creds);
tokenManager.storeToken(token);

// Service returns TOKEN_EXPIRED — non-recoverable
stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED);

// First call: hits service, gets non-recoverable error — thrown and cached
assertThrows(AccessDeniedException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(1, mockHttpClient.getRequests().size());

// Second call: immediate retry — should re-raise cached error without calling service
assertThrows(AccessDeniedException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(1, mockHttpClient.getRequests().size()); // Still 1 — service NOT called again
}

@Test
public void resolveCredentials_tokenMissing_cachedBriefly_immediateRetryDoesNotReadDiskAgain() {
// No token on disk — will throw InvalidTokenException (non-recoverable)
// This is a client-side error that doesn't call the service at all

// First call: fails with missing token
assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(0, mockHttpClient.getRequests().size()); // Service never called

// Second call: immediate retry — should re-raise cached error
assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(0, mockHttpClient.getRequests().size()); // Service still never called
}

@Test
public void resolveCredentials_userCredentialsChanged_cachedBriefly_immediateRetryDoesNotCallService() {
// Expired credentials force a refresh
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60));
LoginAccessToken token = buildAccessToken(creds);
tokenManager.storeToken(token);

// Service returns USER_CREDENTIALS_CHANGED — non-recoverable
stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED);

// First call: hits service, gets non-recoverable error — thrown and cached
assertThrows(AccessDeniedException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(1, mockHttpClient.getRequests().size());

// Second call: immediate retry — should re-raise cached error without calling service
assertThrows(AccessDeniedException.class, () -> loginCredentialsProvider.resolveCredentials());
assertEquals(1, mockHttpClient.getRequests().size()); // Still 1 — service NOT called again
}

@Test
public void resolveCredentials_tokenCacheMissingAfterSuccessfulCache_throwsAndBypassesStaticStability() throws Exception {
// Build a provider without async updates so refresh is synchronous
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,80 @@ public void expiredTokenException_bypassesStaticStability() {
}
}

@Test
public void unauthorizedException_cachedBriefly_immediateRetryDoesNotCallSso() {
ssoClient = mock(SsoClient.class);
RoleCredentials credentials = RoleCredentials.builder()
.accessKeyId("a")
.secretAccessKey("b")
.sessionToken("c")
.expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli())
.build();

Supplier<GetRoleCredentialsRequest> 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
credentialsProvider.resolveCredentials();

// Second call triggers refresh, hits non-recoverable UnauthorizedException — thrown and cached
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(UnauthorizedException.class);

// Third call: immediate retry — should re-raise cached error without calling SSO
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(UnauthorizedException.class);

// Verify SSO was called only twice: initial fetch + one failed refresh.
// The third resolveCredentials() re-raised the cached error without contacting SSO.
callClient(verify(ssoClient, times(2)), Mockito.any());
}
}

@Test
public void expiredTokenException_cachedBriefly_immediateRetryDoesNotCallSso() {
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<GetRoleCredentialsRequest> expiredSupplier = () -> {
throw expiredTokenException;
};

try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder()
.refreshRequest(expiredSupplier)
.ssoClient(ssoClient)
.build()) {
// First call: initial fetch fails with non-recoverable error
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(ExpiredTokenException.class);

// Second call: immediate retry — should re-raise cached error without calling SSO
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(ExpiredTokenException.class);

// Since the ExpiredTokenException is thrown by the supplier (before reaching SSO),
// the SSO client should never have been called
callClient(verify(ssoClient, times(0)), Mockito.any());
}
}

@Test
public void noCachedCredentials_anyFailure_throwsImmediately() {
ssoClient = mock(SsoClient.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,102 @@ public void nonRecoverableError_wrappedInSdkClientException_throwsImmediately()
}

/**
* Verifies that recoverable errors (those with error codes NOT in the non-recoverable set) still
* Non-recoverable errors are cached for a short period (1-5 seconds) to protect the credential source
* from callers that catch the error and retry in a tight loop. An immediate retry after receiving a
* non-recoverable error should re-raise the cached error without contacting STS again.
*/
@Test
public void nonRecoverableError_cachedBriefly_immediateRetryDoesNotCallSts() {
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);

AwsServiceException accessDenied = AwsServiceException.builder()
.message("Access denied")
.awsErrorDetails(AwsErrorDetails.builder()
.errorCode("AccessDenied")
.errorMessage("User is not authorized")
.serviceName("STS")
.build())
.statusCode(403)
.build();

// First call succeeds (caches expired credentials), second call fails with non-recoverable error
when(callClient(stsClient, request))
.thenReturn(response)
.thenThrow(accessDenied);

StsCredentialsProvider.BaseBuilder<?, ? extends StsCredentialsProvider> credentialsProviderBuilder =
createCredentialsProviderBuilder(request);

try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) {
// First call succeeds and caches credentials
credentialsProvider.resolveCredentials();

// Second call triggers refresh, hits non-recoverable error — thrown and cached
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(AwsServiceException.class)
.satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode())
.isEqualTo("AccessDenied"));

// Third call: immediate retry — should re-raise cached error without calling STS
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(AwsServiceException.class)
.satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode())
.isEqualTo("AccessDenied"));

// Verify STS was called only twice: initial fetch + one failed refresh.
// The third resolveCredentials() re-raised the cached error without contacting STS.
callClient(verify(stsClient, times(2)), Mockito.any());
}
}

/**
* Non-recoverable errors are cached for a short period on the initial fetch path as well.
* When the very first STS call fails with a non-recoverable error and the caller retries immediately,
* the cached error is re-raised without contacting STS again.
*/
@Test
public void nonRecoverableError_initialFetch_cachedBriefly_immediateRetryDoesNotCallSts() {
RequestT request = getRequest();

AwsServiceException accessDenied = AwsServiceException.builder()
.message("Access denied")
.awsErrorDetails(AwsErrorDetails.builder()
.errorCode("AccessDenied")
.errorMessage("User is not authorized")
.serviceName("STS")
.build())
.statusCode(403)
.build();

when(callClient(stsClient, request))
.thenThrow(accessDenied);

StsCredentialsProvider.BaseBuilder<?, ? extends StsCredentialsProvider> credentialsProviderBuilder =
createCredentialsProviderBuilder(request);

try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) {
// First call: initial fetch fails with non-recoverable error
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(AwsServiceException.class);

// Second call: immediate retry — should re-raise cached error without calling STS
assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(AwsServiceException.class);

// Verify STS was called only once — the second call used the cached error
callClient(verify(stsClient, times(1)), Mockito.any());
}
}

/**
* Recoverable errors (those with error codes NOT in the non-recoverable set) still
* benefit from static stability — the provider returns cached credentials instead of throwing.
* This is the complement to the non-recoverable error tests: a service unavailable or throttling
* error should not propagate immediately.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ public class CachedSupplier<T> implements Supplier<T>, SdkAutoCloseable {
*/
private static final Duration STATIC_STABILITY_BACKOFF_MAX = Duration.ofMinutes(10);

/**
* Minimum cache duration for a non-recoverable error (inclusive).
*/
private static final int NON_RECOVERABLE_ERROR_CACHE_MIN_SECONDS = 1;

/**
* Maximum cache duration for a non-recoverable error (inclusive).
*/
private static final int NON_RECOVERABLE_ERROR_CACHE_MAX_SECONDS = 5;


/**
* Used as a primitive form of rate limiting for the speed of our refreshes. This will make sure that the backing supplier has
Expand Down Expand Up @@ -128,6 +138,23 @@ public class CachedSupplier<T> implements Supplier<T>, SdkAutoCloseable {
*/
private volatile Instant nextAllowedRefreshTime;

/**
* The most recent non-recoverable error returned by the credential source. While {@link #cachedNonRecoverableErrorExpiresAt}
* is in the future, subsequent refresh attempts re-raise this error without contacting the source. This protects the
* credential source from callers that catch and retry in a tight loop.
*
* <p>Set under {@link #refreshLock}. Cleared on successful refresh.
*/
private volatile RuntimeException cachedNonRecoverableError;

/**
* The expiration time for {@link #cachedNonRecoverableError}. After this instant, the next refresh attempt will contact
* the credential source again instead of re-raising the cached error.
*
* <p>Set under {@link #refreshLock}. Cleared on successful refresh.
*/
private volatile Instant cachedNonRecoverableErrorExpiresAt;

private CachedSupplier(Builder<T> builder) {
Validate.notNull(builder.supplier, "builder.supplier");
Validate.notNull(builder.prefetchJitterEnabled, "builder.prefetchJitterEnabled");
Expand Down Expand Up @@ -261,6 +288,31 @@ private boolean refreshRateLimited() {
return nextAllowed != null && clock.instant().isBefore(nextAllowed);
}

/**
* Returns {@code true} if a non-recoverable error is cached and has not yet expired. While this returns
* {@code true}, refresh attempts re-raise the cached error without contacting the credential source.
*
* <p>Must be called under {@link #refreshLock}.
*/
private boolean nonRecoverableErrorCached() {
RuntimeException error = this.cachedNonRecoverableError;
Instant expiresAt = this.cachedNonRecoverableErrorExpiresAt;
return error != null && expiresAt != null && clock.instant().isBefore(expiresAt);
}

/**
* Caches a non-recoverable error for a short jittered duration (1-5 seconds). This prevents a caller that catches
* and retries in a loop from hammering the credential source with requests that are known to fail.
*
* <p>Must be called under {@link #refreshLock}.
*/
private void cacheNonRecoverableError(RuntimeException error, Instant now) {
this.cachedNonRecoverableError = error;
int cacheSeconds = NON_RECOVERABLE_ERROR_CACHE_MIN_SECONDS
+ jitterRandom.nextInt(NON_RECOVERABLE_ERROR_CACHE_MAX_SECONDS - NON_RECOVERABLE_ERROR_CACHE_MIN_SECONDS + 1);
this.cachedNonRecoverableErrorExpiresAt = now.plusSeconds(cacheSeconds);
}

/**
* Initiate a pre-fetch of the data using the configured {@link #prefetchStrategy}.
*/
Expand All @@ -280,6 +332,12 @@ private void refreshCache() {
try {
// Make sure the value was not refreshed while we waited for the lock.
if (cacheIsStale() || shouldInitiateCachePrefetch()) {
// Check if a non-recoverable error is still cached. If so, re-raise it without
// contacting the credential source.
if (nonRecoverableErrorCached()) {
throw cachedNonRecoverableError;
}

log.debug(() -> "(" + cachedValueName + ") Refreshing cached value.");

// It wasn't, call the supplier to update it.
Expand Down Expand Up @@ -320,6 +378,8 @@ private RefreshResult<T> handleFetchedSuccess(RefreshResult<T> fetch) {

if (now.isBefore(fetch.staleTime())) {
this.nextAllowedRefreshTime = null; // Clear backoff gate on success
this.cachedNonRecoverableError = null; // Clear any cached non-recoverable error
this.cachedNonRecoverableErrorExpiresAt = null;
return fetch;
}

Expand Down Expand Up @@ -366,6 +426,10 @@ private RefreshResult<T> handleFetchFailure(RuntimeException e) {

RefreshResult<T> currentCachedValue = cachedValue;
if (currentCachedValue == null) {
// No cached value. Cache the error if it's non-recoverable (protects the source on initial fetch loops).
if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) {
cacheNonRecoverableError(e, clock.instant());
}
throw e;
}

Expand All @@ -375,8 +439,10 @@ private RefreshResult<T> handleFetchFailure(RuntimeException e) {
case STRICT:
throw e;
case ALLOW:
// Non-recoverable errors bypass static stability
// Non-recoverable errors bypass static stability but are cached briefly
// to protect the credential source from tight retry loops.
if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) {
cacheNonRecoverableError(e, now);
throw e;
}

Expand All @@ -399,6 +465,7 @@ private RefreshResult<T> handleFetchFailure(RuntimeException e) {
// Not yet stale — we're in the prefetch window. Handle failure based on mode.
if (staleValueBehavior == StaleValueBehavior.ALLOW) {
if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) {
cacheNonRecoverableError(e, now);
throw e;
}

Expand Down
Loading
Loading