Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eb543ff
feat: initial retry-after support
jimmyjames Jul 23, 2025
0f1b809
update to make breaking changes as required
jimmyjames Jul 23, 2025
f7ddfa6
update README
jimmyjames Jul 23, 2025
43276f6
default min delay is 100ms, not 500ms
jimmyjames Jul 24, 2025
b87abb0
fix default retry count
jimmyjames Jul 24, 2025
d3ccdaa
updated docs
jimmyjames Jul 28, 2025
f6664c4
docs: updated README
jimmyjames Jul 28, 2025
7db669d
docs: fixed comments
jimmyjames Jul 28, 2025
1a4a752
Update CHANGELOG.md
jimmyjames Jul 28, 2025
fe894cc
refactor: remove dup'd code
jimmyjames Jul 28, 2025
355008e
limit visibilty
jimmyjames Jul 30, 2025
1d94c30
update retry based on refined requirements
jimmyjames Jul 30, 2025
37d23a1
handle network errors, cleanup
jimmyjames Aug 1, 2025
1620505
review: remove intelligent from description
jimmyjames Aug 4, 2025
e52cf6b
review: add test for network error
jimmyjames Aug 4, 2025
1504e1e
fix: remove faulty test
jimmyjames Aug 4, 2025
5d37e75
review: use constant for retry testing
jimmyjames Aug 4, 2025
7563a96
review: fix inaccurate comment
jimmyjames Aug 4, 2025
d9d7fdb
review: remove unused params
jimmyjames Aug 4, 2025
163314f
fix: enforce min retry delay
jimmyjames Aug 5, 2025
f4096a3
fix: retryDelay config validation and default value
jimmyjames Aug 5, 2025
aeecfa6
fix: no null minRetry value allowed
jimmyjames Aug 5, 2025
2af1d07
fix: larger test tolerance
jimmyjames Aug 5, 2025
32ac670
fix: linter
jimmyjames Aug 5, 2025
4b7215f
test debugging output
jimmyjames Aug 5, 2025
f04d224
increased test tolerance
jimmyjames Aug 5, 2025
57bfd19
minimum retry delay config should be used as the base delay
jimmyjames Aug 6, 2025
c242df1
lint fix
jimmyjames Aug 7, 2025
e4b0e97
Update src/main/java/dev/openfga/sdk/util/RetryStrategy.java
jimmyjames Aug 7, 2025
03062c2
Update src/main/java/dev/openfga/sdk/api/configuration/Configuration.…
jimmyjames Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

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

### Added
- feat: RFC 9110 compliant Retry-After header support with exponential backoff and jitter
- feat: Enhanced retry strategy with delay calculation
- feat: Retry-After header value exposed in error objects for better observability
- feat: Input validation for Configuration.minimumRetryDelay() to prevent negative values
- feat: Default value (100ms) now explicitly set for minimumRetryDelay configuration

### Changed
- **BREAKING**: Maximum allowable retry count is now enforced at 15 (default remains 3)
- **BREAKING**: FgaError now exposes Retry-After header value via getRetryAfterHeader() method
- **BREAKING**: Configuration.minimumRetryDelay() now requires non-null values and validates input, throwing IllegalArgumentException for null or negative values

### Technical Details
- Implements RFC 9110 compliant Retry-After header parsing (supports both integer seconds and HTTP-date formats)
- Adds exponential backoff with jitter (base delay: 2^retryCount * 100ms, capped at 120 seconds)
- Validates Retry-After values between 1-1800 seconds (30 minutes maximum)
- Prioritizes Retry-After header delays over exponential backoff when present
- Unified retry behavior: All requests retry on 429s and 5xx errors (except 501 Not Implemented)

**Migration Guide**:
- Update error handling code if using FgaError properties - new getRetryAfterHeader() method available
- Note: Maximum allowable retries is now enforced at 15 (validation added to prevent exceeding this limit)
- **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.

## v0.8.3

