Skip to content

Commit 5599fa2

Browse files
committed
update to make breaking changes as required
1 parent 5055de1 commit 5599fa2

9 files changed

Lines changed: 144 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
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 differentiated behavior for state-affecting operations
8+
- feat: Retry-After header value exposed in error objects for better observability
9+
10+
### Changed
11+
- **BREAKING**: Default maximum retry count increased from 3 to 15
12+
- **BREAKING**: State-affecting operations (POST, PUT, PATCH, DELETE) now only retry on 5xx errors when Retry-After header is present
13+
- **BREAKING**: FgaError now exposes Retry-After header value via getRetryAfterHeader() method
14+
15+
### Technical Details
16+
- Implements RFC 9110 compliant Retry-After header parsing (supports both integer seconds and HTTP-date formats)
17+
- Adds exponential backoff with jitter (base delay: 2^retryCount * 500ms, capped at 120 seconds)
18+
- Validates Retry-After values between 1-1800 seconds (30 minutes maximum)
19+
- Prioritizes Retry-After header delays over exponential backoff when present
20+
- Maintains backward compatibility for non-state-affecting operations (GET, HEAD, OPTIONS)
21+
22+
**Migration Guide**:
23+
- Review retry behavior for state-affecting operations - they now require Retry-After header for 5xx retries
24+
- Update error handling code if using FgaError properties - new getRetryAfterHeader() method available
25+
- Consider adjusting maxRetries configuration if relying on previous default of 3
26+
527
## v0.8.3
628

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ 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 = 15;
3940

4041
private String apiUrl;
4142
private Credentials credentials;
@@ -52,6 +53,7 @@ public Configuration() {
5253
this.userAgent = DEFAULT_USER_AGENT;
5354
this.readTimeout = DEFAULT_READ_TIMEOUT;
5455
this.connectTimeout = DEFAULT_CONNECT_TIMEOUT;
56+
this.maxRetries = DEFAULT_MAX_RETRIES;
5557
}
5658

5759
/**

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
}

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

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,36 +16,58 @@
1616
import java.net.http.HttpRequest;
1717
import java.time.Duration;
1818
import java.util.Optional;
19-
19+
import java.util.Set;
2020

2121
/**
2222
* Utility class for determining retry behavior based on HTTP methods and status codes.
2323
*
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)
24+
* Implements RFC 9110 compliant retry logic with differentiated behavior for state-affecting operations.
25+
* State-affecting operations (POST, PUT, PATCH, DELETE) are more conservative about retries on 5xx errors.
2826
*/
2927
public class RetryStrategy {
3028

29+
// HTTP methods that affect server state and should be more conservative about retries
30+
private static final Set<String> STATE_AFFECTING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
31+
3132
private RetryStrategy() {
3233
// Utility class - no instantiation
3334
}
3435

3536
/**
3637
* 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.
38+
*
39+
* Retry Logic:
40+
* - 429 (Too Many Requests): Always retry regardless of method
41+
* - 5xx errors (except 501):
42+
* - State-affecting operations: Only retry if Retry-After header is present
43+
* - Non-state-affecting operations: Always retry
44+
* - All other status codes: Do not retry
4045
*
4146
* @param request The HTTP request
4247
* @param statusCode The HTTP response status code
4348
* @param hasRetryAfterHeader Whether the response contains a valid Retry-After header
4449
* @return true if the request should be retried, false otherwise
4550
*/
4651
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);
52+
String method = request.method().toUpperCase();
53+
54+
// Always retry 429 (Too Many Requests) regardless of method
55+
if (statusCode == HttpStatusCode.TOO_MANY_REQUESTS) {
56+
return true;
57+
}
58+
59+
// For 5xx errors (except 501 Not Implemented)
60+
if (HttpStatusCode.isServerError(statusCode) && statusCode != HttpStatusCode.NOT_IMPLEMENTED) {
61+
if (isStateAffectingMethod(method)) {
62+
// For state-affecting operations: only retry 5xx if Retry-After header is present
63+
return hasRetryAfterHeader;
64+
} else {
65+
// For non-state-affecting operations: always retry 5xx
66+
return true;
67+
}
68+
}
69+
70+
return false;
4971
}
5072

