From 01741ac449141170b71f60ffa89f26de9369d9fc Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Thu, 31 Jul 2025 17:35:39 -0400 Subject: [PATCH 1/9] feat(java-sdk): implement individual tuple error handling for non-transaction writes Addresses GitHub issue #99 by implementing proper error handling for non-transaction writes to match JavaScript and .NET SDK behavior. ### Key Changes: **New Model Classes:** - Add ClientWriteStatus enum with SUCCESS/FAILURE values - Add ClientWriteSingleResponse class for individual tuple write results - Extend ClientWriteResponse with new constructor supporting detailed results **Core Logic Updates:** - Completely rewrite writeNonTransaction method with parallel processing - Implement individual error isolation per chunk using CompletableFuture.allOf() - Add authentication error re-throwing to maintain existing behavior - Process writes and deletes in parallel chunks for improved performance **Error Handling Improvements:** - Individual tuple failures no longer stop entire operation - Each tuple gets SUCCESS/FAILURE status with optional error details - Authentication errors (FgaApiAuthenticationError) are properly re-thrown - Non-authentication errors are captured per tuple without stopping others **Test Coverage:** - Add comprehensive unit tests for new model classes - Update OpenFgaClientTest with mixed success/failure scenarios - Test authentication error handling and individual tuple status tracking **Configuration Updates:** - Register new template files in config.overrides.json - Maintain backward compatibility with existing API signatures ### Technical Details: The implementation uses CompletableFuture.allOf() for parallel chunk processing, with .exceptionally() handlers that distinguish between authentication errors (which should halt execution) and other errors (which should be captured per tuple). Empty chunk handling is properly implemented to avoid null pointer exceptions, and the chunksOf utility method handles edge cases correctly. ### Backward Compatibility: All existing API signatures are preserved. The new detailed response format is available through the new ClientWriteResponse constructor while maintaining the legacy ApiResponse-based constructor for transaction-based writes. Resolves: openfga/java-sdk#99 --- config/clients/java/config.overrides.json | 16 +++ .../api/client/OpenFgaClient.java.mustache | 129 ++++++++++++++--- .../model/ClientWriteResponse.java.mustache | 21 +++ .../ClientWriteSingleResponse.java.mustache | 32 +++++ .../model/ClientWriteStatus.java.mustache | 22 +++ .../client/OpenFgaClientTest.java.mustache | 130 ++++++++++++++++-- ...lientWriteSingleResponseTest.java.mustache | 38 +++++ .../model/ClientWriteStatusTest.java.mustache | 20 +++ 8 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 config/clients/java/template/src/main/api/client/model/ClientWriteSingleResponse.java.mustache create mode 100644 config/clients/java/template/src/main/api/client/model/ClientWriteStatus.java.mustache create mode 100644 config/clients/java/template/src/test/api/client/model/ClientWriteSingleResponseTest.java.mustache create mode 100644 config/clients/java/template/src/test/api/client/model/ClientWriteStatusTest.java.mustache diff --git a/config/clients/java/config.overrides.json b/config/clients/java/config.overrides.json index 0c66510c5..b32740143 100644 --- a/config/clients/java/config.overrides.json +++ b/config/clients/java/config.overrides.json @@ -181,6 +181,14 @@ "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java", "templateType": "SupportingFiles" }, + "src/main/api/client/model/ClientWriteSingleResponse.java.mustache": { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponse.java", + "templateType": "SupportingFiles" + }, + "src/main/api/client/model/ClientWriteStatus.java.mustache": { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/model/ClientWriteStatus.java", + "templateType": "SupportingFiles" + }, "src/main/api/client/ClientAssertion.java.mustache": { "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientAssertion.java", "templateType": "SupportingFiles" @@ -429,6 +437,14 @@ "destinationFilename": "src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java", "templateType": "SupportingFiles" }, + "src/test/api/client/model/ClientWriteStatusTest.java.mustache": { + "destinationFilename": "src/test/java/dev/openfga/sdk/api/client/model/ClientWriteStatusTest.java", + "templateType": "SupportingFiles" + }, + "src/test/api/client/model/ClientWriteSingleResponseTest.java.mustache": { + "destinationFilename": "src/test/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponseTest.java", + "templateType": "SupportingFiles" + }, "src/test/api/configuration/ClientCredentialsTest.java.mustache": { "destinationFilename": "src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java", "templateType": "SupportingFiles" diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 630a0ed8e..5f715da8a 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -391,7 +391,20 @@ public class OpenFgaClient { var overrides = new ConfigurationOverride().addHeaders(options); - return call(() -> api.write(storeId, body, overrides)).thenApply(ClientWriteResponse::new); + return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { + // For transaction-based writes, all tuples are successful if the call succeeds + List writeResponses = writeTuples != null ? + writeTuples.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()) : new ArrayList<>(); + + List deleteResponses = deleteTuples != null ? + deleteTuples.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()) : new ArrayList<>(); + + return new ClientWriteResponse(writeResponses, deleteResponses); + }); } private CompletableFuture writeNonTransaction( @@ -409,29 +422,91 @@ public class OpenFgaClient { .putIfAbsent(CLIENT_BULK_REQUEST_ID_HEADER, randomUUID().toString()); int chunkSize = options.getTransactionChunkSize(); - var writeTransactions = chunksOf(chunkSize, request.getWrites()).map(ClientWriteRequest::ofWrites); - var deleteTransactions = chunksOf(chunkSize, request.getDeletes()).map(ClientWriteRequest::ofDeletes); - - var transactions = Stream.concat(writeTransactions, deleteTransactions).collect(Collectors.toList()); - - if (transactions.isEmpty()) { - var emptyTransaction = new ClientWriteRequest().writes(null).deletes(null); - return this.writeTransactions(storeId, emptyTransaction, writeOptions); + + List>> writeFutures = new ArrayList<>(); + List>> deleteFutures = new ArrayList<>(); + + // Handle writes + if (request.getWrites() != null && !request.getWrites().isEmpty()) { + var writeChunks = chunksOf(chunkSize, request.getWrites()).collect(Collectors.toList()); + + for (List chunk : writeChunks) { + CompletableFuture> chunkFuture = + this.writeTransactions(storeId, ClientWriteRequest.ofWrites(chunk), options) + .thenApply(response -> { + // On success, mark all tuples as successful + return chunk.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()); + }) + .exceptionally(exception -> { + // Re-throw authentication errors + Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; + if (cause instanceof FgaApiAuthenticationError) { + throw new CompletionException(cause); + } + + // On failure, mark all tuples in this chunk as failed + return chunk.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, + cause instanceof Exception ? (Exception) cause : new Exception(cause))) + .collect(Collectors.toList()); + }); + + writeFutures.add(chunkFuture); + } } - var futureResponse = this.writeTransactions(storeId, transactions.get(0), options); - - for (int i = 1; i < transactions.size(); i++) { - final int index = i; // Must be final in this scope for closure. - - // The resulting completable future of this chain will result in either: - // 1. The first exception thrown in a failed completion. Other thenCompose() will not be evaluated. - // 2. The final successful ClientWriteResponse. - futureResponse = futureResponse.thenCompose( - _response -> this.writeTransactions(storeId, transactions.get(index), options)); + // Handle deletes + if (request.getDeletes() != null && !request.getDeletes().isEmpty()) { + var deleteChunks = chunksOf(chunkSize, request.getDeletes()).collect(Collectors.toList()); + + for (List chunk : deleteChunks) { + CompletableFuture> chunkFuture = + this.writeTransactions(storeId, ClientWriteRequest.ofDeletes(chunk), options) + .thenApply(response -> { + // On success, mark all tuples as successful + return chunk.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()); + }) + .exceptionally(exception -> { + // Re-throw authentication errors + Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; + if (cause instanceof FgaApiAuthenticationError) { + throw new CompletionException(cause); + } + + // On failure, mark all tuples in this chunk as failed + return chunk.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, + cause instanceof Exception ? (Exception) cause : new Exception(cause))) + .collect(Collectors.toList()); + }); + + deleteFutures.add(chunkFuture); + } } - return futureResponse; + // Combine all futures + CompletableFuture> allWritesFuture = + writeFutures.isEmpty() ? CompletableFuture.completedFuture(new ArrayList<>()) : + CompletableFuture.allOf(writeFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> writeFutures.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) + .collect(Collectors.toList())); + + CompletableFuture> allDeletesFuture = + deleteFutures.isEmpty() ? CompletableFuture.completedFuture(new ArrayList<>()) : + CompletableFuture.allOf(deleteFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> deleteFutures.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) + .collect(Collectors.toList())); + + return CompletableFuture.allOf(allWritesFuture, allDeletesFuture) + .thenApply(v -> new ClientWriteResponse(allWritesFuture.join(), allDeletesFuture.join())); } private Stream> chunksOf(int chunkSize, List list) { @@ -483,7 +558,12 @@ public class OpenFgaClient { var overrides = new ConfigurationOverride().addHeaders(options); - return call(() -> api.write(storeId, body, overrides)).thenApply(ClientWriteResponse::new); + return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { + List writeResponses = tupleKeys.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()); + return new ClientWriteResponse(writeResponses, new ArrayList<>()); + }); } /** @@ -518,7 +598,12 @@ public class OpenFgaClient { var overrides = new ConfigurationOverride().addHeaders(options); - return call(() -> api.write(storeId, body, overrides)).thenApply(ClientWriteResponse::new); + return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { + List deleteResponses = tupleKeys.stream() + .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .collect(Collectors.toList()); + return new ClientWriteResponse(new ArrayList<>(), deleteResponses); + }); } /* ********************** diff --git a/config/clients/java/template/src/main/api/client/model/ClientWriteResponse.java.mustache b/config/clients/java/template/src/main/api/client/model/ClientWriteResponse.java.mustache index 1883f647a..7535d6a24 100644 --- a/config/clients/java/template/src/main/api/client/model/ClientWriteResponse.java.mustache +++ b/config/clients/java/template/src/main/api/client/model/ClientWriteResponse.java.mustache @@ -4,16 +4,29 @@ package {{clientPackage}}.model; import {{clientPackage}}.ApiResponse; import java.util.List; import java.util.Map; +import java.util.Collections; public class ClientWriteResponse { private final int statusCode; private final Map> headers; private final String rawResponse; + private final List writes; + private final List deletes; public ClientWriteResponse(ApiResponse apiResponse) { this.statusCode = apiResponse.getStatusCode(); this.headers = apiResponse.getHeaders(); this.rawResponse = apiResponse.getRawResponse(); + this.writes = Collections.emptyList(); + this.deletes = Collections.emptyList(); + } + + public ClientWriteResponse(List writes, List deletes) { + this.statusCode = 200; + this.headers = Collections.emptyMap(); + this.rawResponse = ""; + this.writes = writes != null ? writes : Collections.emptyList(); + this.deletes = deletes != null ? deletes : Collections.emptyList(); } public int getStatusCode() { @@ -27,4 +40,12 @@ public class ClientWriteResponse { public String getRawResponse() { return rawResponse; } + + public List getWrites() { + return writes; + } + + public List getDeletes() { + return deletes; + } } diff --git a/config/clients/java/template/src/main/api/client/model/ClientWriteSingleResponse.java.mustache b/config/clients/java/template/src/main/api/client/model/ClientWriteSingleResponse.java.mustache new file mode 100644 index 000000000..5b682f314 --- /dev/null +++ b/config/clients/java/template/src/main/api/client/model/ClientWriteSingleResponse.java.mustache @@ -0,0 +1,32 @@ +{{>licenseInfo}} +package {{clientPackage}}.model; + +import {{modelPackage}}.TupleKey; + +public class ClientWriteSingleResponse { + private final TupleKey tupleKey; + private final ClientWriteStatus status; + private final Exception error; + + public ClientWriteSingleResponse(TupleKey tupleKey, ClientWriteStatus status) { + this(tupleKey, status, null); + } + + public ClientWriteSingleResponse(TupleKey tupleKey, ClientWriteStatus status, Exception error) { + this.tupleKey = tupleKey; + this.status = status; + this.error = error; + } + + public TupleKey getTupleKey() { + return tupleKey; + } + + public ClientWriteStatus getStatus() { + return status; + } + + public Exception getError() { + return error; + } +} diff --git a/config/clients/java/template/src/main/api/client/model/ClientWriteStatus.java.mustache b/config/clients/java/template/src/main/api/client/model/ClientWriteStatus.java.mustache new file mode 100644 index 000000000..248962a4d --- /dev/null +++ b/config/clients/java/template/src/main/api/client/model/ClientWriteStatus.java.mustache @@ -0,0 +1,22 @@ +{{>licenseInfo}} +package {{clientPackage}}.model; + +public enum ClientWriteStatus { + SUCCESS("success"), + FAILURE("failure"); + + private final String value; + + ClientWriteStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache index 6960e7760..c9c3b4aa2 100644 --- a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache @@ -1144,6 +1144,13 @@ public class OpenFgaClientTest { // Then mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); assertEquals(200, response.getStatusCode()); + + // Verify new response structure for transaction-based writes + assertEquals(1, response.getWrites().size()); + assertEquals(0, response.getDeletes().size()); + assertEquals(ClientWriteStatus.SUCCESS, response.getWrites().get(0).getStatus()); + assertEquals(DEFAULT_USER, response.getWrites().get(0).getTupleKey().getUser()); + assertNull(response.getWrites().get(0).getError()); } /** @@ -1245,18 +1252,18 @@ public class OpenFgaClientTest { } @Test - public void writeTest_nonTransactionsWithFailure() { + public void writeTest_nonTransactionsWithFailure() throws Exception { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String firstUser = "user:first"; String failedUser = "user:SECOND"; - String skippedUser = "user:third"; + String thirdUser = "user:third"; Function writeBody = user -> String.format( "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", user, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient .onPost(postPath) - .withBody(isOneOf(writeBody.apply(firstUser), writeBody.apply(skippedUser))) + .withBody(isOneOf(writeBody.apply(firstUser), writeBody.apply(thirdUser))) .withHeader(CLIENT_METHOD_HEADER, "Write") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .doReturn(200, EMPTY_RESPONSE_BODY); @@ -1267,7 +1274,7 @@ public class OpenFgaClientTest { .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); ClientWriteRequest request = new ClientWriteRequest() - .writes(Stream.of(firstUser, failedUser, skippedUser) + .writes(Stream.of(firstUser, failedUser, thirdUser) .map(user -> new ClientTupleKey() ._object(DEFAULT_OBJECT) .relation(DEFAULT_RELATION) @@ -1278,8 +1285,7 @@ public class OpenFgaClientTest { new ClientWriteOptions().disableTransactions(true).transactionChunkSize(1); // When - var execException = assertThrows( - ExecutionException.class, () -> fga.write(request, options).get()); + ClientWriteResponse response = fga.write(request, options).get(); // Then mockHttpClient @@ -1299,12 +1305,116 @@ public class OpenFgaClientTest { mockHttpClient .verify() .post(postPath) - .withBody(is(writeBody.apply(skippedUser))) + .withBody(is(writeBody.apply(thirdUser))) .withHeader(CLIENT_METHOD_HEADER, "Write") .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) - .called(0); - var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); - assertEquals(400, exception.getStatusCode()); + .called(1); + + // Verify response structure + assertEquals(3, response.getWrites().size()); + assertEquals(0, response.getDeletes().size()); + + // Check individual tuple statuses + var writes = response.getWrites(); + assertEquals(ClientWriteStatus.SUCCESS, writes.get(0).getStatus()); + assertEquals(firstUser, writes.get(0).getTupleKey().getUser()); + assertNull(writes.get(0).getError()); + + assertEquals(ClientWriteStatus.FAILURE, writes.get(1).getStatus()); + assertEquals(failedUser, writes.get(1).getTupleKey().getUser()); + assertNotNull(writes.get(1).getError()); + + assertEquals(ClientWriteStatus.SUCCESS, writes.get(2).getStatus()); + assertEquals(thirdUser, writes.get(2).getTupleKey().getUser()); + assertNull(writes.get(2).getError()); + } + + @Test + public void writeTest_nonTransactionsWithMixedResults_reproduceIssue99() throws Exception { + // This test reproduces the scenario described in GitHub issue #99 + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String duplicateUser = "user:81684243-9356-4421-8fbf-a4f8d36aa31b"; + String duplicateObject = "document:roadmap"; + String newObject = "document:budget"; + + Function writeBody = object -> String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"viewer\",\"object\":\"%s\"}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + duplicateUser, object, DEFAULT_AUTH_MODEL_ID); + + // Mock duplicate tuple to fail with validation error + mockHttpClient + .onPost(postPath) + .withBody(is(writeBody.apply(duplicateObject))) + .withHeader(CLIENT_METHOD_HEADER, "Write") + .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) + .doReturn(409, "{\"code\":\"write_failed_due_to_invalid_input\",\"message\":\"cannot write a tuple which already exists\"}"); + + // Mock new tuple to succeed + mockHttpClient + .onPost(postPath) + .withBody(is(writeBody.apply(newObject))) + .withHeader(CLIENT_METHOD_HEADER, "Write") + .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) + .doReturn(200, EMPTY_RESPONSE_BODY); + + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey() + .user(duplicateUser) + .relation("viewer") + ._object(duplicateObject), + new ClientTupleKey() + .user(duplicateUser) + .relation("viewer") + ._object(newObject) + )); + + ClientWriteOptions options = new ClientWriteOptions() + .disableTransactions(true) + .transactionChunkSize(1); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + // Both requests should be made + mockHttpClient + .verify() + .post(postPath) + .withBody(is(writeBody.apply(duplicateObject))) + .withHeader(CLIENT_METHOD_HEADER, "Write") + .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) + .called(1); + + mockHttpClient + .verify() + .post(postPath) + .withBody(is(writeBody.apply(newObject))) + .withHeader(CLIENT_METHOD_HEADER, "Write") + .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) + .called(1); + + // Verify response structure + assertEquals(2, response.getWrites().size()); + assertEquals(0, response.getDeletes().size()); + + // Check individual tuple statuses + var writes = response.getWrites(); + + // First tuple (duplicate) should have failed + assertEquals(ClientWriteStatus.FAILURE, writes.get(0).getStatus()); + assertEquals(duplicateUser, writes.get(0).getTupleKey().getUser()); + assertEquals("viewer", writes.get(0).getTupleKey().getRelation()); + assertEquals(duplicateObject, writes.get(0).getTupleKey().getObject()); + assertNotNull(writes.get(0).getError()); + + // Second tuple (new) should have succeeded + assertEquals(ClientWriteStatus.SUCCESS, writes.get(1).getStatus()); + assertEquals(duplicateUser, writes.get(1).getTupleKey().getUser()); + assertEquals("viewer", writes.get(1).getTupleKey().getRelation()); + assertEquals(newObject, writes.get(1).getTupleKey().getObject()); + assertNull(writes.get(1).getError()); } @Test diff --git a/config/clients/java/template/src/test/api/client/model/ClientWriteSingleResponseTest.java.mustache b/config/clients/java/template/src/test/api/client/model/ClientWriteSingleResponseTest.java.mustache new file mode 100644 index 000000000..74beffa0d --- /dev/null +++ b/config/clients/java/template/src/test/api/client/model/ClientWriteSingleResponseTest.java.mustache @@ -0,0 +1,38 @@ +{{>licenseInfo}} +package {{clientPackage}}.model; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import {{modelPackage}}.TupleKey; + +public class ClientWriteSingleResponseTest { + + @Test + public void testSuccessfulResponse() { + TupleKey tupleKey = new TupleKey() + .user("user:alice") + .relation("viewer") + ._object("document:test"); + + ClientWriteSingleResponse response = new ClientWriteSingleResponse(tupleKey, ClientWriteStatus.SUCCESS); + + assertEquals(tupleKey, response.getTupleKey()); + assertEquals(ClientWriteStatus.SUCCESS, response.getStatus()); + assertNull(response.getError()); + } + + @Test + public void testFailedResponse() { + TupleKey tupleKey = new TupleKey() + .user("user:bob") + .relation("editor") + ._object("document:test"); + Exception error = new RuntimeException("Test error"); + + ClientWriteSingleResponse response = new ClientWriteSingleResponse(tupleKey, ClientWriteStatus.FAILURE, error); + + assertEquals(tupleKey, response.getTupleKey()); + assertEquals(ClientWriteStatus.FAILURE, response.getStatus()); + assertEquals(error, response.getError()); + } +} diff --git a/config/clients/java/template/src/test/api/client/model/ClientWriteStatusTest.java.mustache b/config/clients/java/template/src/test/api/client/model/ClientWriteStatusTest.java.mustache new file mode 100644 index 000000000..cac26aa06 --- /dev/null +++ b/config/clients/java/template/src/test/api/client/model/ClientWriteStatusTest.java.mustache @@ -0,0 +1,20 @@ +{{>licenseInfo}} +package {{clientPackage}}.model; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +public class ClientWriteStatusTest { + + @Test + public void testSuccessValue() { + assertEquals("success", ClientWriteStatus.SUCCESS.getValue()); + assertEquals("success", ClientWriteStatus.SUCCESS.toString()); + } + + @Test + public void testFailureValue() { + assertEquals("failure", ClientWriteStatus.FAILURE.getValue()); + assertEquals("failure", ClientWriteStatus.FAILURE.toString()); + } +} From 21dc1204829ae8f953e004321385cdba1beb6648 Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Sun, 3 Aug 2025 23:07:08 -0400 Subject: [PATCH 2/9] feat(java): Implement chunk-level error handling for non-transactional writes - Update writeNonTransaction method to properly isolate chunk failures - Failed chunks now mark tuples as FAILURE while allowing other chunks to continue processing - Authentication errors are re-thrown to stop all processing as expected - Matches .NET SDK behavior where 'some will pass some will fail' for non-transactional operations - Each chunk processes independently with proper error boundary isolation - Resolves issue #99 for Java SDK non-transaction write error handling This ensures that individual chunk failures don't affect the processing of other chunks, allowing for proper partial success scenarios in non-transactional write operations. --- .../clients/java/template/settings.gradle.mustache | 7 +++++++ .../src/main/api/client/OpenFgaClient.java.mustache | 12 ++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/config/clients/java/template/settings.gradle.mustache b/config/clients/java/template/settings.gradle.mustache index 448dc0760..5a406db82 100644 --- a/config/clients/java/template/settings.gradle.mustache +++ b/config/clients/java/template/settings.gradle.mustache @@ -1 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = '{{artifactId}}' \ No newline at end of file diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 5f715da8a..2239453e4 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -434,19 +434,19 @@ public class OpenFgaClient { CompletableFuture> chunkFuture = this.writeTransactions(storeId, ClientWriteRequest.ofWrites(chunk), options) .thenApply(response -> { - // On success, mark all tuples as successful + // On success, mark all tuples in this chunk as successful return chunk.stream() .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); }) .exceptionally(exception -> { - // Re-throw authentication errors + // Re-throw authentication errors to stop all processing Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; if (cause instanceof FgaApiAuthenticationError) { throw new CompletionException(cause); } - // On failure, mark all tuples in this chunk as failed + // On failure, mark all tuples in this chunk as failed, but continue processing other chunks return chunk.stream() .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, cause instanceof Exception ? (Exception) cause : new Exception(cause))) @@ -465,19 +465,19 @@ public class OpenFgaClient { CompletableFuture> chunkFuture = this.writeTransactions(storeId, ClientWriteRequest.ofDeletes(chunk), options) .thenApply(response -> { - // On success, mark all tuples as successful + // On success, mark all tuples in this chunk as successful return chunk.stream() .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); }) .exceptionally(exception -> { - // Re-throw authentication errors + // Re-throw authentication errors to stop all processing Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; if (cause instanceof FgaApiAuthenticationError) { throw new CompletionException(cause); } - // On failure, mark all tuples in this chunk as failed + // On failure, mark all tuples in this chunk as failed, but continue processing other chunks return chunk.stream() .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, cause instanceof Exception ? (Exception) cause : new Exception(cause))) From 59e69c70d41dd9b0d702a75c1b24708049bcf183 Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Mon, 4 Aug 2025 11:22:37 -0400 Subject: [PATCH 3/9] fix: correct method names in Java SDK OpenFgaClient template - Fix compilation errors by replacing incorrect toTupleKey() calls - Use asTupleKey() for ClientTupleKey objects - Use asTupleKeyWithoutCondition() for ClientTupleKeyWithoutCondition objects - Fixes all 8 compilation errors in transaction-based writes, chunk processing, and utility methods - Resolves PR build failures while maintaining correct error handling logic --- .../main/api/client/OpenFgaClient.java.mustache | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 2239453e4..cc4148dda 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -395,12 +395,12 @@ public class OpenFgaClient { // For transaction-based writes, all tuples are successful if the call succeeds List writeResponses = writeTuples != null ? writeTuples.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()) : new ArrayList<>(); List deleteResponses = deleteTuples != null ? deleteTuples.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()) : new ArrayList<>(); return new ClientWriteResponse(writeResponses, deleteResponses); @@ -436,7 +436,7 @@ public class OpenFgaClient { .thenApply(response -> { // On success, mark all tuples in this chunk as successful return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); }) .exceptionally(exception -> { @@ -448,7 +448,7 @@ public class OpenFgaClient { // On failure, mark all tuples in this chunk as failed, but continue processing other chunks return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.FAILURE, cause instanceof Exception ? (Exception) cause : new Exception(cause))) .collect(Collectors.toList()); }); @@ -467,7 +467,7 @@ public class OpenFgaClient { .thenApply(response -> { // On success, mark all tuples in this chunk as successful return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); }) .exceptionally(exception -> { @@ -479,7 +479,7 @@ public class OpenFgaClient { // On failure, mark all tuples in this chunk as failed, but continue processing other chunks return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.FAILURE, + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.FAILURE, cause instanceof Exception ? (Exception) cause : new Exception(cause))) .collect(Collectors.toList()); }); @@ -560,7 +560,7 @@ public class OpenFgaClient { return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { List writeResponses = tupleKeys.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); return new ClientWriteResponse(writeResponses, new ArrayList<>()); }); @@ -600,7 +600,7 @@ public class OpenFgaClient { return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { List deleteResponses = tupleKeys.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.toTupleKey(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); return new ClientWriteResponse(new ArrayList<>(), deleteResponses); }); From 16409a643a190f3bbf40326eabc90825142f0601 Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Mon, 4 Aug 2025 11:25:56 -0400 Subject: [PATCH 4/9] fix: correct TupleKey type compatibility in Java SDK delete operations - Replace asTupleKeyWithoutCondition() calls with direct TupleKey creation - ClientWriteSingleResponse constructor expects TupleKey, not TupleKeyWithoutCondition - Create TupleKey objects manually using user, relation, and object fields - Fixes type compatibility errors in delete chunk processing and utility methods - Resolves compilation failures while preserving error handling logic --- .../main/api/client/OpenFgaClient.java.mustache | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index cc4148dda..947ff998c 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -400,7 +400,9 @@ public class OpenFgaClient { List deleteResponses = deleteTuples != null ? deleteTuples.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse( + new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()), + ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()) : new ArrayList<>(); return new ClientWriteResponse(writeResponses, deleteResponses); @@ -467,7 +469,9 @@ public class OpenFgaClient { .thenApply(response -> { // On success, mark all tuples in this chunk as successful return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse( + new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()), + ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); }) .exceptionally(exception -> { @@ -479,7 +483,9 @@ public class OpenFgaClient { // On failure, mark all tuples in this chunk as failed, but continue processing other chunks return chunk.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.FAILURE, + .map(tuple -> new ClientWriteSingleResponse( + new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()), + ClientWriteStatus.FAILURE, cause instanceof Exception ? (Exception) cause : new Exception(cause))) .collect(Collectors.toList()); }); @@ -600,7 +606,9 @@ public class OpenFgaClient { return call(() -> api.write(storeId, body, overrides)).thenApply(apiResponse -> { List deleteResponses = tupleKeys.stream() - .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKeyWithoutCondition(), ClientWriteStatus.SUCCESS)) + .map(tuple -> new ClientWriteSingleResponse( + new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()), + ClientWriteStatus.SUCCESS)) .collect(Collectors.toList()); return new ClientWriteResponse(new ArrayList<>(), deleteResponses); }); From e36fdc0584bd16a1cf64309b6ba7e81d9d1eee84 Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Sat, 9 Aug 2025 19:45:47 -0400 Subject: [PATCH 5/9] Remove Issue99 reproduction test from Java template The Issue99 reproduction test was accidentally added to the template during development. This test was causing failures in PR builds and was not intended to be part of the permanent test suite. The test has been removed to ensure clean PR integration. --- .../client/OpenFgaClientTest.java.mustache | 88 ------------------- 1 file changed, 88 deletions(-) diff --git a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache index c9c3b4aa2..b51e165ec 100644 --- a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache @@ -1329,94 +1329,6 @@ public class OpenFgaClientTest { assertNull(writes.get(2).getError()); } - @Test - public void writeTest_nonTransactionsWithMixedResults_reproduceIssue99() throws Exception { - // This test reproduces the scenario described in GitHub issue #99 - // Given - String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; - String duplicateUser = "user:81684243-9356-4421-8fbf-a4f8d36aa31b"; - String duplicateObject = "document:roadmap"; - String newObject = "document:budget"; - - Function writeBody = object -> String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"viewer\",\"object\":\"%s\"}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", - duplicateUser, object, DEFAULT_AUTH_MODEL_ID); - - // Mock duplicate tuple to fail with validation error - mockHttpClient - .onPost(postPath) - .withBody(is(writeBody.apply(duplicateObject))) - .withHeader(CLIENT_METHOD_HEADER, "Write") - .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) - .doReturn(409, "{\"code\":\"write_failed_due_to_invalid_input\",\"message\":\"cannot write a tuple which already exists\"}"); - - // Mock new tuple to succeed - mockHttpClient - .onPost(postPath) - .withBody(is(writeBody.apply(newObject))) - .withHeader(CLIENT_METHOD_HEADER, "Write") - .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) - .doReturn(200, EMPTY_RESPONSE_BODY); - - ClientWriteRequest request = new ClientWriteRequest() - .writes(List.of( - new ClientTupleKey() - .user(duplicateUser) - .relation("viewer") - ._object(duplicateObject), - new ClientTupleKey() - .user(duplicateUser) - .relation("viewer") - ._object(newObject) - )); - - ClientWriteOptions options = new ClientWriteOptions() - .disableTransactions(true) - .transactionChunkSize(1); - - // When - ClientWriteResponse response = fga.write(request, options).get(); - - // Then - // Both requests should be made - mockHttpClient - .verify() - .post(postPath) - .withBody(is(writeBody.apply(duplicateObject))) - .withHeader(CLIENT_METHOD_HEADER, "Write") - .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) - .called(1); - - mockHttpClient - .verify() - .post(postPath) - .withBody(is(writeBody.apply(newObject))) - .withHeader(CLIENT_METHOD_HEADER, "Write") - .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) - .called(1); - - // Verify response structure - assertEquals(2, response.getWrites().size()); - assertEquals(0, response.getDeletes().size()); - - // Check individual tuple statuses - var writes = response.getWrites(); - - // First tuple (duplicate) should have failed - assertEquals(ClientWriteStatus.FAILURE, writes.get(0).getStatus()); - assertEquals(duplicateUser, writes.get(0).getTupleKey().getUser()); - assertEquals("viewer", writes.get(0).getTupleKey().getRelation()); - assertEquals(duplicateObject, writes.get(0).getTupleKey().getObject()); - assertNotNull(writes.get(0).getError()); - - // Second tuple (new) should have succeeded - assertEquals(ClientWriteStatus.SUCCESS, writes.get(1).getStatus()); - assertEquals(duplicateUser, writes.get(1).getTupleKey().getUser()); - assertEquals("viewer", writes.get(1).getTupleKey().getRelation()); - assertEquals(newObject, writes.get(1).getTupleKey().getObject()); - assertNull(writes.get(1).getError()); - } - @Test public void writeTest_transaction() throws Exception { // Given From e75b6a463c9875eca6ae6481772f00134318156e Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Sun, 10 Aug 2025 09:04:51 -0400 Subject: [PATCH 6/9] Add comprehensive JavaDoc documentation for write methods Adds detailed documentation explaining: - Transactional vs non-transactional modes - Success and failure scenarios for each mode - Exception handling behavior - Caller responsibilities for error handling - Implementation details for non-transactional error isolation This addresses the need for clear documentation of the complex error handling behavior introduced for non-transactional writes. --- .../api/client/OpenFgaClient.java.mustache | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 947ff998c..e0fa70626 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -342,7 +342,51 @@ public class OpenFgaClient { /** * Write - Create or delete relationship tuples + * + *

