From a53c11f0d13b5531876e7e26b27ecd84c097a567 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Sat, 29 Aug 2026 09:33:19 +0530 Subject: [PATCH 1/2] fix: expose error status, headers and body on failed clientBatchCheck items The throwable passed to ClientBatchCheckClientResponse is the CompletionException from the check future, so the instanceof FgaError branch never matched and statusCode, headers and rawResponse stayed null, making getStatusCode() throw NullPointerException. Unwrap the wrapper exception before the check. Fixes #380 --- .../model/ClientBatchCheckClientResponse.java | 15 +++++++++++---- .../openfga/sdk/api/client/OpenFgaClientTest.java | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java index 462e5bfb..883b1d98 100644 --- a/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java @@ -4,6 +4,8 @@ import dev.openfga.sdk.errors.FgaError; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.function.BiFunction; public class ClientBatchCheckClientResponse extends CheckResponse { @@ -18,19 +20,24 @@ public ClientBatchCheckClientResponse( this.request = request; this.throwable = throwable; + Throwable cause = throwable instanceof CompletionException || throwable instanceof ExecutionException + ? throwable.getCause() + : throwable; + if (clientCheckResponse != null) { this.statusCode = clientCheckResponse.getStatusCode(); this.headers = clientCheckResponse.getHeaders(); this.rawResponse = clientCheckResponse.getRawResponse(); this.setAllowed(clientCheckResponse.getAllowed()); this.setResolution(clientCheckResponse.getResolution()); - } else if (throwable instanceof FgaError) { - FgaError error = (FgaError) throwable; + } else if (cause instanceof FgaError) { + FgaError error = (FgaError) cause; this.statusCode = error.getStatusCode(); - this.headers = error.getResponseHeaders().map(); + var responseHeaders = error.getResponseHeaders(); + this.headers = responseHeaders != null ? responseHeaders.map() : null; this.rawResponse = error.getResponseData(); } else { - // Should be unreachable, but required for type completion + // no HTTP response available, e.g. the request never reached the server this.statusCode = null; this.headers = null; this.rawResponse = null; diff --git a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java index bad5b5fd..97310b32 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -2066,6 +2066,11 @@ public void clientBatchCheck_400() throws Exception { assertNotNull(response); assertEquals(1, response.size()); assertNull(response.get(0).getAllowed()); + assertEquals(400, response.get(0).getStatusCode()); + assertEquals( + "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}", + response.get(0).getRawResponse()); + assertNotNull(response.get(0).getHeaders()); Throwable execException = response.get(0).getThrowable(); var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); assertEquals(400, exception.getStatusCode()); @@ -2092,6 +2097,11 @@ public void clientBatchCheck_404() throws Exception { assertNotNull(response); assertEquals(1, response.size()); assertNull(response.get(0).getAllowed()); + assertEquals(404, response.get(0).getStatusCode()); + assertEquals( + "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}", + response.get(0).getRawResponse()); + assertNotNull(response.get(0).getHeaders()); Throwable execException = response.get(0).getThrowable(); var exception = assertInstanceOf(FgaApiNotFoundError.class, execException.getCause()); assertEquals(404, exception.getStatusCode()); @@ -2118,6 +2128,11 @@ public void clientBatchCheck_500() throws Exception { assertNotNull(response); assertEquals(1, response.size()); assertNull(response.get(0).getAllowed()); + assertEquals(500, response.get(0).getStatusCode()); + assertEquals( + "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", + response.get(0).getRawResponse()); + assertNotNull(response.get(0).getHeaders()); Throwable execException = response.get(0).getThrowable(); var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause()); assertEquals(500, exception.getStatusCode()); From 661ebeec0dda5c9c6bc2908f6004504cb3844a47 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Mon, 31 Aug 2026 18:02:01 +0530 Subject: [PATCH 2/2] fix: return nullable status code when no HTTP response is available Network failures (connection refused, timeout, DNS) exhaust retries and fail with a plain ApiException rather than an FgaError, so statusCode stays null and the primitive getStatusCode() threw the same NullPointerException as issue #380. Return a boxed, null-documented Integer instead of a fake sentinel status, document the null behaviour of getHeaders() and getRawResponse(), and cover the exhausted-retries network-failure path with a WireMock connection-fault test. --- .../model/ClientBatchCheckClientResponse.java | 18 ++++++++- .../sdk/api/client/OpenFgaClientTest.java | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java index 883b1d98..5d8cbfa4 100644 --- a/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientBatchCheckClientResponse.java @@ -75,14 +75,30 @@ public Throwable getThrowable() { return throwable; } - public int getStatusCode() { + /** + * Returns the HTTP status code of the check response. + *

+ * If no HTTP response was received — for example, the request never reached the server because of a + * network failure (connection refused, timeout, DNS failure) and all retries were exhausted — this + * returns {@code null}. In that case the underlying cause can be examined with + * {@link ClientBatchCheckClientResponse#getThrowable()}. + * + * @return the HTTP status code, or {@code null} if no HTTP response was received. + */ + public Integer getStatusCode() { return statusCode; } + /** + * @return the HTTP response headers, or {@code null} if no HTTP response was received. + */ public Map> getHeaders() { return headers; } + /** + * @return the raw HTTP response body, or {@code null} if no HTTP response was received. + */ public String getRawResponse() { return rawResponse; } diff --git a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java index 97310b32..425b0c4b 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.pgssoft.httpclient.HttpClientMock; @@ -18,6 +19,7 @@ import dev.openfga.sdk.api.model.*; import dev.openfga.sdk.constants.FgaConstants; import dev.openfga.sdk.errors.*; +import java.io.IOException; import java.net.http.HttpClient; import java.time.Duration; import java.time.OffsetDateTime; @@ -2140,6 +2142,41 @@ public void clientBatchCheck_500() throws Exception { "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); } + @Test + public void clientBatchCheck_networkError(WireMockRuntimeInfo wireMockRuntimeInfo) throws Exception { + // Given + String httpBaseUrl = wireMockRuntimeInfo.getHttpBaseUrl(); + var fga = new OpenFgaClient(clientConfiguration.apiUrl(httpBaseUrl), new ApiClient()); + String postUrl = String.format("/stores/%s/check", DEFAULT_STORE_ID); + WireMock.stubFor( + WireMock.post(postUrl).willReturn(WireMock.aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + + // When + List response = fga.clientBatchCheck( + List.of(new ClientCheckRequest()), new ClientBatchCheckClientOptions()) + .join(); + + // Then + // Network errors are retried (1 initial + 3 retries = 4 total) + WireMock.verify(4, WireMock.postRequestedFor(WireMock.urlEqualTo(postUrl))); + assertNotNull(response); + assertEquals(1, response.size()); + assertNull(response.get(0).getAllowed()); + // No HTTP response was received, so status code, headers and body are null + assertNull(response.get(0).getStatusCode()); + assertNull(response.get(0).getHeaders()); + assertNull(response.get(0).getRawResponse()); + Throwable execException = response.get(0).getThrowable(); + assertNotNull(execException); + var exception = assertInstanceOf(ApiException.class, execException.getCause()); + assertFalse(exception instanceof FgaError); + Throwable rootCause = exception; + while (rootCause.getCause() != null) { + rootCause = rootCause.getCause(); + } + assertInstanceOf(IOException.class, rootCause); + } + @Test public void shouldThrowExceptionWhenCorrelationIdsAreDuplicated() { // Given