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

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

### Changed
- 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
225 changes: 224 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,15 +2,24 @@

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

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import dev.openfga.sdk.api.configuration.Configuration;
import dev.openfga.sdk.api.configuration.CredentialsMethod;
import dev.openfga.sdk.constants.FgaConstants;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
import org.openapitools.jackson.nullable.JsonNullableModule;

public class FgaError extends ApiException {
private static final String UNKNOWN_ERROR_CODE = "unknown_error";

private String method = null;
private String requestUrl = null;
private String clientId = null;
Expand All @@ -19,6 +28,60 @@ 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 = createConfiguredObjectMapper();

private static ObjectMapper createConfiguredObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE);
mapper.registerModule(new JavaTimeModule());
mapper.registerModule(new JsonNullableModule());
return mapper;
}

@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
private 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 +116,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 +138,27 @@ 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) {
// Wrap unparseable response
error.setApiErrorCode(UNKNOWN_ERROR_CODE);
error.setApiErrorMessage("Unable to parse error response. Raw response: " + body);
}
}

// 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 +226,143 @@ 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 an unknown error due to unparseable response.
* This occurs when the error response could not be parsed as JSON.
*
* @return true if this is an unknown error
*/
public boolean isUnknownError() {
return UNKNOWN_ERROR_CODE.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() {
return HttpStatusCode.isRetryable(getStatusCode());
}

/**
* 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() {
return HttpStatusCode.isServerError(getStatusCode());
}
}
Loading
Loading