This method can operate in two modes depending on the options provided:

+ * + *

Transactional Mode (default)

+ *

When {@code options.disableTransactions()} is false or not set:

+ *
    + *
  • All writes and deletes are executed as a single atomic transaction
  • + *
  • If any tuple fails, the entire operation fails and no changes are made
  • + *
  • On success: All tuples in the response have {@code ClientWriteStatus.SUCCESS}
  • + *
  • On failure: The method throws an exception (no partial results)
  • + *
+ * + *

Non-Transactional Mode

+ *

When {@code options.disableTransactions()} is true:

+ *
    + *
  • Tuples are processed in chunks (size controlled by {@code transactionChunkSize})
  • + *
  • Each chunk is processed independently - some may succeed while others fail
  • + *
  • The method always returns a response (never throws for tuple-level failures)
  • + *
  • Individual tuple results are indicated by {@code ClientWriteStatus} in the response
  • + *
+ * + *

Non-Transactional Success Scenarios:

+ *
    + *
  • All tuples succeed: All responses have {@code status = SUCCESS, error = null}
  • + *
  • Mixed results: Some responses have {@code status = SUCCESS}, others have {@code status = FAILURE} with error details
  • + *
  • All tuples fail: All responses have {@code status = FAILURE} with individual error details
  • + *
