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)
@@ -391,9 +480,51 @@ 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.asTupleKey(), ClientWriteStatus.SUCCESS))
+ .collect(Collectors.toList()) : new ArrayList<>();
+
+ List deleteResponses = deleteTuples != null ?
+ deleteTuples.stream()
+ .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);
+ });
}
+ /**
+ * 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:
+ *
+ *
+ *
Splits writes and deletes into chunks based on {@code transactionChunkSize}
+ *
Processes each chunk as an independent transaction in parallel
+ *
Collects results from all chunks, marking individual tuples as SUCCESS or FAILURE
+ *
Re-throws authentication errors immediately to stop all processing
+ *
Converts other errors to FAILURE status for affected tuples
+ *
+ *
+ *
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) {
@@ -409,29 +540,95 @@ 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 in this chunk as successful
+ return chunk.stream()
+ .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), ClientWriteStatus.SUCCESS))
+ .collect(Collectors.toList());
+ })
+ .exceptionally(exception -> {
+ // 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, but continue processing other chunks
+ return chunk.stream()
+ .map(tuple -> new ClientWriteSingleResponse(tuple.asTupleKey(), 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 in this chunk as successful
+ return chunk.stream()
+ .map(tuple -> new ClientWriteSingleResponse(
+ new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()),
+ ClientWriteStatus.SUCCESS))
+ .collect(Collectors.toList());
+ })
+ .exceptionally(exception -> {
+ // 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, but continue processing other chunks
+ return chunk.stream()
+ .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());
+ });
+
+ 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 +680,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.asTupleKey(), ClientWriteStatus.SUCCESS))
+ .collect(Collectors.toList());
+ return new ClientWriteResponse(writeResponses, new ArrayList<>());
+ });
}
/**
@@ -518,7 +720,14 @@ 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(
+ new TupleKey().user(tuple.getUser()).relation(tuple.getRelation())._object(tuple.getObject()),
+ 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 1883f647..7535d6a2 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