Skip to content

Commit 25c91a1

Browse files
committed
[Credentials Cache PR4]Add dynamic advisory refresh window and update prefetch behavior (#7093)
Add dynamic advisory refresh window and fix prefetch failure stale time Implement two changes from the updated cross-SDK credential refresh spec: 1. Dynamic advisory refresh window: When the user has NOT explicitly configured a prefetchTime, compute the advisory window dynamically based on the credential's remaining lifetime: - remaining < 20 min -> 5 min window - 20 min <= remaining < 90 min -> 15 min window - remaining >= 90 min -> 60 min window This is recomputed on each successful refresh. If the user HAS explicitly configured a value, it is always honored unchanged. 2. Prefetch failure stale time preservation: When a credential refresh fails during the advisory (prefetch) window, extend the prefetch time by backoff but preserve the existing stale time if it is later than the new prefetch time. Previously both were set to the same backoff value, which could move the mandatory refresh boundary closer than intended. Affected providers: STS (all), IMDS, Container, SSO, Login, Process. New utility: CacheRefreshUtils.computeDynamicPrefetchWindow() * Update approach to dynamic prefetch window
1 parent 74ca0f0 commit 25c91a1

16 files changed

Lines changed: 496 additions & 58 deletions

File tree

core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import software.amazon.awssdk.utils.Validate;
5151
import software.amazon.awssdk.utils.builder.CopyableBuilder;
5252
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
53+
import software.amazon.awssdk.utils.cache.CacheRefreshUtils;
5354
import software.amazon.awssdk.utils.cache.CachedSupplier;
5455
import software.amazon.awssdk.utils.cache.NonBlocking;
5556
import software.amazon.awssdk.utils.cache.RefreshResult;
@@ -87,7 +88,6 @@ public final class ContainerCredentialsProvider
8788
private static final List<String> VALID_LOOP_BACK_IPV6 = Arrays.asList(EKS_CONTAINER_HOST_IPV6);
8889

8990
private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1);
90-
private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5);
9191

9292
private final String endpoint;
9393
private final HttpCredentialsLoader httpCredentialsLoader;
@@ -114,9 +114,11 @@ private ContainerCredentialsProvider(BuilderImpl builder) {
114114
: builder.sourceChain + "," + PROVIDER_NAME;
115115
this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName);
116116
this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME);
117-
this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME);
118-
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
119-
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
117+
this.prefetchTime = builder.prefetchTime;
118+
if (this.prefetchTime != null) {
119+
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
120+
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
121+
}
120122

121123
if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) {
122124
Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName");
@@ -172,7 +174,11 @@ private Instant prefetchTime(Instant expiration) {
172174
if (expiration == null) {
173175
return Instant.now().plus(1, ChronoUnit.HOURS);
174176
}
175-
return expiration.minus(prefetchTime);
177+
178+
Instant now = Instant.now();
179+
Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now);
180+
181+
return expiration.minus(effectivePrefetchWindow);
176182
}
177183

178184
@Override
@@ -356,7 +362,10 @@ public interface Builder extends HttpCredentialsProvider.Builder<ContainerCreden
356362
* <p>This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
357363
* {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
358364
*
359-
* <p>By default, this is 5 minutes.
365+
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
366+
* remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90
367+
* minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
368+
* successful refresh.
360369
*
361370
* @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
362371
*/

core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import software.amazon.awssdk.utils.Validate;
5050
import software.amazon.awssdk.utils.builder.CopyableBuilder;
5151
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
52+
import software.amazon.awssdk.utils.cache.CacheRefreshUtils;
5253
import software.amazon.awssdk.utils.cache.CachedSupplier;
5354
import software.amazon.awssdk.utils.cache.NonBlocking;
5455
import software.amazon.awssdk.utils.cache.RefreshResult;
@@ -122,9 +123,11 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) {
122123
.build();
123124

124125
this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1));
125-
this.prefetchTime = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5));
126-
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
127-
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
126+
this.prefetchTime = builder.prefetchTime;
127+
if (this.prefetchTime != null) {
128+
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
129+
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
130+
}
128131

129132
if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) {
130133
Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName");
@@ -208,12 +211,14 @@ private Instant prefetchTime(Instant expiration) {
208211
return null;
209212
}
210213

211-
// Advisory refresh window: use configured prefetchTime before expiry.
212-
// If remaining lifetime < prefetchTime, refresh immediately.
213-
if (timeUntilExpiration.compareTo(prefetchTime) < 0) {
214+
// Use dynamic window when user has not explicitly configured prefetchTime
215+
Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now);
216+
217+
// If remaining lifetime < the advisory window, refresh immediately.
218+
if (timeUntilExpiration.compareTo(effectivePrefetchWindow) < 0) {
214219
return now;
215220
}
216-
return expiration.minus(prefetchTime);
221+
return expiration.minus(effectivePrefetchWindow);
217222
}
218223

