Skip to content

Commit 5055de1

Browse files
committed
feat: initial retry-after support
1 parent 59ee8dc commit 5055de1

8 files changed

Lines changed: 1274 additions & 6 deletions

File tree

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import dev.openfga.sdk.telemetry.Attribute;
99
import dev.openfga.sdk.telemetry.Attributes;
1010
import dev.openfga.sdk.telemetry.Telemetry;
11+
import dev.openfga.sdk.util.RetryAfterHeaderParser;
12+
import dev.openfga.sdk.util.RetryStrategy;
1113
import java.io.IOException;
1214
import java.io.PrintStream;
1315
import java.net.http.HttpClient;
@@ -98,13 +100,25 @@ private CompletableFuture<ApiResponse<T>> attemptHttpRequest(
98100

99101
if (fgaError.isPresent()) {
100102
FgaError error = fgaError.get();
103+
int statusCode = error.getStatusCode();
101104

102-
if (HttpStatusCode.isRetryable(error.getStatusCode())
103-
&& retryNumber < configuration.getMaxRetries()) {
105+
if (retryNumber < configuration.getMaxRetries()) {
106+
// Parse Retry-After header if present
107+
Optional<Duration> retryAfterDelay = response.headers()
108+
.firstValue("retry-after")
109+
.flatMap(RetryAfterHeaderParser::parseRetryAfter);
104110

105-
HttpClient delayingClient = getDelayedHttpClient();
111+
boolean hasValidRetryAfter = retryAfterDelay.isPresent();
106112

107-
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
113+
// Check if we should retry based on the new strategy
114+
if (RetryStrategy.shouldRetry(request, statusCode, hasValidRetryAfter)) {
115+
// Calculate appropriate delay
116+
Duration retryDelay = RetryStrategy.calculateRetryDelay(retryAfterDelay, retryNumber);
117+
118+
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
119+
120+
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
121+
}
108122
}
109123

110124
return CompletableFuture.failedFuture(error);
@@ -151,8 +165,15 @@ private CompletableFuture<T> deserializeResponse(HttpResponse<String> response)
151165
}
152166
}
153167

154-
private HttpClient getDelayedHttpClient() {
155-
Duration retryDelay = configuration.getMinimumRetryDelay();
168+
private HttpClient getDelayedHttpClient(Duration retryDelay) {
169+
if (retryDelay == null || retryDelay.isZero() || retryDelay.isNegative()) {
170+
// Fallback to minimum retry delay if invalid
171+
retryDelay = configuration.getMinimumRetryDelay();
172+
if (retryDelay == null) {
173+
// Default fallback if no minimum retry delay is configured
174+
retryDelay = Duration.ofMillis(100);
175+
}
176+
}
156177

157178
return apiClient
158179
.getHttpClientBuilder()
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* OpenFGA
3+
* A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar.
4+
*
5+
* The version of the OpenAPI document: 1.x
6+
* Contact: community@openfga.dev
7+
*
8+
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
9+
* https://openapi-generator.tech
10+
* Do not edit the class manually.
11+
*/
12+
13+
package dev.openfga.sdk.util;
14+
15+
import java.time.Duration;
16+
import java.util.Random;
17+
18+
/**
19+
* Utility class for calculating exponential backoff delays with jitter.
20+
*
21+
* Implements the retry strategy specified in GitHub issue #155:
22+
* - Base delay: 2^retryCount * 500ms
23+
* - Jitter: Random value between base and 2 * base
24+
* - Maximum delay: 120 seconds (capped after 8th retry)
25+
*/
26+
public class ExponentialBackoff {
27+
28+
private static final int BASE_DELAY_MS = 500;
29+
private static final int MAX_DELAY_SECONDS = 120;
30+
private static final Random RANDOM = new Random();
31+
32+
private ExponentialBackoff() {
33+
// Utility class - no instantiation
34+
}
35+
36+
/**
37+
* Calculates the exponential backoff delay with jitter for a given retry attempt.
38+
*
39+
* @param retryCount The current retry attempt (0-based, so first retry is 0)
40+
* @return Duration representing the delay before the next retry
41+
*/
42+
public static Duration calculateDelay(int retryCount) {
43+
if (retryCount < 0) {
44+
return Duration.ZERO;
45+
}
46+
47+
// Calculate base delay: 2^retryCount * 500ms
48+
long baseDelayMs = (long) Math.pow(2, retryCount) * BASE_DELAY_MS;
49+
50+
// Cap at maximum delay
51+
long maxDelayMs = MAX_DELAY_SECONDS * 1000L;
52+
if (baseDelayMs > maxDelayMs) {
53+
baseDelayMs = maxDelayMs;
54+
}
55+
56+
// Add jitter: random value between baseDelay and 2 * baseDelay
57+
long minDelayMs = baseDelayMs;
58+
long maxDelayMsWithJitter = Math.min(baseDelayMs * 2, maxDelayMs);
59+
60+
// Generate random delay within the jitter range
61+
long jitterRange = maxDelayMsWithJitter - minDelayMs;
62+
long actualDelayMs = minDelayMs + (jitterRange > 0 ? (long) (RANDOM.nextDouble() * (jitterRange + 1)) : 0);
63+
64+
return Duration.ofMillis(actualDelayMs);
65+
}
66+
67+
/**
68+
* Calculates the exponential backoff delay with jitter using a provided Random instance.
69+
* This method is primarily for testing purposes to ensure deterministic behavior.
70+
*
71+
* @param retryCount The current retry attempt (0-based, so first retry is 0)
72+
* @param random Random instance to use for jitter calculation
73+
* @return Duration representing the delay before the next retry
74+
*/
75+
public static Duration calculateDelay(int retryCount, Random random) {
76+
if (retryCount < 0) {
77+
return Duration.ZERO;
78+
}
79+
80+
// Calculate base delay: 2^retryCount * 500ms
81+
long baseDelayMs = (long) Math.pow(2, retryCount) * BASE_DELAY_MS;
82+
83+
// Cap at maximum delay
84+
long maxDelayMs = MAX_DELAY_SECONDS * 1000L;
85+
if (baseDelayMs > maxDelayMs) {
86+
baseDelayMs = maxDelayMs;
87+
}
88+
89+
// Add jitter: random value between baseDelay and 2 * baseDelay
90+
long minDelayMs = baseDelayMs;
91+
long maxDelayMsWithJitter = Math.min(baseDelayMs * 2, maxDelayMs);
92+
93+
// Generate random delay within the jitter range
94+
long jitterRange = maxDelayMsWithJitter - minDelayMs;
95+
long actualDelayMs = minDelayMs + (jitterRange > 0 ? (long) (random.nextDouble() * (jitterRange + 1)) : 0);
96+
97+
return Duration.ofMillis(actualDelayMs);
98+
}
99+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* OpenFGA
3+
* A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar.
4+
*
5+
* The version of the OpenAPI document: 1.x
6+
* Contact: community@openfga.dev
7+
*
8+
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
9+
* https://openapi-generator.tech
10+
* Do not edit the class manually.
11+
*/
12+
13+
package dev.openfga.sdk.util;
14+
15+
import java.time.Duration;
16+
import java.time.Instant;
17+
import java.time.format.DateTimeFormatter;
18+
import java.time.format.DateTimeParseException;
19+
import java.util.Optional;
20+
21+
/**
22+
* Utility class for parsing and validating Retry-After header values according to RFC 9110.
23+
*
24+
* The Retry-After header can contain either:
25+
* 1. An integer representing seconds to wait
26+
* 2. An HTTP-date representing when to retry
27+
*
28+
* This parser validates that the delay is between 1 second and 1800 seconds (30 minutes).
29+
*/
30+
public class RetryAfterHeaderParser {
31+
32+
private static final int MIN_RETRY_AFTER_SECONDS = 1;
33+
private static final int MAX_RETRY_AFTER_SECONDS = 1800; // 30 minutes
34+
35+
private RetryAfterHeaderParser() {
36+
// Utility class - no instantiation
37+
}
38+
39+
/**
40+
* Parses a Retry-After header value and returns the delay duration if valid.
41+
*
42+
* @param retryAfterValue The value of the Retry-After header
43+
* @return Optional containing the delay duration if valid, empty otherwise
44+
*/
45+
public static Optional<Duration> parseRetryAfter(String retryAfterValue) {
46+
if (retryAfterValue == null || retryAfterValue.trim().isEmpty()) {
47+
return Optional.empty();
48+
}
49+
50+
String trimmedValue = retryAfterValue.trim();
51+
52+
// Try parsing as integer (seconds)
53+
Optional<Duration> integerResult = parseAsInteger(trimmedValue);
54+
if (integerResult.isPresent()) {
55+
return integerResult;
56+
}
57+
58+
// Try parsing as HTTP-date
59+
return parseAsHttpDate(trimmedValue);
60+
}
61+
62+
/**
63+
* Attempts to parse the value as an integer representing seconds.
64+
*/
65+
private static Optional<Duration> parseAsInteger(String value) {
66+
try {
67+
long seconds = Long.parseLong(value);
68+
69+
// Validate range: must be between 1 and 1800 seconds
70+
if (seconds >= MIN_RETRY_AFTER_SECONDS && seconds <= MAX_RETRY_AFTER_SECONDS) {
71+
return Optional.of(Duration.ofSeconds(seconds));
72+
}
73+
74+
return Optional.empty();
75+
} catch (NumberFormatException e) {
76+
return Optional.empty();
77+
}
78+
}
79+
80+
/**
81+
* Attempts to parse the value as an HTTP-date.
82+
* Supports RFC 1123 format as specified in RFC 9110.
83+
*/
84+
private static Optional<Duration> parseAsHttpDate(String value) {
85+
try {
86+
// Parse HTTP-date in RFC 1123 format (e.g., "Sun, 06 Nov 1994 08:49:37 GMT")
87+
Instant retryTime = Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(value));
88+
Instant now = Instant.now();
89+
90+
// Calculate duration from now
91+
Duration duration = Duration.between(now, retryTime);
92+
93+
// Validate range: must be between 1 and 1800 seconds from now
94+
long seconds = duration.getSeconds();
95+
if (seconds >= MIN_RETRY_AFTER_SECONDS && seconds <= MAX_RETRY_AFTER_SECONDS) {
96+
return Optional.of(duration);
97+
}
98+
99+
return Optional.empty();
100+
} catch (DateTimeParseException e) {
101+
return Optional.empty();
102+
}
103+
}
104+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* OpenFGA
3+
* A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar.
4+
*
5+
* The version of the OpenAPI document: 1.x
6+
* Contact: community@openfga.dev
7+
*
8+
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
9+
* https://openapi-generator.tech
10+
* Do not edit the class manually.
11+
*/
12+
13+
package dev.openfga.sdk.util;
14+
15+
import dev.openfga.sdk.errors.HttpStatusCode;
16+
import java.net.http.HttpRequest;
17+
import java.time.Duration;
18+
import java.util.Optional;
19+
20+
21+
/**
22+
* Utility class for determining retry behavior based on HTTP methods and status codes.
23+
*
24+
* Implements the retry strategy specified in GitHub issue #155:
25+
* - For state-affecting operations (POST, PUT, PATCH, DELETE): Retry on 429s with fallback to exponential backoff,
26+
* retry on 5xxs only if Retry-After header is present
27+
* - For non-state-affecting operations (GET, HEAD, OPTIONS): Retry on all 429s and >=500 (except 501)
28+
*/
29+
public class RetryStrategy {
30+
31+
private RetryStrategy() {
32+
// Utility class - no instantiation
33+
}
34+
35+
/**
36+
* Determines if a request should be retried based on the HTTP method, status code, and presence of Retry-After header.
37+
*
38+
* Note: To maintain backward compatibility, all retryable status codes (429 and 5xx except 501) are retried
39+
* regardless of HTTP method. The Retry-After header is honored when present for delay calculation.
40+
*
41+
* @param request The HTTP request
42+
* @param statusCode The HTTP response status code
43+
* @param hasRetryAfterHeader Whether the response contains a valid Retry-After header
44+
* @return true if the request should be retried, false otherwise
45+
*/
46+
public static boolean shouldRetry(HttpRequest request, int statusCode, boolean hasRetryAfterHeader) {
47+
// Use the existing HttpStatusCode.isRetryable() logic to maintain backward compatibility
48+
return HttpStatusCode.isRetryable(statusCode);
49+
}
50+
51+
/**
52+
* Calculates the appropriate retry delay based on the presence of Retry-After header and retry count.
53+
*
54+
* @param retryAfterDelay Optional delay from Retry-After header
55+
* @param retryCount Current retry attempt (0-based)
56+
* @return Duration representing the delay before the next retry
57+
*/
58+
public static Duration calculateRetryDelay(Optional<Duration> retryAfterDelay, int retryCount) {
59+
// If Retry-After header is present and valid, use it
60+
if (retryAfterDelay.isPresent()) {
61+
return retryAfterDelay.get();
62+
}
63+
64+
// Otherwise, use exponential backoff with jitter
65+
return ExponentialBackoff.calculateDelay(retryCount);
66+
}
67+
}

0 commit comments

Comments
 (0)