### [0.8.3](https://github.com/openfga/java-sdk/compare/v0.8.2...v0.8.3) (2025-07-15)
Expand Down
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ libraryDependencies += "dev.openfga" % "openfga-sdk" % "0.8.3"

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.

> The `Client` will by default retry API requests up to 3 times on 429 and 5xx errors.
> 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.

#### No Credentials

Expand Down Expand Up @@ -714,7 +714,7 @@ response.getResult() = [{

If you are using an OpenFGA version less than 1.8.0, you can use `clientBatchCheck`,
which calls `check` in parallel. It will return `allowed: false` if it encounters an error, and will return the error in the body.
If 429s or 5xxs are encountered, the underlying check will retry up to 3 times before giving up.
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.

```
var request = List.of(
Expand Down Expand Up @@ -965,9 +965,28 @@ fgaClient.writeAssertions(assertions, options).get();

### Retries

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.
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).

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.
#### Retry Behavior

**Rate Limiting (429 errors):** Always retried regardless of HTTP method.

**Server Errors (5xx):** All requests are retried on 5xx errors (except 501 Not Implemented) regardless of HTTP method:
- **All operations** (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS): Always retried on 5xx errors with delay calculation

#### Delay Calculation

1. **Retry-After header present**: Uses the server-specified delay (supports both integer seconds and HTTP-date formats)
2. **No Retry-After header**: Uses exponential backoff with jitter (base delay: 2^retryCount * 100ms, capped at 120 seconds)
3. **Minimum delay**: Respects the configured `minimumRetryDelay` as a floor value

#### Configuration

Customize retry behavior using the `ClientConfiguration` builder. The SDK enforces a maximum of 15 retries to prevent accidental server overload:

**⚠️ Breaking Changes:**
- Configuration validation now prevents setting `maxRetries` above 15
- `FgaError` now exposes the `Retry-After` header value via `getRetryAfterHeader()`

```java
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -981,15 +1000,37 @@ public class Example {
.apiUrl(System.getenv("FGA_API_URL")) // If not specified, will default to "http://localhost:8080"
.storeId(System.getenv("FGA_STORE_ID")) // Not required when calling createStore() or listStores()
.authorizationModelId(System.getenv("FGA_MODEL_ID")) // Optional, can be overridden per request
.maxRetries(3) // retry up to 3 times on API requests
.minimumRetryDelay(250); // wait a minimum of 250 milliseconds between requests
.maxRetries(3) // retry up to 3 times on API requests (default: 3, maximum: 15)
.minimumRetryDelay(100); // minimum wait time between retries in milliseconds (default: 100ms)

var fgaClient = new OpenFgaClient(config);
var response = fgaClient.readAuthorizationModels().get();
}
}
```

#### Error Handling with Retry Information

When handling errors, you can access the `Retry-After` header value for debugging or custom retry logic:

```java
try {
var response = fgaClient.check(request).get();
} catch (ExecutionException e) {
if (e.getCause() instanceof FgaError) {
FgaError error = (FgaError) e.getCause();

// Access Retry-After header if present
String retryAfter = error.getRetryAfterHeader();
if (retryAfter != null) {
System.out.println("Server requested retry after: " + retryAfter + " seconds");
}

System.out.println("Error: " + error.getMessage());
}
}
```

### API Endpoints

| Method | HTTP request | Description |
Expand Down
114 changes: 81 additions & 33 deletions src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import dev.openfga.sdk.telemetry.Attribute;
import dev.openfga.sdk.telemetry.Attributes;
import dev.openfga.sdk.telemetry.Telemetry;
import dev.openfga.sdk.util.RetryAfterHeaderParser;
import dev.openfga.sdk.util.RetryStrategy;
import java.io.IOException;
import java.io.PrintStream;
import java.net.http.HttpClient;
Expand Down Expand Up @@ -92,49 +94,92 @@ private CompletableFuture<ApiResponse<T>> attemptHttpRequest(
HttpClient httpClient, int retryNumber, Throwable previousError) {
return httpClient
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenCompose(response -> {
Optional<FgaError> fgaError =
FgaError.getError(name, request, configuration, response, previousError);
.handle((response, throwable) -> {
if (throwable != null) {
// Handle network errors (no HTTP response received)
return handleNetworkError(throwable, retryNumber);
}
// No network error, proceed with normal HTTP response handling
return processHttpResponse(response, retryNumber, previousError);
})
.thenCompose(future -> future);
}

if (fgaError.isPresent()) {
FgaError error = fgaError.get();
private CompletableFuture<ApiResponse<T>> handleNetworkError(Throwable throwable, int retryNumber) {
if (retryNumber < configuration.getMaxRetries()) {
// Network errors should be retried with exponential backoff (no Retry-After header available)
Duration retryDelay = RetryStrategy.calculateRetryDelay(
Optional.empty(), retryNumber, configuration.getMinimumRetryDelay());

// Add telemetry for network error retry
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber + 1));

// Create delayed client and retry asynchronously without blocking
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
return attemptHttpRequest(delayingClient, retryNumber + 1, throwable);
} else {
// Max retries exceeded, fail with the network error
return CompletableFuture.failedFuture(new ApiException(throwable));
}
}

if (HttpStatusCode.isRetryable(error.getStatusCode())
&& retryNumber < configuration.getMaxRetries()) {
private CompletableFuture<ApiResponse<T>> handleHttpErrorRetry(
Optional<Duration> retryAfterDelay, int retryNumber, FgaError error) {
// Calculate appropriate delay
Duration retryDelay =
RetryStrategy.calculateRetryDelay(retryAfterDelay, retryNumber, configuration.getMinimumRetryDelay());

HttpClient delayingClient = getDelayedHttpClient();
// Create delayed client and retry asynchronously without blocking
HttpClient delayingClient = getDelayedHttpClient(retryDelay);
return attemptHttpRequest(delayingClient, retryNumber + 1, error);
}

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

return CompletableFuture.failedFuture(error);
}
if (fgaError.isPresent()) {
FgaError error = fgaError.get();
int statusCode = error.getStatusCode();

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

if (retryNumber > 0) {
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber));
}
// Check if we should retry based on the new strategy
if (RetryStrategy.shouldRetry(statusCode)) {
return handleHttpErrorRetry(retryAfterDelay, retryNumber, error);
} else {
}
}

if (response.headers().firstValue("fga-query-duration-ms").isPresent()) {
String queryDuration = response.headers()
.firstValue("fga-query-duration-ms")
.orElse(null);
return CompletableFuture.failedFuture(error);
}

if (!isNullOrWhitespace(queryDuration)) {
double queryDurationDouble = Double.parseDouble(queryDuration);
telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes());
}
}
addTelemetryAttributes(Attributes.fromHttpResponse(response, this.configuration.getCredentials()));

if (retryNumber > 0) {
addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber));
}

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

telemetry.metrics().requestDuration(requestDuration, this.getTelemetryAttributes());
if (!isNullOrWhitespace(queryDuration)) {
double queryDurationDouble = Double.parseDouble(queryDuration);
telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes());
}
}

return deserializeResponse(response)
.thenApply(modeledResponse -> new ApiResponse<>(
response.statusCode(), response.headers().map(), response.body(), modeledResponse));
});
Double requestDuration = (double) (System.currentTimeMillis() - requestStarted);

telemetry.metrics().requestDuration(requestDuration, this.getTelemetryAttributes());

return deserializeResponse(response)
.thenApply(modeledResponse -> new ApiResponse<>(
response.statusCode(), response.headers().map(), response.body(), modeledResponse));
}

private CompletableFuture<T> deserializeResponse(HttpResponse<String> response) {
Expand All @@ -151,8 +196,11 @@ private CompletableFuture<T> deserializeResponse(HttpResponse<String> response)
}
}

private HttpClient getDelayedHttpClient() {
Duration retryDelay = configuration.getMinimumRetryDelay();
private HttpClient getDelayedHttpClient(Duration retryDelay) {
if (retryDelay == null || retryDelay.isZero() || retryDelay.isNegative()) {
// Fallback to minimum retry delay if invalid
retryDelay = configuration.getMinimumRetryDelay();
}

return apiClient
.getHttpClientBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public class Configuration implements BaseConfiguration {
private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.8.3";
private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(10);
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final int DEFAULT_MAX_RETRIES = 3;
private static final int MAX_ALLOWABLE_RETRIES = 15;

/**
* Default minimum retry delay of 100ms.
* This value is used as the default base delay for exponential backoff calculations.
*/
public static final Duration DEFAULT_MINIMUM_RETRY_DELAY = Duration.ofMillis(100);

private String apiUrl;
private Credentials credentials;
Expand All @@ -52,6 +60,8 @@ public Configuration() {
this.userAgent = DEFAULT_USER_AGENT;
this.readTimeout = DEFAULT_READ_TIMEOUT;
this.connectTimeout = DEFAULT_CONNECT_TIMEOUT;
this.maxRetries = DEFAULT_MAX_RETRIES;
this.minimumRetryDelay = DEFAULT_MINIMUM_RETRY_DELAY;
}

/**
Expand Down Expand Up @@ -265,6 +275,13 @@ public Duration getConnectTimeout() {
}

public Configuration maxRetries(int maxRetries) {
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must be non-negative");
}
if (maxRetries > MAX_ALLOWABLE_RETRIES) {
throw new IllegalArgumentException(
"maxRetries cannot exceed " + MAX_ALLOWABLE_RETRIES + " (maximum allowable retries)");
}
this.maxRetries = maxRetries;
return this;
}
Expand All @@ -274,11 +291,29 @@ public Integer getMaxRetries() {
return maxRetries;
}

/**
* Sets the minimum delay to wait before retrying a failed request.
*
* @param minimumRetryDelay The minimum delay. Must be non-null and non-negative.
* @return This Configuration instance for method chaining.
* @throws IllegalArgumentException if minimumRetryDelay is null or negative.
*/
public Configuration minimumRetryDelay(Duration minimumRetryDelay) {
if (minimumRetryDelay == null) {
throw new IllegalArgumentException("minimumRetryDelay cannot be null");
}
if (minimumRetryDelay.isNegative()) {
throw new IllegalArgumentException("minimumRetryDelay cannot be negative");
}
this.minimumRetryDelay = minimumRetryDelay;
return this;
}

/**
* Gets the minimum delay to wait before retrying a failed request.
*
* @return The minimum retry delay. Never null.
*/
@Override
public Duration getMinimumRetryDelay() {
return minimumRetryDelay;
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/dev/openfga/sdk/errors/FgaError.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class FgaError extends ApiException {
private String grantType = null;
private String requestId = null;
private String apiErrorCode = null;
private String retryAfterHeader = null;

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

// Extract and set Retry-After header if present
Optional<String> retryAfter = headers.firstValue("Retry-After");
if (retryAfter.isPresent()) {
error.setRetryAfterHeader(retryAfter.get());
}

var credentials = configuration.getCredentials();
if (CredentialsMethod.CLIENT_CREDENTIALS == credentials.getCredentialsMethod()) {
var clientCredentials = credentials.getClientCredentials();
Expand Down Expand Up @@ -126,4 +133,12 @@ public void setApiErrorCode(String apiErrorCode) {
public String getApiErrorCode() {
return apiErrorCode;
}

public void setRetryAfterHeader(String retryAfterHeader) {
this.retryAfterHeader = retryAfterHeader;
}

public String getRetryAfterHeader() {
return retryAfterHeader;
}
}
Loading