Skip to content

Commit fa6845c

Browse files
SoulPancakeCopilot
andauthored
feat: Improve error messaging (#260)
* feat: improve errors, relay api err msg * feat: more tcs * feat: log the exception for json processing * feat: address copilot comments * feat: add changelog * feat: thorough tests and sync with rfc * feat: address comments and use consts * feat: apply changelog suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * feat: fallback to using resp body as err msg when parsing fails * fix: use unknown err code and msg raw resp --------- Co-authored-by: Copilot <[email protected]>
1 parent e45b1e4 commit fa6845c

File tree

4 files changed

+811
-2
lines changed

4 files changed

+811
-2
lines changed

CHANGELOG.md

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

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

5+
### Changed
6+
- Improved error handling and integration test coverage for FgaError and related classes. (#260)
7+
58
## v0.9.3
69

710
### [0.9.3](https://github.com/openfga/java-sdk/compare/v0.9.2...v0.9.3)) (2025-11-10)

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

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22

33
import static dev.openfga.sdk.errors.HttpStatusCode.*;
44

5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
import com.fasterxml.jackson.core.JsonProcessingException;
7+
import com.fasterxml.jackson.databind.DeserializationFeature;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import com.fasterxml.jackson.databind.SerializationFeature;
10+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
511
import dev.openfga.sdk.api.configuration.Configuration;
612
import dev.openfga.sdk.api.configuration.CredentialsMethod;
713
import dev.openfga.sdk.constants.FgaConstants;
814
import java.net.http.HttpHeaders;
915
import java.net.http.HttpRequest;
1016
import java.net.http.HttpResponse;
1117
import java.util.Optional;
18+
import org.openapitools.jackson.nullable.JsonNullableModule;
1219

1320
public class FgaError extends ApiException {
21+
private static final String UNKNOWN_ERROR_CODE = "unknown_error";
22+
1423
private String method = null;
1524
private String requestUrl = null;
1625
private String clientId = null;
@@ -19,6 +28,60 @@ public class FgaError extends ApiException {
1928
private String requestId = null;
2029
private String apiErrorCode = null;
2130
private String retryAfterHeader = null;
31+
private String apiErrorMessage = null;
32+
private String operationName = null;
33+
34+
private static final ObjectMapper OBJECT_MAPPER = createConfiguredObjectMapper();
35+
36+
private static ObjectMapper createConfiguredObjectMapper() {
37+
ObjectMapper mapper = new ObjectMapper();
38+
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
39+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
40+
mapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);
41+
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
42+
mapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
43+
mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
44+
mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE);
45+
mapper.registerModule(new JavaTimeModule());
46+
mapper.registerModule(new JsonNullableModule());
47+
return mapper;
48+
}
49+
50+
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
51+
private static class ApiErrorResponse {
52+
@com.fasterxml.jackson.annotation.JsonProperty("code")
53+
private String code;
54+
55+
@com.fasterxml.jackson.annotation.JsonProperty("message")
56+
private String message;
57+
58+
@com.fasterxml.jackson.annotation.JsonProperty("error")
59+
private String error;
60+
61+
public String getCode() {
62+
return code;
63+
}
64+
65+
public void setCode(String code) {
66+
this.code = code;
67+
}
68+
69+
public String getMessage() {
70+
return message != null ? message : error;
71+
}
72+
73+
public void setMessage(String message) {
74+
this.message = message;
75+
}
76+
77+
public String getError() {
78+
return error;
79+
}
80+
81+
public void setError(String error) {
82+
this.error = error;
83+
}
84+
}
2285

2386
public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) {
2487
super(message, cause, code, responseHeaders, responseBody);
@@ -53,7 +116,7 @@ public static Optional<FgaError> getError(
53116
error = new FgaApiNotFoundError(name, previousError, status, headers, body);
54117
} else if (status == TOO_MANY_REQUESTS) {
55118
error = new FgaApiRateLimitExceededError(name, previousError, status, headers, body);
56-
} else if (isServerError(status)) {
119+
} else if (HttpStatusCode.isServerError(status)) {
57120
error = new FgaApiInternalError(name, previousError, status, headers, body);
58121
} else {
59122
error = new FgaError(name, previousError, status, headers, body);
@@ -75,6 +138,27 @@ public static Optional<FgaError> getError(
75138
error.setAudience(clientCredentials.getApiAudience());
76139
}
77140

141+
error.setOperationName(name);
142+
143+
// Parse API error response
144+
if (body != null && !body.trim().isEmpty()) {
145+
try {
146+
ApiErrorResponse resp = OBJECT_MAPPER.readValue(body, ApiErrorResponse.class);
147+
error.setApiErrorCode(resp.getCode());
148+
error.setApiErrorMessage(resp.getMessage());
149+
} catch (JsonProcessingException e) {
150+
// Wrap unparseable response
151+
error.setApiErrorCode(UNKNOWN_ERROR_CODE);
152+
error.setApiErrorMessage("Unable to parse error response. Raw response: " + body);
153+
}
154+
}
155+
156+
// Extract requestId from headers
157+
Optional<String> requestIdOpt = headers.firstValue("x-request-id");
158+
if (requestIdOpt.isPresent()) {
159+
error.setRequestId(requestIdOpt.get());
160+
}
161+
78162
// Unknown error
79163
return Optional.of(error);
80164
}
@@ -142,4 +226,143 @@ public void setRetryAfterHeader(String retryAfterHeader) {
142226
public String getRetryAfterHeader() {
143227
return retryAfterHeader;
144228
}
229+
230+
public void setApiErrorMessage(String apiErrorMessage) {
231+
this.apiErrorMessage = apiErrorMessage;
232+
}
233+
234+
public String getApiErrorMessage() {
235+
return apiErrorMessage;
236+
}
237+
238+
public void setOperationName(String operationName) {
239+
this.operationName = operationName;
240+
}
241+
242+
public String getOperationName() {
243+
return operationName;
244+
}
245+
246+
/**
247+
* Returns a formatted error message for FgaError.
248+
* <p>
249+
* The message is formatted as:
250+
* <pre>
251+
* [operationName] HTTP statusCode apiErrorMessage (apiErrorCode) [request-id: requestId]
252+
* </pre>
253+
* Example: [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]
254+
* </p>
255+
*
256+
* @return the formatted error message string
257+
*/
258+
@Override
259+
public String getMessage() {
260+
// Use apiErrorMessage if available, otherwise fall back to the original message
261+
String message = (apiErrorMessage != null && !apiErrorMessage.isEmpty()) ? apiErrorMessage : super.getMessage();
262+
263+
StringBuilder sb = new StringBuilder();
264+
265+
// [operationName]
266+
if (operationName != null && !operationName.isEmpty()) {
267+
sb.append("[").append(operationName).append("] ");
268+
}
269+
270+
// HTTP 400
271+
sb.append("HTTP ").append(getStatusCode()).append(" ");
272+
273+
// type 'invalid_type' not found
274+
if (message != null && !message.isEmpty()) {
275+
sb.append(message);
276+
}
277+
278+
// (validation_error)
279+
if (apiErrorCode != null && !apiErrorCode.isEmpty()) {
280+
sb.append(" (").append(apiErrorCode).append(")");
281+
}
282+
283+
// [request-id: abc-123]
284+
if (requestId != null && !requestId.isEmpty()) {
285+
sb.append(" [request-id: ").append(requestId).append("]");
286+
}
287+
288+
return sb.toString().trim();
289+
}
290+
291+
// --- Helper Methods ---
292+
293+
/**
294+
* Checks if this is a validation error.
295+
* Reliable error type checking based on error code.
296+
*
297+
* @return true if this is a validation error
298+
*/
299+
public boolean isValidationError() {
300+
return "validation_error".equals(apiErrorCode);
301+
}
302+
303+
/**
304+
* Checks if this is an unknown error due to unparseable response.
305+
* This occurs when the error response could not be parsed as JSON.
306+
*
307+
* @return true if this is an unknown error
308+
*/
309+
public boolean isUnknownError() {
310+
return UNKNOWN_ERROR_CODE.equals(apiErrorCode);
311+
}
312+
313+
/**
314+
* Checks if this is a not found (404) error.
315+
*
316+
* @return true if this is a 404 error
317+
*/
318+
public boolean isNotFoundError() {
319+
return getStatusCode() == NOT_FOUND;
320+
}
321+
322+
/**
323+
* Checks if this is an authentication (401) error.
324+
*
325+
* @return true if this is a 401 error
326+
*/
327+
public boolean isAuthenticationError() {
328+
return getStatusCode() == UNAUTHORIZED;
329+
}
330+
331+
/**
332+
* Checks if this is a rate limit (429) error.
333+
*
334+
* @return true if this is a rate limit error
335+
*/
336+
public boolean isRateLimitError() {
337+
return getStatusCode() == TOO_MANY_REQUESTS || "rate_limit_exceeded".equals(apiErrorCode);
338+
}
339+
340+
/**
341+
* Checks if this error should be retried.
342+
* 429 (Rate Limit) and 5xx (Server Errors) are typically retryable.
343+
*
344+
* @return true if this error is retryable
345+
*/
346+
public boolean isRetryable() {
347+
return HttpStatusCode.isRetryable(getStatusCode());
348+
}
349+
350+
/**
351+
* Checks if this is a client error (4xx).
352+
*
353+
* @return true if this is a 4xx error
354+
*/
355+
public boolean isClientError() {
356+
int status = getStatusCode();
357+
return status >= 400 && status < 500;
358+
}
359+
360+
/**
361+
* Checks if this is a server error (5xx).
362+
*
363+
* @return true if this is a 5xx error
364+
*/
365+
public boolean isServerError() {
366+
return HttpStatusCode.isServerError(getStatusCode());
367+
}
145368
}

0 commit comments

Comments
 (0)