diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6171468c..1e8a29d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased](https://github.com/openfga/java-sdk/compare/v0.9.3...HEAD)
+- feat: Improve error messaging by parsing error details from response bodies (#258)
## v0.9.3
diff --git a/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java b/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java
index 16a573e2..1b37af05 100644
--- a/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java
+++ b/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java
@@ -1,14 +1,147 @@
package dev.openfga.sdk.errors;
+import com.fasterxml.jackson.databind.JsonNode;
import java.net.http.HttpHeaders;
public class FgaApiValidationError extends FgaError {
+
+ // String prefixes for parsing error messages
+ private static final String RELATION_PREFIX = "relation '";
+ private static final String TYPE_PREFIX = "type '";
+ private static final String CHECK_REQUEST_TUPLE_KEY_PREFIX = "CheckRequestTupleKey.";
+ private static final String TUPLE_KEY_PREFIX = "TupleKey.";
+ private static final String QUOTE_SUFFIX = "'";
+ private static final String NOT_FOUND_SUFFIX = "' not found";
+ private static final String MUST_NOT_BE_EMPTY = "must not be empty";
+
+ private String invalidField;
+ private String invalidValue;
+
public FgaApiValidationError(
String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) {
super(message, cause, code, responseHeaders, responseBody);
+ parseValidationDetails(responseBody, null);
}
public FgaApiValidationError(String message, int code, HttpHeaders responseHeaders, String responseBody) {
super(message, code, responseHeaders, responseBody);
+ parseValidationDetails(responseBody, null);
+ }
+
+ /**
+ * Constructor that accepts a pre-parsed JsonNode to avoid re-parsing the response body.
+ * This is more efficient when the JSON has already been parsed by the parent class.
+ *
+ * @param message The error message
+ * @param cause The underlying cause (if any)
+ * @param code The HTTP status code
+ * @param responseHeaders The response headers
+ * @param responseBody The raw response body
+ * @param parsedJson The already-parsed JSON root node (may be null)
+ */
+ public FgaApiValidationError(
+ String message,
+ Throwable cause,
+ int code,
+ HttpHeaders responseHeaders,
+ String responseBody,
+ JsonNode parsedJson) {
+ super(message, cause, code, responseHeaders, responseBody);
+ parseValidationDetails(responseBody, parsedJson);
+ }
+
+ /**
+ * Try to extract specific validation details from the error message.
+ *
+ * This parsing is best-effort and based on current OpenFGA API error message formats.
+ * If the message format changes or doesn't match expected patterns, fields will be null.
+ * The application should not rely on these fields for critical logic.
+ *
+ * @param responseBody The API error response body
+ * @param parsedJson The already-parsed JSON root node (may be null, in which case we parse it)
+ */
+ private void parseValidationDetails(String responseBody, JsonNode parsedJson) {
+ if (responseBody == null || responseBody.trim().isEmpty()) {
+ return;
+ }
+
+ try {
+ // Use the pre-parsed JSON node if available, otherwise parse it
+ JsonNode root = parsedJson != null ? parsedJson : getErrorMapper().readTree(responseBody);
+ String message = root.has("message") ? root.get("message").asText() : null;
+
+ if (message != null) {
+ // Parse patterns like: "relation 'document#invalid_relation' not found"
+ if (message.contains(RELATION_PREFIX) && message.contains(NOT_FOUND_SUFFIX)) {
+ int start = message.indexOf(RELATION_PREFIX) + RELATION_PREFIX.length();
+ int end = message.indexOf(QUOTE_SUFFIX, start);
+ if (end > start) {
+ this.invalidField = "relation";
+ this.invalidValue = message.substring(start, end);
+ addMetadata("invalid_field", invalidField);
+ addMetadata("invalid_value", invalidValue);
+ }
+ }
+ // Parse patterns like: "type 'invalid_type' not found"
+ else if (message.contains(TYPE_PREFIX) && message.contains(NOT_FOUND_SUFFIX)) {
+ int start = message.indexOf(TYPE_PREFIX) + TYPE_PREFIX.length();
+ int end = message.indexOf(QUOTE_SUFFIX, start);
+ if (end > start) {
+ this.invalidField = "type";
+ this.invalidValue = message.substring(start, end);
+ addMetadata("invalid_field", invalidField);
+ addMetadata("invalid_value", invalidValue);
+ }
+ }
+ // Parse patterns like: "invalid CheckRequestTupleKey.User: value does not match regex..."
+ else if (message.contains(CHECK_REQUEST_TUPLE_KEY_PREFIX)) {
+ int start =
+ message.indexOf(CHECK_REQUEST_TUPLE_KEY_PREFIX) + CHECK_REQUEST_TUPLE_KEY_PREFIX.length();
+ // Search for ": " (colon followed by space) for more robust matching
+ int end = message.indexOf(": ", start);
+ if (end > start) {
+ this.invalidField = message.substring(start, end);
+ addMetadata("invalid_field", invalidField);
+ }
+ }
+ // Parse patterns like: "invalid TupleKey.User: value does not match regex..."
+ else if (message.contains(TUPLE_KEY_PREFIX)) {
+ int start = message.indexOf(TUPLE_KEY_PREFIX) + TUPLE_KEY_PREFIX.length();
+ int end = message.indexOf(": ", start);
+ if (end > start) {
+ this.invalidField = message.substring(start, end);
+ addMetadata("invalid_field", invalidField);
+ }
+ }
+ // Parse patterns like: "object must not be empty"
+ else if (message.contains(MUST_NOT_BE_EMPTY)) {
+ String[] parts = message.trim().split("\\s+");
+ if (parts.length > 0 && !parts[0].isEmpty()) {
+ this.invalidField = parts[0];
+ addMetadata("invalid_field", invalidField);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Parsing is best-effort, ignore failures
+ }
+ }
+
+ /**
+ * Gets the field name that failed validation, if it could be parsed from the error message.
+ *
+ * @return The invalid field name (e.g., "relation", "type", "User"), or null if not parsed
+ */
+ public String getInvalidField() {
+ return invalidField;
+ }
+
+ /**
+ * Gets the invalid value that caused the validation error, if available.
+ *
+ * @return The invalid value, or null if not parsed from the error message
+ */
+ public String getInvalidValue() {
+ return invalidValue;
}
}
diff --git a/src/main/java/dev/openfga/sdk/errors/FgaError.java b/src/main/java/dev/openfga/sdk/errors/FgaError.java
index 85f54d88..e274507b 100644
--- a/src/main/java/dev/openfga/sdk/errors/FgaError.java
+++ b/src/main/java/dev/openfga/sdk/errors/FgaError.java
@@ -2,15 +2,26 @@
import static dev.openfga.sdk.errors.HttpStatusCode.*;
+import com.fasterxml.jackson.databind.JsonNode;
+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;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Optional;
public class FgaError extends ApiException {
+ /**
+ * Shared ObjectMapper instance for parsing error responses.
+ * ObjectMapper is thread-safe for read operations (parsing JSON).
+ * This instance is shared across all error classes to reduce memory overhead.
+ */
+ private static final ObjectMapper ERROR_MAPPER = new ObjectMapper();
+
private String method = null;
private String requestUrl = null;
private String clientId = null;
@@ -18,8 +29,18 @@ public class FgaError extends ApiException {
private String grantType = null;
private String requestId = null;
private String apiErrorCode = null;
+ private String apiErrorMessage = null;
+ private String operationName = null;
private String retryAfterHeader = null;
+ /**
+ * Metadata map for additional error context.
+ *
+ * Note: Error instances follow a single-threaded lifecycle (create → populate → throw → catch).
+ * They are not shared between threads, so thread-safety is not required.
+ */
+ private final Map metadata = new HashMap<>();
+
public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) {
super(message, cause, code, responseHeaders, responseBody);
}
@@ -28,6 +49,56 @@ public FgaError(String message, int code, HttpHeaders responseHeaders, String re
super(message, code, responseHeaders, responseBody);
}
+ /**
+ * Container for parsed error response data.
+ */
+ private static class ParsedErrorResponse {
+ final String message;
+ final String code;
+ final JsonNode rootNode;
+
+ ParsedErrorResponse(String message, String code, JsonNode rootNode) {
+ this.message = message;
+ this.code = code;
+ this.rootNode = rootNode;
+ }
+ }
+
+ /**
+ * Parse the API error response body once to extract the error message, code, and root JSON node.
+ * This method parses the JSON only once and extracts all needed fields, improving efficiency.
+ *
+ * @param methodName The API method name that was called
+ * @param responseBody The response body JSON string
+ * @return ParsedErrorResponse containing message, code, and root JSON node
+ */
+ private static ParsedErrorResponse parseErrorResponse(String methodName, String responseBody) {
+ if (responseBody == null || responseBody.trim().isEmpty()) {
+ return new ParsedErrorResponse(methodName, null, null);
+ }
+
+ try {
+ JsonNode rootNode = ERROR_MAPPER.readTree(responseBody);
+
+ // Extract message field
+ JsonNode messageNode = rootNode.get("message");
+ String message = (messageNode != null && !messageNode.isNull()) ? messageNode.asText() : null;
+
+ // Extract code field
+ JsonNode codeNode = rootNode.get("code");
+ String code = (codeNode != null && !codeNode.isNull()) ? codeNode.asText() : null;
+
+ // If we have a message, use it, otherwise fall back to method name
+ String finalMessage = (message != null && !message.trim().isEmpty()) ? message : methodName;
+
+ return new ParsedErrorResponse(finalMessage, code, rootNode);
+ } catch (Exception e) {
+ // If parsing fails, fall back to the method name
+ // This is intentional to ensure errors are still reported even if the response format is unexpected
+ return new ParsedErrorResponse(methodName, null, null);
+ }
+ }
+
public static Optional getError(
String name,
HttpRequest request,
@@ -43,25 +114,54 @@ public static Optional getError(
final String body = response.body();
final var headers = response.headers();
+
+ // Parse the error response once to extract message, code, and JSON node
+ final ParsedErrorResponse parsedResponse = parseErrorResponse(name, body);
final FgaError error;
if (status == BAD_REQUEST || status == UNPROCESSABLE_ENTITY) {
- error = new FgaApiValidationError(name, previousError, status, headers, body);
+ error = new FgaApiValidationError(
+ parsedResponse.message, previousError, status, headers, body, parsedResponse.rootNode);
} else if (status == UNAUTHORIZED || status == FORBIDDEN) {
- error = new FgaApiAuthenticationError(name, previousError, status, headers, body);
+ error = new FgaApiAuthenticationError(parsedResponse.message, previousError, status, headers, body);
} else if (status == NOT_FOUND) {
- error = new FgaApiNotFoundError(name, previousError, status, headers, body);
+ error = new FgaApiNotFoundError(parsedResponse.message, previousError, status, headers, body);
} else if (status == TOO_MANY_REQUESTS) {
- error = new FgaApiRateLimitExceededError(name, previousError, status, headers, body);
+ error = new FgaApiRateLimitExceededError(parsedResponse.message, previousError, status, headers, body);
} else if (isServerError(status)) {
- error = new FgaApiInternalError(name, previousError, status, headers, body);
+ error = new FgaApiInternalError(parsedResponse.message, previousError, status, headers, body);
} else {
- error = new FgaError(name, previousError, status, headers, body);
+ error = new FgaError(parsedResponse.message, previousError, status, headers, body);
}
error.setMethod(request.method());
error.setRequestUrl(configuration.getApiUrl());
+ // Set the operation name
+ error.setOperationName(name);
+
+ // Set API error code if extracted from response
+ if (parsedResponse.code != null) {
+ error.setApiErrorCode(parsedResponse.code);
+ }
+
+ // Set the API error message (same as what was parsed for the constructor)
+ // This allows getMessage() to return a formatted version
+ if (!parsedResponse.message.equals(name)) {
+ // Only set apiErrorMessage if we actually got a message from the API
+ // (not just falling back to the operation name)
+ error.setApiErrorMessage(parsedResponse.message);
+ }
+
+ // Extract and set request ID from response headers if present
+ // Common request ID header names
+ Optional requestId = headers.firstValue("X-Request-Id")
+ .or(() -> headers.firstValue("x-request-id"))
+ .or(() -> headers.firstValue("Request-Id"));
+ if (requestId.isPresent()) {
+ error.setRequestId(requestId.get());
+ }
+
// Extract and set Retry-After header if present
Optional retryAfter = headers.firstValue(FgaConstants.RETRY_AFTER_HEADER_NAME);
if (retryAfter.isPresent()) {
@@ -83,6 +183,11 @@ public void setMethod(String method) {
this.method = method;
}
+ /**
+ * Gets the HTTP method used for the request that caused this error.
+ *
+ * @return The HTTP method (e.g., "GET", "POST"), or null if not set
+ */
public String getMethod() {
return method;
}
@@ -91,6 +196,11 @@ public void setRequestUrl(String requestUrl) {
this.requestUrl = requestUrl;
}
+ /**
+ * Gets the API URL for the request that caused this error.
+ *
+ * @return The request URL, or null if not set
+ */
public String getRequestUrl() {
return requestUrl;
}
@@ -99,6 +209,11 @@ public void setClientId(String clientId) {
this.clientId = clientId;
}
+ /**
+ * Gets the OAuth2 client ID used in the request, if client credentials authentication was used.
+ *
+ * @return The client ID, or null if not using client credentials or not set
+ */
public String getClientId() {
return clientId;
}
@@ -107,6 +222,11 @@ public void setAudience(String audience) {
this.audience = audience;
}
+ /**
+ * Gets the OAuth2 audience used in the request, if client credentials authentication was used.
+ *
+ * @return The audience, or null if not using client credentials or not set
+ */
public String getAudience() {
return audience;
}
@@ -115,6 +235,11 @@ public void setGrantType(String grantType) {
this.grantType = grantType;
}
+ /**
+ * Gets the OAuth2 grant type used in the request.
+ *
+ * @return The grant type, or null if not set
+ */
public String getGrantType() {
return grantType;
}
@@ -123,6 +248,11 @@ public void setRequestId(String requestId) {
this.requestId = requestId;
}
+ /**
+ * Gets the request ID from the response headers, useful for debugging and support.
+ *
+ * @return The request ID (from X-Request-Id header), or null if not present
+ */
public String getRequestId() {
return requestId;
}
@@ -131,15 +261,183 @@ public void setApiErrorCode(String apiErrorCode) {
this.apiErrorCode = apiErrorCode;
}
+ /**
+ * Gets the error code returned by the API in the response body.
+ *
+ * @return The API error code, or null if not available in the response
+ */
public String getApiErrorCode() {
return apiErrorCode;
}
+ /**
+ * Get the API error code.
+ * This is an alias for getApiErrorCode() for convenience.
+ * @return The API error code from the response
+ */
+ public String getCode() {
+ return apiErrorCode;
+ }
+
public void setRetryAfterHeader(String retryAfterHeader) {
this.retryAfterHeader = retryAfterHeader;
}
+ /**
+ * Gets the Retry-After header value from rate limit responses.
+ *
+ * @return The Retry-After header value (in seconds or HTTP date), or null if not present
+ */
public String getRetryAfterHeader() {
return retryAfterHeader;
}
+
+ public void setApiErrorMessage(String apiErrorMessage) {
+ this.apiErrorMessage = apiErrorMessage;
+ }
+
+ /**
+ * Gets the error message parsed from the API response body.
+ *
+ * @return The API error message, or null if not available in the response
+ */
+ public String getApiErrorMessage() {
+ return apiErrorMessage;
+ }
+
+ public void setOperationName(String operationName) {
+ this.operationName = operationName;
+ }
+
+ /**
+ * Gets the operation name that resulted in this error.
+ *
+ * @return The operation name (e.g., "check", "write"), or null if not set
+ */
+ public String getOperationName() {
+ return operationName;
+ }
+
+ /**
+ * Gets the metadata map containing additional error context.
+ *
+ * @return A map of metadata key-value pairs (never null)
+ */
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ public void addMetadata(String key, Object value) {
+ getMetadata().put(key, value);
+ }
+
+ /**
+ * Provides access to the shared ObjectMapper for subclasses.
+ * This mapper is thread-safe for read operations.
+ *
+ * @return The shared ObjectMapper instance
+ */
+ protected static ObjectMapper getErrorMapper() {
+ return ERROR_MAPPER;
+ }
+
+ /**
+ * Override getMessage() to return the actual API error message
+ * instead of the generic operation name.
+ *
+ * This makes errors understandable everywhere they're displayed:
+ * - Exception stack traces
+ * - IDE error tooltips
+ * - Log files
+ * - toString() output
+ */
+ @Override
+ public String getMessage() {
+ // Return the actual API error if available
+ if (apiErrorMessage != null && !apiErrorMessage.isEmpty()) {
+ StringBuilder sb = new StringBuilder();
+
+ // Include operation context
+ if (operationName != null) {
+ sb.append("[").append(operationName).append("] ");
+ }
+
+ // Main error message from API
+ sb.append(apiErrorMessage);
+
+ // Add error code if available
+ if (apiErrorCode != null) {
+ sb.append(" (").append(apiErrorCode).append(")");
+ }
+
+ return sb.toString();
+ }
+
+ // Fallback to original message (operation name)
+ return super.getMessage();
+ }
+
+ /**
+ * Build the core error message (operation + message + code)
+ */
+ private String buildCoreMessage() {
+ StringBuilder sb = new StringBuilder();
+
+ if (operationName != null) {
+ sb.append("[").append(operationName).append("] ");
+ }
+
+ if (apiErrorMessage != null) {
+ sb.append(apiErrorMessage);
+ } else if (super.getMessage() != null) {
+ sb.append(super.getMessage());
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Returns a developer-friendly error message with all context
+ */
+ public String getDetailedMessage() {
+ StringBuilder sb = new StringBuilder(buildCoreMessage());
+
+ if (apiErrorCode != null) {
+ sb.append(" (code: ").append(apiErrorCode).append(")");
+ }
+
+ if (requestId != null) {
+ sb.append(" [request-id: ").append(requestId).append("]");
+ }
+
+ if (getStatusCode() > 0) {
+ sb.append(" [HTTP ").append(getStatusCode()).append("]");
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Override toString() to provide a formatted string for better logging
+ */
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(getClass().getSimpleName()).append(": ");
+ sb.append(buildCoreMessage());
+
+ if (getStatusCode() > 0) {
+ sb.append(" (HTTP ").append(getStatusCode()).append(")");
+ }
+
+ if (apiErrorCode != null) {
+ sb.append(" [code: ").append(apiErrorCode).append("]");
+ }
+
+ if (requestId != null) {
+ sb.append(" [request-id: ").append(requestId).append("]");
+ }
+
+ return sb.toString();
+ }
}
diff --git a/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java b/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java
new file mode 100644
index 00000000..c308d1ec
--- /dev/null
+++ b/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java
@@ -0,0 +1,289 @@
+package dev.openfga.sdk.errors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.openfga.sdk.api.client.OpenFgaClient;
+import dev.openfga.sdk.api.client.model.*;
+import dev.openfga.sdk.api.configuration.ClientConfiguration;
+import dev.openfga.sdk.api.model.*;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.openfga.OpenFGAContainer;
+
+@TestInstance(Lifecycle.PER_CLASS)
+@Testcontainers
+class FgaErrorIntegrationTest {
+
+ @Container
+ private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2");
+
+ private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
+ private static WriteAuthorizationModelRequest authModelRequest;
+
+ private OpenFgaClient fga;
+ private String storeId;
+
+ @BeforeAll
+ static void loadAuthModel() throws IOException {
+ String authModelJson = Files.readString(Paths.get("src", "test-integration", "resources", "auth-model.json"));
+ authModelRequest = mapper.readValue(authModelJson, WriteAuthorizationModelRequest.class);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ ClientConfiguration config = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint());
+ fga = new OpenFgaClient(config);
+
+ CreateStoreResponse storeResponse =
+ fga.createStore(new CreateStoreRequest().name("test-store")).get();
+ storeId = storeResponse.getId();
+ fga.setStoreId(storeId);
+ }
+
+ @Test
+ void testValidationError_InvalidType() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request =
+ new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1");
+
+ CompletableFuture future = fga.check(request);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ String exceptionMessage = exception.getMessage();
+ assertNotNull(exceptionMessage);
+ assertTrue(exceptionMessage.contains("FgaApiValidationError"), "Should include error class name");
+ assertTrue(exceptionMessage.contains("[check]"), "Should include operation name");
+ assertTrue(
+ exceptionMessage.contains("type 'invalid_type' not found"), "Should include actual error from server");
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+ assertEquals("check", error.getOperationName());
+ assertEquals(400, error.getStatusCode());
+ assertEquals("validation_error", error.getApiErrorCode());
+ assertEquals("type 'invalid_type' not found", error.getApiErrorMessage());
+
+ // Verify formatted messages
+ String errorMessage = error.getMessage();
+ assertNotNull(errorMessage);
+ assertTrue(errorMessage.contains("[check]"), "Should include operation name");
+ assertTrue(errorMessage.contains("type 'invalid_type' not found"), "Should include server error");
+ assertTrue(errorMessage.contains("validation_error"), "Should include error code");
+ }
+
+ @Test
+ void testValidationError_InvalidRelation() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request = new ClientCheckRequest()
+ .user("user:123")
+ .relation("invalid_relation")
+ ._object("document:doc1");
+
+ CompletableFuture future = fga.check(request);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ // Verify ExecutionException message contains the full error details from server
+ // Note: These assertions will fail if OpenFGA server changes its error message format,
+ // which is intentional - integration tests should catch server behavior changes
+ String exceptionMessage = exception.getMessage();
+ assertNotNull(exceptionMessage);
+ assertTrue(exceptionMessage.contains("FgaApiValidationError"), "Should include error class name");
+ assertTrue(exceptionMessage.contains("[check]"), "Should include operation name");
+ assertTrue(
+ exceptionMessage.contains("relation 'document#invalid_relation' not found"),
+ "Should include actual error from server");
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+
+ // Verify error object contains expected details
+ assertEquals("check", error.getOperationName());
+ assertEquals(400, error.getStatusCode());
+ assertEquals("validation_error", error.getApiErrorCode());
+ assertEquals("relation 'document#invalid_relation' not found", error.getApiErrorMessage());
+
+ // Verify formatted messages
+ String errorMessage = error.getMessage();
+ assertNotNull(errorMessage);
+ assertTrue(errorMessage.contains("[check]"), "Should include operation name");
+ assertTrue(
+ errorMessage.contains("relation 'document#invalid_relation' not found"), "Should include server error");
+ assertTrue(errorMessage.contains("validation_error"), "Should include error code");
+ }
+
+ @Test
+ void testValidationError_EmptyUser() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request =
+ new ClientCheckRequest().user("").relation("reader")._object("document:doc1");
+
+ CompletableFuture future = fga.check(request);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+ assertEquals(400, error.getStatusCode());
+ assertNotNull(error.getApiErrorMessage());
+ }
+
+ @Test
+ void testValidationError_InvalidStoreId() throws Exception {
+ fga.setStoreId("01HZZZZZZZZZZZZZZZZZZZZZZ");
+
+ CompletableFuture future = fga.getStore();
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+ assertEquals(400, error.getStatusCode());
+ assertNotNull(error.getMessage());
+ }
+
+ @Test
+ void testValidationError_InvalidAuthorizationModelId() throws Exception {
+ fga.setAuthorizationModelId("01HZZZZZZZZZZZZZZZZZZZZZZ");
+
+ CompletableFuture future = fga.readAuthorizationModel();
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+ assertEquals(400, error.getStatusCode());
+ assertEquals("readAuthorizationModel", error.getOperationName());
+ }
+
+ @Test
+ void testErrorMetadataExtensibility() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request =
+ new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1");
+
+ CompletableFuture future = fga.check(request);
+
+ try {
+ future.get();
+ fail("Expected ExecutionException");
+ } catch (ExecutionException e) {
+ FgaError error = (FgaError) e.getCause();
+
+ error.addMetadata("retry_attempt", 1);
+ error.addMetadata("user_context", "admin");
+ error.addMetadata("store_id", storeId);
+
+ Map metadata = error.getMetadata();
+ assertEquals(1, metadata.get("retry_attempt"));
+ assertEquals("admin", metadata.get("user_context"));
+ assertEquals(storeId, metadata.get("store_id"));
+ }
+ }
+
+ @Test
+ void testErrorMessageVisibilityInExecutionException() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request =
+ new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1");
+
+ CompletableFuture future = fga.check(request);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ String exceptionMessage = exception.getMessage();
+ assertNotNull(exceptionMessage);
+ assertFalse(exceptionMessage.trim().isEmpty());
+
+ FgaApiValidationError error = (FgaApiValidationError) exception.getCause();
+ assertEquals("check", error.getOperationName());
+ assertNotNull(error.getMessage());
+ assertNotNull(error.getDetailedMessage());
+ assertNotNull(error.toString());
+ }
+
+ @Test
+ void testCompleteErrorContext() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientCheckRequest request =
+ new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1");
+
+ CompletableFuture future = fga.check(request);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+ FgaApiValidationError error = (FgaApiValidationError) exception.getCause();
+
+ assertEquals(400, error.getStatusCode());
+ assertEquals("POST", error.getMethod());
+ assertNotNull(error.getRequestUrl());
+ assertEquals("check", error.getOperationName());
+ assertNotNull(error.getApiErrorMessage());
+
+ assertDoesNotThrow(() -> error.getRequestId());
+ assertDoesNotThrow(() -> error.getApiErrorCode());
+
+ assertNotNull(error.getMessage());
+ assertFalse(error.getMessage().isEmpty());
+ assertNotNull(error.getDetailedMessage());
+ assertFalse(error.getDetailedMessage().isEmpty());
+ assertNotNull(error.toString());
+ assertTrue(error.toString().startsWith("FgaApiValidationError"));
+ }
+
+ @Test
+ void testWriteValidationError_InvalidTupleKey() throws Exception {
+ WriteAuthorizationModelResponse authModelResponse =
+ fga.writeAuthorizationModel(authModelRequest).get();
+ fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId());
+
+ ClientWriteRequest writeRequest = new ClientWriteRequest()
+ .writes(List.of(
+ new ClientTupleKey().user("user:123").relation("reader")._object("invalid_type:doc1")));
+
+ CompletableFuture future = fga.write(writeRequest);
+ ExecutionException exception = assertThrows(ExecutionException.class, future::get);
+
+ Throwable cause = exception.getCause();
+ assertInstanceOf(FgaApiValidationError.class, cause);
+
+ FgaApiValidationError error = (FgaApiValidationError) cause;
+ assertEquals("write", error.getOperationName());
+ assertEquals(400, error.getStatusCode());
+ assertNotNull(error.getApiErrorMessage());
+ }
+}
diff --git a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java
index 8fcae337..bb5deafb 100644
--- a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java
+++ b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java
@@ -160,7 +160,11 @@ public void exchangeOAuth2TokenWithRetriesFailure(WireMockRuntimeInfo wm) throws
var exception = assertThrows(java.util.concurrent.ExecutionException.class, () -> auth0.getAccessToken()
.get());
- assertEquals("dev.openfga.sdk.errors.FgaApiRateLimitExceededError: exchangeToken", exception.getMessage());
+ // The error message now includes the formatted message from getMessage() override
+ // which shows: FgaApiRateLimitExceededError [operation]: message (error_code)
+ // Since the response has no "message" field, it falls back to operation name
+ assertTrue(exception.getMessage().contains("FgaApiRateLimitExceededError"));
+ assertTrue(exception.getMessage().contains("exchangeToken"));
verify(3, postRequestedFor(urlEqualTo("/oauth/token")));
}
diff --git a/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java b/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java
new file mode 100644
index 00000000..4237a080
--- /dev/null
+++ b/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java
@@ -0,0 +1,340 @@
+package dev.openfga.sdk.errors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.openfga.sdk.api.configuration.ClientConfiguration;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests that verify the complete error handling flow,
+ * especially the getMessage() override behavior when wrapped in ExecutionException.
+ */
+class FgaErrorIntegrationTest {
+
+ @Test
+ void testErrorMessageVisibilityInExecutionException() throws Exception {
+ // Given - Simulate a validation error from the API
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/stores/01ARZ3NDEKTSV4RRFFQ69G5FAV/check"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ FgaError fgaError = errorOpt.get();
+
+ // When - Simulate wrapping in CompletableFuture (as the SDK does)
+ CompletableFuture future = CompletableFuture.failedFuture(fgaError);
+
+ // Then - Verify the error message is visible even when wrapped
+ try {
+ future.get();
+ throw new AssertionError("Expected ExecutionException to be thrown");
+ } catch (ExecutionException e) {
+ // This is what developers will see in their IDE and logs
+ String exceptionMessage = e.getMessage();
+
+ // Before Phase 2: Would have shown "dev.openfga.sdk.errors.FgaApiValidationError: check"
+ // After Phase 2: Shows the actual error
+ assertThat(exceptionMessage).contains("type 'invalid_type' not found");
+ assertThat(exceptionMessage).contains("validation_error");
+ assertThat(exceptionMessage).contains("check");
+
+ // Verify cause is properly accessible
+ assertThat(e.getCause()).isInstanceOf(FgaApiValidationError.class);
+ FgaApiValidationError validationError = (FgaApiValidationError) e.getCause();
+ assertThat(validationError.getInvalidField()).isEqualTo("type");
+ assertThat(validationError.getInvalidValue()).isEqualTo("invalid_type");
+ }
+ }
+
+ @Test
+ void testCompleteErrorContext() {
+ // Given - A fully populated error response
+ String responseBody = "{\"code\":\"relation_not_found\",\"message\":\"relation 'document#editor' not found\"}";
+ Map> headers = Map.of(
+ "X-Request-Id", List.of("550e8400-e29b-41d4-a716-446655440000"),
+ "Retry-After", List.of("30"));
+ HttpResponse response = createMockResponse(400, responseBody, headers);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/stores/01ARZ3NDEKTSV4RRFFQ69G5FAV/check"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then - Verify all error context is available
+ assertThat(errorOpt).isPresent();
+ FgaApiValidationError error = (FgaApiValidationError) errorOpt.get();
+
+ // Basic error info
+ assertThat(error.getStatusCode()).isEqualTo(400);
+ assertThat(error.getMethod()).isEqualTo("POST");
+ assertThat(error.getRequestUrl()).isEqualTo("http://localhost:8080");
+
+ // Parsed error details
+ assertThat(error.getOperationName()).isEqualTo("check");
+ assertThat(error.getApiErrorCode()).isEqualTo("relation_not_found");
+ assertThat(error.getApiErrorMessage()).isEqualTo("relation 'document#editor' not found");
+
+ // Headers
+ assertThat(error.getRequestId()).isEqualTo("550e8400-e29b-41d4-a716-446655440000");
+ assertThat(error.getRetryAfterHeader()).isEqualTo("30");
+
+ // Validation-specific parsing
+ assertThat(error.getInvalidField()).isEqualTo("relation");
+ assertThat(error.getInvalidValue()).isEqualTo("document#editor");
+ assertThat(error.getMetadata()).containsEntry("invalid_field", "relation");
+ assertThat(error.getMetadata()).containsEntry("invalid_value", "document#editor");
+
+ // Formatted messages
+ assertThat(error.getMessage()).isEqualTo("[check] relation 'document#editor' not found (relation_not_found)");
+
+ assertThat(error.getDetailedMessage())
+ .contains("[check]")
+ .contains("relation 'document#editor' not found")
+ .contains("(code: relation_not_found)")
+ .contains("[request-id: 550e8400-e29b-41d4-a716-446655440000]")
+ .contains("[HTTP 400]");
+
+ assertThat(error.toString())
+ .startsWith("FgaApiValidationError")
+ .contains("[check]")
+ .contains("relation 'document#editor' not found")
+ .contains("(HTTP 400)")
+ .contains("[code: relation_not_found]")
+ .contains("[request-id: 550e8400-e29b-41d4-a716-446655440000]");
+ }
+
+ @Test
+ void testMetadataExtensibility() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid tuple\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // When - Add custom metadata for application-specific error tracking
+ error.addMetadata("retry_attempt", 1);
+ error.addMetadata("user_id", "user:123");
+ error.addMetadata("store_id", "01ARZ3NDEKTSV4RRFFQ69G5FAV");
+ error.addMetadata("client_version", "1.0.0");
+
+ // Then - Metadata is accessible
+ Map metadata = error.getMetadata();
+ assertThat(metadata).hasSize(4);
+ assertThat(metadata).containsEntry("retry_attempt", 1);
+ assertThat(metadata).containsEntry("user_id", "user:123");
+ assertThat(metadata).containsEntry("store_id", "01ARZ3NDEKTSV4RRFFQ69G5FAV");
+ assertThat(metadata).containsEntry("client_version", "1.0.0");
+
+ // Metadata can be used for structured logging
+ assertThat(metadata.get("retry_attempt")).isEqualTo(1);
+ }
+
+ @Test
+ void testValidationErrorPatternMatching() {
+ // Test all supported validation error patterns
+
+ // Pattern 1: type 'X' not found
+ testPattern("{\"code\":\"validation_error\",\"message\":\"type 'user' not found\"}", "type", "user");
+
+ // Pattern 2: relation 'X' not found
+ testPattern(
+ "{\"code\":\"validation_error\",\"message\":\"relation 'document#viewer' not found\"}",
+ "relation",
+ "document#viewer");
+
+ // Pattern 3: invalid CheckRequestTupleKey.Field
+ testPatternFieldOnly(
+ "{\"code\":\"validation_error\",\"message\":\"invalid CheckRequestTupleKey.Object: value does not match regex\"}",
+ "Object");
+
+ // Pattern 4: invalid TupleKey.Field
+ testPatternFieldOnly(
+ "{\"code\":\"validation_error\",\"message\":\"invalid TupleKey.Relation: value does not match regex\"}",
+ "Relation");
+
+ // Pattern 5: field must not be empty
+ testPatternFieldOnly("{\"code\":\"validation_error\",\"message\":\"user must not be empty\"}", "user");
+ }
+
+ @Test
+ void testDifferentErrorTypes() {
+ // Test that different HTTP status codes create the right error types
+
+ // 400/422 -> FgaApiValidationError
+ testErrorType(400, "{\"code\":\"validation_error\",\"message\":\"invalid\"}", FgaApiValidationError.class);
+
+ // 401 -> FgaApiAuthenticationError
+ testErrorType(401, "{\"code\":\"unauthorized\",\"message\":\"auth failed\"}", FgaApiAuthenticationError.class);
+
+ // 403 -> FgaApiAuthenticationError
+ testErrorType(403, "{\"code\":\"forbidden\",\"message\":\"access denied\"}", FgaApiAuthenticationError.class);
+
+ // 404 -> FgaApiNotFoundError
+ testErrorType(404, "{\"code\":\"not_found\",\"message\":\"store not found\"}", FgaApiNotFoundError.class);
+
+ // 429 -> FgaApiRateLimitExceededError
+ testErrorType(
+ 429, "{\"code\":\"rate_limit\",\"message\":\"too many requests\"}", FgaApiRateLimitExceededError.class);
+
+ // 500+ -> FgaApiInternalError
+ testErrorType(500, "{\"code\":\"internal_error\",\"message\":\"server error\"}", FgaApiInternalError.class);
+ }
+
+ @Test
+ void testErrorMessageFormattingVariations() {
+ // Test getMessage() formatting with different combinations of available data
+
+ // Full data: operation + message + code
+ FgaError error1 =
+ createError("{\"code\":\"validation_error\",\"message\":\"type 'user' not found\"}", 400, "check");
+ assertThat(error1.getMessage()).isEqualTo("[check] type 'user' not found (validation_error)");
+
+ // Message without code
+ FgaError error2 = createError("{\"message\":\"something went wrong\"}", 500, "write");
+ assertThat(error2.getMessage()).isEqualTo("[write] something went wrong");
+
+ // No message (fallback to operation name)
+ FgaError error3 = createError("{\"code\":\"error\"}", 500, "read");
+ assertThat(error3.getMessage()).isEqualTo("read");
+
+ // Empty body (fallback to operation name)
+ FgaError error4 = createError("", 500, "listStores");
+ assertThat(error4.getMessage()).isEqualTo("listStores");
+ }
+
+ // Helper methods
+
+ private void testPattern(String responseBody, String expectedField, String expectedValue) {
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError error = (FgaApiValidationError) errorOpt.get();
+ assertThat(error.getInvalidField()).isEqualTo(expectedField);
+ assertThat(error.getInvalidValue()).isEqualTo(expectedValue);
+ }
+
+ private void testPatternFieldOnly(String responseBody, String expectedField) {
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError error = (FgaApiValidationError) errorOpt.get();
+ assertThat(error.getInvalidField()).isEqualTo(expectedField);
+ }
+
+ private void testErrorType(int statusCode, String responseBody, Class extends FgaError> expectedType) {
+ HttpResponse response = createMockResponse(statusCode, responseBody, Map.of());
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError("test", request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(expectedType);
+ }
+
+ private FgaError createError(String responseBody, int statusCode, String operationName) {
+ HttpResponse response = createMockResponse(statusCode, responseBody, Map.of());
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ Optional errorOpt = FgaError.getError(operationName, request, config, response, null);
+ assertThat(errorOpt).isPresent();
+ return errorOpt.get();
+ }
+
+ private HttpResponse createMockResponse(int statusCode, String body, Map> headers) {
+ return new HttpResponse<>() {
+ @Override
+ public int statusCode() {
+ return statusCode;
+ }
+
+ @Override
+ public HttpRequest request() {
+ return null;
+ }
+
+ @Override
+ public Optional> previousResponse() {
+ return Optional.empty();
+ }
+
+ @Override
+ public HttpHeaders headers() {
+ return HttpHeaders.of(headers, (k, v) -> true);
+ }
+
+ @Override
+ public String body() {
+ return body;
+ }
+
+ @Override
+ public Optional sslSession() {
+ return Optional.empty();
+ }
+
+ @Override
+ public URI uri() {
+ return URI.create("http://localhost:8080/test");
+ }
+
+ @Override
+ public HttpClient.Version version() {
+ return HttpClient.Version.HTTP_1_1;
+ }
+ };
+ }
+}
diff --git a/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java b/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java
new file mode 100644
index 00000000..1cacc601
--- /dev/null
+++ b/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java
@@ -0,0 +1,926 @@
+package dev.openfga.sdk.errors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.openfga.sdk.api.configuration.ClientConfiguration;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class FgaErrorTest {
+
+ @Test
+ void shouldParseValidationErrorMessageFromResponseBody() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid relation 'foo'\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiValidationError.class);
+ assertThat(error.getMessage()).isEqualTo("[write] invalid relation 'foo' (validation_error)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("invalid relation 'foo'");
+ assertThat(error.getCode()).isEqualTo("validation_error");
+ assertThat(error.getApiErrorCode()).isEqualTo("validation_error");
+ assertThat(error.getStatusCode()).isEqualTo(400);
+ assertThat(error.getMethod()).isEqualTo("POST");
+ }
+
+ @Test
+ void shouldParseInternalErrorMessageFromResponseBody() {
+ // Given
+ String responseBody = "{\"code\":\"internal_error\",\"message\":\"database connection failed\"}";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiInternalError.class);
+ assertThat(error.getMessage()).isEqualTo("[check] database connection failed (internal_error)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("database connection failed");
+ assertThat(error.getCode()).isEqualTo("internal_error");
+ assertThat(error.getStatusCode()).isEqualTo(500);
+ assertThat(error.getMethod()).isEqualTo("GET");
+ }
+
+ @Test
+ void shouldParseNotFoundErrorMessageFromResponseBody() {
+ // Given
+ String responseBody = "{\"code\":\"store_id_not_found\",\"message\":\"store not found\"}";
+ HttpResponse response = createMockResponse(404, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("getStore", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiNotFoundError.class);
+ assertThat(error.getMessage()).isEqualTo("[getStore] store not found (store_id_not_found)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("store not found");
+ assertThat(error.getCode()).isEqualTo("store_id_not_found");
+ assertThat(error.getStatusCode()).isEqualTo(404);
+ }
+
+ @Test
+ void shouldFallBackToMethodNameWhenMessageIsMissing() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getMessage()).isEqualTo("write");
+ assertThat(error.getCode()).isEqualTo("validation_error");
+ }
+
+ @Test
+ void shouldFallBackToMethodNameWhenResponseBodyIsEmpty() {
+ // Given
+ String responseBody = "";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getMessage()).isEqualTo("write");
+ assertThat(error.getCode()).isNull();
+ }
+
+ @Test
+ void shouldFallBackToMethodNameWhenResponseBodyIsNotJson() {
+ // Given
+ String responseBody = "Server Error";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getMessage()).isEqualTo("write");
+ assertThat(error.getCode()).isNull();
+ }
+
+ @Test
+ void shouldExtractRequestIdFromHeaders() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid tuple\"}";
+ Map> headers = Map.of("X-Request-Id", List.of("abc-123-def-456"));
+ HttpResponse response = createMockResponse(400, responseBody, headers);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getRequestId()).isEqualTo("abc-123-def-456");
+ }
+
+ @Test
+ void shouldHandleUnprocessableEntityAsValidationError() {
+ // Given
+ String responseBody = "{\"code\":\"invalid_tuple\",\"message\":\"tuple validation failed\"}";
+ HttpResponse response = createMockResponse(422, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiValidationError.class);
+ assertThat(error.getMessage()).isEqualTo("[write] tuple validation failed (invalid_tuple)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("tuple validation failed");
+ assertThat(error.getCode()).isEqualTo("invalid_tuple");
+ assertThat(error.getStatusCode()).isEqualTo(422);
+ }
+
+ @Test
+ void shouldHandleAuthenticationError() {
+ // Given
+ String responseBody = "{\"code\":\"auth_failed\",\"message\":\"authentication failed\"}";
+ HttpResponse response = createMockResponse(401, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("read", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiAuthenticationError.class);
+ assertThat(error.getMessage()).isEqualTo("[read] authentication failed (auth_failed)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("authentication failed");
+ assertThat(error.getCode()).isEqualTo("auth_failed");
+ assertThat(error.getStatusCode()).isEqualTo(401);
+ }
+
+ @Test
+ void shouldHandleRateLimitError() {
+ // Given
+ String responseBody = "{\"code\":\"rate_limit_exceeded\",\"message\":\"too many requests\"}";
+ HttpResponse response = createMockResponse(429, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error).isInstanceOf(FgaApiRateLimitExceededError.class);
+ assertThat(error.getMessage()).isEqualTo("[check] too many requests (rate_limit_exceeded)");
+ assertThat(error.getApiErrorMessage()).isEqualTo("too many requests");
+ assertThat(error.getCode()).isEqualTo("rate_limit_exceeded");
+ assertThat(error.getStatusCode()).isEqualTo(429);
+ }
+
+ @Test
+ void shouldReturnEmptyForSuccessfulResponse() {
+ // Given
+ HttpResponse response = createMockResponse(200, "{}", Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("read", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isEmpty();
+ }
+
+ @Test
+ void shouldSetApiErrorMessageWhenMessageIsParsedFromResponse() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getApiErrorMessage()).isEqualTo("type 'invalid_type' not found");
+ assertThat(error.getApiErrorCode()).isEqualTo("validation_error");
+ assertThat(error.getOperationName()).isEqualTo("check");
+ }
+
+ @Test
+ void shouldNotSetApiErrorMessageWhenFallingBackToOperationName() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getApiErrorMessage()).isNull(); // Should be null when falling back
+ assertThat(error.getMessage()).isEqualTo("write"); // Falls back to operation name
+ assertThat(error.getOperationName()).isEqualTo("write");
+ }
+
+ @Test
+ void shouldSetOperationNameForAllErrorTypes() {
+ // Given
+ String responseBody = "{\"code\":\"store_not_found\",\"message\":\"store not found\"}";
+ HttpResponse response = createMockResponse(404, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("getStore", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getOperationName()).isEqualTo("getStore");
+ assertThat(error.getApiErrorMessage()).isEqualTo("store not found");
+ }
+
+ @Test
+ void shouldHandleAllFieldsWhenFullyPopulated() {
+ // Given
+ String responseBody = "{\"code\":\"rate_limit_exceeded\",\"message\":\"too many requests\"}";
+ Map> headers = Map.of(
+ "X-Request-Id", List.of("req-123-456"),
+ "Retry-After", List.of("60"));
+ HttpResponse response = createMockResponse(429, responseBody, headers);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getOperationName()).isEqualTo("write");
+ assertThat(error.getApiErrorMessage()).isEqualTo("too many requests");
+ assertThat(error.getApiErrorCode()).isEqualTo("rate_limit_exceeded");
+ assertThat(error.getCode()).isEqualTo("rate_limit_exceeded"); // Alias method
+ assertThat(error.getRequestId()).isEqualTo("req-123-456");
+ assertThat(error.getRetryAfterHeader()).isEqualTo("60");
+ assertThat(error.getMethod()).isEqualTo("POST");
+ assertThat(error.getRequestUrl()).isEqualTo("http://localhost:8080");
+ }
+
+ @Test
+ void shouldHandleEmptyBodyGracefully() {
+ // Given
+ HttpResponse response = createMockResponse(500, "", Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getOperationName()).isEqualTo("check");
+ assertThat(error.getApiErrorMessage()).isNull(); // No message in empty body
+ assertThat(error.getApiErrorCode()).isNull(); // No code in empty body
+ assertThat(error.getMessage()).isEqualTo("check"); // Falls back to operation name
+ }
+
+ @Test
+ void shouldHandleNonJsonBodyGracefully() {
+ // Given
+ HttpResponse response = createMockResponse(500, "Internal Server Error", Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("listStores", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+ assertThat(error.getOperationName()).isEqualTo("listStores");
+ assertThat(error.getApiErrorMessage()).isNull(); // Can't parse non-JSON
+ assertThat(error.getApiErrorCode()).isNull(); // Can't parse non-JSON
+ assertThat(error.getMessage()).isEqualTo("listStores"); // Falls back to operation name
+ }
+
+ // ============================================================================
+ // PHASE 2 TESTS: getMessage() override, metadata, getDetailedMessage(), toString()
+ // ============================================================================
+
+ @Test
+ void testGetMessageOverrideReturnsApiErrorMessage() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // getMessage() should return formatted message with operation name and error code
+ assertThat(error.getMessage()).isEqualTo("[check] type 'invalid_type' not found (validation_error)");
+ }
+
+ @Test
+ void testGetMessageFallsBackToSuperWhenNoApiErrorMessage() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // Should fall back to the original message (operation name)
+ assertThat(error.getMessage()).isEqualTo("write");
+ }
+
+ @Test
+ void testGetMessageWithoutErrorCode() {
+ // Given - API returns message but no code
+ String responseBody = "{\"message\":\"something went wrong\"}";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // getMessage() should include operation name but not error code
+ assertThat(error.getMessage()).isEqualTo("[check] something went wrong");
+ }
+
+ @Test
+ void testMetadataOperations() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid relation\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // Add metadata
+ error.addMetadata("custom_field", "custom_value");
+ error.addMetadata("retry_count", 3);
+
+ // Verify metadata
+ assertThat(error.getMetadata()).isNotNull();
+ assertThat(error.getMetadata()).containsEntry("custom_field", "custom_value");
+ assertThat(error.getMetadata()).containsEntry("retry_count", 3);
+ }
+
+ @Test
+ void testGetDetailedMessage() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ Map> headers = Map.of("X-Request-Id", List.of("req-12345"));
+ HttpResponse response = createMockResponse(400, responseBody, headers);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ String detailedMessage = error.getDetailedMessage();
+
+ // Should include operation name, message, code, request-id, and HTTP status
+ assertThat(detailedMessage).contains("[check]");
+ assertThat(detailedMessage).contains("type 'invalid_type' not found");
+ assertThat(detailedMessage).contains("(code: validation_error)");
+ assertThat(detailedMessage).contains("[request-id: req-12345]");
+ assertThat(detailedMessage).contains("[HTTP 400]");
+ }
+
+ @Test
+ void testGetDetailedMessageWithMinimalInfo() {
+ // Given
+ String responseBody = "";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ String detailedMessage = error.getDetailedMessage();
+
+ // Should at least include operation name and HTTP status
+ assertThat(detailedMessage).contains("[check]");
+ assertThat(detailedMessage).contains("[HTTP 500]");
+ }
+
+ @Test
+ void testToString() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ Map> headers = Map.of("X-Request-Id", List.of("req-67890"));
+ HttpResponse response = createMockResponse(400, responseBody, headers);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ String toString = error.toString();
+
+ // Should include class name, operation, message, HTTP status, code, and request-id
+ assertThat(toString).startsWith("FgaApiValidationError");
+ assertThat(toString).contains("[check]");
+ assertThat(toString).contains("type 'invalid_type' not found");
+ assertThat(toString).contains("(HTTP 400)");
+ assertThat(toString).contains("[code: validation_error]");
+ assertThat(toString).contains("[request-id: req-67890]");
+ }
+
+ @Test
+ void testToStringWithMinimalInfo() {
+ // Given
+ String responseBody = "";
+ HttpResponse response = createMockResponse(500, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .GET()
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("listStores", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ String toString = error.toString();
+
+ // Should include class name, operation, and HTTP status
+ assertThat(toString).startsWith("FgaApiInternalError");
+ assertThat(toString).contains("[listStores]");
+ assertThat(toString).contains("(HTTP 500)");
+ }
+
+ // ============================================================================
+ // VALIDATION ERROR SPECIFIC TESTS
+ // ============================================================================
+
+ @Test
+ void testValidationErrorParsesInvalidRelation() {
+ // Given
+ String responseBody =
+ "{\"code\":\"validation_error\",\"message\":\"relation 'document#invalid_relation' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ assertThat(validationError.getInvalidField()).isEqualTo("relation");
+ assertThat(validationError.getInvalidValue()).isEqualTo("document#invalid_relation");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_field", "relation");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_value", "document#invalid_relation");
+ }
+
+ @Test
+ void testValidationErrorParsesInvalidType() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ assertThat(validationError.getInvalidField()).isEqualTo("type");
+ assertThat(validationError.getInvalidValue()).isEqualTo("invalid_type");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_field", "type");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_value", "invalid_type");
+ }
+
+ @Test
+ void testValidationErrorParsesCheckRequestTupleKeyField() {
+ // Given
+ String responseBody =
+ "{\"code\":\"validation_error\",\"message\":\"invalid CheckRequestTupleKey.User: value does not match regex\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ assertThat(validationError.getInvalidField()).isEqualTo("User");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_field", "User");
+ }
+
+ @Test
+ void testValidationErrorParsesTupleKeyField() {
+ // Given
+ String responseBody =
+ "{\"code\":\"validation_error\",\"message\":\"invalid TupleKey.Object: value does not match regex\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("write", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ assertThat(validationError.getInvalidField()).isEqualTo("Object");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_field", "Object");
+ }
+
+ @Test
+ void testValidationErrorParsesEmptyFieldMessage() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"object must not be empty\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ assertThat(validationError.getInvalidField()).isEqualTo("object");
+ assertThat(validationError.getMetadata()).containsEntry("invalid_field", "object");
+ }
+
+ @Test
+ void testValidationErrorHandlesUnparsableMessage() {
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"some unexpected format\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ // Should not throw, should gracefully handle unparsable message
+ assertThat(validationError.getInvalidField()).isNull();
+ assertThat(validationError.getInvalidValue()).isNull();
+ }
+
+ @Test
+ void testValidationErrorHandlesEmptyBody() {
+ // Given
+ String responseBody = "";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class);
+
+ FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get();
+ // Should not throw, should handle empty body gracefully
+ assertThat(validationError.getInvalidField()).isNull();
+ assertThat(validationError.getInvalidValue()).isNull();
+ }
+
+ @Test
+ void testGetMessageWorksInExecutionExceptionScenario() {
+ // This test simulates how errors appear when wrapped in ExecutionException
+ // Given
+ String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}";
+ HttpResponse response = createMockResponse(400, responseBody, Map.of());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:8080/test"))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080");
+
+ // When
+ Optional errorOpt = FgaError.getError("check", request, config, response, null);
+
+ // Then
+ assertThat(errorOpt).isPresent();
+ FgaError error = errorOpt.get();
+
+ // Simulate what would be shown in ExecutionException
+ String executionExceptionMessage = error.getClass().getName() + ": " + error.getMessage();
+
+ // Before the fix, this would have shown: "...FgaApiValidationError: check"
+ // After the fix, it shows the actual error:
+ assertThat(executionExceptionMessage)
+ .contains("FgaApiValidationError")
+ .contains("[check] type 'invalid_type' not found (validation_error)");
+ }
+
+ // Helper method to create mock HttpResponse
+ private HttpResponse createMockResponse(int statusCode, String body, Map> headers) {
+ return new HttpResponse() {
+ @Override
+ public int statusCode() {
+ return statusCode;
+ }
+
+ @Override
+ public HttpRequest request() {
+ return null;
+ }
+
+ @Override
+ public Optional> previousResponse() {
+ return Optional.empty();
+ }
+
+ @Override
+ public HttpHeaders headers() {
+ return HttpHeaders.of(headers, (k, v) -> true);
+ }
+
+ @Override
+ public String body() {
+ return body;
+ }
+
+ @Override
+ public Optional sslSession() {
+ return Optional.empty();
+ }
+
+ @Override
+ public URI uri() {
+ return URI.create("http://localhost:8080/test");
+ }
+
+ @Override
+ public HttpClient.Version version() {
+ return HttpClient.Version.HTTP_1_1;
+ }
+ };
+ }
+}