Skip to content

Commit 17c34db

Browse files
authored
fix: retry improvements (#198)
* fix: prefer retry-after, improved retry strategy * fix: test optimization * fix: rename method and use runAsync instead of supplyAsync * review: fix hot-loop scenario
1 parent 160ac46 commit 17c34db

File tree

4 files changed

+230
-111
lines changed

4 files changed

+230
-111
lines changed

src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,13 @@
1212
import dev.openfga.sdk.util.RetryStrategy;
1313
import java.io.IOException;
1414
import java.io.PrintStream;
15-
import java.net.http.HttpClient;
16-
import java.net.http.HttpRequest;
17-
import java.net.http.HttpResponse;
15+
import java.net.http.*;
1816
import java.nio.ByteBuffer;
1917
import java.nio.charset.StandardCharsets;
20-
import java.time.Duration;
21-
import java.util.HashMap;
22-
import java.util.Map;
23-
import java.util.Optional;
18+
import java.time.*;
19+
import java.util.*;
2420
import java.util.concurrent.*;
21+
import java.util.function.Function;
2522

2623
public class HttpRequestAttempt<T> {
2724
private final ApiClient apiClient;
@@ -83,10 +80,10 @@ public CompletableFuture<ApiResponse<T>> attemptHttpRequest() throws ApiExceptio
8380
addTelemetryAttribute(Attributes.HTTP_REQUEST_METHOD, request.method());
8481
addTelemetryAttribute(Attributes.USER_AGENT, configuration.getUserAgent());
8582

86-
return attemptHttpRequest(createClient(), 0, null);
83+
return attemptHttpRequest(getHttpClient(), 0, null);
8784
}
8885

89-
private HttpClient createClient() {
86+
private HttpClient getHttpClient() {
9087
return apiClient.getHttpClient();
9188
}
9289

@@ -99,10 +96,11 @@ private CompletableFuture<ApiResponse<T>> attemptHttpRequest(
9996
// Handle network errors (no HTTP response received)
10097
return handleNetworkError(throwable, retryNumber);
10198
}
102-
// No network error, proceed with normal HTTP response handling
99+
100+
// Handle HTTP response (including error status codes)
103101
return processHttpResponse(response, retryNumber, previousError);
104102
})
105-
.thenCompose(future -> future);
103+
.thenCompose(Function.identity());
106104
}
107105

108106
private CompletableFuture<ApiResponse<T>> handleNetworkError(Throwable throwable, int retryNumber) {
@@ -114,9 +112,7 @@ private CompletableFuture<ApiResponse<T>> handleNetworkError(Throwable throwable
114112
// Add telemetry for network error retry
115113
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber + 1));
116114

117-
// Create delayed client and retry asynchronously without blocking
118-
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
119-
return attemptHttpRequest(delayingClient, retryNumber + 1, throwable);
115+
return delayedRetry(retryDelay, retryNumber + 1, throwable);
120116
} else {
121117
// Max retries exceeded, fail with the network error
122118
return CompletableFuture.failedFuture(new ApiException(throwable));
@@ -129,9 +125,30 @@ private CompletableFuture<ApiResponse<T>> handleHttpErrorRetry(
129125
Duration retryDelay =
130126
RetryStrategy.calculateRetryDelay(retryAfterDelay, retryNumber, configuration.getMinimumRetryDelay());
131127

132-
// Create delayed client and retry asynchronously without blocking
133-
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
134-
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
128+
return delayedRetry(retryDelay, retryNumber + 1, error);
129+
}
130+
131+
/**
132+
* Performs a delayed retry using CompletableFuture.delayedExecutor().
133+
* This method centralizes the common delay logic used by both network error and HTTP error retries.
134+
*
135+
* @param retryDelay The duration to wait before retrying
136+
* @param nextRetryNumber The next retry attempt number (1-based)
137+
* @param previousError The previous error that caused the retry
138+
* @return CompletableFuture that completes after the delay with the retry attempt
139+
*/
140+
private CompletableFuture<ApiResponse<T>> delayedRetry(
141+
Duration retryDelay, int nextRetryNumber, Throwable previousError) {
142+
// Use CompletableFuture.delayedExecutor() to delay the retry attempt itself
143+
return CompletableFuture.runAsync(
144+
() -> {
145+
// No-op task, we only care about the delay timing
146+
},
147+
CompletableFuture.delayedExecutor(retryDelay.toNanos(), TimeUnit.NANOSECONDS))
148+
.thenCompose(ignored -> {
149+
// Get HttpClient when needed (just returns cached instance)
150+
return attemptHttpRequest(getHttpClient(), nextRetryNumber, previousError);
151+
});
135152
}
136153

137154
private CompletableFuture<ApiResponse<T>> processHttpResponse(
@@ -150,7 +167,6 @@ private CompletableFuture<ApiResponse<T>> processHttpResponse(
150167
// Check if we should retry based on the new strategy
151168
if (RetryStrategy.shouldRetry(statusCode)) {
152169
return handleHttpErrorRetry(retryAfterDelay, retryNumber, error);
153-
} else {
154170
}
155171
}
156172

@@ -196,18 +212,6 @@ private CompletableFuture<T> deserializeResponse(HttpResponse<String> response)
196212
}
197213
}
198214

199-
private HttpClient getDelayedHttpClient(Duration retryDelay) {
200-
if (retryDelay == null || retryDelay.isZero() || retryDelay.isNegative()) {
201-
// Fallback to minimum retry delay if invalid
202-
retryDelay = configuration.getMinimumRetryDelay();
203-
}
204-
205-
return apiClient
206-
.getHttpClientBuilder()
207-
.executor(CompletableFuture.delayedExecutor(retryDelay.toNanos(), TimeUnit.NANOSECONDS))
208-
.build();
209-
}
210-
211215
private static class BodyLogger implements Flow.Subscriber<ByteBuffer> {
212216
private final PrintStream out;
213217
private final String target;

src/main/java/dev/openfga/sdk/util/RetryStrategy.java

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,17 @@ public static boolean shouldRetry(int statusCode) {
6161
*
6262
* @param retryAfterDelay Optional delay from Retry-After header
6363
* @param retryCount Current retry attempt (0-based)
64-
* @param minimumRetryDelay Base delay for exponential backoff
64+
* @param minimumRetryDelay Minimum delay to enforce (only used when no Retry-After header present)
6565
* @return Duration representing the delay before the next retry
6666
*/
6767
public static Duration calculateRetryDelay(
6868
Optional<Duration> retryAfterDelay, int retryCount, Duration minimumRetryDelay) {
69-
// If Retry-After header is present and valid, use it
69+
// If Retry-After header is present, use it but enforce minimum delay floor
7070
if (retryAfterDelay.isPresent()) {
71-
Duration retryAfterValue = retryAfterDelay.get();
72-
// Honor minimum retry delay if configured and greater than Retry-After value
73-
if (minimumRetryDelay != null && minimumRetryDelay.compareTo(retryAfterValue) > 0) {
74-
return minimumRetryDelay;
75-
}
76-
return retryAfterValue;
71+
Duration serverDelay = retryAfterDelay.get();
72+
// Clamp to minimum 1ms to prevent hot-loop retries and handle malformed server responses
73+
Duration minimumSafeDelay = Duration.ofMillis(1);
74+
return serverDelay.compareTo(minimumSafeDelay) < 0 ? minimumSafeDelay : serverDelay;
7775
}
7876

7977
// Otherwise, use exponential backoff with jitter, respecting minimum retry delay

0 commit comments

Comments
 (0)