5173
/**
@@ -64,4 +86,15 @@ public static Duration calculateRetryDelay(Optional<Duration> retryAfterDelay, i
6486
// Otherwise, use exponential backoff with jitter
6587
return ExponentialBackoff.calculateDelay(retryCount);
6688
}
89+
90+
/**
91+
* Determines if an HTTP method is state-affecting (modifies server state).
92+
* State-affecting methods should be more conservative about retries.
93+
*
94+
* @param method The HTTP method (case-insensitive)
95+
* @return true if the method is state-affecting, false otherwise
96+
*/
97+
private static boolean isStateAffectingMethod(String method) {
98+
return STATE_AFFECTING_METHODS.contains(method.toUpperCase());
99+
}
67100
}

src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ public void createStore_500() throws Exception {
267267
.get());
268268

269269
// Then
270-
mockHttpClient.verify().post("https://api.fga.example/stores").called(1 + DEFAULT_MAX_RETRIES);
270+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
271+
mockHttpClient.verify().post("https://api.fga.example/stores").called(1);
271272
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
272273
assertEquals(500, exception.getStatusCode());
273274
assertEquals(
@@ -448,7 +449,8 @@ public void deleteStore_500() throws Exception {
448449
.get());
449450

450451
// Then
451-
mockHttpClient.verify().delete(deleteUrl).called(1 + DEFAULT_MAX_RETRIES);
452+
// Breaking change: DELETE requests no longer retry on 5xx without Retry-After header
453+
mockHttpClient.verify().delete(deleteUrl).called(1);
452454
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
453455
assertEquals(500, exception.getStatusCode());
454456
assertEquals(
@@ -670,7 +672,8 @@ DEFAULT_STORE_ID, new WriteAuthorizationModelRequest())
670672
.get());
671673

