Skip to content

Commit 0859f7e

Browse files
jimmyjamescoderabbitai[bot]rhamzeh
authored
feat: improved retry handling (#186)
* feat: initial retry-after support * update to make breaking changes as required * update README * default min delay is 100ms, not 500ms * fix default retry count * updated docs * docs: updated README * docs: fixed comments * Update CHANGELOG.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor: remove dup'd code * limit visibilty * update retry based on refined requirements * handle network errors, cleanup * review: remove intelligent from description * review: add test for network error * fix: remove faulty test * review: use constant for retry testing * review: fix inaccurate comment * review: remove unused params * fix: enforce min retry delay * fix: retryDelay config validation and default value * fix: no null minRetry value allowed * fix: larger test tolerance * fix: linter * test debugging output * increased test tolerance * minimum retry delay config should be used as the base delay * lint fix * Update src/main/java/dev/openfga/sdk/util/RetryStrategy.java Co-authored-by: Raghd Hamzeh <raghd.hamzeh@auth0.com> * Update src/main/java/dev/openfga/sdk/api/configuration/Configuration.java Co-authored-by: Raghd Hamzeh <raghd.hamzeh@auth0.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Raghd Hamzeh <raghd.hamzeh@auth0.com>
1 parent b3ccf6f commit 0859f7e

16 files changed

Lines changed: 1856 additions & 52 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
## [Unreleased](https://github.com/openfga/java-sdk/compare/v0.8.3...HEAD)
44

5+
### Added
6+
- feat: RFC 9110 compliant Retry-After header support with exponential backoff and jitter
7+
- feat: Enhanced retry strategy with delay calculation
8+
- feat: Retry-After header value exposed in error objects for better observability
9+
- feat: Input validation for Configuration.minimumRetryDelay() to prevent negative values
10+
- feat: Default value (100ms) now explicitly set for minimumRetryDelay configuration
11+
12+
### Changed
13+
- **BREAKING**: Maximum allowable retry count is now enforced at 15 (default remains 3)
14+
- **BREAKING**: FgaError now exposes Retry-After header value via getRetryAfterHeader() method
15+
- **BREAKING**: Configuration.minimumRetryDelay() now requires non-null values and validates input, throwing IllegalArgumentException for null or negative values
16+
17+
### Technical Details
18+
- Implements RFC 9110 compliant Retry-After header parsing (supports both integer seconds and HTTP-date formats)
19+
- Adds exponential backoff with jitter (base delay: 2^retryCount * 100ms, capped at 120 seconds)
20+
- Validates Retry-After values between 1-1800 seconds (30 minutes maximum)
21+
- Prioritizes Retry-After header delays over exponential backoff when present
22+
- Unified retry behavior: All requests retry on 429s and 5xx errors (except 501 Not Implemented)
23+
24+
**Migration Guide**:
25+
- Update error handling code if using FgaError properties - new getRetryAfterHeader() method available
26+
- Note: Maximum allowable retries is now enforced at 15 (validation added to prevent exceeding this limit)
27+
- **IMPORTANT**: Configuration.minimumRetryDelay() now requires non-null values and validates input - ensure you're not passing null or negative Duration values, as this will now throw IllegalArgumentException. Previously null values were silently accepted and would fall back to default behavior at runtime.
28+
529
## v0.8.3
630

731
### [0.8.3](https://github.com/openfga/java-sdk/compare/v0.8.2...v0.8.3) (2025-07-15)

README.md

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ libraryDependencies += "dev.openfga" % "openfga-sdk" % "0.8.3"
125125

126126
We strongly recommend you initialize the `OpenFgaClient` only once and then re-use it throughout your app, otherwise you will incur the cost of having to re-initialize multiple times or at every request, the cost of reduced connection pooling and re-use, and would be particularly costly in the client credentials flow, as that flow will be preformed on every request.
127127

128-
> The `Client` will by default retry API requests up to 3 times on 429 and 5xx errors.
128+
> The `Client` will by default retry API requests up to 3 times. Rate limiting (429) errors are always retried. Server errors (5xx) are retried for all operations, with delay calculation using `Retry-After` headers when provided or exponential backoff as fallback.
129129
130130
#### No Credentials
131131

@@ -714,7 +714,7 @@ response.getResult() = [{
714714
715715
If you are using an OpenFGA version less than 1.8.0, you can use `clientBatchCheck`,
716716
which calls `check` in parallel. It will return `allowed: false` if it encounters an error, and will return the error in the body.
717-
If 429s or 5xxs are encountered, the underlying check will retry up to 3 times before giving up.
717+
If 429s are encountered, the underlying check will retry up to 3 times. For 5xx errors, all requests will retry with delay calculation using `Retry-After` headers when provided or exponential backoff as fallback.
718718
719719
```
720720
var request = List.of(
@@ -965,9 +965,28 @@ fgaClient.writeAssertions(assertions, options).get();
965965
966966
### Retries
967967
968-
If a network request fails with a 429 or 5xx error from the server, the SDK will automatically retry the request up to 3 times with a minimum wait time of 100 milliseconds between each attempt.
968+
The SDK implements RFC 9110 compliant retry behavior with support for the `Retry-After` header. By default, the SDK will automatically retry failed requests up to **3 times** with delay calculation (maximum allowable: 15 retries).
969969
970-
To customize this behavior, call `maxRetries` and `minimumRetryDelay` on the `ClientConfiguration` builder. `maxRetries` determines the maximum number of retries (up to 15), while `minimumRetryDelay` sets the minimum wait time between retries in milliseconds.
970+
#### Retry Behavior
971+
972+
**Rate Limiting (429 errors):** Always retried regardless of HTTP method.
973+
974+
**Server Errors (5xx):** All requests are retried on 5xx errors (except 501 Not Implemented) regardless of HTTP method:
975+
- **All operations** (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS): Always retried on 5xx errors with delay calculation
976+
977+
#### Delay Calculation
978+
979+
1. **Retry-After header present**: Uses the server-specified delay (supports both integer seconds and HTTP-date formats)
980+
2. **No Retry-After header**: Uses exponential backoff with jitter (base delay: 2^retryCount * 100ms, capped at 120 seconds)
981+
3. **Minimum delay**: Respects the configured `minimumRetryDelay` as a floor value
982+
983+
#### Configuration
984+
985+
Customize retry behavior using the `ClientConfiguration` builder. The SDK enforces a maximum of 15 retries to prevent accidental server overload:
986+
987+
**⚠️ Breaking Changes:**
988+
- Configuration validation now prevents setting `maxRetries` above 15
989+
- `FgaError` now exposes the `Retry-After` header value via `getRetryAfterHeader()`
971990
972991
```java
973992
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -981,15 +1000,37 @@ public class Example {
9811000
.apiUrl(System.getenv("FGA_API_URL")) // If not specified, will default to "http://localhost:8080"
9821001
.storeId(System.getenv("FGA_STORE_ID")) // Not required when calling createStore() or listStores()
9831002
.authorizationModelId(System.getenv("FGA_MODEL_ID")) // Optional, can be overridden per request
984-
.maxRetries(3) // retry up to 3 times on API requests
985-
.minimumRetryDelay(250); // wait a minimum of 250 milliseconds between requests
1003+
.maxRetries(3) // retry up to 3 times on API requests (default: 3, maximum: 15)
1004+
.minimumRetryDelay(100); // minimum wait time between retries in milliseconds (default: 100ms)
9861005

9871006
var fgaClient = new OpenFgaClient(config);
9881007
var response = fgaClient.readAuthorizationModels().get();
9891008
}
9901009
}
9911010
```
9921011

1012+
#### Error Handling with Retry Information
1013+
1014+
When handling errors, you can access the `Retry-After` header value for debugging or custom retry logic:
1015+
1016+
```java
1017+
try {
1018+
var response = fgaClient.check(request).get();
1019+
} catch (ExecutionException e) {
1020+
if (e.getCause() instanceof FgaError) {
1021+
FgaError error = (FgaError) e.getCause();
1022+
1023+
// Access Retry-After header if present
1024+
String retryAfter = error.getRetryAfterHeader();
1025+
if (retryAfter != null) {
1026+
System.out.println("Server requested retry after: " + retryAfter + " seconds");
1027+
}
1028+
1029+
System.out.println("Error: " + error.getMessage());
1030+
}
1031+
}
1032+
```
1033+
9931034
### API Endpoints
9941035

9951036
| Method | HTTP request | Description |

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

Lines changed: 81 additions & 33 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;
@@ -92,49 +94,92 @@ private CompletableFuture<ApiResponse<T>> attemptHttpRequest(
9294
HttpClient httpClient, int retryNumber, Throwable previousError) {
9395
return httpClient
9496
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
95-
.thenCompose(response -> {
96-
Optional<FgaError> fgaError =
97-
FgaError.getError(name, request, configuration, response, previousError);
97+
.handle((response, throwable) -> {
98+
if (throwable != null) {
99+
// Handle network errors (no HTTP response received)
100+
return handleNetworkError(throwable, retryNumber);
101+
}
102+
// No network error, proceed with normal HTTP response handling
103+
return processHttpResponse(response, retryNumber, previousError);
104+
})
105+
.thenCompose(future -> future);
106+
}
98107

99-
if (fgaError.isPresent()) {
100-
FgaError error = fgaError.get();
108+
private CompletableFuture<ApiResponse<T>> handleNetworkError(Throwable throwable, int retryNumber) {
109+
if (retryNumber < configuration.getMaxRetries()) {
110+
// Network errors should be retried with exponential backoff (no Retry-After header available)
111+
Duration retryDelay = RetryStrategy.calculateRetryDelay(
112+
Optional.empty(), retryNumber, configuration.getMinimumRetryDelay());
113+
114+
// Add telemetry for network error retry
115+
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber + 1));
116+
117+
// Create delayed client and retry asynchronously without blocking
118+
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
119+
return attemptHttpRequest(delayingClient, retryNumber + 1, throwable);
120+
} else {
121+
// Max retries exceeded, fail with the network error
122+
return CompletableFuture.failedFuture(new ApiException(throwable));
123+
}
124+
}
101125

102-
if (HttpStatusCode.isRetryable(error.getStatusCode())
103-
&& retryNumber < configuration.getMaxRetries()) {
126+
private CompletableFuture<ApiResponse<T>> handleHttpErrorRetry(
127+
Optional<Duration> retryAfterDelay, int retryNumber, FgaError error) {
128+
// Calculate appropriate delay
129+
Duration retryDelay =
130+
RetryStrategy.calculateRetryDelay(retryAfterDelay, retryNumber, configuration.getMinimumRetryDelay());
104131

105-
HttpClient delayingClient = getDelayedHttpClient();
132+
// Create delayed client and retry asynchronously without blocking
133+
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
134+
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
135+
}
106136

107-
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
108-
}
137+
private CompletableFuture<ApiResponse<T>> processHttpResponse(
138+
HttpResponse<String> response, int retryNumber, Throwable previousError) {
139+
Optional<FgaError> fgaError = FgaError.getError(name, request, configuration, response, previousError);
109140

110-
return CompletableFuture.failedFuture(error);
111-
}
141+
if (fgaError.isPresent()) {
142+
FgaError error = fgaError.get();
143+
int statusCode = error.getStatusCode();
112144

113-
addTelemetryAttributes(Attributes.fromHttpResponse(response, this.configuration.getCredentials()));
145+
if (retryNumber < configuration.getMaxRetries()) {
146+
// Parse Retry-After header if present
147+
Optional<Duration> retryAfterDelay =
148+
response.headers().firstValue("Retry-After").flatMap(RetryAfterHeaderParser::parseRetryAfter);
114149

115-
if (retryNumber > 0) {
116-
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber));
117-
}
150+
// Check if we should retry based on the new strategy
151+
if (RetryStrategy.shouldRetry(statusCode)) {
152+
return handleHttpErrorRetry(retryAfterDelay, retryNumber, error);
153+
} else {
154+
}
155+
}
118156

119-
if (response.headers().firstValue("fga-query-duration-ms").isPresent()) {
120-
String queryDuration = response.headers()
121-
.firstValue("fga-query-duration-ms")
122-
.orElse(null);
157+
return CompletableFuture.failedFuture(error);
158+
}
123159

124-
if (!isNullOrWhitespace(queryDuration)) {
125-
double queryDurationDouble = Double.parseDouble(queryDuration);
126-
telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes());
127-
}
128-
}
160+
addTelemetryAttributes(Attributes.fromHttpResponse(response, this.configuration.getCredentials()));
161+
162+
if (retryNumber > 0) {
163+
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber));
164+
}
129165

130-
Double requestDuration = (double) (System.currentTimeMillis() - requestStarted);
166+
if (response.headers().firstValue("fga-query-duration-ms").isPresent()) {
167+
String queryDuration =
168+
response.headers().firstValue("fga-query-duration-ms").orElse(null);
131169

132-
telemetry.metrics().requestDuration(requestDuration, this.getTelemetryAttributes());
170+
if (!isNullOrWhitespace(queryDuration)) {
171+
double queryDurationDouble = Double.parseDouble(queryDuration);
172+
telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes());
173+
}
174+
}
133175

134-
return deserializeResponse(response)
135-
.thenApply(modeledResponse -> new ApiResponse<>(
136-
response.statusCode(), response.headers().map(), response.body(), modeledResponse));
137-
});
176+
Double requestDuration = (double) (System.currentTimeMillis() - requestStarted);
177+
178+
telemetry.metrics().requestDuration(requestDuration, this.getTelemetryAttributes());
179+
180+
return deserializeResponse(response)
181+
.thenApply(modeledResponse -> new ApiResponse<>(
182+
response.statusCode(), response.headers().map(), response.body(), modeledResponse));
138183
}
139184

140185
private CompletableFuture<T> deserializeResponse(HttpResponse<String> response) {
@@ -151,8 +196,11 @@ private CompletableFuture<T> deserializeResponse(HttpResponse<String> response)
151196
}
152197
}
153198

154-
private HttpClient getDelayedHttpClient() {
155-
Duration retryDelay = configuration.getMinimumRetryDelay();
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+
}
156204

157205
return apiClient
158206
.getHttpClientBuilder()

src/main/java/dev/openfga/sdk/api/configuration/Configuration.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ public class Configuration implements BaseConfiguration {
3636
private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.8.3";
3737
private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(10);
3838
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
39+
private static final int DEFAULT_MAX_RETRIES = 3;
40+
private static final int MAX_ALLOWABLE_RETRIES = 15;
41+
42+
/**
43+
* Default minimum retry delay of 100ms.
44+
* This value is used as the default base delay for exponential backoff calculations.
45+
*/
46+
public static final Duration DEFAULT_MINIMUM_RETRY_DELAY = Duration.ofMillis(100);
3947

4048
private String apiUrl;
4149
private Credentials credentials;
@@ -52,6 +60,8 @@ public Configuration() {
5260
this.userAgent = DEFAULT_USER_AGENT;
5361
this.readTimeout = DEFAULT_READ_TIMEOUT;
5462
this.connectTimeout = DEFAULT_CONNECT_TIMEOUT;
63+
this.maxRetries = DEFAULT_MAX_RETRIES;
64+
this.minimumRetryDelay = DEFAULT_MINIMUM_RETRY_DELAY;
5565
}
5666

5767
/**
@@ -265,6 +275,13 @@ public Duration getConnectTimeout() {
265275
}
266276

267277
public Configuration maxRetries(int maxRetries) {
278+
if (maxRetries < 0) {
279+
throw new IllegalArgumentException("maxRetries must be non-negative");
280+
}
281+
if (maxRetries > MAX_ALLOWABLE_RETRIES) {
282+
throw new IllegalArgumentException(
283+
"maxRetries cannot exceed " + MAX_ALLOWABLE_RETRIES + " (maximum allowable retries)");
284+
}
268285
this.maxRetries = maxRetries;
269286
return this;
270287
}
@@ -274,11 +291,29 @@ public Integer getMaxRetries() {
274291
return maxRetries;
275292
}
276293

294+
/**
295+
* Sets the minimum delay to wait before retrying a failed request.
296+
*
297+
* @param minimumRetryDelay The minimum delay. Must be non-null and non-negative.
298+
* @return This Configuration instance for method chaining.
299+
* @throws IllegalArgumentException if minimumRetryDelay is null or negative.
300+
*/
277301
public Configuration minimumRetryDelay(Duration minimumRetryDelay) {
302+
if (minimumRetryDelay == null) {
303+
throw new IllegalArgumentException("minimumRetryDelay cannot be null");
304+
}
305+
if (minimumRetryDelay.isNegative()) {
306+
throw new IllegalArgumentException("minimumRetryDelay cannot be negative");
307+
}
278308
this.minimumRetryDelay = minimumRetryDelay;
279309
return this;
280310
}
281311

312+
/**
313+
* Gets the minimum delay to wait before retrying a failed request.
314+
*
315+
* @return The minimum retry delay. Never null.
316+
*/
282317
@Override
283318
public Duration getMinimumRetryDelay() {
284319
return minimumRetryDelay;

src/main/java/dev/openfga/sdk/errors/FgaError.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class FgaError extends ApiException {
1717
private String grantType = null;
1818
private String requestId = null;
1919
private String apiErrorCode = null;
20+
private String retryAfterHeader = null;
2021

2122
public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) {
2223
super(message, cause, code, responseHeaders, responseBody);
@@ -60,6 +61,12 @@ public static Optional<FgaError> getError(
6061
error.setMethod(request.method());
6162
error.setRequestUrl(configuration.getApiUrl());
6263

64+
// Extract and set Retry-After header if present
65+
Optional<String> retryAfter = headers.firstValue("Retry-After");
66+
if (retryAfter.isPresent()) {
67+
error.setRetryAfterHeader(retryAfter.get());
68+
}
69+
6370
var credentials = configuration.getCredentials();
6471
if (CredentialsMethod.CLIENT_CREDENTIALS == credentials.getCredentialsMethod()) {
6572
var clientCredentials = credentials.getClientCredentials();
@@ -126,4 +133,12 @@ public void setApiErrorCode(String apiErrorCode) {
126133
public String getApiErrorCode() {
127134
return apiErrorCode;
128135
}
136+
137+
public void setRetryAfterHeader(String retryAfterHeader) {
138+
this.retryAfterHeader = retryAfterHeader;
139+
}
140+
141+
public String getRetryAfterHeader() {
142+
return retryAfterHeader;
143+
}
129144
}

0 commit comments

Comments
 (0)