Skip to content

Commit 973e10b

Browse files
authored
Implement standard and adaptive retry strategies (#1149)
1 parent 09e1f7f commit 973e10b

32 files changed

Lines changed: 2683 additions & 9 deletions

File tree

aws/sdkv2/aws-sdkv2-retries/src/main/java/software/amazon/smithy/java/aws/sdkv2/retries/SdkRetryStrategy.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,6 @@ public AcquireInitialTokenResponse acquireInitialToken(AcquireInitialTokenReques
9090
public RefreshRetryTokenResponse refreshRetryToken(RefreshRetryTokenRequest request) {
9191
try {
9292
var suggestedDelay = request.suggestedDelay();
93-
if (suggestedDelay == null && request.failure() instanceof RetryInfo info) {
94-
suggestedDelay = info.retryAfter();
95-
}
9693
var delegateRequest = software.amazon.awssdk.retries.api.RefreshRetryTokenRequest.builder()
9794
.token(getDelegatedRetryToken(request.token()))
9895
.failure(request.failure())

client/client-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414
api(project(":auth-api"))
1515
api(project(":client:client-auth-api"))
1616
api(project(":retries-api"))
17+
api(project(":retries"))
1718
api(project(":framework-errors"))
1819
implementation(project(":logging"))
1920

client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import software.amazon.smithy.java.endpoints.Endpoint;
2828
import software.amazon.smithy.java.endpoints.EndpointContext;
2929
import software.amazon.smithy.java.endpoints.EndpointResolver;
30+
import software.amazon.smithy.java.retries.StandardRetryStrategy;
31+
import software.amazon.smithy.java.retries.api.Claimable;
3032
import software.amazon.smithy.java.retries.api.RetryStrategy;
3133
import software.amazon.smithy.utils.SmithyInternalApi;
3234

@@ -49,12 +51,15 @@ protected Client(Builder<?, ?> builder) {
4951
this.interceptor = config.interceptorChain();
5052
this.identityResolvers = IdentityResolvers.of(config.identityResolvers());
5153
this.typeRegistry = typeRegistry();
52-
5354
if (config.retryStrategy() != null) {
5455
this.retryStrategy = config.retryStrategy();
5556
} else {
56-
// TODO: Pick a better default retry strategy.
57-
this.retryStrategy = RetryStrategy.noRetries();
57+
this.retryStrategy = StandardRetryStrategy.create();
58+
}
59+
// Claim this strategy, if successful any other client that attempts to claim it
60+
// will fail preventing sharing strategies among unrelated clients.
61+
if (retryStrategy instanceof Claimable c) {
62+
c.claim(this);
5863
}
5964
}
6065

client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientPipeline.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import software.amazon.smithy.java.retries.api.AcquireInitialTokenRequest;
3535
import software.amazon.smithy.java.retries.api.RecordSuccessRequest;
3636
import software.amazon.smithy.java.retries.api.RefreshRetryTokenRequest;
37+
import software.amazon.smithy.java.retries.api.RetryInfo;
3738
import software.amazon.smithy.java.retries.api.RetryToken;
3839
import software.amazon.smithy.java.retries.api.TokenAcquisitionFailedException;
3940

@@ -388,7 +389,8 @@ private <I extends SerializableStruct, O extends SerializableStruct> O deseriali
388389
if (error != null && !call.isRetryDisallowed()) {
389390
try {
390391
// If it's retryable, keep retrying and jump to step 8a.
391-
var acquireRequest = new RefreshRetryTokenRequest(call.retryToken, error, null);
392+
var suggestedDelay = (error instanceof RetryInfo i) ? i.retryAfter() : null;
393+
var acquireRequest = new RefreshRetryTokenRequest(call.retryToken, error, suggestedDelay);
392394
var acquireResult = call.retryStrategy.refreshRetryToken(acquireRequest);
393395
return retry(call, request, acquireResult.token(), acquireResult.delay());
394396
} catch (TokenAcquisitionFailedException tafe) {

client/client-waiters/src/test/java/software/amazon/smithy/java/client/waiters/TestWaiters.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ void testWaiterTimesOut() {
6969
.build();
7070
var exc = assertThrows(
7171
WaiterFailureException.class,
72-
() -> waiter.wait(new GetFoosInput(ID), 10));
72+
() -> waiter.wait(new GetFoosInput(ID), 5));
7373
assertNull(exc.getCause());
7474
assertTrue(exc.getMessage().contains("Waiter timed out after"));
7575
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.retries.api;
7+
8+
/**
9+
* Flags that can be set on an {@link AcquireInitialTokenRequest} to modify retry behavior.
10+
*
11+
* <p>Flags are combined using bitwise OR, e.g. {@code AcquireInitialTokenFlags.IS_LONG_POLLING | otherFlag}.
12+
*/
13+
public final class AcquireInitialTokenFlags {
14+
15+
/**
16+
* Indicates that the operation is long polling. Retry strategies can use this to allow retries even when the
17+
* token bucket would otherwise reject the attempt.
18+
*/
19+
public static final int IS_LONG_POLLING = 1;
20+
21+
/**
22+
* Returns {@code true} if the {@link #IS_LONG_POLLING} flag is set.
23+
*/
24+
public static boolean isLongPolling(int flags) {
25+
return (flags & IS_LONG_POLLING) == IS_LONG_POLLING;
26+
}
27+
28+
private AcquireInitialTokenFlags() {}
29+
}

retries-api/src/main/java/software/amazon/smithy/java/retries/api/AcquireInitialTokenRequest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@
1414
* attempts. All attempts with the same scope share the same token bucket within the same
1515
* {@link RetryStrategy}, ensuring that token-bucket throttling for requests against one resource do not
1616
* result in throttling for requests against other, unrelated resources.
17+
* @param flags A bitmask of flags for the request. See {@link AcquireInitialTokenFlags} for available flags.
1718
*/
18-
public record AcquireInitialTokenRequest(String scope) {}
19+
public record AcquireInitialTokenRequest(String scope, int flags) {
20+
public AcquireInitialTokenRequest(String scope) {
21+
this(scope, 0);
22+
}
23+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.retries.api;
7+
8+
/**
9+
* An object that supports exclusive ownership by a single caller.
10+
*
11+
* <p>Some objects maintain internal state that should not be shared across multiple owners. For example, a
12+
* {@link RetryStrategy} tracks token bucket capacity and rate limiter state per scope. Sharing a single instance
13+
* across multiple clients causes one client's failures to affect another's retry behavior.
14+
*
15+
* <p>Callers should invoke {@link #claim(Object)} during construction to assert ownership. The first call succeeds;
16+
* subsequent calls throw {@link IllegalStateException}.
17+
*
18+
* <pre>{@code
19+
* if (retryStrategy instanceof Claimable c) {
20+
* c.claim(this);
21+
* }
22+
* }</pre>
23+
*
24+
* <p>If a claimed object needs to be reused, callers should create a new instance via
25+
* {@link RetryStrategy#toBuilder()} instead of sharing the original.
26+
*/
27+
public interface Claimable {
28+
29+
/**
30+
* Claims exclusive ownership of this object.
31+
*
32+
* <p>The first call to this method establishes exclusive ownership. Any subsequent call, regardless of the caller,
33+
* throws {@link IllegalStateException}.
34+
*
35+
* @param owner The object claiming ownership, typically {@code this} from the caller's constructor.
36+
* @throws NullPointerException If {@code owner} is null.
37+
* @throws IllegalStateException If this object has already been claimed.
38+
*/
39+
void claim(Object owner);
40+
}

retries/build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
id("smithy-java.module-conventions")
3+
}
4+
5+
description = "This module provides the Smithy Java Retries implementation"
6+
7+
extra["displayName"] = "Smithy :: Java :: Retries"
8+
extra["moduleName"] = "software.amazon.smithy.java.retries"
9+
10+
dependencies {
11+
implementation(libs.smithy.utils)
12+
implementation(project(":logging"))
13+
api(project(":retries-api"))
14+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.retries;
7+
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
10+
import java.time.Duration;
11+
import java.util.concurrent.CountDownLatch;
12+
import java.util.concurrent.ExecutorService;
13+
import java.util.concurrent.Executors;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
import java.util.concurrent.atomic.AtomicLong;
16+
import org.junit.jupiter.api.Test;
17+
import software.amazon.smithy.java.retries.api.AcquireInitialTokenRequest;
18+
import software.amazon.smithy.java.retries.api.RecordSuccessRequest;
19+
import software.amazon.smithy.java.retries.api.RefreshRetryTokenRequest;
20+
import software.amazon.smithy.java.retries.api.RetryInfo;
21+
import software.amazon.smithy.java.retries.api.RetrySafety;
22+
import software.amazon.smithy.java.retries.api.TokenAcquisitionFailedException;
23+
24+
class AdaptiveRetryStrategyIntegrationTest {
25+
private static final int THREAD_COUNT = 5;
26+
private static final int CALLS_PER_THREAD = 10;
27+
private static final long RATE_LIMIT_MS = 5;
28+
29+
@Test
30+
void rateLimiterThrottlesClientsToServerRate() throws InterruptedException {
31+
var strategy = AdaptiveRetryStrategy.builder()
32+
.maxAttempts(10)
33+
.build();
34+
var server = new RateLimitedServer(RATE_LIMIT_MS);
35+
var successCount = new AtomicInteger(0);
36+
var throttleCount = new AtomicInteger(0);
37+
var attemptCount = new AtomicInteger(0);
38+
var latch = new CountDownLatch(THREAD_COUNT);
39+
40+
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
41+
for (var i = 0; i < THREAD_COUNT; i++) {
42+
executor.submit(() -> {
43+
try {
44+
for (var c = 0; c < CALLS_PER_THREAD; c++) {
45+
executeWithRetry(strategy, server, successCount, throttleCount, attemptCount);
46+
}
47+
} finally {
48+
latch.countDown();
49+
}
50+
});
51+
}
52+
53+
latch.await();
54+
executor.shutdown();
55+
56+
var totalCalls = THREAD_COUNT * CALLS_PER_THREAD;
57+
// All calls should eventually succeed
58+
assertTrue(successCount.get() == totalCalls,
59+
"Expected " + totalCalls + " successes but got " + successCount.get());
60+
// Some calls should have been throttled initially
61+
assertTrue(throttleCount.get() > 0,
62+
"Expected some throttling but got none");
63+
// Success rate should be greater than 80%
64+
var totalAttempts = attemptCount.get();
65+
var successRate = (double) successCount.get() / totalAttempts;
66+
assertTrue(successRate > 0.80,
67+
String.format("Expected success rate > 80%% but got %.1f%% (%d/%d)",
68+
successRate * 100,
69+
successCount.get(),
70+
totalAttempts));
71+
}
72+
73+
private void executeWithRetry(
74+
AdaptiveRetryStrategy strategy,
75+
RateLimitedServer server,
76+
AtomicInteger successCount,
77+
AtomicInteger throttleCount,
78+
AtomicInteger attemptCount
79+
) {
80+
var acquireResponse = strategy.acquireInitialToken(new AcquireInitialTokenRequest("test-scope"));
81+
var token = acquireResponse.token();
82+
sleep(acquireResponse.delay());
83+
84+
while (true) {
85+
try {
86+
attemptCount.incrementAndGet();
87+
server.call();
88+
strategy.recordSuccess(new RecordSuccessRequest(token));
89+
successCount.incrementAndGet();
90+
return;
91+
} catch (ServerThrottleException e) {
92+
throttleCount.incrementAndGet();
93+
try {
94+
var refreshResponse = strategy.refreshRetryToken(
95+
new RefreshRetryTokenRequest(token, e, null));
96+
token = refreshResponse.token();
97+
sleep(refreshResponse.delay());
98+
} catch (TokenAcquisitionFailedException ex) {
99+
// Retries exhausted, count as success anyway since we'll retry the outer loop
100+
// This shouldn't happen with maxAttempts=10 and adaptive backoff
101+
throw new AssertionError("Retries exhausted unexpectedly", ex);
102+
}
103+
}
104+
}
105+
}
106+
107+
private static void sleep(Duration delay) {
108+
if (delay.isZero()) {
109+
return;
110+
}
111+
try {
112+
Thread.sleep(delay.toMillis());
113+
} catch (InterruptedException e) {
114+
Thread.currentThread().interrupt();
115+
}
116+
}
117+
118+
/**
119+
* A simulated server that only allows one operation every {@code rateLimitMs} milliseconds.
120+
* Calls that arrive too fast are rejected with a {@link ServerThrottleException}.
121+
*/
122+
static class RateLimitedServer {
123+
private final long rateLimitMs;
124+
private final AtomicLong lastAllowedTime = new AtomicLong(0);
125+
126+
RateLimitedServer(long rateLimitMs) {
127+
this.rateLimitMs = rateLimitMs;
128+
}
129+
130+
void call() {
131+
var now = System.nanoTime() / 1_000_000;
132+
var last = lastAllowedTime.get();
133+
if (now - last >= rateLimitMs && lastAllowedTime.compareAndSet(last, now)) {
134+
return; // success
135+
}
136+
throw new ServerThrottleException();
137+
}
138+
}
139+
140+
static class ServerThrottleException extends RuntimeException implements RetryInfo {
141+
@Override
142+
public RetrySafety isRetrySafe() {
143+
return RetrySafety.YES;
144+
}
145+
146+
@Override
147+
public boolean isThrottle() {
148+
return true;
149+
}
150+
151+
@Override
152+
public Duration retryAfter() {
153+
return null;
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)