From d1bc067c33be0531ceb4530d51d91403aae98ee4 Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Mon, 22 Sep 2025 21:30:23 +0300 Subject: [PATCH 1/7] Add support for aggregator API keys --- build.gradle.kts | 2 + .../unicitylabs/sdk/api/AggregatorClient.java | 8 +- .../sdk/jsonrpc/JsonRpcHttpTransport.java | 55 ++++++- .../jsonrpc/RateLimitExceededException.java | 20 +++ .../sdk/jsonrpc/UnauthorizedException.java | 16 ++ .../unicitylabs/sdk/MockAggregatorServer.java | 152 ++++++++++++++++++ .../sdk/TestApiKeyIntegration.java | 147 +++++++++++++++++ 7 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java create mode 100644 src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java create mode 100644 src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java create mode 100644 src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java diff --git a/build.gradle.kts b/build.gradle.kts index ce8e542..01d46c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,12 +45,14 @@ dependencies { // Testing testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.testcontainers:testcontainers:1.19.8") testImplementation("org.testcontainers:junit-jupiter:1.19.8") testImplementation("org.testcontainers:mongodb:1.19.8") testImplementation("org.awaitility:awaitility:4.2.0") testImplementation("org.slf4j:slf4j-simple:2.0.13") testImplementation("com.google.guava:guava:33.0.0-jre") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") // ✅ Cucumber for BDD testImplementation("io.cucumber:cucumber-java:7.27.2") diff --git a/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java b/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java index 66d4a64..5e6acfa 100644 --- a/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java +++ b/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java @@ -9,9 +9,15 @@ public class AggregatorClient implements IAggregatorClient { private final JsonRpcHttpTransport transport; + private final String apiKey; public AggregatorClient(String url) { + this(url, null); + } + + public AggregatorClient(String url, String apiKey) { this.transport = new JsonRpcHttpTransport(url); + this.apiKey = apiKey; } public CompletableFuture submitCommitment( @@ -21,7 +27,7 @@ public CompletableFuture submitCommitment( SubmitCommitmentRequest request = new SubmitCommitmentRequest(requestId, transactionHash, authenticator, false); - return this.transport.request("submit_commitment", request, SubmitCommitmentResponse.class); + return this.transport.request("submit_commitment", request, SubmitCommitmentResponse.class, this.apiKey); } public CompletableFuture getInclusionProof(RequestId requestId) { diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java index 3941b5d..5ab542a 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java @@ -13,14 +13,18 @@ import okhttp3.Response; import okhttp3.ResponseBody; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; + /** * JSON-RPC HTTP service. */ public class JsonRpcHttpTransport { - private static final MediaType MEDIA_TYPE_JSON = MediaType.get("application/json; charset=utf-8"); + private static final MediaType MEDIA_TYPE_JSON = MediaType.get("application/json; charset=utf-8"); + private static final int HTTP_TOO_MANY_REQUESTS = 429; + private static final String HTTP_RETRY_AFTER = "Retry-After"; - private final String url; + private final String url; private final OkHttpClient httpClient; /** @@ -35,17 +39,29 @@ public JsonRpcHttpTransport(String url) { * Send a JSON-RPC request. */ public CompletableFuture request(String method, Object params, Class resultType) { + return request(method, params, resultType, null); + } + + /** + * Send a JSON-RPC request with optional API key. + */ + public CompletableFuture request(String method, Object params, Class resultType, String apiKey) { CompletableFuture future = new CompletableFuture<>(); try { - Request request = new Request.Builder() + Request.Builder requestBuilder = new Request.Builder() .url(this.url) .post( RequestBody.create( UnicityObjectMapper.JSON.writeValueAsString(new JsonRpcRequest(method, params)), JsonRpcHttpTransport.MEDIA_TYPE_JSON) - ) - .build(); + ); + + if (apiKey != null) { + requestBuilder.header("Authorization", "Bearer " + apiKey); + } + + Request request = requestBuilder.build(); this.httpClient.newCall(request).enqueue(new Callback() { @Override @@ -58,8 +74,21 @@ public void onResponse(Call call, Response response) throws IOException { try (ResponseBody body = response.body()) { if (!response.isSuccessful()) { String error = body != null ? body.string() : ""; - future.completeExceptionally(new JsonRpcNetworkError(response.code(), error)); - return; + + if (response.code() == HTTP_UNAUTHORIZED) { + future.completeExceptionally(new UnauthorizedException( + "Unauthorized: Invalid or missing API key")); + return; + } else if (response.code() == HTTP_TOO_MANY_REQUESTS) { + int retryAfterSeconds = extractRetryAfterSeconds(response); + future.completeExceptionally(new RateLimitExceededException( + "Rate limit exceeded. Please retry after " + retryAfterSeconds + " seconds", + retryAfterSeconds)); + return; + } else { + future.completeExceptionally(new JsonRpcNetworkError(response.code(), error)); + return; + } } JsonRpcResponse data = UnicityObjectMapper.JSON.readValue( @@ -85,4 +114,16 @@ public void onResponse(Call call, Response response) throws IOException { return future; } + + private int extractRetryAfterSeconds(Response response) { + String retryAfterHeader = response.header(HTTP_RETRY_AFTER); + if (retryAfterHeader != null) { + try { + return Integer.parseInt(retryAfterHeader); + } catch (NumberFormatException ignored) { + } + } + // Default to 60 seconds if the HTTP header is missing, e.g. if the response is coming from a different component that is not using this header. + return 60; + } } diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java new file mode 100644 index 0000000..e0cfcc2 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java @@ -0,0 +1,20 @@ +package org.unicitylabs.sdk.jsonrpc; + +public class RateLimitExceededException extends RuntimeException { + + private final int retryAfterSeconds; + + public RateLimitExceededException(String message, int retryAfterSeconds) { + super(message); + this.retryAfterSeconds = retryAfterSeconds; + } + + public RateLimitExceededException(String message, int retryAfterSeconds, Throwable cause) { + super(message, cause); + this.retryAfterSeconds = retryAfterSeconds; + } + + public int getRetryAfterSeconds() { + return retryAfterSeconds; + } +} \ No newline at end of file diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java new file mode 100644 index 0000000..01d3363 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java @@ -0,0 +1,16 @@ +package org.unicitylabs.sdk.jsonrpc; + +/** + * Exception thrown when an API request is unauthorized (HTTP 401). + * This typically occurs when an API key is missing or invalid. + */ +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } + + public UnauthorizedException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java new file mode 100644 index 0000000..65eb4bb --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java @@ -0,0 +1,152 @@ +package org.unicitylabs.sdk; + +import com.fasterxml.jackson.core.JsonProcessingException; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.Set; +import java.util.HashSet; +import java.util.UUID; + +public class MockAggregatorServer { + + private final MockWebServer server; + private final ObjectMapper objectMapper; + private final Set protectedMethods; + private volatile boolean simulateRateLimit = false; + private volatile int rateLimitRetryAfter = 60; + private volatile String expectedApiKey = null; + + public MockAggregatorServer() { + this.server = new MockWebServer(); + this.objectMapper = new ObjectMapper(); + this.protectedMethods = new HashSet<>(); + this.protectedMethods.add("submit_commitment"); + + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return handleRequest(request); + } + }); + } + + public void start() throws IOException { + server.start(); + } + + public void shutdown() throws IOException { + server.shutdown(); + } + + public String getUrl() { + return server.url("/").toString(); + } + + public RecordedRequest takeRequest() throws InterruptedException { + return server.takeRequest(); + } + + public void simulateRateLimitForNextRequest(int retryAfterSeconds) { + this.simulateRateLimit = true; + this.rateLimitRetryAfter = retryAfterSeconds; + } + + public void setExpectedApiKey(String apiKey) { + this.expectedApiKey = apiKey; + } + + private MockResponse handleRequest(RecordedRequest request) { + try { + if (simulateRateLimit) { + simulateRateLimit = false; // Reset for next request + return new MockResponse() + .setResponseCode(429) + .setHeader("Retry-After", String.valueOf(rateLimitRetryAfter)) + .setBody("Too Many Requests"); + } + + String method = extractJsonRpcMethod(request); + + if (protectedMethods.contains(method) && expectedApiKey != null && !hasValidApiKey(request)) { + return new MockResponse() + .setResponseCode(401) + .setHeader("WWW-Authenticate", "Bearer") + .setBody("Unauthorized"); + } + + return generateSuccessResponse(method); + + } catch (Exception e) { + return new MockResponse() + .setResponseCode(400) + .setBody("Bad Request"); + } + } + + private boolean hasValidApiKey(RecordedRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String providedKey = authHeader.substring(7); + return expectedApiKey.equals(providedKey); + } + return false; + } + + private @Nullable String extractJsonRpcMethod(RecordedRequest request) throws JsonProcessingException { + if (!"POST".equals(request.getMethod())) { + return null; + } + JsonNode jsonRequest = objectMapper.readTree(request.getBody().readUtf8()); + return jsonRequest.has("method") ? jsonRequest.get("method").asText() : null; + } + + private MockResponse generateSuccessResponse(String method) { + String responseBody; + String id = UUID.randomUUID().toString(); + + switch (method != null ? method : "") { + case "submit_commitment": + responseBody = String.format( + "{\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + + " \"status\": \"SUCCESS\"\n" + + " },\n" + + " \"id\": \"%s\"\n" + + "}", id); + break; + + case "get_block_height": + responseBody = String.format( + "{\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + + " \"blockNumber\": \"67890\"\n" + + " },\n" + + " \"id\": \"%s\"\n" + + "}", id); + break; + + default: + responseBody = String.format( + "{\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": \"OK\",\n" + + " \"id\": \"%s\"\n" + + "}", id); + break; + } + + return new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(responseBody); + } +} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java new file mode 100644 index 0000000..0410c69 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -0,0 +1,147 @@ +package org.unicitylabs.sdk; + +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.api.AggregatorClient; +import org.unicitylabs.sdk.api.Authenticator; +import org.unicitylabs.sdk.api.RequestId; +import org.unicitylabs.sdk.api.SubmitCommitmentResponse; +import org.unicitylabs.sdk.api.SubmitCommitmentStatus; +import org.unicitylabs.sdk.hash.DataHash; +import org.unicitylabs.sdk.hash.HashAlgorithm; +import org.unicitylabs.sdk.jsonrpc.RateLimitExceededException; +import org.unicitylabs.sdk.jsonrpc.UnauthorizedException; +import org.unicitylabs.sdk.signing.SigningService; +import org.unicitylabs.sdk.util.HexConverter; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestApiKeyIntegration { + + private static final String TEST_API_KEY = "test-api-key-12345"; + + private MockAggregatorServer mockServer; + private AggregatorClient clientWithApiKey; + private AggregatorClient clientWithoutApiKey; + private SigningService signingService; + + private DataHash transactionHash; + private DataHash stateHash; + private RequestId requestId; + private Authenticator authenticator; + + @BeforeEach + void setUp() throws Exception { + mockServer = new MockAggregatorServer(); + mockServer.setExpectedApiKey(TEST_API_KEY); + mockServer.start(); + + clientWithApiKey = new AggregatorClient(mockServer.getUrl(), TEST_API_KEY); + clientWithoutApiKey = new AggregatorClient(mockServer.getUrl()); + + signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); + + stateHash = new DataHash(HashAlgorithm.SHA256, HexConverter.decode("fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321")); + requestId = RequestId.create(signingService.getPublicKey(), stateHash); + transactionHash = new DataHash(HashAlgorithm.SHA256, HexConverter.decode("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")); + + authenticator = Authenticator.create(signingService, transactionHash, stateHash); + } + + @AfterEach + void tearDown() throws Exception { + mockServer.shutdown(); + } + + @Test + public void testSubmitCommitmentWithApiKey() throws Exception { + CompletableFuture future = + clientWithApiKey.submitCommitment(requestId, transactionHash, authenticator); + + SubmitCommitmentResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(SubmitCommitmentStatus.SUCCESS, response.getStatus()); + + RecordedRequest request = mockServer.takeRequest(); + assertEquals("Bearer " + TEST_API_KEY, request.getHeader("Authorization")); + } + + @Test + public void testSubmitCommitmentWithoutApiKeyThrowsUnauthorized() throws Exception { + CompletableFuture future = + clientWithoutApiKey.submitCommitment(requestId, transactionHash, authenticator); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected UnauthorizedException to be thrown"); + } catch (Exception e) { + assertTrue(e instanceof java.util.concurrent.ExecutionException); + assertTrue(e.getCause() instanceof UnauthorizedException); + assertEquals("Unauthorized: Invalid or missing API key", e.getCause().getMessage()); + } + + RecordedRequest request = mockServer.takeRequest(); + assertNull(request.getHeader("Authorization")); + } + + @Test + public void testSubmitCommitmentWithWrongApiKeyThrowsUnauthorized() throws Exception { + mockServer.setExpectedApiKey("different-api-key"); + + CompletableFuture future = + clientWithApiKey.submitCommitment(requestId, transactionHash, authenticator); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected UnauthorizedException to be thrown"); + } catch (Exception e) { + assertTrue(e instanceof java.util.concurrent.ExecutionException); + assertTrue(e.getCause() instanceof UnauthorizedException); + } + + RecordedRequest request = mockServer.takeRequest(); + assertEquals("Bearer " + TEST_API_KEY, request.getHeader("Authorization")); + } + + @Test + public void testRateLimitExceeded() throws Exception { + mockServer.simulateRateLimitForNextRequest(30); + + CompletableFuture future = + clientWithApiKey.submitCommitment(requestId, transactionHash, authenticator); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected RateLimitExceededException to be thrown"); + } catch (Exception e) { + assertTrue(e instanceof java.util.concurrent.ExecutionException); + assertTrue(e.getCause() instanceof RateLimitExceededException); + RateLimitExceededException rateLimitEx = (RateLimitExceededException) e.getCause(); + assertEquals(30, rateLimitEx.getRetryAfterSeconds()); + assertTrue(rateLimitEx.getMessage().contains("30 seconds")); + } + } + + @Test + public void testGetBlockHeightWorksWithoutApiKey() throws Exception { + CompletableFuture future = clientWithoutApiKey.getBlockHeight(); + + Long blockHeight = future.get(5, TimeUnit.SECONDS); + assertNotNull(blockHeight); + assertEquals(67890L, blockHeight); + } + + @Test + public void testGetBlockHeightAlsoWorksWithApiKey() throws Exception { + CompletableFuture future = clientWithApiKey.getBlockHeight(); + + Long blockHeight = future.get(5, TimeUnit.SECONDS); + assertNotNull(blockHeight); + assertEquals(67890L, blockHeight); + } +} \ No newline at end of file From c9f263e4fa58ffed9ecc9aa5b3eb02ff52f8f46b Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Mon, 29 Sep 2025 22:30:13 +0300 Subject: [PATCH 2/7] Update src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../unicitylabs/sdk/jsonrpc/RateLimitExceededException.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java index e0cfcc2..7271c9e 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java @@ -1,5 +1,11 @@ package org.unicitylabs.sdk.jsonrpc; +/** + * Exception thrown when a rate limit is exceeded in the API. + *

+ * The {@code retryAfterSeconds} field indicates the number of seconds + * the client should wait before retrying the request. + */ public class RateLimitExceededException extends RuntimeException { private final int retryAfterSeconds; From 7f7b197cdd78e863c3a49103dddb94c4c2e5e41f Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Tue, 30 Sep 2025 12:29:12 +0300 Subject: [PATCH 3/7] Improve mock state resets between requests --- .../unicitylabs/sdk/MockAggregatorServer.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java index 65eb4bb..fc63d44 100644 --- a/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java +++ b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java @@ -20,7 +20,7 @@ public class MockAggregatorServer { private final ObjectMapper objectMapper; private final Set protectedMethods; private volatile boolean simulateRateLimit = false; - private volatile int rateLimitRetryAfter = 60; + private volatile int rateLimitRetryAfter = 0; private volatile String expectedApiKey = null; public MockAggregatorServer() { @@ -65,11 +65,16 @@ public void setExpectedApiKey(String apiKey) { private MockResponse handleRequest(RecordedRequest request) { try { if (simulateRateLimit) { - simulateRateLimit = false; // Reset for next request - return new MockResponse() - .setResponseCode(429) - .setHeader("Retry-After", String.valueOf(rateLimitRetryAfter)) - .setBody("Too Many Requests"); + try { + return new MockResponse() + .setResponseCode(429) + .setHeader("Retry-After", String.valueOf(rateLimitRetryAfter)) + .setBody("Too Many Requests"); + } finally { + // Reset for next request + simulateRateLimit = false; + rateLimitRetryAfter = 0; + } } String method = extractJsonRpcMethod(request); From 178ec0805b26052a3a854919708f95f423b1532d Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Tue, 30 Sep 2025 12:36:11 +0300 Subject: [PATCH 4/7] Refactor error message construction --- .../sdk/jsonrpc/JsonRpcHttpTransport.java | 7 ++----- .../sdk/jsonrpc/RateLimitExceededException.java | 17 ++++++++++++----- .../sdk/jsonrpc/UnauthorizedException.java | 12 +++++++----- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java index dc3de72..83bcda4 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java @@ -76,14 +76,11 @@ public void onResponse(Call call, Response response) { String error = body != null ? body.string() : ""; if (response.code() == HTTP_UNAUTHORIZED) { - future.completeExceptionally(new UnauthorizedException( - "Unauthorized: Invalid or missing API key")); + future.completeExceptionally(new UnauthorizedException()); return; } else if (response.code() == HTTP_TOO_MANY_REQUESTS) { int retryAfterSeconds = extractRetryAfterSeconds(response); - future.completeExceptionally(new RateLimitExceededException( - "Rate limit exceeded. Please retry after " + retryAfterSeconds + " seconds", - retryAfterSeconds)); + future.completeExceptionally(new RateLimitExceededException( retryAfterSeconds)); return; } else { future.completeExceptionally(new JsonRpcNetworkError(response.code(), error)); diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java index 7271c9e..afba9d6 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java @@ -1,5 +1,7 @@ package org.unicitylabs.sdk.jsonrpc; +import org.jetbrains.annotations.NotNull; + /** * Exception thrown when a rate limit is exceeded in the API. *

@@ -10,17 +12,22 @@ public class RateLimitExceededException extends RuntimeException { private final int retryAfterSeconds; - public RateLimitExceededException(String message, int retryAfterSeconds) { - super(message); + public RateLimitExceededException(int retryAfterSeconds) { + super(getMessage(retryAfterSeconds)); this.retryAfterSeconds = retryAfterSeconds; } - - public RateLimitExceededException(String message, int retryAfterSeconds, Throwable cause) { - super(message, cause); + + public RateLimitExceededException(int retryAfterSeconds, Throwable cause) { + super(getMessage(retryAfterSeconds), cause); this.retryAfterSeconds = retryAfterSeconds; } public int getRetryAfterSeconds() { return retryAfterSeconds; } + + @NotNull + private static String getMessage(int retryAfterSeconds) { + return "Rate limit exceeded. Please retry after " + retryAfterSeconds + " seconds"; + } } \ No newline at end of file diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java index 01d3363..b693b3f 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java @@ -5,12 +5,14 @@ * This typically occurs when an API key is missing or invalid. */ public class UnauthorizedException extends RuntimeException { - - public UnauthorizedException(String message) { - super(message); + + public static final String MESSAGE = "Unauthorized: Invalid or missing API key"; + + public UnauthorizedException() { + super(MESSAGE); } - public UnauthorizedException(String message, Throwable cause) { - super(message, cause); + public UnauthorizedException(Throwable cause) { + super(MESSAGE, cause); } } \ No newline at end of file From d908839ca693955bb075673a1bdb6ceed5c700db Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Tue, 30 Sep 2025 15:03:42 +0300 Subject: [PATCH 5/7] Refactor JsonRpcHttpTransport to not depend on a higher level detail like specific HTTP header names --- .../org/unicitylabs/sdk/api/AggregatorClient.java | 11 ++++++++++- .../sdk/jsonrpc/JsonRpcHttpTransport.java | 14 ++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java b/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java index 6f000c1..0a9e96f 100644 --- a/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java +++ b/src/main/java/org/unicitylabs/sdk/api/AggregatorClient.java @@ -1,10 +1,15 @@ package org.unicitylabs.sdk.api; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import org.unicitylabs.sdk.hash.DataHash; import org.unicitylabs.sdk.jsonrpc.JsonRpcHttpTransport; +import static com.google.common.net.HttpHeaders.AUTHORIZATION; + public class AggregatorClient implements IAggregatorClient { private final JsonRpcHttpTransport transport; @@ -26,7 +31,11 @@ public CompletableFuture submitCommitment( SubmitCommitmentRequest request = new SubmitCommitmentRequest(requestId, transactionHash, authenticator, false); - return this.transport.request("submit_commitment", request, SubmitCommitmentResponse.class, this.apiKey); + Map> headers = new LinkedHashMap<>(); + if (apiKey != null) { + headers.put(AUTHORIZATION, Collections.singletonList("Bearer " + apiKey)); + } + return this.transport.request("submit_commitment", request, SubmitCommitmentResponse.class, headers); } public CompletableFuture getInclusionProof(RequestId requestId) { diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java index 83bcda4..fdc79f0 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java @@ -2,6 +2,8 @@ package org.unicitylabs.sdk.jsonrpc; import java.io.IOException; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import okhttp3.Call; import okhttp3.Callback; @@ -39,13 +41,13 @@ public JsonRpcHttpTransport(String url) { * Send a JSON-RPC request. */ public CompletableFuture request(String method, Object params, Class resultType) { - return request(method, params, resultType, null); + return request(method, params, resultType, Map.of()); } /** * Send a JSON-RPC request with optional API key. */ - public CompletableFuture request(String method, Object params, Class resultType, String apiKey) { + public CompletableFuture request(String method, Object params, Class resultType, Map> headers) { CompletableFuture future = new CompletableFuture<>(); try { @@ -57,10 +59,10 @@ public CompletableFuture request(String method, Object params, Class r JsonRpcHttpTransport.MEDIA_TYPE_JSON) ); - if (apiKey != null) { - requestBuilder.header("Authorization", "Bearer " + apiKey); - } - + headers.forEach((header, values) -> + values.forEach(value -> + requestBuilder.addHeader(header, value))); + Request request = requestBuilder.build(); this.httpClient.newCall(request).enqueue(new Callback() { From 4ec68c8b5676a7b632b5e8b55cecb5227c5721e3 Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Tue, 30 Sep 2025 21:01:12 +0300 Subject: [PATCH 6/7] Simplify error handling --- .../sdk/jsonrpc/JsonRpcHttpTransport.java | 14 ++------ .../jsonrpc/RateLimitExceededException.java | 33 ----------------- .../sdk/jsonrpc/UnauthorizedException.java | 18 ---------- .../sdk/TestApiKeyIntegration.java | 35 +++++++++---------- 4 files changed, 18 insertions(+), 82 deletions(-) delete mode 100644 src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java delete mode 100644 src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java index fdc79f0..bfec3c1 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java @@ -76,18 +76,8 @@ public void onResponse(Call call, Response response) { try (ResponseBody body = response.body()) { if (!response.isSuccessful()) { String error = body != null ? body.string() : ""; - - if (response.code() == HTTP_UNAUTHORIZED) { - future.completeExceptionally(new UnauthorizedException()); - return; - } else if (response.code() == HTTP_TOO_MANY_REQUESTS) { - int retryAfterSeconds = extractRetryAfterSeconds(response); - future.completeExceptionally(new RateLimitExceededException( retryAfterSeconds)); - return; - } else { - future.completeExceptionally(new JsonRpcNetworkError(response.code(), error)); - return; - } + future.completeExceptionally(new JsonRpcNetworkError(response.code(), error)); + return; } JsonRpcResponse data = UnicityObjectMapper.JSON.readValue( diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java deleted file mode 100644 index afba9d6..0000000 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/RateLimitExceededException.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.unicitylabs.sdk.jsonrpc; - -import org.jetbrains.annotations.NotNull; - -/** - * Exception thrown when a rate limit is exceeded in the API. - *

- * The {@code retryAfterSeconds} field indicates the number of seconds - * the client should wait before retrying the request. - */ -public class RateLimitExceededException extends RuntimeException { - - private final int retryAfterSeconds; - - public RateLimitExceededException(int retryAfterSeconds) { - super(getMessage(retryAfterSeconds)); - this.retryAfterSeconds = retryAfterSeconds; - } - - public RateLimitExceededException(int retryAfterSeconds, Throwable cause) { - super(getMessage(retryAfterSeconds), cause); - this.retryAfterSeconds = retryAfterSeconds; - } - - public int getRetryAfterSeconds() { - return retryAfterSeconds; - } - - @NotNull - private static String getMessage(int retryAfterSeconds) { - return "Rate limit exceeded. Please retry after " + retryAfterSeconds + " seconds"; - } -} \ No newline at end of file diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java deleted file mode 100644 index b693b3f..0000000 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/UnauthorizedException.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.unicitylabs.sdk.jsonrpc; - -/** - * Exception thrown when an API request is unauthorized (HTTP 401). - * This typically occurs when an API key is missing or invalid. - */ -public class UnauthorizedException extends RuntimeException { - - public static final String MESSAGE = "Unauthorized: Invalid or missing API key"; - - public UnauthorizedException() { - super(MESSAGE); - } - - public UnauthorizedException(Throwable cause) { - super(MESSAGE, cause); - } -} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java index 0410c69..02f23bc 100644 --- a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -11,12 +11,12 @@ import org.unicitylabs.sdk.api.SubmitCommitmentStatus; import org.unicitylabs.sdk.hash.DataHash; import org.unicitylabs.sdk.hash.HashAlgorithm; -import org.unicitylabs.sdk.jsonrpc.RateLimitExceededException; -import org.unicitylabs.sdk.jsonrpc.UnauthorizedException; +import org.unicitylabs.sdk.jsonrpc.JsonRpcNetworkError; import org.unicitylabs.sdk.signing.SigningService; import org.unicitylabs.sdk.util.HexConverter; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; @@ -28,10 +28,8 @@ public class TestApiKeyIntegration { private MockAggregatorServer mockServer; private AggregatorClient clientWithApiKey; private AggregatorClient clientWithoutApiKey; - private SigningService signingService; - + private DataHash transactionHash; - private DataHash stateHash; private RequestId requestId; private Authenticator authenticator; @@ -43,11 +41,11 @@ void setUp() throws Exception { clientWithApiKey = new AggregatorClient(mockServer.getUrl(), TEST_API_KEY); clientWithoutApiKey = new AggregatorClient(mockServer.getUrl()); - - signingService = new SigningService( - HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); - stateHash = new DataHash(HashAlgorithm.SHA256, HexConverter.decode("fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321")); + SigningService signingService = new SigningService( + HexConverter.decode("0000000000000000000000000000000000000000000000000000000000000001")); + + DataHash stateHash = new DataHash(HashAlgorithm.SHA256, HexConverter.decode("fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321")); requestId = RequestId.create(signingService.getPublicKey(), stateHash); transactionHash = new DataHash(HashAlgorithm.SHA256, HexConverter.decode("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")); @@ -80,9 +78,9 @@ public void testSubmitCommitmentWithoutApiKeyThrowsUnauthorized() throws Excepti future.get(5, TimeUnit.SECONDS); fail("Expected UnauthorizedException to be thrown"); } catch (Exception e) { - assertTrue(e instanceof java.util.concurrent.ExecutionException); - assertTrue(e.getCause() instanceof UnauthorizedException); - assertEquals("Unauthorized: Invalid or missing API key", e.getCause().getMessage()); + assertInstanceOf(ExecutionException.class, e); + assertInstanceOf(JsonRpcNetworkError.class, e.getCause()); + assertEquals("Network error [401] occurred: Unauthorized", e.getCause().getMessage()); } RecordedRequest request = mockServer.takeRequest(); @@ -100,8 +98,9 @@ public void testSubmitCommitmentWithWrongApiKeyThrowsUnauthorized() throws Excep future.get(5, TimeUnit.SECONDS); fail("Expected UnauthorizedException to be thrown"); } catch (Exception e) { - assertTrue(e instanceof java.util.concurrent.ExecutionException); - assertTrue(e.getCause() instanceof UnauthorizedException); + assertInstanceOf(ExecutionException.class, e); + assertInstanceOf(JsonRpcNetworkError.class, e.getCause()); + assertEquals("Network error [401] occurred: Unauthorized", e.getCause().getMessage()); } RecordedRequest request = mockServer.takeRequest(); @@ -119,11 +118,9 @@ public void testRateLimitExceeded() throws Exception { future.get(5, TimeUnit.SECONDS); fail("Expected RateLimitExceededException to be thrown"); } catch (Exception e) { - assertTrue(e instanceof java.util.concurrent.ExecutionException); - assertTrue(e.getCause() instanceof RateLimitExceededException); - RateLimitExceededException rateLimitEx = (RateLimitExceededException) e.getCause(); - assertEquals(30, rateLimitEx.getRetryAfterSeconds()); - assertTrue(rateLimitEx.getMessage().contains("30 seconds")); + assertInstanceOf(ExecutionException.class, e); + assertInstanceOf(JsonRpcNetworkError.class, e.getCause()); + assertTrue(e.getCause().getMessage().contains("Network error [429] occurred: Too Many Requests"), e.getCause().getMessage()); } } From 2258d56a26033cce5d7be069586624a2354f8b94 Mon Sep 17 00:00:00 2001 From: Risto Alas Date: Tue, 30 Sep 2025 21:21:59 +0300 Subject: [PATCH 7/7] Remove unused code --- .../sdk/jsonrpc/JsonRpcHttpTransport.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java index bfec3c1..8fbb236 100644 --- a/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/jsonrpc/JsonRpcHttpTransport.java @@ -15,16 +15,12 @@ import okhttp3.ResponseBody; import org.unicitylabs.sdk.serializer.UnicityObjectMapper; -import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; - /** * JSON-RPC HTTP service. */ public class JsonRpcHttpTransport { private static final MediaType MEDIA_TYPE_JSON = MediaType.get("application/json; charset=utf-8"); - private static final int HTTP_TOO_MANY_REQUESTS = 429; - private static final String HTTP_RETRY_AFTER = "Retry-After"; private final String url; private final OkHttpClient httpClient; @@ -104,16 +100,4 @@ public void onResponse(Call call, Response response) { return future; } - - private int extractRetryAfterSeconds(Response response) { - String retryAfterHeader = response.header(HTTP_RETRY_AFTER); - if (retryAfterHeader != null) { - try { - return Integer.parseInt(retryAfterHeader); - } catch (NumberFormatException ignored) { - } - } - // Default to 60 seconds if the HTTP header is missing, e.g. if the response is coming from a different component that is not using this header. - return 60; - } }