219224
@Override
@@ -391,7 +396,10 @@ public interface Builder extends HttpCredentialsProvider.Builder<InstanceProfile
391396
* <p>This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
392397
* {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
393398
*
394-
* <p>By default, this is 5 minutes.
399+
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
400+
* remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90
401+
* minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
402+
* successful refresh.
395403
*
396404
* @param duration the duration before expiration that triggers advisory (proactive) refresh
397405
*/

core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import software.amazon.awssdk.utils.Validate;
3939
import software.amazon.awssdk.utils.builder.CopyableBuilder;
4040
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
41+
import software.amazon.awssdk.utils.cache.CacheRefreshUtils;
4142
import software.amazon.awssdk.utils.cache.CachedSupplier;
4243
import software.amazon.awssdk.utils.cache.NonBlocking;
4344
import software.amazon.awssdk.utils.cache.RefreshResult;
@@ -85,7 +86,6 @@ public final class ProcessCredentialsProvider
8586
.removeErrorLocations(true)
8687
.build();
8788
private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1);
88-
private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5);
8989

9090
private final List<String> executableCommand;
9191
private final long processOutputLimit;
@@ -119,9 +119,11 @@ private ProcessCredentialsProvider(Builder builder) {
119119
? PROVIDER_NAME
120120
: builder.sourceChain + "," + PROVIDER_NAME;
121121
this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME);
122-
this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME);
123-
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
124-
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
122+
this.prefetchTime = builder.prefetchTime;
123+
if (this.prefetchTime != null) {
124+
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
125+
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
126+
}
125127

126128
CachedSupplier.Builder<AwsCredentials> cacheBuilder = CachedSupplier.builder(this::refreshCredentials)
127129
.cachedValueName(toString());
@@ -194,7 +196,9 @@ private Instant prefetchTime(Instant expiration) {
194196
if (expiration == null || expiration.equals(Instant.MAX)) {
195197
return Instant.MAX;
196198
}
197-
return expiration.minus(prefetchTime);
199+
Instant now = Instant.now();
200+
Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now);
201+
return expiration.minus(dynamicWindow);
198202
}
199203

200204
/**
@@ -382,7 +386,10 @@ public Builder staleTime(Duration staleTime) {
382386
* <p>This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
383387
* {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
384388
*
385-
* <p>By default, this is 5 minutes.</p>
389+
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
390+
* remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90
391+
* minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
392+
* successful refresh.</p>
386393
*
387394
* @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
388395
*/

core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ public interface Builder extends CopyableBuilder<Builder, WebIdentityTokenFileCr
201201
* <p>This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
202202
* {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
203203
*
204-
* <p>By default, this is 5 minutes.
204+
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
205+
* remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90
206+
* minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
207+
* successful refresh.
205208
*
206209
* @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
207210
*/

core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -639,13 +639,13 @@ void imdsCallFrequencyIsLimited() {
639639
stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1));
640640
AwsCredentials credentialsAtStart = credentialsProvider.resolveCredentials();
641641

642-
// Move time forward but still before the prefetch window (5 min before expiry).
643-
// Since prefetchTime = expiration - 5min = now + 5h55m, anything before that should not trigger refresh.
644-
clock.time = now.plus(5, HOURS);
642+
// Move time forward but still before the prefetch window (60 min before expiry for 6h credentials).
643+
// Since dynamic prefetchTime = expiration - 60min = now + 5h, anything before that should not trigger refresh.
644+
clock.time = now.plus(4, HOURS);
645645
stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2));
646-
AwsCredentials credentials5HoursLater = credentialsProvider.resolveCredentials();
646+
AwsCredentials credentialsLater = credentialsProvider.resolveCredentials();
647647

648-
assertThat(credentials5HoursLater).isEqualTo(credentialsAtStart);
648+
assertThat(credentialsLater).isEqualTo(credentialsAtStart);
649649
assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY");
650650
}
651651

services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import software.amazon.awssdk.utils.Validate;
5252
import software.amazon.awssdk.utils.builder.CopyableBuilder;
5353
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
54+
import software.amazon.awssdk.utils.cache.CacheRefreshUtils;
5455
import software.amazon.awssdk.utils.cache.CachedSupplier;
5556
import software.amazon.awssdk.utils.cache.NonBlocking;
5657
import software.amazon.awssdk.utils.cache.RefreshResult;
@@ -78,7 +79,6 @@ public final class LoginCredentialsProvider implements
7879
private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_LOGIN.value();
7980

8081
private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1);
81-
private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5);
8282
private static final Path DEFAULT_TOKEN_LOCATION = Paths.get(userHomeDirectory(), ".aws", "login", "cache");
8383

8484
private static final String ASYNC_THREAD_NAME = "sdk-login-credentials-provider";
@@ -106,9 +106,11 @@ private LoginCredentialsProvider(BuilderImpl builder) {
106106
this.loginSession = paramNotBlank(builder.loginSession, "LoginSession");
107107

108108
this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME);
109-
this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME);
110-
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
111-
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
109+
this.prefetchTime = builder.prefetchTime;
110+
if (this.prefetchTime != null) {
111+
Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0,
112+
"staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime);
113+
}
112114
this.sourceChain = builder.sourceChain;
113115

