diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index f287b43c..875ddfe7 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -164,6 +164,8 @@ src/main/java/dev/openfga/sdk/api/client/model/ClientWriteAssertionsResponse.jav src/main/java/dev/openfga/sdk/api/client/model/ClientWriteAuthorizationModelResponse.java src/main/java/dev/openfga/sdk/api/client/model/ClientWriteRequest.java src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java +src/main/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponse.java +src/main/java/dev/openfga/sdk/api/client/model/ClientWriteStatus.java src/main/java/dev/openfga/sdk/api/configuration/AdditionalHeadersSupplier.java src/main/java/dev/openfga/sdk/api/configuration/ApiToken.java src/main/java/dev/openfga/sdk/api/configuration/BaseConfiguration.java @@ -302,7 +304,10 @@ src/main/java/dev/openfga/sdk/telemetry/Histograms.java src/main/java/dev/openfga/sdk/telemetry/Metric.java src/main/java/dev/openfga/sdk/telemetry/Metrics.java src/main/java/dev/openfga/sdk/telemetry/Telemetry.java +src/main/java/dev/openfga/sdk/util/ExponentialBackoff.java src/main/java/dev/openfga/sdk/util/Pair.java +src/main/java/dev/openfga/sdk/util/RetryAfterHeaderParser.java +src/main/java/dev/openfga/sdk/util/RetryStrategy.java src/main/java/dev/openfga/sdk/util/StringUtil.java src/main/java/dev/openfga/sdk/util/Validation.java src/test-integration/java/dev/openfga/sdk/api/OpenFgaApiIntegrationTest.java @@ -317,6 +322,7 @@ src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java src/test/java/dev/openfga/sdk/api/auth/AccessTokenTest.java src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java +src/test/java/dev/openfga/sdk/api/client/HttpRequestAttemptRetryTest.java src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java src/test/java/dev/openfga/sdk/api/configuration/ClientCredentialsTest.java src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java @@ -330,6 +336,9 @@ src/test/java/dev/openfga/sdk/telemetry/HistogramsTest.java src/test/java/dev/openfga/sdk/telemetry/MetricTest.java src/test/java/dev/openfga/sdk/telemetry/MetricsTest.java src/test/java/dev/openfga/sdk/telemetry/TelemetryTest.java +src/test/java/dev/openfga/sdk/util/ExponentialBackoffTest.java src/test/java/dev/openfga/sdk/util/PairTest.java +src/test/java/dev/openfga/sdk/util/RetryAfterHeaderParserTest.java +src/test/java/dev/openfga/sdk/util/RetryStrategyTest.java src/test/java/dev/openfga/sdk/util/StringUtilTest.java src/test/java/dev/openfga/sdk/util/ValidationTest.java diff --git a/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java b/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java index 931b97e9..3961f84f 100644 --- a/example/example1/src/main/java/dev/openfga/sdk/example/Example1.java +++ b/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())) diff --git a/settings.gradle b/settings.gradle index a9ad0d18..c7a0f97f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = 'openfga-sdk' \ No newline at end of file diff --git a/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java b/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java index d3f2332f..62bab071 100644 --- a/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java +++ b/src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java @@ -125,6 +125,9 @@ private CompletableFuture> handleHttpErrorRetry( Duration retryDelay = RetryStrategy.calculateRetryDelay(retryAfterDelay, retryNumber, configuration.getMinimumRetryDelay()); + // Add telemetry for HTTP error retry + addTelemetryAttribute(Attributes.HTTP_REQUEST_RESEND_COUNT, String.valueOf(retryNumber + 1)); + return delayedRetry(retryDelay, retryNumber + 1, error); } @@ -184,8 +187,13 @@ private CompletableFuture> processHttpResponse( response.headers().firstValue("fga-query-duration-ms").orElse(null); if (!isNullOrWhitespace(queryDuration)) { - double queryDurationDouble = Double.parseDouble(queryDuration); - telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes()); + try { + double queryDurationDouble = Double.parseDouble(queryDuration); + telemetry.metrics().queryDuration(queryDurationDouble, this.getTelemetryAttributes()); + } catch (NumberFormatException e) { + // Ignore malformed fga-query-duration-ms header values to prevent exceptions + // on otherwise valid responses. The telemetry metric will simply not be recorded. + } } } diff --git a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java index 540cf7e2..f3d28b4d 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -358,6 +358,50 @@ public CompletableFuture read(ClientReadRequest request, Cli /** * 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) @@ -368,6 +412,51 @@ public CompletableFuture write(ClientWriteRequest request) /** * 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) @@ -406,9 +495,56 @@ private CompletableFuture writeTransactions( 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:

+ * + *
    + *
  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) { @@ -424,29 +560,106 @@ private CompletableFuture writeNonTransaction( .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()); + 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()); + }); - if (transactions.isEmpty()) { - var emptyTransaction = new ClientWriteRequest().writes(null).deletes(null); - return this.writeTransactions(storeId, emptyTransaction, writeOptions); + 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. + // 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()); + }); - // 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)); + 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) { @@ -498,7 +711,12 @@ public CompletableFuture writeTuples( 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<>()); + }); } /** @@ -533,7 +751,17 @@ public CompletableFuture deleteTuples( 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/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java index 505a5730..d15acf24 100644 --- a/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteResponse.java @@ -13,6 +13,7 @@ package dev.openfga.sdk.api.client.model; import dev.openfga.sdk.api.client.ApiResponse; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -20,11 +21,23 @@ 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() { @@ -38,4 +51,12 @@ public Map> getHeaders() { public String getRawResponse() { return rawResponse; } + + public List getWrites() { + return writes; + } + + public List getDeletes() { + return deletes; + } } diff --git a/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponse.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponse.java new file mode 100644 index 00000000..c8222077 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteSingleResponse.java @@ -0,0 +1,43 @@ +/* + * OpenFGA + * A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar. + * + * The version of the OpenAPI document: 1.x + * Contact: community@openfga.dev + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +package dev.openfga.sdk.api.client.model; + +import dev.openfga.sdk.api.model.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/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteStatus.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteStatus.java new file mode 100644 index 00000000..a5082e1b --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientWriteStatus.java @@ -0,0 +1,18 @@ +/* + * OpenFGA + * A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar. + * + * The version of the OpenAPI document: 1.x + * Contact: community@openfga.dev + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +package dev.openfga.sdk.api.client.model; + +public enum ClientWriteStatus { + SUCCESS, + FAILURE +} diff --git a/src/main/java/dev/openfga/sdk/telemetry/Metrics.java b/src/main/java/dev/openfga/sdk/telemetry/Metrics.java index 9a4fca69..2ef56071 100644 --- a/src/main/java/dev/openfga/sdk/telemetry/Metrics.java +++ b/src/main/java/dev/openfga/sdk/telemetry/Metrics.java @@ -14,7 +14,7 @@ import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.TelemetryConfiguration; -import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.metrics.DoubleHistogram; import io.opentelemetry.api.metrics.LongCounter; import io.opentelemetry.api.metrics.Meter; @@ -35,7 +35,7 @@ public Metrics() { } public Metrics(Configuration configuration) { - this.meter = OpenTelemetry.noop().getMeterProvider().get("openfga-sdk"); + this.meter = GlobalOpenTelemetry.get().getMeterProvider().get("openfga-sdk"); this.counters = new HashMap<>(); this.histograms = new HashMap<>(); this.configuration = configuration; diff --git a/src/main/java/dev/openfga/sdk/util/ExponentialBackoff.java b/src/main/java/dev/openfga/sdk/util/ExponentialBackoff.java index 4439e5ff..e3f51a08 100644 --- a/src/main/java/dev/openfga/sdk/util/ExponentialBackoff.java +++ b/src/main/java/dev/openfga/sdk/util/ExponentialBackoff.java @@ -14,6 +14,7 @@ import java.time.Duration; import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; /** * Utility class for calculating exponential backoff delays with jitter. @@ -26,7 +27,6 @@ public class ExponentialBackoff { private static final int MAX_DELAY_SECONDS = 120; - private static final Random RANDOM = new Random(); private ExponentialBackoff() { // Utility class - no instantiation @@ -40,7 +40,7 @@ private ExponentialBackoff() { * @return Duration representing the delay before the next retry */ public static Duration calculateDelay(int retryCount, Duration baseDelay) { - return calculateDelay(retryCount, baseDelay, RANDOM); + return calculateDelay(retryCount, baseDelay, ThreadLocalRandom.current()); } /** @@ -61,6 +61,12 @@ static Duration calculateDelay(int retryCount, Duration baseDelay, Random random if (baseDelay == null) { throw new IllegalArgumentException("baseDelay cannot be null"); } + if (baseDelay.isNegative()) { + throw new IllegalArgumentException("baseDelay cannot be negative"); + } + if (baseDelay.isZero()) { + throw new IllegalArgumentException("baseDelay cannot be zero"); + } long baseDelayMs = baseDelay.toMillis(); // Calculate exponential delay: 2^retryCount * baseDelay diff --git a/src/test-integration/java/dev/openfga/sdk/example/Example1.java b/src/test-integration/java/dev/openfga/sdk/example/Example1.java index 1af068bf..97d0cfe0 100644 --- a/src/test-integration/java/dev/openfga/sdk/example/Example1.java +++ b/src/test-integration/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)"); } @@ -101,10 +101,21 @@ 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())) 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 c869b313..fff5bf27 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1158,6 +1158,13 @@ public void writeTest_writes() throws Exception { // 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()); } /** @@ -1259,18 +1266,18 @@ public void writeTest_nonTransaction() throws Exception { } @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); @@ -1281,7 +1288,7 @@ public void writeTest_nonTransactionsWithFailure() { .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) @@ -1292,8 +1299,7 @@ public void writeTest_nonTransactionsWithFailure() { 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 @@ -1313,12 +1319,28 @@ public void writeTest_nonTransactionsWithFailure() { 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 @@ -1984,7 +2006,7 @@ public void shouldSplitBatchesSuccessfully(WireMockRuntimeInfo wireMockRuntimeIn WireMock.aResponse() .withStatus(200) .withBody( - "{\"result\": {\"cor-3\": {\"allowed\": false, \"error\": {\"input_error\": \"relation_not_found\", \"message\": \"relation not found\"}}}}}"))); + "{\"result\": {\"cor-3\": {\"allowed\": false, \"error\": {\"input_error\": \"relation_not_found\", \"message\": \"relation not found\"}}}}"))); ClientBatchCheckItem item1 = new ClientBatchCheckItem() .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") diff --git a/src/test/java/dev/openfga/sdk/util/ExponentialBackoffTest.java b/src/test/java/dev/openfga/sdk/util/ExponentialBackoffTest.java index d26ccf22..d49b60ac 100644 --- a/src/test/java/dev/openfga/sdk/util/ExponentialBackoffTest.java +++ b/src/test/java/dev/openfga/sdk/util/ExponentialBackoffTest.java @@ -14,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.Duration; import java.util.Random; @@ -194,6 +195,44 @@ void calculateDelay_withCustomBaseDelay_shouldUseConfigurableBase() { assertThat(result.toMillis()).isBetween(1000L, 2000L); } + @Test + void calculateDelay_withNegativeBaseDelay_shouldThrowException() { + // Given + int retryCount = 1; + Duration negativeBaseDelay = Duration.ofMillis(-100); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> ExponentialBackoff.calculateDelay(retryCount, negativeBaseDelay)); + assertThat(exception.getMessage()).isEqualTo("baseDelay cannot be negative"); + } + + @Test + void calculateDelay_withNegativeBaseDuration_shouldThrowException() { + // Given + int retryCount = 2; + Duration negativeBaseDuration = Duration.ofSeconds(-1); + Random random = new Random(42); + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ExponentialBackoff.calculateDelay(retryCount, negativeBaseDuration, random)); + assertThat(exception.getMessage()).isEqualTo("baseDelay cannot be negative"); + } + + @Test + void calculateDelay_withZeroBaseDelay_shouldThrowException() { + // Given + int retryCount = 1; + Duration zeroBaseDelay = Duration.ZERO; + + // When & Then + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> ExponentialBackoff.calculateDelay(retryCount, zeroBaseDelay)); + assertThat(exception.getMessage()).isEqualTo("baseDelay cannot be zero"); + } + @Test void calculateDelay_withNullBaseDelay_shouldThrowException() { // Given