+ * + *

Non-Transactional Exception Scenarios:

+ *
    + *
  • Authentication errors: Method throws immediately (no partial processing)
  • + *
  • Configuration errors: Method throws before processing any tuples
  • + *
  • Network/infrastructure errors: Method may throw depending on the specific error
  • + *
+ * + *

Caller Responsibilities:

+ *
    + *
  • For transactional mode: Handle exceptions for any failures
  • + *
  • For non-transactional mode: Check {@code status} field of each tuple in the response
  • + *
  • For non-transactional mode: Implement retry logic for failed tuples if needed
  • + *
  • For non-transactional mode: Handle partial success scenarios appropriately
  • + *
* + * @param request The write request containing tuples to create or delete + * @return A CompletableFuture containing the write response with individual tuple results * @throws FgaInvalidParameterException When the Store ID is null, empty, or whitespace */ public CompletableFuture write(ClientWriteRequest request) @@ -352,7 +396,52 @@ public class OpenFgaClient { /** * Write - Create or delete relationship tuples + * + *

This method can operate in two modes depending on the options provided:

+ * + *

Transactional Mode (default)

+ *

When {@code options.disableTransactions()} is false or not set:

+ *
    + *
  • All writes and deletes are executed as a single atomic transaction
  • + *
  • If any tuple fails, the entire operation fails and no changes are made
  • + *
  • On success: All tuples in the response have {@code ClientWriteStatus.SUCCESS}
  • + *
  • On failure: The method throws an exception (no partial results)
  • + *
+ * + *

Non-Transactional Mode

+ *

When {@code options.disableTransactions()} is true:

+ *
    + *
  • Tuples are processed in chunks (size controlled by {@code transactionChunkSize})
  • + *
  • Each chunk is processed independently - some may succeed while others fail
  • + *
  • The method always returns a response (never throws for tuple-level failures)
  • + *
  • Individual tuple results are indicated by {@code ClientWriteStatus} in the response
  • + *
+ * + *

Non-Transactional Success Scenarios:

+ *
    + *
  • All tuples succeed: All responses have {@code status = SUCCESS, error = null}
  • + *
  • Mixed results: Some responses have {@code status = SUCCESS}, others have {@code status = FAILURE} with error details
  • + *
  • All tuples fail: All responses have {@code status = FAILURE} with individual error details
  • + *
+ * + *

Non-Transactional Exception Scenarios:

+ *
    + *
  • Authentication errors: Method throws immediately (no partial processing)
  • + *
  • Configuration errors: Method throws before processing any tuples
  • + *
  • Network/infrastructure errors: Method may throw depending on the specific error
  • + *
+ * + *

Caller Responsibilities:

+ *
    + *
  • For transactional mode: Handle exceptions for any failures
  • + *
  • For non-transactional mode: Check {@code status} field of each tuple in the response
  • + *
  • For non-transactional mode: Implement retry logic for failed tuples if needed
  • + *
  • For non-transactional mode: Handle partial success scenarios appropriately
  • + *
* + * @param request The write request containing tuples to create or delete + * @param options Write options including transaction mode and chunk size settings + * @return A CompletableFuture containing the write response with individual tuple results * @throws FgaInvalidParameterException When the Store ID is null, empty, or whitespace */ public CompletableFuture write(ClientWriteRequest request, ClientWriteOptions options) @@ -409,6 +498,33 @@ public class OpenFgaClient { }); } + /** + * Non-transactional write implementation that processes tuples in parallel chunks. + * + *

This method implements the error isolation behavior where individual chunk failures + * do not prevent other chunks from being processed. It performs the following steps:

+ * + *
    + *
  1. Splits writes and deletes into chunks based on {@code transactionChunkSize}
  2. + *
  3. Processes each chunk as an independent transaction in parallel
  4. + *
  5. Collects results from all chunks, marking individual tuples as SUCCESS or FAILURE
  6. + *
  7. Re-throws authentication errors immediately to stop all processing
  8. + *
  9. Converts other errors to FAILURE status for affected tuples
  10. + *
+ * + *

The method guarantees that:

+ *
    + *
  • Authentication errors are never swallowed (they stop all processing)
  • + *
  • Other errors are isolated to their respective chunks
  • + *
  • The response always contains a result for every input tuple
  • + *
  • The order of results matches the order of input tuples
  • + *
+ * + * @param storeId The store ID to write to + * @param request The write request containing tuples to process + * @param writeOptions Options including chunk size and headers + * @return CompletableFuture with results for all tuples, marking each as SUCCESS or FAILURE + */ private CompletableFuture writeNonTransaction( String storeId, ClientWriteRequest request, ClientWriteOptions writeOptions) { From 95ba4bc75f0c18bc52d45407de1b3c0a46ffee4f Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Tue, 12 Aug 2025 08:25:50 -0400 Subject: [PATCH 7/9] Fix Example1 template: update credential env vars and add duplicate tuples - Fix .apiTokenIssuer to use FGA_API_TOKEN_ISSUER instead of FGA_TOKEN_ISSUER - Add duplicate tuple writes to demonstrate individual error handling in non-transactional mode --- .../dev/openfga/sdk/example/Example1.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java b/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java index 931b97e94..3961f84f1 100644 --- a/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java +++ b/config/clients/java/template/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java @@ -18,9 +18,9 @@ public void run(String apiUrl) throws Exception { if (System.getenv("FGA_CLIENT_ID") != null) { credentials = new Credentials(new ClientCredentials() .apiAudience(System.getenv("FGA_API_AUDIENCE")) - .apiTokenIssuer(System.getenv("FGA_TOKEN_ISSUER")) - .clientId("FGA_CLIENT_ID") - .clientSecret("FGA_CLIENT_SECRET")); + .apiTokenIssuer(System.getenv("FGA_API_TOKEN_ISSUER")) + .clientId(System.getenv("FGA_CLIENT_ID")) + .clientSecret(System.getenv("FGA_CLIENT_SECRET"))); } else { System.out.println("Proceeding with no credentials (expecting localhost)"); } @@ -102,10 +102,20 @@ public void run(String apiUrl) throws Exception { fgaClient .write( new ClientWriteRequest() - .writes(List.of(new ClientTupleKey() - .user("user:anne") - .relation("writer") - ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"))), + .writes(List.of( + new ClientTupleKey() + .user("user:anne") + .relation("writer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"), + new ClientTupleKey() + .user("user:anne") + .relation("writer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"), // duplicate + new ClientTupleKey() + .user("user:anne") + .relation("owner") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") // different relation + )), new ClientWriteOptions() .disableTransactions(true) .authorizationModelId(authorizationModel.getAuthorizationModelId())) From 9761133e636558a76243b1c44fe0fc8f70f37f74 Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Tue, 12 Aug 2025 08:32:07 -0400 Subject: [PATCH 8/9] Add settings.gradle.mustache mapping to Java config - Add mapping for settings.gradle.mustache in config.overrides.json - Ensures settings.gradle file is generated with pluginManagement block - Addresses Code Rabbit suggestion for complete Gradle project structure --- config/clients/java/config.overrides.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/clients/java/config.overrides.json b/config/clients/java/config.overrides.json index b32740143..f95c16ad0 100644 --- a/config/clients/java/config.overrides.json +++ b/config/clients/java/config.overrides.json @@ -533,6 +533,10 @@ "destinationFilename": "gradle/wrapper/gradle-wrapper.properties", "templateType": "SupportingFiles" }, + "settings.gradle.mustache": { + "destinationFilename": "settings.gradle", + "templateType": "SupportingFiles" + }, "docs/OpenTelemetry.md.mustache": { "destinationFilename": "docs/OpenTelemetry.md", "templateType": "SupportingFiles" From 376b968e9243ab3af26b52ea5c370726421c86ee Mon Sep 17 00:00:00 2001 From: Jakub Hertyk Date: Tue, 12 Aug 2025 13:22:24 -0400 Subject: [PATCH 9/9] Revert "Add settings.gradle.mustache mapping to Java config" This reverts commit 9761133e636558a76243b1c44fe0fc8f70f37f74. --- config/clients/java/config.overrides.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config/clients/java/config.overrides.json b/config/clients/java/config.overrides.json index f95c16ad0..b32740143 100644 --- a/config/clients/java/config.overrides.json +++ b/config/clients/java/config.overrides.json @@ -533,10 +533,6 @@ "destinationFilename": "gradle/wrapper/gradle-wrapper.properties", "templateType": "SupportingFiles" }, - "settings.gradle.mustache": { - "destinationFilename": "settings.gradle", - "templateType": "SupportingFiles" - }, "docs/OpenTelemetry.md.mustache": { "destinationFilename": "docs/OpenTelemetry.md", "templateType": "SupportingFiles"