Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

- Improved error handling and integration test coverage for FgaError and related classes. (#260)

## v0.9.3

### [0.9.3](https://github.com/openfga/java-sdk/compare/v0.9.2...v0.9.3)) (2025-11-10)
Expand Down
196 changes: 195 additions & 1 deletion src/main/java/dev/openfga/sdk/errors/FgaError.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.openfga.sdk.api.configuration.Configuration;
import dev.openfga.sdk.api.configuration.CredentialsMethod;
import dev.openfga.sdk.constants.FgaConstants;
Expand All @@ -19,6 +21,46 @@ public class FgaError extends ApiException {
private String requestId = null;
private String apiErrorCode = null;
private String retryAfterHeader = null;
private String apiErrorMessage = null;
private String operationName = null;

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public static class ApiErrorResponse {
@com.fasterxml.jackson.annotation.JsonProperty("code")
private String code;

@com.fasterxml.jackson.annotation.JsonProperty("message")
private String message;

@com.fasterxml.jackson.annotation.JsonProperty("error")
private String error;

public String getCode() {
return code;
}

public void setCode(String code) {
this.code = code;
}

public String getMessage() {
return message != null ? message : error;
}

public void setMessage(String message) {
this.message = message;
}

public String getError() {
return error;
}

public void setError(String error) {
this.error = error;
}
}

public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) {
super(message, cause, code, responseHeaders, responseBody);
Expand Down Expand Up @@ -53,7 +95,7 @@ public static Optional<FgaError> getError(
error = new FgaApiNotFoundError(name, previousError, status, headers, body);
} else if (status == TOO_MANY_REQUESTS) {
error = new FgaApiRateLimitExceededError(name, previousError, status, headers, body);
} else if (isServerError(status)) {
} else if (HttpStatusCode.isServerError(status)) {
error = new FgaApiInternalError(name, previousError, status, headers, body);
} else {
error = new FgaError(name, previousError, status, headers, body);
Expand All @@ -75,6 +117,26 @@ public static Optional<FgaError> getError(
error.setAudience(clientCredentials.getApiAudience());
}

error.setOperationName(name);

// Parse API error response
if (body != null && !body.trim().isEmpty()) {
try {
ApiErrorResponse resp = OBJECT_MAPPER.readValue(body, ApiErrorResponse.class);
error.setApiErrorCode(resp.getCode());
error.setApiErrorMessage(resp.getMessage());
} catch (JsonProcessingException e) {
// Fall back, do nothing - log the exception for debugging
System.err.println("Failed to parse API error response JSON: " + e.getMessage());
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using System.err.println() for error logging in production code is not a best practice. Consider using a proper logging framework (like SLF4J) or, if intentional for debugging purposes, consider whether this should be silently swallowed instead. Library code should avoid writing directly to stderr as it interferes with application-level logging configuration.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's not a good idea to introduce a logging framework dependency within the SDK
I wonder if we should even log this but we shouldn't swallow exceptions either
@jimmyjames Would like your thoughts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a logger but not with this PR

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any problem with silently swallowing the error given that we should fallback to what the current behaviour is?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any problem with silently swallowing the error given that we should fallback to what the current behaviour is?

There isn't but I was thinking more from a debugging perspective, But I guess it's not really required to log to do that when we need to.

Comment on lines +148 to +149
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using System.err.println() for error logging is not suitable for production code. Consider using a proper logging framework (e.g., SLF4J) or removing the statement entirely if silent fallback is acceptable. Console output can interfere with application logs and is not configurable.

Copilot uses AI. Check for mistakes.
}
}

// Extract requestId from headers
Optional<String> requestIdOpt = headers.firstValue("x-request-id");
if (requestIdOpt.isPresent()) {
error.setRequestId(requestIdOpt.get());
}

// Unknown error
return Optional.of(error);
}
Expand Down Expand Up @@ -142,4 +204,136 @@ public void setRetryAfterHeader(String retryAfterHeader) {
public String getRetryAfterHeader() {
return retryAfterHeader;
}

public void setApiErrorMessage(String apiErrorMessage) {
this.apiErrorMessage = apiErrorMessage;
}

public String getApiErrorMessage() {
return apiErrorMessage;
}

public void setOperationName(String operationName) {
this.operationName = operationName;
}

public String getOperationName() {
return operationName;
}

/**
* Returns a formatted error message for FgaError.
* <p>
* The message is formatted as:
* <pre>
* [operationName] HTTP statusCode apiErrorMessage (apiErrorCode) [request-id: requestId]
* </pre>
* Example: [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123]
* </p>
*
* @return the formatted error message string
*/
@Override
public String getMessage() {
// Use apiErrorMessage if available, otherwise fall back to the original message
String message = (apiErrorMessage != null && !apiErrorMessage.isEmpty()) ? apiErrorMessage : super.getMessage();

StringBuilder sb = new StringBuilder();

// [operationName]
if (operationName != null && !operationName.isEmpty()) {
sb.append("[").append(operationName).append("] ");
}

// HTTP 400
sb.append("HTTP ").append(getStatusCode()).append(" ");

// type 'invalid_type' not found
if (message != null && !message.isEmpty()) {
sb.append(message);
}

// (validation_error)
if (apiErrorCode != null && !apiErrorCode.isEmpty()) {
sb.append(" (").append(apiErrorCode).append(")");
}

// [request-id: abc-123]
if (requestId != null && !requestId.isEmpty()) {
sb.append(" [request-id: ").append(requestId).append("]");
}

return sb.toString().trim();
}

// --- Helper Methods ---

/**
* Checks if this is a validation error.
* Reliable error type checking based on error code.
*
* @return true if this is a validation error
*/
public boolean isValidationError() {
return "validation_error".equals(apiErrorCode);
}

/**
* Checks if this is a not found (404) error.
*
* @return true if this is a 404 error
*/
public boolean isNotFoundError() {
return getStatusCode() == NOT_FOUND;
}

/**
* Checks if this is an authentication (401) error.
*
* @return true if this is a 401 error
*/
public boolean isAuthenticationError() {
return getStatusCode() == UNAUTHORIZED;
}

/**
* Checks if this is a rate limit (429) error.
*
* @return true if this is a rate limit error
*/
public boolean isRateLimitError() {
return getStatusCode() == TOO_MANY_REQUESTS || "rate_limit_exceeded".equals(apiErrorCode);
}

/**
* Checks if this error should be retried.
* 429 (Rate Limit) and 5xx (Server Errors) are typically retryable.
*
* @return true if this error is retryable
*/
public boolean isRetryable() {
int status = getStatusCode();
// 429 (Rate Limit) and 5xx (Server Errors) are typically retryable.
return status == TOO_MANY_REQUESTS || (status >= 500 && status < 600);
}

/**
* Checks if this is a client error (4xx).
*
* @return true if this is a 4xx error
*/
public boolean isClientError() {
int status = getStatusCode();
return status >= 400 && status < 500;
}

/**
* Checks if this is a server error (5xx).
*
* @return true if this is a 5xx error
*/
public boolean isServerError() {
int status = getStatusCode();
return status >= 500 && status < 600;
}
}
Loading
Loading