672674
// Then
673-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
675+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
676+
mockHttpClient.verify().post(postUrl).called(1);
674677
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
675678
assertEquals(500, exception.getStatusCode());
676679
assertEquals(
@@ -1079,7 +1082,8 @@ public void read_500() throws Exception {
10791082
.get());
10801083

10811084
// Then
1082-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
1085+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
1086+
mockHttpClient.verify().post(postUrl).called(1);
10831087
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
10841088
assertEquals(500, exception.getStatusCode());
10851089
assertEquals(
@@ -1308,7 +1312,8 @@ public void write_500() throws Exception {
13081312
.get());
13091313

13101314
// Then
1311-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
1315+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
1316+
mockHttpClient.verify().post(postUrl).called(1);
13121317
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
13131318
assertEquals(500, exception.getStatusCode());
13141319
assertEquals(
@@ -1422,7 +1427,8 @@ public void check_500() throws Exception {
14221427
.get());
14231428

14241429
// Then
1425-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
1430+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
1431+
mockHttpClient.verify().post(postUrl).called(1);
14261432
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
14271433
assertEquals(500, exception.getStatusCode());
14281434
assertEquals(
@@ -1548,7 +1554,8 @@ public void expand_500() throws Exception {
15481554
.get());
15491555

15501556
// Then
1551-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
1557+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
1558+
mockHttpClient.verify().post(postUrl).called(1);
15521559
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
15531560
assertEquals(500, exception.getStatusCode());
15541561
assertEquals(
@@ -1662,7 +1669,8 @@ public void listObjects_500() throws Exception {
16621669
.get());
16631670

16641671
// Then
1665-
mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES);
1672+
// Breaking change: POST requests no longer retry on 5xx without Retry-After header
1673+
mockHttpClient.verify().post(postUrl).called(1);
16661674
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
16671675
assertEquals(500, exception.getStatusCode());
16681676
assertEquals(
@@ -1913,7 +1921,8 @@ DEFAULT_STORE_ID, DEFAULT_AUTH_MODEL_ID, new WriteAssertionsRequest())
19131921
.get());
19141922

19151923
// Then
1916-
mockHttpClient.verify().put(putUrl).called(1 + DEFAULT_MAX_RETRIES);
1924+
// Breaking change: PUT requests no longer retry on 5xx without Retry-After header
1925+
mockHttpClient.verify().put(putUrl).called(1);
19171926
var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause());
19181927
assertEquals(500, exception.getStatusCode());
19191928
assertEquals(

src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ public void exchangeOAuth2TokenWithRetriesSuccess(WireMockRuntimeInfo wm) throws
140140
.willReturn(jsonResponse("rate_limited", 429))
141141
.willSetStateTo("rate limited once"));
142142

143-
// Then return 500
143+
// Then return 500 with Retry-After header (breaking change: POST requests need Retry-After for 5xx retries)
144144
stubFor(post(urlEqualTo("/oauth/token"))
145145
.inScenario("retries")
146146
.whenScenarioStateIs("rate limited once")
147-
.willReturn(jsonResponse("rate_limited", 500))
147+
.willReturn(jsonResponse("rate_limited", 500).withHeader("Retry-After", "1"))
148148
.willSetStateTo("rate limited twice"));
149149

150150
// Finally return 200

src/test/java/dev/openfga/sdk/api/client/HttpRequestAttemptRetryTest.java

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,31 +125,29 @@ void shouldRetryWith500AndRetryAfterHeaderForGetRequest() throws Exception {
125125
}
126126

127127
@Test
128-
void shouldRetryWith500WithoutRetryAfterHeaderForPostRequest() throws Exception {
129-
// Given - backward compatibility: POST requests should retry on 5xx even without Retry-After
128+
void shouldNotRetryWith500WithoutRetryAfterHeaderForPostRequest() throws Exception {
129+
// Given - Breaking change: POST requests should NOT retry on 5xx without Retry-After
130130
wireMockServer.stubFor(post(urlEqualTo("/test"))
131-
.willReturn(aResponse()
132-
.withStatus(500)
133-
.withBody("{\"error\":\"server error\"}")));
131+
.willReturn(aResponse().withStatus(500).withBody("{\"error\":\"server error\"}")));
134132

135133
HttpRequest request = HttpRequest.newBuilder()
136134
.uri(java.net.URI.create("http://localhost:" + wireMockServer.port() + "/test"))
137135
.POST(HttpRequest.BodyPublishers.ofString("{}"))
138136
.build();
139137

140-
HttpRequestAttempt<Void> attempt = new HttpRequestAttempt<>(
141-
request, "test", Void.class, apiClient, configuration);
138+
HttpRequestAttempt<Void> attempt =
139+
new HttpRequestAttempt<>(request, "test", Void.class, apiClient, configuration);
142140

143141
// When & Then
144-
ExecutionException exception = assertThrows(ExecutionException.class,
145-
() -> attempt.attemptHttpRequest().get());
146-
142+
ExecutionException exception = assertThrows(
143+
ExecutionException.class, () -> attempt.attemptHttpRequest().get());
144+
147145
assertThat(exception.getCause()).isInstanceOf(FgaError.class);
148146
FgaError error = (FgaError) exception.getCause();
149147
assertThat(error.getStatusCode()).isEqualTo(500);
150-
151-
// Verify maxRetries + 1 requests were made (initial + 3 retries for backward compatibility)
152-
wireMockServer.verify(4, postRequestedFor(urlEqualTo("/test")));
148+
149+
// Verify only one request was made (no retry for state-affecting operations without Retry-After)
150+
wireMockServer.verify(1, postRequestedFor(urlEqualTo("/test")));
153151
}
154152

155153
@Test

0 commit comments

Comments
 (0)