114116
this.providerName = StringUtils.isEmpty(builder.sourceChain)
@@ -136,21 +138,22 @@ private LoginCredentialsProvider(BuilderImpl builder) {
136138
}
137139

138140
/**
139-
* Update the expiring session SSO credentials by calling SSO. Invoked by {@link CachedSupplier} when the credentials are
140-
* close to expiring.
141+
* Update the expiring session Login credentials by calling the Signin Service. Invoked by {@link CachedSupplier} when the
142+
* credentials are close to expiring.
141143
*/
142144
private RefreshResult<AwsCredentials> updateSigninCredentials() {
143145
// always re-load token from the disk in case it has been updated elsewhere
144146
LoginAccessToken tokenFromDisc = onDiskTokenManager.loadToken().orElseThrow(
145-
() -> SdkClientException.create("Token cache file for login_session `" + loginSession + "` not found. "
147+
() -> new InvalidTokenException("Token cache file for login_session `" + loginSession + "` not found. "
146148
+ "You must re-authenticate."));
147149

148150
Instant currentExpirationTime = tokenFromDisc.getAccessToken().expirationTime().orElseThrow(
149-
() -> SdkClientException.create("Invalid token expiration time. You must re-authenticate.")
151+
() -> new InvalidTokenException("Invalid token expiration time. You must re-authenticate.")
150152
);
151153

154+
Duration effectivePrefetch = CacheRefreshUtils.computePrefetchWindow(currentExpirationTime, prefetchTime, Instant.now());
152155
if (shouldNotRefresh(currentExpirationTime, staleTime)
153-
&& shouldNotRefresh(currentExpirationTime, prefetchTime)) {
156+
&& shouldNotRefresh(currentExpirationTime, effectivePrefetch)) {
154157
log.debug(() -> "Using access token from disk, current expiration time is : " + currentExpirationTime);
155158
AwsCredentials credentials = tokenFromDisc.getAccessToken()
156159
.toBuilder()
@@ -159,7 +162,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) {
159162

160163
return RefreshResult.builder(credentials)
161164
.staleTime(currentExpirationTime.minus(staleTime))
162-
.prefetchTime(currentExpirationTime.minus(prefetchTime))
165+
.prefetchTime(currentExpirationTime.minus(effectivePrefetch))
163166
.build();
164167
}
165168

@@ -203,7 +206,8 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken
203206

204207
return RefreshResult.builder((AwsCredentials) updatedCredentials)
205208
.staleTime(newExpiration.minus(staleTime))
206-
.prefetchTime(newExpiration.minus(prefetchTime))
209+
.prefetchTime(newExpiration.minus(
210+
CacheRefreshUtils.computePrefetchWindow(newExpiration, prefetchTime, Instant.now())))
207211
.build();
208212
} catch (AccessDeniedException accessDeniedException) {
209213
if (accessDeniedException.error() == null) {
@@ -232,11 +236,18 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken
232236

233237
/**
234238
* Determines whether a given exception represents a non-recoverable refresh failure that should bypass
235-
* static stability. For Login, this is an {@link AccessDeniedException} with error code
236-
* {@link OAuth2ErrorCode#TOKEN_EXPIRED}, {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED},
237-
* or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}.
239+
* static stability. For Login, this includes:
240+
* <ul>
241+
* <li>Missing or invalid token cache on disk (requires re-authentication)</li>
242+
* <li>An {@link AccessDeniedException} with error code {@link OAuth2ErrorCode#TOKEN_EXPIRED},
243+
* {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}</li>
244+
* </ul>
238245
*/
239246
private static boolean isCacheInvalidating(RuntimeException e) {
247+
if (e instanceof InvalidTokenException) {
248+
return true;
249+
}
250+
240251
AccessDeniedException ade = extractAccessDeniedException(e);
241252
if (ade == null) {
242253
return false;
@@ -355,7 +366,10 @@ public interface Builder extends CopyableBuilder<Builder, LoginCredentialsProvid
355366
* <p>This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to
356367
* {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking).
357368
*
358-
* <p>By default, this is 5 minutes.
369+
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
370+
* remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90
371+
* minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each
372+
* successful refresh.
359373
*
360374
* @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
361375
*/
@@ -386,6 +400,17 @@ public interface Builder extends CopyableBuilder<Builder, LoginCredentialsProvid
386400
LoginCredentialsProvider build();
387401
}
388402

403+
/**
404+
* Exception indicating that the cached login token is missing or invalid, requiring the user to re-authenticate.
405+
* This exception bypasses static stability (cache extension on failure) because there is no valid token state
406+
* to fall back to.
407+
*/
408+
private static final class InvalidTokenException extends SdkClientException {
409+
private InvalidTokenException(String message) {
410+
super(builder().message(message));
411+
}
412+
}
413+
389414
protected static final class BuilderImpl implements Builder {
390415
private Boolean asyncCredentialUpdateEnabled = true;
391416
private SigninClient signinClient;

0 commit comments

Comments
 (0)