From c8461ab747127a9b21dc4799495f2f043ed3eb94 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 14:55:18 -0300 Subject: [PATCH 01/57] wipe src/ for clean-architecture restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes all Java sources from main/test/integrationTest source sets to restart implementation under the layered architecture described in the plan (HttpTransport agnostic → generic pipeline → MarketDataExecutor → façades). Skeleton, CI workflows, ADRs, and documentation are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/marketdata/sdk/AsyncSemaphoreIT.java | 59 -- .../com/marketdata/sdk/MarketsStatusIT.java | 52 -- .../com/marketdata/sdk/AsyncSemaphore.java | 102 ---- .../com/marketdata/sdk/Configuration.java | 107 ---- src/main/java/com/marketdata/sdk/EnvVars.java | 21 - .../com/marketdata/sdk/HttpStatusMapper.java | 37 -- .../com/marketdata/sdk/HttpTransport.java | 384 ------------ .../com/marketdata/sdk/MarketDataClient.java | 167 ------ .../sdk/MarketStatusDeserializer.java | 81 --- .../com/marketdata/sdk/MarketsResource.java | 91 --- .../com/marketdata/sdk/RateLimitHeaders.java | 52 -- .../java/com/marketdata/sdk/RateLimits.java | 16 - .../java/com/marketdata/sdk/RequestSpec.java | 54 -- .../java/com/marketdata/sdk/RetryPolicy.java | 101 ---- src/main/java/com/marketdata/sdk/Tokens.java | 36 -- src/main/java/com/marketdata/sdk/Version.java | 27 - .../sdk/exception/AuthenticationError.java | 15 - .../sdk/exception/BadRequestError.java | 15 - .../sdk/exception/ErrorContext.java | 24 - .../sdk/exception/MarketDataException.java | 80 --- .../sdk/exception/NetworkError.java | 15 - .../sdk/exception/NotFoundError.java | 21 - .../marketdata/sdk/exception/ParseError.java | 15 - .../sdk/exception/RateLimitError.java | 15 - .../marketdata/sdk/exception/ServerError.java | 15 - .../sdk/exception/package-info.java | 11 - .../marketdata/sdk/markets/DailyStatus.java | 13 - .../marketdata/sdk/markets/MarketStatus.java | 23 - .../marketdata/sdk/markets/package-info.java | 8 - .../java/com/marketdata/sdk/package-info.java | 18 - .../marketdata/sdk/AsyncSemaphoreTest.java | 230 -------- .../java/com/marketdata/sdk/CallMode.java | 68 --- .../com/marketdata/sdk/ConfigurationTest.java | 165 ------ .../marketdata/sdk/HttpStatusMapperTest.java | 83 --- .../marketdata/sdk/HttpTransportE2ETest.java | 119 ---- .../sdk/HttpTransportRetryTest.java | 548 ------------------ .../com/marketdata/sdk/HttpTransportTest.java | 499 ---------------- .../marketdata/sdk/MarketDataClientTest.java | 131 ----- .../sdk/MarketStatusDeserializerTest.java | 104 ---- .../marketdata/sdk/MarketsResourceTest.java | 461 --------------- .../marketdata/sdk/RateLimitHeadersTest.java | 164 ------ .../com/marketdata/sdk/RateLimitsTest.java | 20 - .../com/marketdata/sdk/RequestSpecTest.java | 56 -- .../com/marketdata/sdk/RetryPolicyTest.java | 159 ----- .../java/com/marketdata/sdk/TokensTest.java | 36 -- .../java/com/marketdata/sdk/VersionTest.java | 43 -- .../exception/MarketDataExceptionTest.java | 129 ----- 47 files changed, 4690 deletions(-) delete mode 100644 src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java delete mode 100644 src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java delete mode 100644 src/main/java/com/marketdata/sdk/AsyncSemaphore.java delete mode 100644 src/main/java/com/marketdata/sdk/Configuration.java delete mode 100644 src/main/java/com/marketdata/sdk/EnvVars.java delete mode 100644 src/main/java/com/marketdata/sdk/HttpStatusMapper.java delete mode 100644 src/main/java/com/marketdata/sdk/HttpTransport.java delete mode 100644 src/main/java/com/marketdata/sdk/MarketDataClient.java delete mode 100644 src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java delete mode 100644 src/main/java/com/marketdata/sdk/MarketsResource.java delete mode 100644 src/main/java/com/marketdata/sdk/RateLimitHeaders.java delete mode 100644 src/main/java/com/marketdata/sdk/RateLimits.java delete mode 100644 src/main/java/com/marketdata/sdk/RequestSpec.java delete mode 100644 src/main/java/com/marketdata/sdk/RetryPolicy.java delete mode 100644 src/main/java/com/marketdata/sdk/Tokens.java delete mode 100644 src/main/java/com/marketdata/sdk/Version.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/AuthenticationError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/BadRequestError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/ErrorContext.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/MarketDataException.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/NetworkError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/NotFoundError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/ParseError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/RateLimitError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/ServerError.java delete mode 100644 src/main/java/com/marketdata/sdk/exception/package-info.java delete mode 100644 src/main/java/com/marketdata/sdk/markets/DailyStatus.java delete mode 100644 src/main/java/com/marketdata/sdk/markets/MarketStatus.java delete mode 100644 src/main/java/com/marketdata/sdk/markets/package-info.java delete mode 100644 src/main/java/com/marketdata/sdk/package-info.java delete mode 100644 src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java delete mode 100644 src/test/java/com/marketdata/sdk/CallMode.java delete mode 100644 src/test/java/com/marketdata/sdk/ConfigurationTest.java delete mode 100644 src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java delete mode 100644 src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java delete mode 100644 src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java delete mode 100644 src/test/java/com/marketdata/sdk/HttpTransportTest.java delete mode 100644 src/test/java/com/marketdata/sdk/MarketDataClientTest.java delete mode 100644 src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java delete mode 100644 src/test/java/com/marketdata/sdk/MarketsResourceTest.java delete mode 100644 src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java delete mode 100644 src/test/java/com/marketdata/sdk/RateLimitsTest.java delete mode 100644 src/test/java/com/marketdata/sdk/RequestSpecTest.java delete mode 100644 src/test/java/com/marketdata/sdk/RetryPolicyTest.java delete mode 100644 src/test/java/com/marketdata/sdk/TokensTest.java delete mode 100644 src/test/java/com/marketdata/sdk/VersionTest.java delete mode 100644 src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java diff --git a/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java b/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java deleted file mode 100644 index 0676736..0000000 --- a/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.marketdata.sdk.markets.MarketStatus; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -/** - * Concurrency integration test against the live Market Data API. Verifies that the {@link - * AsyncSemaphore} + {@link HttpTransport} pipeline correctly handles fan-out beyond the pool size: - * the requests over the limit must traverse the semaphore's slow path (queue the waiter, complete - * it later via {@code release}) without deadlocking or losing a permit. - * - *

Costs {@code CONCURRENCY_LIMIT + 5 = 55} requests against the live {@code /markets/status/} - * endpoint per run. With a typical RTT of ~100 ms and pool size 50, the test wall time is well - * under a second. - * - *

Gated by {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} like the rest of this source set. - */ -class AsyncSemaphoreIT { - - /** - * If a permit ever leaked or the slow-path queue stopped being drained, {@code allOf.join()} - * would block forever. The 30 s timeout fails the test fast instead of leaving CI hung. - */ - @Test - @Timeout(value = 30, unit = TimeUnit.SECONDS) - void concurrentFanOutBeyondPoolLimitCompletesWithoutDeadlock() { - try (var client = new MarketDataClient(null, null, null, false)) { - int n = HttpTransport.CONCURRENCY_LIMIT + 5; - List> futures = new ArrayList<>(n); - - // Fire all N requests as fast as the loop runs. With pool=50, the first 50 take the - // fast path (already-completed acquire future) and dispatch immediately; requests - // 51..55 take the slow path and enqueue waiters that complete only when one of the - // first 50 releases. - for (int i = 0; i < n; i++) { - futures.add(client.markets().statusAsync()); - } - - // allOf.join() throws on any underlying failure; we let it propagate so a 429 / network - // hiccup surfaces as a real test failure rather than silently masking the issue. - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - - // Every response must be a valid MarketStatus. Empty results would suggest a hidden - // failure (auth issue, rate limit) that wasn't observable from allOf alone. - for (CompletableFuture f : futures) { - MarketStatus status = f.join(); - assertThat(status.days()).isNotEmpty(); - assertThat(status.days().get(0).date()).isNotNull(); - } - } - } -} diff --git a/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java deleted file mode 100644 index 3fe05ee..0000000 --- a/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.marketdata.sdk.markets.MarketStatus; -import java.time.LocalDate; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -/** - * Integration test against the live Market Data API. Gated by the {@code integrationTest} source - * set, which itself only runs when {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} is exported (see - * {@code build.gradle.kts}). - * - *

Requires a valid {@code MARKETDATA_TOKEN} env var (or {@code .env} entry). Without one the - * client enters demo mode and the {@code /markets/status/} endpoint is not on the demo allow-list, - * so the test would receive an {@code AuthenticationError}. - * - *

Each scenario runs once for {@link CallMode#SYNC} and once for {@link CallMode#ASYNC} so we - * satisfy SDK requirements §13's "tests must cover both sync and async variants for every endpoint" - * against the real wire. - */ -class MarketsStatusIT { - - @ParameterizedTest - @EnumSource(CallMode.class) - void todayStatusReturnsAtLeastOneEntry(CallMode mode) { - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = mode.statusNoArgs(client.markets()); - - // The endpoint always returns at least one entry for "today" — even on weekends/holidays - // there's a row with status="closed". - assertThat(status.days()).isNotEmpty(); - assertThat(status.days().get(0).date()).isNotNull(); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void historicalRangeReturnsExpectedDays(CallMode mode) { - LocalDate from = LocalDate.now().minusDays(7); - LocalDate to = LocalDate.now().minusDays(1); - - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = mode.statusForRange(client.markets(), from, to); - - assertThat(status.days()).hasSizeBetween(1, 7); - assertThat(status.days()) - .allSatisfy(d -> assertThat(d.date()).isBetween(from.minusDays(1), to.plusDays(1))); - } - } -} diff --git a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java deleted file mode 100644 index 7945108..0000000 --- a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.marketdata.sdk; - -import java.util.ArrayDeque; -import java.util.Deque; -import java.util.concurrent.CompletableFuture; - -/** - * Async-safe concurrency limiter. Replaces {@link java.util.concurrent.Semaphore} in the HTTP path - * so that {@code executeAsync} never parks the caller's thread when the pool is at capacity — it - * returns a {@link CompletableFuture} that completes when a permit is released by an in-flight - * request. See ADR-007 for the rationale. - * - *

Two invariants: - * - *

    - *
  1. Every permit is accounted for exactly once — it is either in {@link #availablePermits()} - * (free), held by an in-flight caller (and will be released via {@link #release()}), or - * pending in the waiter queue (and will be released by completing the waiter's future). - *
  2. {@link CompletableFuture#complete} of a transferred permit always runs outside the - * lock. Completing a future runs the caller's attached callbacks synchronously on the - * releasing thread, and we never want those running while our lock is held. - *
- * - *

Cancelled or otherwise-completed waiters are skipped on {@link #release()} so a cancelled - * {@code acquire} doesn't burn a permit. - */ -final class AsyncSemaphore { - - private final Object lock = new Object(); - private final Deque> waiters = new ArrayDeque<>(); - private int available; - - AsyncSemaphore(int permits) { - if (permits < 0) { - throw new IllegalArgumentException("permits must be >= 0, was " + permits); - } - this.available = permits; - } - - /** - * Asynchronously claim a permit. - * - *

Fast path: a permit is available, returns an already-completed future. Slow path: pool is - * exhausted, returns a pending future enqueued FIFO; it completes when some in-flight caller - * calls {@link #release()}. Either way, the caller's thread is never parked. - */ - CompletableFuture acquire() { - synchronized (lock) { - if (available > 0) { - available--; - return CompletableFuture.completedFuture(null); - } - CompletableFuture waiter = new CompletableFuture<>(); - waiters.addLast(waiter); - return waiter; - } - } - - /** - * Release a permit. If a live waiter is enqueued, the permit is transferred to it (its future is - * completed) without going through the counter. Otherwise the counter is incremented. - */ - void release() { - // Outer loop handles the TOCTOU window between pollFirst (inside the lock) and - // complete (outside): if the waiter is cancelled in that gap, complete(null) returns - // false and the permit hasn't actually been transferred. Retry with the next waiter, - // or fall through to the counter when the queue runs out of live waiters. - while (true) { - CompletableFuture next = null; - synchronized (lock) { - while (!waiters.isEmpty()) { - CompletableFuture w = waiters.pollFirst(); - if (!w.isDone()) { - next = w; - break; - } - } - if (next == null) { - available++; - return; - } - } - if (next.complete(null)) { - return; - } - } - } - - /** Permits not currently held nor pending in the queue. */ - int availablePermits() { - synchronized (lock) { - return available; - } - } - - /** Number of pending waiters on the slow path. Useful for diagnostics and tests. */ - int queueLength() { - synchronized (lock) { - return waiters.size(); - } - } -} diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java deleted file mode 100644 index a2dbc6f..0000000 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.marketdata.sdk; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; -import org.jspecify.annotations.Nullable; - -/** - * Resolves SDK configuration values per the cascade in SDK requirements §4: {@code explicit value → - * MARKETDATA_* env var → .env file in CWD → built-in default}. - * - *

The single canonical construction path is {@link #loadFromProcess()}, which snapshots the live - * environment and the {@code .env} file once. The constructor is strictly private — there is no - * production-callable backdoor for injecting arbitrary maps. Tests reach the private constructor - * via reflection (see {@code ConfigurationTest}); this is by design so a developer can't - * accidentally take a shortcut around the canonical load path. - */ -final class Configuration { - - public static final String DEFAULT_BASE_URL = "https://api.marketdata.app"; - public static final String DEFAULT_API_VERSION = "v1"; - private static final Path DEFAULT_DOTENV_PATH = Paths.get(".env"); - - private final Map systemEnv; - private final Map dotEnv; - - private Configuration(Map systemEnv, Map dotEnv) { - this.systemEnv = Map.copyOf(systemEnv); - this.dotEnv = Map.copyOf(dotEnv); - } - - /** - * Production factory: snapshots {@code System.getenv()} and reads {@code ./.env} once. Call - * during client construction. - */ - public static Configuration loadFromProcess() { - return new Configuration(System.getenv(), readDotEnvFile(DEFAULT_DOTENV_PATH)); - } - - /** Cascade: explicit → system env → .env → {@code null}. */ - public @Nullable String resolve(@Nullable String explicit, String envKey) { - if (isPresent(explicit)) { - return explicit; - } - String fromSystem = systemEnv.get(envKey); - if (isPresent(fromSystem)) { - return fromSystem; - } - String fromDotEnv = dotEnv.get(envKey); - return isPresent(fromDotEnv) ? fromDotEnv : null; - } - - /** Same as {@link #resolve} but returns {@code defaultValue} when the cascade yields nothing. */ - public String resolveOrDefault(@Nullable String explicit, String envKey, String defaultValue) { - String resolved = resolve(explicit, envKey); - return resolved != null ? resolved : defaultValue; - } - - private static boolean isPresent(@Nullable String value) { - return value != null && !value.isBlank(); - } - - /** - * Reads a {@code .env}-style file: lines like {@code KEY=value}, {@code #} for comments, - * surrounding single or double quotes stripped. Package-private so tests can target an arbitrary - * {@link Path} (e.g. inside a JUnit {@code @TempDir}) instead of CWD. - */ - static Map readDotEnvFile(Path path) { - if (!Files.isRegularFile(path)) { - return Map.of(); - } - Map result = new HashMap<>(); - try { - for (String raw : Files.readAllLines(path)) { - String line = raw.trim(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - int eq = line.indexOf('='); - if (eq < 1) { - continue; - } - String key = line.substring(0, eq).trim(); - String value = stripQuotes(line.substring(eq + 1).trim()); - result.put(key, value); - } - } catch (IOException ignored) { - return Map.of(); - } - return Map.copyOf(result); - } - - private static String stripQuotes(String value) { - if (value.length() < 2) { - return value; - } - char first = value.charAt(0); - char last = value.charAt(value.length() - 1); - if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { - return value.substring(1, value.length() - 1); - } - return value; - } -} diff --git a/src/main/java/com/marketdata/sdk/EnvVars.java b/src/main/java/com/marketdata/sdk/EnvVars.java deleted file mode 100644 index e7ba768..0000000 --- a/src/main/java/com/marketdata/sdk/EnvVars.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.marketdata.sdk; - -/** - * Names of the {@code MARKETDATA_*} environment variables consulted by the SDK. Mirrors SDK - * requirements §4. - */ -final class EnvVars { - - public static final String TOKEN = "MARKETDATA_TOKEN"; - public static final String BASE_URL = "MARKETDATA_BASE_URL"; - public static final String API_VERSION = "MARKETDATA_API_VERSION"; - public static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; - public static final String OUTPUT_FORMAT = "MARKETDATA_OUTPUT_FORMAT"; - public static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; - public static final String COLUMNS = "MARKETDATA_COLUMNS"; - public static final String ADD_HEADERS = "MARKETDATA_ADD_HEADERS"; - public static final String USE_HUMAN_READABLE = "MARKETDATA_USE_HUMAN_READABLE"; - public static final String MODE = "MARKETDATA_MODE"; - - private EnvVars() {} -} diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java deleted file mode 100644 index dff9f0f..0000000 --- a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.marketdata.sdk; - -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.ErrorContext; -import com.marketdata.sdk.exception.MarketDataException; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import org.jspecify.annotations.Nullable; - -/** - * Maps an HTTP status code to the {@link MarketDataException} subtype the SDK requirements doc §9.1 - * mandates. - * - *

Note that 200 / 203 (success) and 404 (no-data sentinel returned by the API as {@code - * {"s":"no_data"}}) are not handled here — those status codes mean "got a body, - * decode it" and the resource layer interprets them. This mapper only fires on hard failures. - */ -final class HttpStatusMapper { - - private HttpStatusMapper() {} - - static MarketDataException toException( - int status, String requestUrl, @Nullable String requestId) { - ErrorContext ctx = new ErrorContext(emptyToNull(requestId), requestUrl, status); - return switch (status) { - case 400, 422 -> new BadRequestError("HTTP " + status + ": invalid request", ctx); - case 401 -> new AuthenticationError("HTTP 401: invalid or missing API token", ctx); - case 429 -> new RateLimitError("HTTP 429: rate limit exceeded", ctx); - default -> new ServerError("HTTP " + status + ": server error", ctx); - }; - } - - private static @Nullable String emptyToNull(@Nullable String s) { - return (s == null || s.isBlank()) ? null : s; - } -} diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java deleted file mode 100644 index 4549556..0000000 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ /dev/null @@ -1,384 +0,0 @@ -package com.marketdata.sdk; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.marketdata.sdk.exception.ErrorContext; -import com.marketdata.sdk.exception.MarketDataException; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.markets.MarketStatus; -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpResponse.BodyHandlers; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.jspecify.annotations.Nullable; - -/** - * The single point of contact between resource façades and the network. - * - *

Owned by {@link MarketDataClient}, instantiated once per client. All HTTP-shaped concerns live - * here so resources never see a {@link HttpClient}, an {@link ObjectMapper}, the concurrency - * semaphore, or the rate-limit headers — they get a {@link RequestSpec} in and a typed domain - * object out. - * - *

Per ADR-006 the design is async-first: {@link #executeAsync} is the canonical path; {@link - * #executeSync} is a thin wrapper that calls {@link CompletableFuture#join()} and unwraps any - * {@link CompletionException} so the caller sees the underlying cause directly. - * - *

Per ADR-007 wire-format deserializers are registered programmatically on the {@link - * ObjectMapper} via a {@link SimpleModule}, so response records do not carry - * {@code @JsonDeserialize} annotations. - */ -final class HttpTransport implements AutoCloseable { - - /** SDK requirements §10: fixed 99-second per-request timeout. */ - static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); - - /** SDK requirements §10: fixed 2-second connect timeout. */ - static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); - - /** SDK requirements §12: 50-permit global concurrency pool. */ - static final int CONCURRENCY_LIMIT = 50; - - private static final String CF_RAY = "cf-ray"; - - private final HttpClient httpClient; - private final ObjectMapper jsonMapper; - private final AsyncSemaphore concurrencyPermits; - private final RetryPolicy retryPolicy; - private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); - - private final String baseUrl; - private final String apiVersion; - private final String userAgent; - private final @Nullable String token; - - HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) { - this(baseUrl, apiVersion, userAgent, token, defaultHttpClient(), RetryPolicy.defaults()); - } - - // Package-private constructor used by tests to inject a stubbed HttpClient - // (e.g. one whose sendAsync throws synchronously, to verify permit release). - HttpTransport( - String baseUrl, - String apiVersion, - String userAgent, - @Nullable String token, - HttpClient httpClient) { - this(baseUrl, apiVersion, userAgent, token, httpClient, RetryPolicy.defaults()); - } - - // Package-private constructor used by retry tests to drive sub-millisecond backoffs. - HttpTransport( - String baseUrl, - String apiVersion, - String userAgent, - @Nullable String token, - HttpClient httpClient, - RetryPolicy retryPolicy) { - this.baseUrl = baseUrl; - this.apiVersion = apiVersion; - this.userAgent = userAgent; - this.token = token; - this.concurrencyPermits = new AsyncSemaphore(CONCURRENCY_LIMIT); - this.jsonMapper = buildJsonMapper(); - this.httpClient = httpClient; - this.retryPolicy = retryPolicy; - } - - private static HttpClient defaultHttpClient() { - return HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .version(HttpClient.Version.HTTP_2) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - } - - /** - * Latest client-level rate-limit snapshot, or {@code null} if the client has not yet received a - * response that carried parseable {@code x-api-ratelimit-*} headers. Once populated, the snapshot - * reflects the most recent rate-limit-bearing response — successful responses that arrive without - * headers do not reset it. - */ - @Nullable RateLimits getLatestRateLimits() { - return latestRateLimits.get(); - } - - /** - * Async-first request execution with retry. Orchestrates one or more attempts according to {@link - * RetryPolicy}: retries 501–599 and IOException-shaped {@link NetworkError}s with exponential - * backoff, surfaces every other failure immediately. Cancellation of the returned future bails - * out of any pending backoff and propagates to the current in-flight attempt. - */ - CompletableFuture executeAsync(RequestSpec spec, Class responseType) { - CompletableFuture result = new CompletableFuture<>(); - // One cascade-cancel handler installed once: whichever attempt is currently in flight is - // tracked in `currentDispatched`; cancelling `result` cancels that. Previous attempts in - // the chain are already done by the time the next one updates the reference, so this - // avoids accumulating a handler per attempt. - AtomicReference<@Nullable CompletableFuture> currentDispatched = new AtomicReference<>(); - result.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - CompletableFuture inFlight = currentDispatched.get(); - if (inFlight != null && !inFlight.isDone()) { - inFlight.cancel(false); - } - } - }); - attempt(spec, responseType, 0, result, currentDispatched); - return result; - } - - private void attempt( - RequestSpec spec, - Class responseType, - int attemptIdx, - CompletableFuture result, - AtomicReference<@Nullable CompletableFuture> currentDispatched) { - if (result.isDone()) { - // Caller cancelled (or completed exceptionally from a previous attempt's whenComplete). - // Don't burn another HTTP request. - return; - } - CompletableFuture dispatched = executeOnce(spec, responseType); - currentDispatched.set(dispatched); - - // If the caller cancelled `result` between attempts (during a backoff window), the handler - // installed in executeAsync has fired but `currentDispatched` was either null or pointing - // to the previous (already-done) attempt — so the new one was never cancelled. Check here - // and propagate immediately. - if (result.isCancelled() && !dispatched.isDone()) { - dispatched.cancel(false); - return; - } - - dispatched.whenComplete( - (value, error) -> { - if (result.isDone()) { - return; - } - if (error == null) { - result.complete(value); - return; - } - Throwable cause = unwrap(error); - if (retryPolicy.shouldRetry(cause, attemptIdx)) { - long delayMs = retryPolicy.backoffDelay(attemptIdx).toMillis(); - CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) - .execute( - () -> attempt(spec, responseType, attemptIdx + 1, result, currentDispatched)); - } else { - result.completeExceptionally(cause); - } - }); - } - - /** - * Single-shot dispatch — one HTTP request, one permit lease, one response decode. Public retry - * orchestration lives in {@link #executeAsync}. - */ - private CompletableFuture executeOnce(RequestSpec spec, Class responseType) { - URI uri = buildUri(spec); - HttpRequest request = buildRequest(uri); - - // ADR-007: acquire returns a CompletableFuture instead of parking the caller's thread. - // When permits are available the future is already completed (fast path) and thenCompose - // runs synchronously; when the pool is exhausted the future completes later, on the - // thread that calls release() — the caller's thread is never blocked here. - CompletableFuture permit = concurrencyPermits.acquire(); - CompletableFuture dispatched = - permit.thenCompose(unused -> dispatch(uri, request, responseType)); - - // Cancellation of `dispatched` doesn't propagate to `permit` by default, so a slow-path - // waiter would stay live in the semaphore queue; release() would later "transfer" the - // permit by completing the waiter, but thenCompose's function wouldn't run (its - // dependent is already cancelled), and dispatch — which registers whenComplete(release) - // — would never fire. Cancelling `permit` here makes AsyncSemaphore.release skip the - // waiter. The narrow race where the waiter is cancelled between release()'s pollFirst - // and complete() is handled inside release() itself by retrying. - dispatched.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - permit.cancel(false); - } - }); - - return dispatched; - } - - private CompletableFuture dispatch(URI uri, HttpRequest request, Class responseType) { - CompletableFuture> sendFuture; - try { - sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray()); - } catch (Throwable t) { - // sendAsync threw synchronously (e.g. malformed request, internal NPE, OOM). - // The future never formed, so whenComplete will not fire — release the permit - // here to prevent a permanent leak that would degrade the pool to deadlock. - concurrencyPermits.release(); - if (t instanceof Error err) { - throw err; - } - return CompletableFuture.failedFuture( - new NetworkError( - "Request to " + uri + " failed before dispatch: " + t.getMessage(), - new ErrorContext(null, uri.toString(), null), - t)); - } - - return sendFuture - .whenComplete((r, t) -> concurrencyPermits.release()) - .handle( - (response, error) -> { - if (error != null) { - Throwable root = unwrap(error); - throw new CompletionException( - new NetworkError( - "Request to " + uri + " failed: " + root.getMessage(), - new ErrorContext(null, uri.toString(), null), - root)); - } - // Only overwrite the snapshot when the response carried parseable rate-limit - // headers. The API's rate-limit middleware can silently swallow its own errors - // and respond without headers; clobbering with null on every such response would - // make `client.getRateLimits()` flicker between populated and null across - // consecutive calls. Spec §8 says "update client-level snapshot" — implicitly only - // when there is something to update. - RateLimits parsed = RateLimitHeaders.parse(response.headers()); - if (parsed != null) { - latestRateLimits.set(parsed); - } - return processResponse(response, responseType, uri.toString()); - }); - } - - /** - * Sync wrapper around {@link #executeAsync}. Per ADR-006, calls {@code .join()} and unwraps - * {@link CompletionException} so callers see the underlying {@link MarketDataException} directly. - * - *

{@link CancellationException} can in principle escape {@code .join()} as a sibling of {@link - * CompletionException} (not nested), so it's caught explicitly. Today no internal code cancels - * the future {@code executeSync} owns, but covering it keeps the contract honest if a future - * change (timeout watchdog, retry coordinator) starts cancelling internally. - */ - T executeSync(RequestSpec spec, Class responseType) { - try { - return executeAsync(spec, responseType).join(); - } catch (CompletionException e) { - throw asRuntime(e.getCause()); - } catch (CancellationException e) { - throw asRuntime(e); - } - } - - // Visible for tests: under our current SDK design, executeAsync always wraps failures as - // MarketDataException so the `MDE` branch is the only one reached from the public surface. - // The other two branches are defensive guardrails — extracted so they can be exercised - // directly by tests rather than relying on a synthetic public-API path. - static RuntimeException asRuntime(@Nullable Throwable cause) { - if (cause instanceof MarketDataException mde) { - return mde; - } - if (cause instanceof RuntimeException re) { - return re; - } - return new NetworkError("Unexpected failure invoking SDK", ErrorContext.empty(), cause); - } - - @Override - public void close() { - // java.net.http.HttpClient gained explicit close() in JDK 21; until - // the SDK's minimum bumps to 21+ this is a no-op (ADR-002). - } - - private T processResponse(HttpResponse response, Class responseType, String url) { - int status = response.statusCode(); - String requestId = response.headers().firstValue(CF_RAY).orElse(null); - - // 200 OK + 203 Non-Authoritative + 404 (with {"s":"no_data"} body) all - // carry a JSON payload the resource wants to decode. Other statuses - // mean we never got a usable body — translate to a typed exception. - if (status == 200 || status == 203 || status == 404) { - try { - return jsonMapper.readValue(response.body(), responseType); - } catch (IOException e) { - throw new ParseError( - "Failed to decode response from " + url + ": " + e.getMessage(), - new ErrorContext(requestId, url, status), - e); - } - } - throw HttpStatusMapper.toException(status, url, requestId); - } - - private URI buildUri(RequestSpec spec) { - // RequestSpec's Javadoc says path has no leading slash, but a caller mistake would produce - // baseUrl/v1//markets/status (double slash). Strip defensively so the URL stays well-formed - // regardless of which side of the contract the bug is on. - String path = spec.path(); - if (path.startsWith("/")) { - path = path.substring(1); - } - StringBuilder sb = new StringBuilder(); - sb.append(baseUrl).append('/').append(apiVersion).append('/').append(path); - if (!path.endsWith("/")) { - sb.append('/'); - } - Map params = spec.queryParams(); - if (!params.isEmpty()) { - sb.append('?'); - boolean first = true; - for (Map.Entry e : params.entrySet()) { - if (!first) { - sb.append('&'); - } - sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) - .append('=') - .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); - first = false; - } - } - return URI.create(sb.toString()); - } - - private HttpRequest buildRequest(URI uri) { - HttpRequest.Builder b = - HttpRequest.newBuilder(uri) - .GET() - .timeout(REQUEST_TIMEOUT) - .header("User-Agent", userAgent) - .header("Accept", "application/json"); - if (token != null) { - b.header("Authorization", "Bearer " + token); - } - return b.build(); - } - - /** - * Builds the {@link ObjectMapper} used to decode every wire body. Per ADR-007 the wire-format - * deserializers register here, not via annotations on the response records. - */ - private static ObjectMapper buildJsonMapper() { - ObjectMapper mapper = new ObjectMapper(); - SimpleModule wireModule = new SimpleModule("marketdata-wire"); - wireModule.addDeserializer(MarketStatus.class, new MarketStatusDeserializer()); - mapper.registerModule(wireModule); - return mapper; - } - - // Package-private so the unwrap-when-nested-and-when-not branches are reachable from tests. - static Throwable unwrap(Throwable t) { - return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; - } -} diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java deleted file mode 100644 index 85cc821..0000000 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.marketdata.sdk; - -import java.time.Duration; -import java.util.logging.Level; -import java.util.logging.Logger; -import org.jspecify.annotations.Nullable; - -/** - * Entry point to the Market Data Java SDK. - * - *

One {@code MarketDataClient} per application. Resource façades (e.g. {@link #markets()}) are - * accessed through the client; all HTTP-shaped concerns (connection pooling, HTTP/2, the global - * concurrency semaphore, rate-limit header parsing) live in the internal {@link HttpTransport} the - * client owns. - * - *

Two constructors: - * - *

- * - *

Instances are immutable: every field is {@code final} and assigned in the constructor. - */ -public final class MarketDataClient implements AutoCloseable { - - /** SDK requirements §10: fixed 99-second per-request timeout. */ - public static final Duration REQUEST_TIMEOUT = HttpTransport.REQUEST_TIMEOUT; - - /** SDK requirements §10: fixed 2-second connect timeout. */ - public static final Duration CONNECT_TIMEOUT = HttpTransport.CONNECT_TIMEOUT; - - /** SDK requirements §12: maximum concurrent in-flight requests per client. */ - public static final int CONCURRENCY_LIMIT = HttpTransport.CONCURRENCY_LIMIT; - - private static final Logger LOG = Logger.getLogger(MarketDataClient.class.getName()); - - private final HttpTransport transport; - - private final @Nullable String token; - private final String baseUrl; - private final String apiVersion; - private final String userAgent; - private final boolean demoMode; - private final boolean validateOnStartup; - - // Resources — eagerly constructed; one record-shaped object per resource group. - private final MarketsResource markets; - - /** - * Production constructor. Resolves all settings from the configuration cascade in SDK - * requirements §4 (env var → {@code .env} → built-in default) and enables startup validation. - * - *

Equivalent to {@link #MarketDataClient(String, String, String, boolean) new - * MarketDataClient(null, null, null, true)}. - */ - public MarketDataClient() { - this(null, null, null, true); - } - - /** - * Explicit-control constructor for tests and short-lived runtimes. Each of {@code apiKey}, {@code - * baseUrl}, and {@code apiVersion} may be {@code null} to defer to the cascade in §4 for that - * single value. - * - * @param apiKey explicit API token, or {@code null} to resolve from {@code MARKETDATA_TOKEN} → - * {@code .env} → demo mode - * @param baseUrl override the API base URL, or {@code null} to resolve to {@link - * Configuration#DEFAULT_BASE_URL} - * @param apiVersion override the API version segment, or {@code null} to resolve to {@link - * Configuration#DEFAULT_API_VERSION} - * @param validateOnStartup whether to validate the token on construction by calling {@code - * /user/} (SDK requirements §5). Pass {@code false} for short-lived runtimes where the - * startup hit is undesirable. - */ - public MarketDataClient( - @Nullable String apiKey, - @Nullable String baseUrl, - @Nullable String apiVersion, - boolean validateOnStartup) { - Configuration config = Configuration.loadFromProcess(); - this.token = config.resolve(apiKey, EnvVars.TOKEN); - this.baseUrl = - trimTrailingSlash( - config.resolveOrDefault(baseUrl, EnvVars.BASE_URL, Configuration.DEFAULT_BASE_URL)); - this.apiVersion = - config.resolveOrDefault(apiVersion, EnvVars.API_VERSION, Configuration.DEFAULT_API_VERSION); - this.demoMode = this.token == null; - this.validateOnStartup = validateOnStartup; - this.userAgent = "marketdata-sdk-java/" + Version.current(); - - this.transport = new HttpTransport(this.baseUrl, this.apiVersion, this.userAgent, this.token); - this.markets = new MarketsResource(this.transport); - - LOG.log( - Level.INFO, - "Initialized Market Data SDK {0} (baseUrl={1}, apiVersion={2}, demoMode={3})", - new Object[] {Version.current(), this.baseUrl, this.apiVersion, this.demoMode}); - if (this.demoMode) { - LOG.warning( - "No API token provided — running in demo mode. Authenticated endpoints will fail with" - + " AuthenticationError on first call."); - } else if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Token: {0}", Tokens.redact(this.token)); - } - - // SDK requirements §5: validate on startup by default. The actual - // /user/ call lands with the user resource; this flag is the seam. - } - - // --------------------------------------------------------------------- - // Resource accessors - // --------------------------------------------------------------------- - - /** Façade for the {@code /v1/markets/*} endpoint group. */ - public MarketsResource markets() { - return markets; - } - - // --------------------------------------------------------------------- - // Configuration accessors - // --------------------------------------------------------------------- - - public String getBaseUrl() { - return baseUrl; - } - - public String getApiVersion() { - return apiVersion; - } - - public String getUserAgent() { - return userAgent; - } - - public boolean isDemoMode() { - return demoMode; - } - - public boolean isValidateOnStartup() { - return validateOnStartup; - } - - /** - * Latest client-level rate-limit snapshot, or {@code null} if no rate-limit-bearing response has - * been received yet. Once populated, the snapshot persists across subsequent calls — a successful - * response that arrives without {@code x-api-ratelimit-*} headers (e.g. during a server-side - * middleware outage) does not clear it. - */ - public @Nullable RateLimits getRateLimits() { - return transport.getLatestRateLimits(); - } - - @Override - public void close() { - transport.close(); - } - - private static String trimTrailingSlash(String url) { - return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; - } -} diff --git a/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java b/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java deleted file mode 100644 index 5dd4815..0000000 --- a/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.marketdata.sdk; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.marketdata.sdk.markets.DailyStatus; -import com.marketdata.sdk.markets.MarketStatus; -import java.io.IOException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; - -/** - * Jackson deserializer for the {@code /v1/markets/status/} parallel-arrays wire format. - * - *

Wire shape (success): - * - *

{@code
- * { "s": "ok",
- *   "date":   [1706745600, 1706832000, 1706918400],
- *   "status": ["open", "open", "closed"] }
- * }
- * - *

Wire shape (no data, also returned for non-US countries by design): - * - *

{@code
- * { "s": "no_data" }
- * }
- * - *

The deserializer expands the parallel arrays into a list of {@link DailyStatus} (one per - * index), normalizes the unix timestamps to {@link LocalDate} in US/Eastern (SDK requirements - * §11.4), and represents {@code "no_data"} as an empty list. - */ -final class MarketStatusDeserializer extends JsonDeserializer { - - private static final ZoneId EASTERN = ZoneId.of("America/New_York"); - private static final String STATUS_OK = "ok"; - private static final String STATUS_NO_DATA = "no_data"; - - @Override - public MarketStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - JsonNode root = p.readValueAsTree(); - String s = root.path("s").asText(""); - - if (STATUS_NO_DATA.equals(s)) { - return new MarketStatus(List.of()); - } - if (!STATUS_OK.equals(s)) { - throw new IOException( - "Unexpected status field in /markets/status response: '" - + s - + "' (expected 'ok' or 'no_data')"); - } - - JsonNode dates = root.path("date"); - JsonNode statuses = root.path("status"); - if (!dates.isArray() || !statuses.isArray()) { - throw new IOException( - "Malformed /markets/status response: expected 'date' and 'status' arrays"); - } - if (dates.size() != statuses.size()) { - throw new IOException( - "Malformed /markets/status response: 'date' and 'status' arrays have different sizes (" - + dates.size() - + " vs " - + statuses.size() - + ")"); - } - - List days = new ArrayList<>(dates.size()); - for (int i = 0; i < dates.size(); i++) { - LocalDate date = Instant.ofEpochSecond(dates.get(i).asLong()).atZone(EASTERN).toLocalDate(); - boolean open = "open".equalsIgnoreCase(statuses.get(i).asText()); - days.add(new DailyStatus(date, open)); - } - return new MarketStatus(List.copyOf(days)); - } -} diff --git a/src/main/java/com/marketdata/sdk/MarketsResource.java b/src/main/java/com/marketdata/sdk/MarketsResource.java deleted file mode 100644 index 13af185..0000000 --- a/src/main/java/com/marketdata/sdk/MarketsResource.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.marketdata.sdk; - -import com.marketdata.sdk.markets.MarketStatus; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.concurrent.CompletableFuture; - -/** - * Façade for the {@code /v1/markets/*} endpoint group. - * - *

Per ADR-006 every endpoint exposes a sync and an {@code …Async} variant. Both share the same - * request-building code; the sync forms are thin wrappers around the async path. - * - *

Per ADR-007 this resource lives in the SDK root package alongside the infra it depends on - * ({@link HttpTransport}, {@link RequestSpec}). Its constructor is package-private so only {@link - * MarketDataClient} can build one — consumers reach it via {@link MarketDataClient#markets()}. - * - *

Currently only {@code /v1/markets/status/} is implemented. Future markets-related endpoints - * (none planned today) would land here. - */ -public final class MarketsResource { - - private static final String STATUS_PATH = "markets/status"; - private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE; - - private final HttpTransport transport; - - MarketsResource(HttpTransport transport) { - this.transport = transport; - } - - /** - * Today's market status for US exchanges. Equivalent to {@code GET /v1/markets/status/}. - * - *

Sync. The async sibling is {@link #statusAsync()}. - */ - public MarketStatus status() { - return transport.executeSync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); - } - - /** Async variant of {@link #status()}. */ - public CompletableFuture statusAsync() { - return transport.executeAsync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); - } - - /** - * Market status for a single trading day. Equivalent to {@code GET - * /v1/markets/status/?date=YYYY-MM-DD}. - * - * @param date the trading day to look up; sent in ISO-8601 format - */ - public MarketStatus status(LocalDate date) { - return transport.executeSync(forDate(date), MarketStatus.class); - } - - /** Async variant of {@link #status(LocalDate)}. */ - public CompletableFuture statusAsync(LocalDate date) { - return transport.executeAsync(forDate(date), MarketStatus.class); - } - - /** - * Market status for a closed date range. Equivalent to {@code GET - * /v1/markets/status/?from=YYYY-MM-DD&to=YYYY-MM-DD}. Both endpoints are inclusive. - * - * @param from start of the range (inclusive) - * @param to end of the range (inclusive) - * @throws IllegalArgumentException if {@code from} is after {@code to} - */ - public MarketStatus status(LocalDate from, LocalDate to) { - return transport.executeSync(forRange(from, to), MarketStatus.class); - } - - /** Async variant of {@link #status(LocalDate, LocalDate)}. */ - public CompletableFuture statusAsync(LocalDate from, LocalDate to) { - return transport.executeAsync(forRange(from, to), MarketStatus.class); - } - - private static RequestSpec forDate(LocalDate date) { - return RequestSpec.get(STATUS_PATH).query("date", ISO_DATE.format(date)).build(); - } - - private static RequestSpec forRange(LocalDate from, LocalDate to) { - if (from.isAfter(to)) { - throw new IllegalArgumentException("from (" + from + ") must not be after to (" + to + ")"); - } - return RequestSpec.get(STATUS_PATH) - .query("from", ISO_DATE.format(from)) - .query("to", ISO_DATE.format(to)) - .build(); - } -} diff --git a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java deleted file mode 100644 index 909cffa..0000000 --- a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.marketdata.sdk; - -import java.net.http.HttpHeaders; -import java.time.Instant; -import org.jspecify.annotations.Nullable; - -/** - * Parses the {@code x-api-ratelimit-*} response headers that the API sets on every successful - * request (SDK requirements §8.2) into a {@link RateLimits} record. - * - *

Returns {@code null} when none of the relevant headers are present, which happens during a - * rate-limit-tracking outage on the server side (the API silently swallows the error and keeps - * serving the request, see {@code request_rate_middleware.py:30–40}). - */ -final class RateLimitHeaders { - - private static final String LIMIT = "x-api-ratelimit-limit"; - private static final String REMAINING = "x-api-ratelimit-remaining"; - private static final String RESET = "x-api-ratelimit-reset"; - private static final String CONSUMED = "x-api-ratelimit-consumed"; - - private RateLimitHeaders() {} - - static @Nullable RateLimits parse(HttpHeaders headers) { - Long limit = readLong(headers, LIMIT); - Long remaining = readLong(headers, REMAINING); - Long reset = readLong(headers, RESET); - Long consumed = readLong(headers, CONSUMED); - if (limit == null && remaining == null && reset == null && consumed == null) { - return null; - } - return new RateLimits( - limit != null ? limit : 0L, - remaining != null ? remaining : 0L, - Instant.ofEpochSecond(reset != null ? reset : 0L), - consumed != null ? consumed : 0L); - } - - private static @Nullable Long readLong(HttpHeaders headers, String name) { - return headers - .firstValue(name) - .map( - v -> { - try { - return Long.parseLong(v.trim()); - } catch (NumberFormatException e) { - return null; - } - }) - .orElse(null); - } -} diff --git a/src/main/java/com/marketdata/sdk/RateLimits.java b/src/main/java/com/marketdata/sdk/RateLimits.java deleted file mode 100644 index ef8b12a..0000000 --- a/src/main/java/com/marketdata/sdk/RateLimits.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.marketdata.sdk; - -import java.time.Instant; - -/** - * Snapshot of the API rate-limit state, parsed from the {@code x-api-ratelimit-*} response headers. - * - *

Per SDK requirements §8, this is a client-level snapshot and is non-deterministic under - * concurrent use; per-request metadata is attached to each response separately. - * - * @param limit total credits available in the current window - * @param remaining credits left in the current window - * @param reset instant at which {@code remaining} resets to {@code limit} - * @param consumed credits consumed by the most recent request - */ -public record RateLimits(long limit, long remaining, Instant reset, long consumed) {} diff --git a/src/main/java/com/marketdata/sdk/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java deleted file mode 100644 index 428c30c..0000000 --- a/src/main/java/com/marketdata/sdk/RequestSpec.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.marketdata.sdk; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Declarative description of an HTTP GET request the SDK wants to make. - * - *

Resources build instances of this and hand them to {@link HttpTransport}; the transport is the - * only code that knows about base URLs, auth headers, timeouts, and the like. - * - * @param path API-relative path with no leading {@code /v1/} prefix and no trailing slash, e.g. - * {@code "markets/status"}. The transport adds the base URL, version prefix, and trailing - * slash. - * @param queryParams ordered query parameters (insertion order preserved for predictable URLs in - * tests). Values are URL-encoded by the transport. - */ -record RequestSpec(String path, Map queryParams) { - - RequestSpec { - // Preserve insertion order — Map.copyOf would defensively copy but - // strip the iteration order, which breaks predictable URLs in tests - // and in any caller that cares about query-param order on the wire. - queryParams = Collections.unmodifiableMap(new LinkedHashMap<>(queryParams)); - } - - static Builder get(String path) { - return new Builder(path); - } - - static final class Builder { - private final String path; - private final Map queryParams = new LinkedHashMap<>(); - - private Builder(String path) { - this.path = path; - } - - /** Adds a query parameter only if {@code value} is non-null. */ - Builder query(String key, Object value) { - if (value != null) { - queryParams.put(key, value.toString()); - } - return this; - } - - RequestSpec build() { - // Pass the raw LinkedHashMap — the record's compact constructor defensively copies and - // wraps it as unmodifiable, so wrapping here too would just rebuild a redundant view. - return new RequestSpec(path, queryParams); - } - } -} diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java deleted file mode 100644 index 9b3eb65..0000000 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.marketdata.sdk; - -import com.marketdata.sdk.exception.MarketDataException; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.ServerError; -import java.io.IOException; -import java.time.Duration; - -/** - * Decides which failures get retried and how long to wait between attempts. Per SDK requirements - * §9.3: max 3 retries (yielding 4 total attempts) with exponential backoff {@code initial * - * 2^retry} starting at 1s, capped at 30s. Network errors (only when wrapping an {@link - * IOException}-shaped cause — see {@link #shouldRetry}) and HTTP 501–599 are retriable; 500 - * specifically is not, and 4xx (including 401/429) surfaces immediately. - * - *

Worst-case wall-clock per {@code executeAsync} call (defaults): 4 attempts × - * 99s per-request timeout + 1s + 2s + 4s backoff ≈ 6.75 minutes. SDK requirements §10 only mandates - * the per-request timeout, not an overall deadline, so this is compliant — but callers in - * latency-sensitive contexts may want to wrap calls with their own {@code orTimeout} cap. - * - *

The constructor accepts custom values so tests can drive retries with sub-millisecond delays - * without waiting on real wall-clock backoffs. - */ -final class RetryPolicy { - - private final int maxAttempts; - private final Duration initialBackoff; - private final Duration maxBackoff; - - RetryPolicy(int maxAttempts, Duration initialBackoff, Duration maxBackoff) { - if (maxAttempts < 1) { - throw new IllegalArgumentException("maxAttempts must be >= 1, was " + maxAttempts); - } - this.maxAttempts = maxAttempts; - this.initialBackoff = initialBackoff; - this.maxBackoff = maxBackoff; - } - - /** Defaults: 4 attempts, 1s → 30s exponential. */ - static RetryPolicy defaults() { - return new RetryPolicy(4, Duration.ofSeconds(1), Duration.ofSeconds(30)); - } - - /** - * Whether the SDK should retry after {@code cause}, given that {@code attempt} attempts have - * already been spent (zero-indexed: {@code attempt == 0} means the original call just failed and - * we're considering the first retry). - */ - boolean shouldRetry(Throwable cause, int attempt) { - if (attempt + 1 >= maxAttempts) { - return false; - } - return isRetriable(cause); - } - - /** - * Backoff before the next attempt. {@code attempt == 0} means "before the first retry", i.e. the - * delay applied right after the original call failed. - */ - Duration backoffDelay(int attempt) { - long base = initialBackoff.toMillis(); - long max = maxBackoff.toMillis(); - // Two saturation points: (1) for large attempt indices, the shift `1L << N` would silently - // wrap once N >= 63 (Java masks the shift count to its low 6 bits), and (2) for moderate - // indices, `base * 2^attempt` can overflow Long before we get a chance to cap. (1) is - // handled by the early return; (2) by the rearranged inequality - // `base > max / multiplier ⇔ base * multiplier > max`, which detects overflow without - // actually overflowing. - if (attempt >= 62) { - return Duration.ofMillis(max); - } - long multiplier = 1L << Math.max(attempt, 0); - long delay = (base > max / multiplier) ? max : base * multiplier; - return Duration.ofMillis(delay); - } - - private static boolean isRetriable(Throwable cause) { - if (!(cause instanceof MarketDataException)) { - // Conservative: unknown failure types don't get retried. The caller sees the original - // exception rather than an amplified series of identical hits. - return false; - } - if (cause instanceof NetworkError net) { - // NetworkError wraps two shapes: actual transport failures (IOException + subtypes: - // ConnectException, HttpTimeoutException, ...) and sync-throws from httpClient.sendAsync - // (NPE, IllegalArgumentException — bugs, not network). Retry only the former; the latter - // is deterministic and just burns the backoff for the same crash. - return net.getCause() instanceof IOException; - } - if (cause instanceof ServerError server) { - Integer status = server.getStatusCode(); - // Spec §9: 500 is not retriable; 501–599 are. A null status means "we threw a ServerError - // without a real HTTP code" — that's only the synthetic-path of HttpStatusMapper today, so - // don't retry it. - return status != null && status >= 501 && status <= 599; - } - // AuthenticationError, BadRequestError, RateLimitError, NotFoundError, ParseError: §9 says - // never retry 4xx, and ParseError is deterministic. - return false; - } -} diff --git a/src/main/java/com/marketdata/sdk/Tokens.java b/src/main/java/com/marketdata/sdk/Tokens.java deleted file mode 100644 index a17948a..0000000 --- a/src/main/java/com/marketdata/sdk/Tokens.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.marketdata.sdk; - -import org.jspecify.annotations.Nullable; - -/** - * Token redaction helpers. SDK requirements §5 / §16: API tokens must never appear in log output - * verbatim. - */ -final class Tokens { - - /** - * Minimum number of asterisks emitted before the trailing 4 chars, matching the SDK requirements - * §7 example ({@code ************************************YKT0}). - */ - private static final int MIN_MASK_LENGTH = 32; - - private static final int VISIBLE_TAIL = 4; - - private Tokens() {} - - /** - * Returns a redacted form of {@code token} suitable for logging. The last 4 characters are - * preserved; the rest is replaced with asterisks padded to at least {@value #MIN_MASK_LENGTH} - * characters. - */ - public static String redact(@Nullable String token) { - if (token == null || token.isBlank()) { - return "(none)"; - } - if (token.length() <= VISIBLE_TAIL) { - return "*".repeat(token.length()); - } - int hidden = Math.max(token.length() - VISIBLE_TAIL, MIN_MASK_LENGTH); - return "*".repeat(hidden) + token.substring(token.length() - VISIBLE_TAIL); - } -} diff --git a/src/main/java/com/marketdata/sdk/Version.java b/src/main/java/com/marketdata/sdk/Version.java deleted file mode 100644 index 909c6c9..0000000 --- a/src/main/java/com/marketdata/sdk/Version.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.marketdata.sdk; - -import org.jspecify.annotations.Nullable; - -/** - * Reads the SDK's version from the JAR manifest's {@code Implementation-Version} attribute (SDK - * requirements §15: "version must be automatically detected from package metadata"). - * - *

Falls back to {@code "0.0.0-dev"} when the class is not loaded from a JAR (e.g. running tests - * from class files). - */ -final class Version { - - static final String FALLBACK = "0.0.0-dev"; - - private Version() {} - - static String current() { - return resolve(Version.class.getPackage().getImplementationVersion()); - } - - // Extracted so tests can exercise both the present-version and fallback branches without - // requiring the SDK to be loaded from an actual JAR with an Implementation-Version manifest. - static String resolve(@Nullable String detected) { - return detected != null && !detected.isBlank() ? detected : FALLBACK; - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java deleted file mode 100644 index 6efa76f..0000000 --- a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The API rejected the credentials (HTTP 401). */ -public final class AuthenticationError extends MarketDataException { - - public AuthenticationError(String message, ErrorContext context) { - super(message, context, null); - } - - public AuthenticationError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java deleted file mode 100644 index 9e4ae68..0000000 --- a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The request was malformed or invalid (HTTP 400 / 422). */ -public final class BadRequestError extends MarketDataException { - - public BadRequestError(String message, ErrorContext context) { - super(message, context, null); - } - - public BadRequestError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/ErrorContext.java b/src/main/java/com/marketdata/sdk/exception/ErrorContext.java deleted file mode 100644 index b84e509..0000000 --- a/src/main/java/com/marketdata/sdk/exception/ErrorContext.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** - * Diagnostic context attached to a {@link MarketDataException}, carrying the fields required by SDK - * requirements §6.2. - * - *

Use {@link #empty()} for client-side errors that occur before any HTTP request is dispatched - * (e.g. parameter validation). - * - * @param requestId value of the {@code cf-ray} response header, if any - * @param requestUrl full URL of the request that produced the error - * @param statusCode HTTP status code returned by the server - */ -public record ErrorContext( - @Nullable String requestId, @Nullable String requestUrl, @Nullable Integer statusCode) { - - private static final ErrorContext EMPTY = new ErrorContext(null, null, null); - - public static ErrorContext empty() { - return EMPTY; - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java deleted file mode 100644 index bb16a91..0000000 --- a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.marketdata.sdk.exception; - -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import org.jspecify.annotations.Nullable; - -/** - * Root of the SDK exception hierarchy. - * - *

Sealed so consumer {@code switch} statements over its subtypes are compile-time exhaustive. - * Every instance carries the support context fields required by SDK requirements §6.2 and exposes a - * {@link #getSupportInfo()} string per §6.3. - * - *

Subtypes use {@link ErrorContext#empty()} for client-side validation errors that occur before - * any HTTP request is dispatched. - */ -public abstract sealed class MarketDataException extends RuntimeException - permits AuthenticationError, - BadRequestError, - NotFoundError, - RateLimitError, - ServerError, - NetworkError, - ParseError { - - private static final ZoneId EASTERN = ZoneId.of("America/New_York"); - private static final DateTimeFormatter TIMESTAMP_FORMAT = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - private final @Nullable String requestId; - private final @Nullable String requestUrl; - private final @Nullable Integer statusCode; - private final ZonedDateTime timestamp; - - protected MarketDataException(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, cause); - this.requestId = context.requestId(); - this.requestUrl = context.requestUrl(); - this.statusCode = context.statusCode(); - this.timestamp = ZonedDateTime.now(EASTERN); - } - - public @Nullable String getRequestId() { - return requestId; - } - - public @Nullable String getRequestUrl() { - return requestUrl; - } - - public @Nullable Integer getStatusCode() { - return statusCode; - } - - public ZonedDateTime getTimestamp() { - return timestamp; - } - - public String getExceptionType() { - return getClass().getSimpleName(); - } - - /** - * Multi-line, human-readable summary of the error and its context, intended to be copy-pasted - * into a support ticket. Never contains the API token or request body. - */ - public String getSupportInfo() { - StringBuilder sb = new StringBuilder(256); - sb.append("Market Data SDK Error\n"); - sb.append("---------------------\n"); - sb.append("Type: ").append(getExceptionType()).append('\n'); - sb.append("Message: ").append(getMessage()).append('\n'); - sb.append("Status code: ").append(statusCode != null ? statusCode : "(n/a)").append('\n'); - sb.append("Request ID: ").append(requestId != null ? requestId : "(n/a)").append('\n'); - sb.append("Request URL: ").append(requestUrl != null ? requestUrl : "(n/a)").append('\n'); - sb.append("Timestamp: ").append(timestamp.format(TIMESTAMP_FORMAT)).append(" (US/Eastern)"); - return sb.toString(); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/NetworkError.java b/src/main/java/com/marketdata/sdk/exception/NetworkError.java deleted file mode 100644 index 8d318de..0000000 --- a/src/main/java/com/marketdata/sdk/exception/NetworkError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** Transport-level failure: connection refused, DNS error, timeout, TLS, etc. */ -public final class NetworkError extends MarketDataException { - - public NetworkError(String message, ErrorContext context) { - super(message, context, null); - } - - public NetworkError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java b/src/main/java/com/marketdata/sdk/exception/NotFoundError.java deleted file mode 100644 index 3f050cd..0000000 --- a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** - * The requested resource was not found (HTTP 404). - * - *

Per SDK requirements §9.1, most endpoints translate 404 into an empty no-data response rather - * than throwing this exception. It exists for the cases where 404 truly indicates a programming - * error. - */ -public final class NotFoundError extends MarketDataException { - - public NotFoundError(String message, ErrorContext context) { - super(message, context, null); - } - - public NotFoundError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/ParseError.java b/src/main/java/com/marketdata/sdk/exception/ParseError.java deleted file mode 100644 index 205c59c..0000000 --- a/src/main/java/com/marketdata/sdk/exception/ParseError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The API response could not be decoded into the expected model. */ -public final class ParseError extends MarketDataException { - - public ParseError(String message, ErrorContext context) { - super(message, context, null); - } - - public ParseError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java deleted file mode 100644 index ba4ca54..0000000 --- a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The client exceeded the API's rate limit (HTTP 429). */ -public final class RateLimitError extends MarketDataException { - - public RateLimitError(String message, ErrorContext context) { - super(message, context, null); - } - - public RateLimitError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/ServerError.java b/src/main/java/com/marketdata/sdk/exception/ServerError.java deleted file mode 100644 index 8ae929b..0000000 --- a/src/main/java/com/marketdata/sdk/exception/ServerError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The API returned a 5xx response. */ -public final class ServerError extends MarketDataException { - - public ServerError(String message, ErrorContext context) { - super(message, context, null); - } - - public ServerError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/package-info.java b/src/main/java/com/marketdata/sdk/exception/package-info.java deleted file mode 100644 index 456d22e..0000000 --- a/src/main/java/com/marketdata/sdk/exception/package-info.java +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Sealed exception hierarchy thrown by the SDK. - * - *

The {@link com.marketdata.sdk.exception.MarketDataException} root is sealed so consumer {@code - * switch} statements over the known subtypes are compiler-checked for exhaustiveness. Adding a new - * subtype is a breaking change. - */ -@NullMarked -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/markets/DailyStatus.java b/src/main/java/com/marketdata/sdk/markets/DailyStatus.java deleted file mode 100644 index 2f20caa..0000000 --- a/src/main/java/com/marketdata/sdk/markets/DailyStatus.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.marketdata.sdk.markets; - -import java.time.LocalDate; - -/** - * Whether the market was open on a single trading day. - * - * @param date the calendar date in the exchange's local time zone (US/Eastern for the default - * country US, per SDK requirements §11.4) - * @param open {@code true} if the market session was open on that date, {@code false} if closed - * (weekend, holiday, etc.) - */ -public record DailyStatus(LocalDate date, boolean open) {} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketStatus.java b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java deleted file mode 100644 index 18ff857..0000000 --- a/src/main/java/com/marketdata/sdk/markets/MarketStatus.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.marketdata.sdk.markets; - -import java.util.List; - -/** - * Result of a {@code /v1/markets/status/} call: one {@link DailyStatus} per requested date, in - * chronological order. - * - *

The wire format the API returns is a compressed parallel-arrays JSON payload (per SDK - * requirements §11.1); the SDK expands it into this idiomatic typed shape via a custom Jackson - * deserializer registered programmatically by the transport (ADR-005, ADR-007). - * - *

An empty {@code days} list means the API responded with no data — either an HTTP 404 with - * {@code {"s":"no_data"}} or an unsupported country (currently only {@code US} returns data). - * - * @param days the per-day market status, never {@code null}; empty when the API has no data - */ -public record MarketStatus(List days) { - - public boolean isEmpty() { - return days.isEmpty(); - } -} diff --git a/src/main/java/com/marketdata/sdk/markets/package-info.java b/src/main/java/com/marketdata/sdk/markets/package-info.java deleted file mode 100644 index f8f4dee..0000000 --- a/src/main/java/com/marketdata/sdk/markets/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Public response records for the {@code /v1/markets/*} endpoint group. The façade itself ({@link - * com.marketdata.sdk.MarketsResource}) lives in the SDK root package per ADR-007. - */ -@NullMarked -package com.marketdata.sdk.markets; - -import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/package-info.java b/src/main/java/com/marketdata/sdk/package-info.java deleted file mode 100644 index cac5c4e..0000000 --- a/src/main/java/com/marketdata/sdk/package-info.java +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Market Data Java SDK — public API root. - * - *

This package hosts both the public API surface ({@link com.marketdata.sdk.MarketDataClient}, - * {@link com.marketdata.sdk.RateLimits}, and the resource façades) and every package-private - * internal class (configuration cascade, env-var keys, token redaction, version detection, and the - * HTTP/wire-format infrastructure). Per ADR-007, the "internal" boundary is enforced by Java's - * package-private visibility: types not meant for consumers omit the {@code public} modifier so the - * consumer's compiler simply cannot reference them. - * - *

{@code @NullMarked} applies at the package level — every type, parameter, return, and field is - * non-null by default. Mark nullable items explicitly with {@link - * org.jspecify.annotations.Nullable}. - */ -@NullMarked -package com.marketdata.sdk; - -import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java deleted file mode 100644 index ed03e6f..0000000 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CyclicBarrier; -import org.junit.jupiter.api.RepeatedTest; -import org.junit.jupiter.api.Test; - -class AsyncSemaphoreTest { - - // ---------- fast path ---------- - - @Test - void acquireReturnsCompletedFutureWhenPermitsAvailable() { - AsyncSemaphore sem = new AsyncSemaphore(3); - - CompletableFuture a = sem.acquire(); - CompletableFuture b = sem.acquire(); - CompletableFuture c = sem.acquire(); - - assertThat(a).isCompleted(); - assertThat(b).isCompleted(); - assertThat(c).isCompleted(); - assertThat(sem.availablePermits()).isZero(); - assertThat(sem.queueLength()).isZero(); - } - - // ---------- slow path ---------- - - @Test - void acquireReturnsPendingFutureWhenPoolExhausted() { - AsyncSemaphore sem = new AsyncSemaphore(2); - sem.acquire(); - sem.acquire(); - - CompletableFuture waiter = sem.acquire(); - - assertThat(waiter).isNotCompleted(); - assertThat(sem.availablePermits()).isZero(); - assertThat(sem.queueLength()).isOne(); - } - - @Test - void releaseTransfersPermitDirectlyToFirstWaiter() { - AsyncSemaphore sem = new AsyncSemaphore(1); - sem.acquire(); // pool empty - - CompletableFuture w1 = sem.acquire(); - CompletableFuture w2 = sem.acquire(); - - sem.release(); - - // The permit goes from the in-flight caller straight to w1 — never re-counted. - assertThat(w1).isCompleted(); - assertThat(w2).isNotCompleted(); - assertThat(sem.availablePermits()).isZero(); - assertThat(sem.queueLength()).isOne(); - - sem.release(); - - assertThat(w2).isCompleted(); - assertThat(sem.availablePermits()).isZero(); - assertThat(sem.queueLength()).isZero(); - } - - @Test - void releaseWithNoWaitersIncrementsCounter() { - AsyncSemaphore sem = new AsyncSemaphore(2); - sem.acquire(); - sem.acquire(); - - sem.release(); - assertThat(sem.availablePermits()).isOne(); - - sem.release(); - assertThat(sem.availablePermits()).isEqualTo(2); - } - - // ---------- cancellation ---------- - - @Test - void cancelledWaiterIsSkippedOnRelease() { - AsyncSemaphore sem = new AsyncSemaphore(1); - sem.acquire(); // pool empty - - CompletableFuture cancelled = sem.acquire(); - CompletableFuture alive = sem.acquire(); - cancelled.cancel(false); - - sem.release(); - - // The cancelled waiter is skipped; the next live one gets the permit. - assertThat(alive).isCompleted(); - assertThat(sem.queueLength()).isZero(); - assertThat(sem.availablePermits()).isZero(); - } - - @Test - void releaseWhenAllWaitersCancelledFallsBackToCounter() { - AsyncSemaphore sem = new AsyncSemaphore(1); - sem.acquire(); - - sem.acquire().cancel(false); - sem.acquire().cancel(false); - - sem.release(); - - // No live waiter — the permit goes back to the pool. - assertThat(sem.availablePermits()).isOne(); - assertThat(sem.queueLength()).isZero(); - } - - // ---------- ordering ---------- - - @Test - void waitersAreServedFifo() { - AsyncSemaphore sem = new AsyncSemaphore(0); - List completionOrder = new ArrayList<>(); - - for (int i = 0; i < 10; i++) { - int id = i; - sem.acquire().thenRun(() -> completionOrder.add(id)); - } - - for (int i = 0; i < 10; i++) { - sem.release(); - } - - assertThat(completionOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); - } - - // ---------- race between release() and waiter cancellation (Issue #1, Component B) ---------- - - /** - * Regression for the TOCTOU race in {@link AsyncSemaphore#release()} between {@code pollFirst()} - * (inside the lock) and {@code complete(null)} (outside the lock). If the polled waiter is - * cancelled in that window, {@code complete(null)} returns false and — under the current - * implementation — the permit is silently lost: it was already removed from the counter when - * release() "transferred" it, and the cancelled waiter never delivers it anywhere. - * - *

The race is timing-sensitive; we coordinate two threads through a {@link CyclicBarrier} and - * repeat the scenario many times so at least some iterations hit the bad window. The invariant we - * assert is permit-conservation: - * - *

- * - * Either way, the permit is never lost. - */ - @RepeatedTest(200) - void releaseDoesNotLosePermitWhenWaiterIsCancelledMidRelease() throws Exception { - AsyncSemaphore sem = new AsyncSemaphore(1); - sem.acquire(); // pool now empty - - CompletableFuture waiter = sem.acquire(); // queued - - CyclicBarrier barrier = new CyclicBarrier(2); - - Thread releaser = - new Thread( - () -> { - awaitBarrier(barrier); - sem.release(); - }); - Thread canceller = - new Thread( - () -> { - awaitBarrier(barrier); - waiter.cancel(false); - }); - - releaser.start(); - canceller.start(); - releaser.join(); - canceller.join(); - - assertThat(sem.queueLength()).as("queue must be drained").isZero(); - - if (waiter.isCancelled()) { - // Canceller observed (or won) the race. Whatever release() did, the permit must have - // landed somewhere — and with no other waiter present, that "somewhere" is the counter. - assertThat(sem.availablePermits()) - .as("permit must return to the pool when the only waiter is cancelled") - .isEqualTo(1); - } else { - // Releaser completed the waiter before cancel arrived. waiter must be done-normally, - // and the permit is considered "held" by the (notional) downstream consumer of the waiter. - assertThat(waiter) - .as("if not cancelled, waiter must be completed normally") - .isCompletedWithValue(null); - assertThat(sem.availablePermits()).isZero(); - } - } - - private static void awaitBarrier(CyclicBarrier barrier) { - try { - barrier.await(); - } catch (Exception e) { - throw new AssertionError("barrier interrupted", e); - } - } - - // ---------- argument validation ---------- - - @Test - void rejectsNegativeInitialPermits() { - assertThatThrownBy(() -> new AsyncSemaphore(-1)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("permits"); - } - - @Test - void zeroInitialPermitsIsValidAndForcesSlowPath() { - AsyncSemaphore sem = new AsyncSemaphore(0); - - CompletableFuture w = sem.acquire(); - assertThat(w).isNotCompleted(); - - sem.release(); - assertThat(w).isCompleted(); - } -} diff --git a/src/test/java/com/marketdata/sdk/CallMode.java b/src/test/java/com/marketdata/sdk/CallMode.java deleted file mode 100644 index d9144bc..0000000 --- a/src/test/java/com/marketdata/sdk/CallMode.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.marketdata.sdk; - -import com.marketdata.sdk.markets.MarketStatus; -import java.time.LocalDate; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; - -/** - * Drives a {@code /v1/markets/*} call through either the sync or async surface. ASYNC mode unwraps - * {@link CompletionException} so caller-visible behavior matches sync (per ADR-006: sync wraps - * {@code .join()} and surfaces the underlying cause directly). - * - *

Lives in the unit-test source set so it is reusable from the integration-test source set — - * {@code integrationTest}'s compileClasspath includes the unit-test output (see {@code - * build.gradle.kts}). Package-private intentionally: only test classes in {@code - * com.marketdata.sdk} need it. - */ -enum CallMode { - SYNC { - @Override - MarketStatus statusNoArgs(MarketsResource r) { - return r.status(); - } - - @Override - MarketStatus statusForDate(MarketsResource r, LocalDate date) { - return r.status(date); - } - - @Override - MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { - return r.status(from, to); - } - }, - ASYNC { - @Override - MarketStatus statusNoArgs(MarketsResource r) { - return joinUnwrapping(r.statusAsync()); - } - - @Override - MarketStatus statusForDate(MarketsResource r, LocalDate date) { - return joinUnwrapping(r.statusAsync(date)); - } - - @Override - MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { - return joinUnwrapping(r.statusAsync(from, to)); - } - }; - - abstract MarketStatus statusNoArgs(MarketsResource r); - - abstract MarketStatus statusForDate(MarketsResource r, LocalDate date); - - abstract MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to); - - private static T joinUnwrapping(CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException e) { - if (e.getCause() instanceof RuntimeException re) { - throw re; - } - throw e; - } - } -} diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java deleted file mode 100644 index 8cda17f..0000000 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class ConfigurationTest { - - /** - * Reflection bridge to {@code Configuration}'s private constructor. Tests need to inject custom - * environment maps; production code cannot — that's the entire point of keeping the constructor - * private. Encapsulating the reflection here keeps individual tests clean. - */ - private static Configuration newConfig( - Map systemEnv, Map dotEnv) { - try { - Constructor ctor = - Configuration.class.getDeclaredConstructor(Map.class, Map.class); - ctor.setAccessible(true); - return ctor.newInstance(systemEnv, dotEnv); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Could not construct Configuration via reflection — has the private ctor signature" - + " changed?", - e); - } - } - - @Test - void explicitWinsOverEverything() { - Configuration config = - newConfig( - Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv")); - - assertThat(config.resolve("explicit-value", "MARKETDATA_TOKEN")).isEqualTo("explicit-value"); - } - - @Test - void envVarWinsOverDotEnv() { - Configuration config = - newConfig( - Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv")); - - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-env"); - } - - @Test - void fallsBackToDotEnvWhenEnvVarMissing() { - Configuration config = newConfig(Map.of(), Map.of("MARKETDATA_TOKEN", "from-dotenv")); - - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv"); - } - - @Test - void blankExplicitDoesNotCount() { - Configuration config = newConfig(Map.of("MARKETDATA_TOKEN", "from-env"), Map.of()); - - assertThat(config.resolve(" ", "MARKETDATA_TOKEN")).isEqualTo("from-env"); - } - - @Test - void blankEnvVarFallsThroughToDotEnv() { - Configuration config = - newConfig(Map.of("MARKETDATA_TOKEN", " "), Map.of("MARKETDATA_TOKEN", "from-dotenv")); - - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv"); - } - - @Test - void resolveReturnsNullWhenAllSourcesEmpty() { - Configuration config = newConfig(Map.of(), Map.of()); - - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isNull(); - } - - @Test - void resolveOrDefaultReturnsDefaultWhenAllEmpty() { - Configuration config = newConfig(Map.of(), Map.of()); - - assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default")) - .isEqualTo("https://default"); - } - - @Test - void resolveOrDefaultPrefersResolvedValue() { - Configuration config = newConfig(Map.of("MARKETDATA_BASE_URL", "https://explicit"), Map.of()); - - assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default")) - .isEqualTo("https://explicit"); - } - - // ---------- .env file parsing ---------- - - @Test - void readsAndParsesDotEnvFile(@TempDir Path tmp) throws IOException { - Path dotenv = tmp.resolve(".env"); - Files.writeString( - dotenv, - """ - # comment line — should be ignored - MARKETDATA_TOKEN=plain-token - MARKETDATA_BASE_URL="https://staging.example.com" - QUOTED_SINGLE='single-quoted' - EMPTY_VALUE= - - # blank line above - BAD_LINE_NO_EQUALS - =BAD_LINE_NO_KEY - """); - - Map parsed = Configuration.readDotEnvFile(dotenv); - - assertThat(parsed) - .containsEntry("MARKETDATA_TOKEN", "plain-token") - .containsEntry("MARKETDATA_BASE_URL", "https://staging.example.com") - .containsEntry("QUOTED_SINGLE", "single-quoted") - .containsEntry("EMPTY_VALUE", "") - .doesNotContainKey("# comment line — should be ignored") - .doesNotContainKey("BAD_LINE_NO_EQUALS"); - } - - @Test - void missingDotEnvReturnsEmpty(@TempDir Path tmp) { - assertThat(Configuration.readDotEnvFile(tmp.resolve(".env"))).isEmpty(); - } - - @Test - void mismatchedQuotesArePreservedVerbatim(@TempDir Path tmp) throws IOException { - // stripQuotes only strips when the first AND last characters match (both " or both '). - // Lines with mixed or unbalanced quotes must keep the value as-is. Covers the right-hand - // false branches of the `||` in (first == '"' && last == '"') || (first == '\'' && last == - // '\''). - Path dotenv = tmp.resolve(".env"); - Files.writeString( - dotenv, - """ - UNCLOSED_DOUBLE="abc - UNCLOSED_SINGLE='abc - MIXED_QUOTES="abc' - """); - - Map parsed = Configuration.readDotEnvFile(dotenv); - - assertThat(parsed) - .containsEntry("UNCLOSED_DOUBLE", "\"abc") - .containsEntry("UNCLOSED_SINGLE", "'abc") - .containsEntry("MIXED_QUOTES", "\"abc'"); - } - - @Test - void dotEnvParsingIntegratesWithCascade(@TempDir Path tmp) throws IOException { - Path dotenv = tmp.resolve(".env"); - Files.writeString(dotenv, "MARKETDATA_TOKEN=from-real-dotenv\n"); - - Configuration config = newConfig(Map.of(), Configuration.readDotEnvFile(dotenv)); - - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-real-dotenv"); - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java deleted file mode 100644 index a7e5cef..0000000 --- a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.MarketDataException; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import org.junit.jupiter.api.Test; - -class HttpStatusMapperTest { - - private static final String URL = "https://api.marketdata.app/v1/test/"; - private static final String RAY = "ray-1"; - - // ---------- switch coverage: each case + default ---------- - - @Test - void status400MapsToBadRequest() { - MarketDataException e = HttpStatusMapper.toException(400, URL, RAY); - assertThat(e).isInstanceOf(BadRequestError.class); - assertThat(e.getStatusCode()).isEqualTo(400); - assertThat(e.getMessage()).contains("400"); - } - - @Test - void status422AlsoMapsToBadRequest() { - // Same case-arm as 400; without exercising 422 explicitly, half the multi-label arm is - // unrecorded by JaCoCo. - MarketDataException e = HttpStatusMapper.toException(422, URL, RAY); - assertThat(e).isInstanceOf(BadRequestError.class); - assertThat(e.getStatusCode()).isEqualTo(422); - assertThat(e.getMessage()).contains("422"); - } - - @Test - void status401MapsToAuthenticationError() { - MarketDataException e = HttpStatusMapper.toException(401, URL, RAY); - assertThat(e).isInstanceOf(AuthenticationError.class); - assertThat(e.getStatusCode()).isEqualTo(401); - } - - @Test - void status429MapsToRateLimitError() { - MarketDataException e = HttpStatusMapper.toException(429, URL, RAY); - assertThat(e).isInstanceOf(RateLimitError.class); - assertThat(e.getStatusCode()).isEqualTo(429); - } - - @Test - void everyOtherStatusFallsThroughToServerError() { - // Any status not explicitly handled (402, 500, 502, 503, 504, weird ones) maps to - // ServerError. Covers the `default ->` arm. - for (int code : new int[] {402, 500, 502, 503, 504, 599}) { - MarketDataException e = HttpStatusMapper.toException(code, URL, RAY); - assertThat(e).as("status %d", code).isInstanceOf(ServerError.class); - assertThat(e.getStatusCode()).isEqualTo(code); - } - } - - // ---------- emptyToNull: null vs blank vs valid ---------- - - @Test - void nullRequestIdIsPropagatedAsNull() { - MarketDataException e = HttpStatusMapper.toException(500, URL, null); - assertThat(e.getRequestId()).isNull(); - } - - @Test - void blankRequestIdIsTreatedAsNull() { - // emptyToNull's `s == null || s.isBlank()` short-circuits — without an explicit blank - // input, the right-hand isBlank() branch is never evaluated. - MarketDataException e = HttpStatusMapper.toException(500, URL, " "); - assertThat(e.getRequestId()).isNull(); - } - - @Test - void validRequestIdIsPreserved() { - MarketDataException e = HttpStatusMapper.toException(500, URL, "ray-abc"); - assertThat(e.getRequestId()).isEqualTo("ray-abc"); - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java b/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java deleted file mode 100644 index f8a8a39..0000000 --- a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * End-to-end tests for {@link HttpTransport} that exercise URI shapes and status codes the public - * resource façades don't naturally hit (status 203, trailing-slash paths). Uses the JDK's built-in - * {@link HttpServer} to avoid any extra mocking dependencies. - */ -class HttpTransportE2ETest { - - private HttpServer server; - private final AtomicReference capturedUri = new AtomicReference<>(); - private RouteHandler handler; - - /** Minimal record matching {@code {"value": "..."}} so we can verify a successful decode. */ - record Echo(@JsonProperty("value") String value) {} - - @BeforeEach - void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - handler = new RouteHandler(); - server.createContext("/", handler); - server.start(); - } - - @AfterEach - void stopServer() { - server.stop(0); - } - - private HttpTransport newTransport() { - int port = server.getAddress().getPort(); - return new HttpTransport("http://127.0.0.1:" + port, "v1", "test/0.0", null); - } - - /** - * Status 203 (Non-Authoritative Information) is treated identically to 200 by the transport — - * decoding the body and returning the result. The check {@code status == 200 || status == 203 || - * status == 404} in {@code processResponse} is the only place 203 appears, and without an - * explicit test the 203 leg is dead from JaCoCo's perspective. - */ - @Test - void status203IsTreatedAsSuccess() { - handler.setResponse(203, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - } - - /** - * When the {@link RequestSpec#path()} already ends with a slash, the transport must not append - * another one. Covers the {@code endsWith("/")} → true branch in {@code buildUri}. - */ - @Test - void pathEndingInSlashIsNotDoubled() { - handler.setResponse(200, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("ping/").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(capturedUri.get().getPath()).isEqualTo("/v1/ping/"); - assertThat(capturedUri.get().getPath()).doesNotContain("//"); - } - - /** - * RequestSpec's Javadoc says paths should not start with {@code /}, but a caller mistake would - * otherwise produce {@code /v1//ping/} (double slash, which some HTTP routers reject). The - * transport strips the leading slash defensively so a path of {@code "/ping"} produces the same - * URL as {@code "ping"}. - */ - @Test - void pathStartingWithSlashIsStripped() { - handler.setResponse(200, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("/ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(capturedUri.get().getPath()).isEqualTo("/v1/ping/"); - assertThat(capturedUri.get().getPath()).doesNotContain("//"); - } - - // ---------- in-process server plumbing ---------- - - private final class RouteHandler implements HttpHandler { - private int statusCode = 200; - private String body = "{}"; - - void setResponse(int code, String body) { - this.statusCode = code; - this.body = body; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - capturedUri.set(exchange.getRequestURI()); - - byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(statusCode, bodyBytes.length); - exchange.getResponseBody().write(bodyBytes); - exchange.getResponseBody().close(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java b/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java deleted file mode 100644 index 002a44c..0000000 --- a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java +++ /dev/null @@ -1,548 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import java.io.IOException; -import java.net.Authenticator; -import java.net.CookieHandler; -import java.net.ProxySelector; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.WebSocket; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.function.Supplier; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import org.junit.jupiter.api.Test; - -/** - * Exercises retry behavior. Uses a scripted {@link HttpClient} stub so a single test can drive a - * sequence of responses (e.g. 503, 503, 200) without spinning up an in-process HTTP server or - * waiting on real backoff durations. - */ -class HttpTransportRetryTest { - - /** Tiny response shape for body-decode assertions. */ - record Echo(@JsonProperty("value") String value) {} - - /** Retry policy with sub-millisecond delays so the suite stays under a second. */ - private static RetryPolicy fastPolicy(int maxAttempts) { - return new RetryPolicy(maxAttempts, Duration.ofMillis(1), Duration.ofMillis(5)); - } - - private static HttpTransport newTransport(MultiResponseHttpClient client, RetryPolicy policy) { - return new HttpTransport("http://stub.local", "v1", "test/0.0", null, client, policy); - } - - // ---------- happy paths ---------- - - @Test - void transientServer5xxRetriesAndEventuallySucceeds() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - - Echo result = - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(client.callCount()).isEqualTo(3); - } - - @Test - void networkFailuresRetryAndEventuallySucceed() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - failedResponse(new IOException("connect refused")), - failedResponse(new IOException("connect refused")), - response(200, "{\"value\":\"ok\"}")); - - Echo result = - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(client.callCount()).isEqualTo(3); - } - - // ---------- non-retriable paths fail immediately ---------- - - @Test - void status500FailsImmediatelyWithoutRetry() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(500, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(ServerError.class); - - // Exactly one attempt — 500 is in the retriable status space but the spec specifically - // excludes it (see §9: "501-599 retry; 500 no retry"). - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void authenticationErrorFailsImmediately() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(401, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(AuthenticationError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void badRequestFailsImmediately() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(400, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(BadRequestError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void rateLimitErrorFailsImmediately() { - // Spec §9 explicitly says "Never retry rate limit errors." Even though the API may send - // Retry-After on 429, the SDK propagates immediately rather than blocking the caller. - MultiResponseHttpClient client = new MultiResponseHttpClient(response(429, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(RateLimitError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - // ---------- exhaustion ---------- - - @Test - void exhaustedRetriesPropagatesLastError() { - // 4 stub responses — only 3 should be consumed before maxAttempts is hit and we give up. - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(503, "{}"), response(503, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(ServerError.class) - .satisfies(t -> assertThat(((ServerError) t).getStatusCode()).isEqualTo(503)); - - assertThat(client.callCount()) - .as("maxAttempts=3 must cap total calls — including the original attempt") - .isEqualTo(3); - } - - @Test - void exhaustedRetriesOnNetworkErrorsPropagatesLastError() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - failedResponse(new IOException("kaboom")), - failedResponse(new IOException("kaboom")), - failedResponse(new IOException("kaboom"))); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(NetworkError.class); - - assertThat(client.callCount()).isEqualTo(3); - } - - // ---------- sync-throw bugs do NOT retry ---------- - - /** - * If {@code httpClient.sendAsync} throws synchronously (malformed request, internal NPE, {@code - * IllegalArgumentException}), the failure is wrapped as {@code NetworkError} but its cause is not - * an {@link IOException}. {@link RetryPolicy} treats that as non-retriable: a deterministic bug - * doesn't get better with 1s+2s of backoff. - */ - @Test - void synchronousThrowDoesNotRetry() { - SyncThrowingHttpClient client = new SyncThrowingHttpClient(); - HttpTransport transport = - new HttpTransport("http://stub.local", "v1", "test/0.0", null, client, fastPolicy(3)); - - assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(NetworkError.class) - .hasMessageContaining("before dispatch") - .hasCauseInstanceOf(IllegalArgumentException.class); - - assertThat(client.callCount()) - .as("a sync-throw is deterministic — retrying just burns backoff for the same crash") - .isEqualTo(1); - } - - // ---------- rate-limit snapshot consistency under retry ---------- - - /** - * If attempt 1 returns 503 with rate-limit headers and attempt 2 returns 200 without them, the - * snapshot must reflect attempt 1's values (Issue #4 conservation rule applies cross-attempt, not - * just cross-request). - */ - @Test - void rateLimitSnapshotPreservedAcrossRetryAttempts() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response( - 503, - "{}", - Map.of( - "x-api-ratelimit-limit", "50000", - "x-api-ratelimit-remaining", "12345", - "x-api-ratelimit-reset", "1735689600", - "x-api-ratelimit-consumed", "37655")), - response(200, "{\"value\":\"ok\"}", Map.of())); - - HttpTransport transport = newTransport(client, fastPolicy(3)); - transport.executeSync(RequestSpec.get("ping").build(), Echo.class); - - RateLimits snapshot = transport.getLatestRateLimits(); - assertThat(snapshot).isNotNull(); - assertThat(snapshot.remaining()) - .as("the snapshot must keep the headers from the 503 attempt, not be cleared by the 200") - .isEqualTo(12345L); - } - - // ---------- mid-backoff cancellation ---------- - - /** - * Cancelling the returned future while a backoff is pending must (a) skip the next attempt and - * (b) leave the permit pool intact. The cascade-cancel chain is the trickiest piece of {@link - * HttpTransport}; this test is the explicit regression for it. - */ - @Test - void cancellationMidBackoffSkipsRemainingAttempts() throws Exception { - // Use a slow policy so we have a real backoff window to cancel into. 200 ms is short enough - // to keep the test fast but long enough to reliably interleave the cancel. - RetryPolicy slowPolicy = new RetryPolicy(3, Duration.ofMillis(200), Duration.ofSeconds(1)); - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - HttpTransport transport = newTransport(client, slowPolicy); - - java.util.concurrent.CompletableFuture future = - transport.executeAsync(RequestSpec.get("ping").build(), Echo.class); - - // Let attempt 1 run and fail (503 → schedule retry with 200 ms backoff). Then cancel before - // the delayedExecutor fires the second attempt. - Thread.sleep(50); - boolean cancelled = future.cancel(false); - assertThat(cancelled).isTrue(); - - // Give the would-be next attempt plenty of time to fire if cancellation didn't stop it. - Thread.sleep(400); - - assertThat(client.callCount()) - .as("after mid-backoff cancellation, no further attempts may run") - .isEqualTo(1); - - AsyncSemaphore permits = readSemaphore(transport); - assertThat(permits.availablePermits()) - .as("permit lent to attempt 1 must have come back to the pool") - .isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - assertThat(permits.queueLength()).isZero(); - } - - // ---------- permits are still conserved across retries ---------- - - @Test - void permitsReturnToPoolAfterEveryAttemptRegardlessOfOutcome() throws Exception { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - HttpTransport transport = newTransport(client, fastPolicy(3)); - - transport.executeSync(RequestSpec.get("ping").build(), Echo.class); - - AsyncSemaphore permits = readSemaphore(transport); - assertThat(permits.availablePermits()) - .as("after a 3-attempt retry chain, every permit must be back in the pool") - .isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - } - - // ---------- helpers ---------- - - private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception { - java.lang.reflect.Field f = HttpTransport.class.getDeclaredField("concurrencyPermits"); - f.setAccessible(true); - return (AsyncSemaphore) f.get(t); - } - - private static Supplier>> response(int code, String body) { - return response(code, body, Map.of()); - } - - private static Supplier>> response( - int code, String body, Map headers) { - return () -> CompletableFuture.completedFuture(new StubHttpResponse(code, body, headers)); - } - - private static Supplier>> failedResponse(Throwable t) { - return () -> CompletableFuture.failedFuture(t); - } - - /** - * {@link HttpClient} that returns scripted responses in order. Each invocation of {@code - * sendAsync} pops the next supplier and invokes it. Running out of script entries throws — that - * surfaces "we retried more times than the test expected" as a clear failure. - */ - private static final class MultiResponseHttpClient extends HttpClient { - private final Deque>>> script; - private int callCount = 0; - - @SafeVarargs - MultiResponseHttpClient(Supplier>>... responses) { - this.script = new ArrayDeque<>(List.of(responses)); - } - - int callCount() { - return callCount; - } - - @SuppressWarnings("unchecked") - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - callCount++; - Supplier>> next = script.pollFirst(); - if (next == null) { - return CompletableFuture.failedFuture( - new AssertionError("Retry overshot the test script — call #" + callCount)); - } - return (CompletableFuture>) (CompletableFuture) next.get(); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** - * Stub {@link HttpClient} whose {@code sendAsync} throws {@link IllegalArgumentException} - * synchronously. Used by {@link #synchronousThrowDoesNotRetry()} to drive the pre-dispatch-fault - * path. - */ - private static final class SyncThrowingHttpClient extends HttpClient { - private int callCount = 0; - - int callCount() { - return callCount; - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - callCount++; - throw new IllegalArgumentException("simulated synchronous throw from sendAsync"); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** Minimal {@link HttpResponse} stub — just the bits {@code HttpTransport} reads. */ - private static final class StubHttpResponse implements HttpResponse { - private final int status; - private final byte[] body; - private final HttpHeaders headers; - - StubHttpResponse(int status, String body, Map headers) { - this.status = status; - this.body = body.getBytes(StandardCharsets.UTF_8); - Map> multi = new java.util.HashMap<>(); - headers.forEach((k, v) -> multi.put(k, new ArrayList<>(List.of(v)))); - this.headers = HttpHeaders.of(multi, (a, b) -> true); - } - - @Override - public int statusCode() { - return status; - } - - @Override - public HttpRequest request() { - return null; - } - - @Override - public Optional> previousResponse() { - return Optional.empty(); - } - - @Override - public HttpHeaders headers() { - return headers; - } - - @Override - public byte[] body() { - return body; - } - - @Override - public Optional sslSession() { - return Optional.empty(); - } - - @Override - public URI uri() { - return URI.create("http://stub.local/v1/ping/"); - } - - @Override - public HttpClient.Version version() { - return HttpClient.Version.HTTP_1_1; - } - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java deleted file mode 100644 index bd69e6a..0000000 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ /dev/null @@ -1,499 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.marketdata.sdk.exception.NetworkError; -import java.io.IOException; -import java.lang.reflect.Field; -import java.net.Authenticator; -import java.net.CookieHandler; -import java.net.ProxySelector; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.WebSocket; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -// These tests cover the SINGLE-ATTEMPT semantics of executeAsync. Retry behavior is exercised -// separately in HttpTransportRetryTest; here we explicitly disable retry so a permit-release -// assertion reflects exactly one HTTP call per executeAsync invocation. -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import org.junit.jupiter.api.Test; - -class HttpTransportTest { - - /** Policy with a single attempt — disables retry so each test asserts one HTTP call only. */ - private static final RetryPolicy NO_RETRY = - new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); - - /** - * Regression for the synchronous-throw permit leak: if {@code httpClient.sendAsync(...)} throws - * before returning a future (rare but possible — malformed request, internal NPE, OOM), the - * {@code whenComplete(release)} chain never forms. Without explicit release in the catch, every - * such failure burns a permit forever; a long-lived process eventually deadlocks once 50 such - * failures accumulate. - * - *

This test runs more requests than {@link HttpTransport#CONCURRENCY_LIMIT} against a stub - * client whose {@code sendAsync} always throws — if a permit ever leaked, the {@code - * (limit+1)}-th call would block indefinitely on {@code acquire()} and the test would time out. - */ - @Test - void permitReleasedWhenSendAsyncThrowsSynchronously() throws Exception { - HttpTransport transport = - new HttpTransport( - "http://localhost", "v1", "test/0.0", null, new SyncThrowingHttpClient(), NO_RETRY); - - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); - assertThat(initial).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - - int n = HttpTransport.CONCURRENCY_LIMIT + 5; - for (int i = 0; i < n; i++) { - CompletableFuture f = - transport.executeAsync(RequestSpec.get("ping").build(), Object.class); - - assertThat(f).isCompletedExceptionally(); - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasCauseInstanceOf(NetworkError.class) - .hasMessageContaining("before dispatch"); - } - - // If even one permit had leaked, this would be < initial; the (limit+1)-th call would - // also have blocked instead of failing fast. - assertThat(permits.availablePermits()).isEqualTo(initial); - } - - /** - * Errors thrown synchronously by {@link HttpClient#sendAsync} (e.g. {@code OutOfMemoryError}) - * must surface with their original type preserved — wrapping a JVM-level {@link Error} in a - * {@link com.marketdata.sdk.exception.NetworkError} would mask the real cause and produce a - * misleading "network failure" for what is actually a runtime crash. Covers the {@code if (t - * instanceof Error err) throw err;} branch in {@code dispatch}; the {@link - * java.util.concurrent.CompletableFuture#thenCompose} machinery catches the rethrown Error and - * exposes it as the future's root cause rather than letting it propagate synchronously. - */ - @Test - void errorThrownSynchronouslyIsPreservedAsRootCause() throws Exception { - HttpTransport transport = - new HttpTransport( - "http://localhost", "v1", "test/0.0", null, new ErrorThrowingHttpClient(), NO_RETRY); - - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); - - CompletableFuture f = - transport.executeAsync(RequestSpec.get("ping").build(), Object.class); - - assertThat(f).isCompletedExceptionally(); - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasRootCauseInstanceOf(OutOfMemoryError.class) - .hasRootCauseMessage("simulated synchronous Error from sendAsync"); - - // Permit released even though the catch took the Error branch — a leak here would - // accumulate over a long-lived process and eventually deadlock the pool. - assertThat(permits.availablePermits()).isEqualTo(initial); - } - - /** - * Regression for the slow-path cancellation leak (Issue #1, Component A). When the pool is - * saturated, {@code acquire()} returns a pending waiter that is enqueued. The future the caller - * actually sees is the downstream {@code thenCompose} result, NOT the waiter. Cancelling the - * downstream does not propagate to the waiter (standard CompletableFuture semantics), so - * the waiter is still alive when {@code release()} runs — release() "transfers" the permit by - * completing the waiter, but the {@code thenCompose} function never executes because its - * dependent future is already cancelled. Result: the permit is lost forever. - * - *

This test saturates the pool with {@link HttpTransport#CONCURRENCY_LIMIT} fast-path - * dispatches whose HTTP futures we control, queues {@code extras} slow-path callers, cancels all - * the slow-path futures, and then completes the fast-path HTTP futures so {@code release()} - * fires. Once every dispatch has settled, every permit must be back in the pool. - */ - @Test - void permitsAreReleasedWhenSlowPathFuturesAreCancelled() throws Exception { - ControllableHttpClient client = new ControllableHttpClient(); - HttpTransport transport = - new HttpTransport("http://localhost", "v1", "test/0.0", null, client, NO_RETRY); - - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); - assertThat(initial).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - - // Saturate the pool — these go through the fast path (acquire returns an already-completed - // future), dispatch is invoked, sendAsync is called → ControllableHttpClient returns a - // pending future we hold the handle to. - List> fastPath = new ArrayList<>(initial); - for (int i = 0; i < initial; i++) { - fastPath.add(transport.executeAsync(RequestSpec.get("ping").build(), Object.class)); - } - assertThat(permits.availablePermits()).isZero(); - assertThat(permits.queueLength()).isZero(); - assertThat(client.pendingCount()).isEqualTo(initial); - - // Slow path — these enqueue waiters in the semaphore. dispatch is NOT yet called for them. - int extras = 5; - List> slowPath = new ArrayList<>(extras); - for (int i = 0; i < extras; i++) { - slowPath.add(transport.executeAsync(RequestSpec.get("ping").build(), Object.class)); - } - assertThat(permits.queueLength()).isEqualTo(extras); - - // Caller cancels every slow-path future. Without the fix, the waiters stay live in the - // queue — release() will later transfer permits into the cancelled-downstream waiters - // and the permits disappear. - for (CompletableFuture f : slowPath) { - f.cancel(false); - } - - // Complete every fast-path HTTP future. Each completion fires whenComplete(release). - // Failing the future bypasses body decoding (which would NPE on a null response) while - // still exercising the release path. - client.failAll(new IOException("simulated end of test")); - - // After every dispatch has settled, the pool must be fully restored. - assertThat(permits.queueLength()).isZero(); - assertThat(permits.availablePermits()) - .as("every permit should be back in the pool — no leaks from cancelled slow-path futures") - .isEqualTo(initial); - } - - // ---------- asRuntime: covers the three branches in the executeSync catch ---------- - - @Test - void asRuntimeReturnsMarketDataExceptionUnchanged() { - // The `instanceof MarketDataException` branch — the only one reached from the public - // surface today (every failure from executeAsync is wrapped as an MDE subtype). - com.marketdata.sdk.exception.BadRequestError mde = - new com.marketdata.sdk.exception.BadRequestError( - "bad", com.marketdata.sdk.exception.ErrorContext.empty()); - - RuntimeException result = HttpTransport.asRuntime(mde); - - assertThat(result).isSameAs(mde); - } - - @Test - void asRuntimeRethrowsNonMdeRuntimeExceptionUnchanged() { - // Defensive guardrail: if some future code path lets a non-MDE RuntimeException reach - // .join()'s cause, surface it as-is rather than wrapping it. - IllegalStateException re = new IllegalStateException("unexpected"); - - RuntimeException result = HttpTransport.asRuntime(re); - - assertThat(result).isSameAs(re); - } - - @Test - void asRuntimeWrapsNonRuntimeCauseInNetworkError() { - // Last-resort branch: cause is an Error (or null). Wrap in NetworkError so the public - // surface still observes the sealed MarketDataException hierarchy. - OutOfMemoryError error = new OutOfMemoryError("simulated"); - - RuntimeException result = HttpTransport.asRuntime(error); - - assertThat(result).isInstanceOf(com.marketdata.sdk.exception.NetworkError.class); - assertThat(result.getCause()).isSameAs(error); - assertThat(result.getMessage()).contains("Unexpected failure invoking SDK"); - } - - @Test - void asRuntimeWrapsNullCauseInNetworkError() { - // CompletableFuture.join() can in principle deliver a CompletionException whose cause - // is null (defensive: should never happen in practice but ergonomically harmless). - RuntimeException result = HttpTransport.asRuntime(null); - - assertThat(result).isInstanceOf(com.marketdata.sdk.exception.NetworkError.class); - assertThat(result.getCause()).isNull(); - } - - // ---------- unwrap: covers all 4 branches of `t instanceof CE && t.getCause() != null` - // ---------- - - @Test - void unwrapReturnsNonCompletionExceptionUnchanged() { - // First branch of `&&` is false → short-circuit, return t as-is. The most common path - // in production: handle() in CompletableFuture already unwraps CompletionException. - java.io.IOException io = new java.io.IOException("boom"); - assertThat(HttpTransport.unwrap(io)).isSameAs(io); - } - - @Test - void unwrapReturnsCauseOfNestedCompletionException() { - // Both branches true: CompletionException with a cause. Returns the cause. - java.io.IOException root = new java.io.IOException("root"); - CompletionException wrapped = new CompletionException(root); - - assertThat(HttpTransport.unwrap(wrapped)).isSameAs(root); - } - - @Test - void unwrapReturnsCompletionExceptionWithoutCauseUnchanged() { - // First branch true, second branch false: CompletionException with `null` cause. The - // method returns t itself rather than dereferencing the missing cause. - CompletionException causeless = new CompletionException(null); - - assertThat(HttpTransport.unwrap(causeless)).isSameAs(causeless); - } - - // ---------- helpers ---------- - - private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception { - Field f = HttpTransport.class.getDeclaredField("concurrencyPermits"); - f.setAccessible(true); - return (AsyncSemaphore) f.get(t); - } - - /** - * Bare-bones {@link HttpClient} subclass whose {@code sendAsync} throws synchronously. Every - * other abstract method is stubbed with {@code UnsupportedOperationException} since the test - * never exercises them. - */ - private static final class SyncThrowingHttpClient extends HttpClient { - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new IllegalArgumentException("simulated synchronous throw from sendAsync"); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** - * Stub {@link HttpClient} whose {@code sendAsync} returns a fresh, never-auto-completing future - * for each call. The test holds the references and chooses when to complete them — that's the - * lever the slow-path cancellation regression test pulls to deterministically drive the {@code - * whenComplete(release)} path. - */ - private static final class ControllableHttpClient extends HttpClient { - private final List>> pending = new ArrayList<>(); - - @SuppressWarnings("unchecked") - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - CompletableFuture> f = new CompletableFuture<>(); - pending.add((CompletableFuture>) (CompletableFuture) f); - return f; - } - - int pendingCount() { - return pending.size(); - } - - void failAll(Throwable t) { - for (CompletableFuture> f : pending) { - f.completeExceptionally(t); - } - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** Same skeleton as {@link SyncThrowingHttpClient} but throws an {@link Error} (OOM-shaped). */ - private static final class ErrorThrowingHttpClient extends HttpClient { - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new OutOfMemoryError("simulated synchronous Error from sendAsync"); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java deleted file mode 100644 index ca51fbd..0000000 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.ArrayList; -import java.util.List; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; -import org.junit.jupiter.api.Test; - -class MarketDataClientTest { - - @Test - void buildsWithExplicitToken() { - try (var client = new MarketDataClient("test-key", null, null, true)) { - assertThat(client.isDemoMode()).isFalse(); - assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); - assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); - } - } - - @Test - void demoModeWhenNoTokenAvailable() { - // Demo mode iff the full cascade (env var → .env → null) yields nothing. Deriving the - // expectation from the same Configuration helper the constructor uses keeps the test - // valid both on CI (no token anywhere → demoMode) and locally (.env-supplied token → - // not demoMode); a plain `System.getenv` check would miss the .env source and break - // locally. - try (var client = new MarketDataClient()) { - boolean expectDemo = Configuration.loadFromProcess().resolve(null, EnvVars.TOKEN) == null; - assertThat(client.isDemoMode()).isEqualTo(expectDemo); - } - } - - @Test - void fineLevelLoggingEmitsRedactedToken() { - // The constructor logs the redacted token at FINE only. With the default logger - // configuration (INFO), `LOG.isLoggable(FINE)` returns false and the line is dead from - // JaCoCo's perspective. This test installs a capturing handler at FINE and asserts the - // redacted token shows up — the unredacted token must not. - Logger logger = Logger.getLogger(MarketDataClient.class.getName()); - Level previousLevel = logger.getLevel(); - boolean previousUseParent = logger.getUseParentHandlers(); - CapturingHandler capture = new CapturingHandler(); - logger.addHandler(capture); - logger.setLevel(Level.FINE); - logger.setUseParentHandlers(false); - - try (var client = new MarketDataClient("supersecret-token-VALUE-YKT0", null, null, false)) { - assertThat(client.isDemoMode()).isFalse(); - } finally { - logger.removeHandler(capture); - logger.setLevel(previousLevel); - logger.setUseParentHandlers(previousUseParent); - } - - assertThat(capture.records) - .anySatisfy( - r -> { - assertThat(r.getLevel()).isEqualTo(Level.FINE); - assertThat(r.getMessage()).contains("Token"); - }); - // Whatever was logged at FINE, the raw token must never appear in any record. - for (LogRecord r : capture.records) { - assertThat(r.getMessage() == null ? "" : r.getMessage()) - .doesNotContain("supersecret-token-VALUE-YKT0"); - } - } - - /** Minimal {@link Handler} that buffers everything in memory for assertions. */ - private static final class CapturingHandler extends Handler { - final List records = new ArrayList<>(); - - @Override - public void publish(LogRecord record) { - records.add(record); - } - - @Override - public void flush() {} - - @Override - public void close() {} - } - - @Test - void noArgConstructorAppliesProductionDefaults() { - // The no-arg constructor must be equivalent to `new MarketDataClient(null, null, null, - // true)` — production path with everything resolved from the cascade and startup - // validation enabled. validateOnStartup and the userAgent format are env-independent, - // so we assert them unconditionally; baseUrl/apiVersion fall back to the documented - // defaults only when the cascade has no override, so we gate those assertions on the - // env vars being unset (mirrors the demo-mode test above). - try (var client = new MarketDataClient()) { - assertThat(client.isValidateOnStartup()).isTrue(); - assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); - - if (System.getenv("MARKETDATA_BASE_URL") == null) { - assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); - } - if (System.getenv("MARKETDATA_API_VERSION") == null) { - assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); - } - } - } - - @Test - void overridesAreHonored() { - try (var client = new MarketDataClient("KEY", "https://example.test/", "v2", false)) { - assertThat(client.getBaseUrl()).isEqualTo("https://example.test"); // trailing slash trimmed - assertThat(client.getApiVersion()).isEqualTo("v2"); - assertThat(client.isValidateOnStartup()).isFalse(); - } - } - - @Test - void userAgentMatchesSpec() { - try (var client = new MarketDataClient("KEY", null, null, true)) { - assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); - } - } - - @Test - void rateLimitsStartUnpopulated() { - try (var client = new MarketDataClient("KEY", null, null, true)) { - assertThat(client.getRateLimits()).isNull(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java deleted file mode 100644 index cdb6c79..0000000 --- a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.marketdata.sdk.markets.MarketStatus; -import java.io.IOException; -import java.time.LocalDate; -import org.junit.jupiter.api.Test; - -class MarketStatusDeserializerTest { - - private final ObjectMapper mapper = newMapper(); - - private static ObjectMapper newMapper() { - // Per ADR-007 response records carry no @JsonDeserialize annotation — the deserializer - // is registered programmatically (HttpTransport does this in production; the test mirrors - // the same wiring so it exercises the real deserializer). - ObjectMapper m = new ObjectMapper(); - SimpleModule module = new SimpleModule("marketdata-wire-test"); - module.addDeserializer(MarketStatus.class, new MarketStatusDeserializer()); - m.registerModule(module); - return m; - } - - @Test - void parsesOkResponseIntoChronologicalDays() throws IOException { - // 1706745600 = 2024-02-01 00:00:00 UTC = 2024-01-31 19:00 US/Eastern → date 2024-01-31 - // The API normalizes "trading day midnight Eastern" to a unix timestamp; we expect the - // deserializer to recover the local Eastern date. - String json = - """ - { "s": "ok", - "date": [1706673600, 1706760000, 1706846400], - "status": ["open", "open", "closed"] } - """; - - MarketStatus status = mapper.readValue(json, MarketStatus.class); - - assertThat(status.days()).hasSize(3); - assertThat(status.days().get(0).open()).isTrue(); - assertThat(status.days().get(1).open()).isTrue(); - assertThat(status.days().get(2).open()).isFalse(); - assertThat(status.days().get(0).date()).isInstanceOf(LocalDate.class); - assertThat(status.isEmpty()).isFalse(); - } - - @Test - void noDataResponseProducesEmptyResult() throws IOException { - MarketStatus status = mapper.readValue("{\"s\":\"no_data\"}", MarketStatus.class); - - assertThat(status.days()).isEmpty(); - assertThat(status.isEmpty()).isTrue(); - } - - @Test - void rejectsUnknownStatusField() { - assertThatThrownBy(() -> mapper.readValue("{\"s\":\"weird\"}", MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("'weird'"); - } - - @Test - void rejectsMismatchedArraySizes() { - String json = - """ - { "s": "ok", - "date": [1706673600, 1706760000], - "status": ["open"] } - """; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("different sizes"); - } - - @Test - void rejectsResponseMissingArrays() { - String json = "{\"s\":\"ok\"}"; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("expected 'date' and 'status' arrays"); - } - - @Test - void rejectsResponseWhereDateIsArrayButStatusIsMissing() { - // Covers the right-hand branch of the `||` in `!dates.isArray() || !statuses.isArray()`: - // dates is a valid array, but statuses is absent. Without this test, the short-circuit - // means the second condition is only ever evaluated when the first is false and matches. - String json = - """ - { "s": "ok", - "date": [1706673600] } - """; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("expected 'date' and 'status' arrays"); - } -} diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java deleted file mode 100644 index 9db68cf..0000000 --- a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java +++ /dev/null @@ -1,461 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import com.marketdata.sdk.markets.MarketStatus; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -/** - * Exercises the full resource → transport → HTTP path against an in-process {@link HttpServer} (JDK - * built-in — no extra mock dep). Verifies URL construction, query-param encoding, response - * decoding, error mapping, and rate-limit header parsing. - */ -class MarketsResourceTest { - - private HttpServer server; - private final AtomicReference lastRequest = new AtomicReference<>(); - private RouteHandler handler; - - @BeforeEach - void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - handler = new RouteHandler(); - server.createContext("/", handler); - server.start(); - } - - @AfterEach - void stopServer() { - server.stop(0); - } - - private MarketDataClient newClient() { - int port = server.getAddress().getPort(); - return new MarketDataClient("test-key", "http://127.0.0.1:" + port, null, false); - } - - // ---------- success paths ---------- - - /** - * The 5 paths exercised below are the load-bearing scenarios — each runs once for {@link - * CallMode#SYNC} and once for {@link CallMode#ASYNC} so we satisfy SDK requirements §13's "tests - * must cover both sync and async variants for every endpoint" without duplicating every single - * mechanical case. - */ - @ParameterizedTest - @EnumSource(CallMode.class) - void statusNoArgsHitsCanonicalUrlAndDecodesPayload(CallMode mode) { - handler.setResponse( - 200, - """ - { "s":"ok", "date":[1706673600,1706760000], "status":["open","closed"] } - """, - List.of( - rateLimitHeader("limit", "50000"), - rateLimitHeader("remaining", "49500"), - rateLimitHeader("reset", "1735689600"), - rateLimitHeader("consumed", "1"))); - - try (var client = newClient()) { - MarketStatus result = mode.statusNoArgs(client.markets()); - - assertThat(result.days()).hasSize(2); - assertThat(result.days().get(0).open()).isTrue(); - assertThat(result.days().get(1).open()).isFalse(); - - RecordedRequest req = lastRequest.get(); - assertThat(req.path).isEqualTo("/v1/markets/status/"); - assertThat(req.query).isNull(); - assertThat(req.headers.firstValue("Authorization")).hasValue("Bearer test-key"); - assertThat(req.headers.firstValue("User-Agent")) - .get() - .asString() - .startsWith("marketdata-sdk-java/"); - assertThat(req.headers.firstValue("Accept")).hasValue("application/json"); - - RateLimits rl = client.getRateLimits(); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(50000L); - assertThat(rl.remaining()).isEqualTo(49500L); - assertThat(rl.consumed()).isEqualTo(1L); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void statusForDateBuildsDateQueryParam(CallMode mode) { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - MarketStatus result = mode.statusForDate(client.markets(), LocalDate.of(2024, 2, 1)); - - assertThat(result.days()).hasSize(1); - assertThat(lastRequest.get().path).isEqualTo("/v1/markets/status/"); - assertThat(lastRequest.get().query).isEqualTo("date=2024-02-01"); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void statusForRangeBuildsFromAndToQueryParams(CallMode mode) { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - mode.statusForRange(client.markets(), LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 5)); - - assertThat(lastRequest.get().query).isEqualTo("from=2024-01-31&to=2024-02-05"); - } - } - - @Test - void rangeWithSwappedBoundsThrowsIllegalArgument() { - try (var client = newClient()) { - assertThatThrownBy( - () -> client.markets().status(LocalDate.of(2024, 2, 5), LocalDate.of(2024, 1, 31))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must not be after"); - } - } - - // ---------- async-specific smoke ---------- - - /** - * Verifies that {@code statusAsync()} returns a real {@link - * java.util.concurrent.CompletableFuture} usable with the standard {@code .get()} contract - * (checked exception path). The {@code @ParameterizedTest}s above cover .join() semantics; this - * one covers .get(). - */ - @Test - void statusAsyncReturnsRealCompletableFuture() throws Exception { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); - - try (var client = newClient()) { - MarketStatus async = client.markets().statusAsync().get(); - assertThat(async.days()).hasSize(1); - assertThat(async.days().get(0).open()).isFalse(); - } - } - - // ---------- no-data and error paths ---------- - - @ParameterizedTest - @EnumSource(CallMode.class) - void notFoundWithNoDataBodyDecodesAsEmpty(CallMode mode) { - handler.setResponse(404, "{\"s\":\"no_data\"}", List.of()); - - try (var client = newClient()) { - MarketStatus result = mode.statusNoArgs(client.markets()); - assertThat(result.isEmpty()).isTrue(); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void http401ThrowsAuthenticationError(CallMode mode) { - handler.setResponse(401, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(AuthenticationError.class) - .satisfies( - t -> { - AuthenticationError ae = (AuthenticationError) t; - assertThat(ae.getStatusCode()).isEqualTo(401); - assertThat(ae.getRequestUrl()).contains("/v1/markets/status/"); - }); - } - } - - @Test - void http429ThrowsRateLimitError() { - handler.setResponse(429, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(RateLimitError.class); - } - } - - @Test - void http500ThrowsServerError() { - handler.setResponse(500, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ServerError.class); - } - } - - // ---------- malformed responses ---------- - - @ParameterizedTest - @EnumSource(CallMode.class) - void garbageBodyOnSuccessProducesParseError(CallMode mode) { - handler.setResponse(200, "this is plainly not json", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(ParseError.class) - .hasMessageContaining("Failed to decode"); - } - } - - @Test - void emptyBodyOnSuccessProducesParseError() { - handler.setResponse(200, "", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ParseError.class); - } - } - - @Test - void unknownStatusFieldProducesParseError() { - handler.setResponse(200, "{\"s\":\"weird\"}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("weird"); - } - } - - @Test - void responseMissingArraysProducesParseError() { - handler.setResponse(200, "{\"s\":\"ok\"}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("date"); - } - } - - @Test - void mismatchedArraySizesProduceParseError() { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600,1706760000],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("different sizes"); - } - } - - // ---------- weird headers ---------- - - @Test - void successWithoutAnyRateLimitHeadersLeavesSnapshotNull() { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - client.markets().status(); - assertThat(client.getRateLimits()).isNull(); - } - } - - @Test - void partialRateLimitHeadersStillProduceSnapshot() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "100000"}, - new String[] {"x-api-ratelimit-remaining", "99999"})); - - try (var client = newClient()) { - client.markets().status(); - - RateLimits rl = client.getRateLimits(); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(100_000L); - assertThat(rl.remaining()).isEqualTo(99_999L); - assertThat(rl.consumed()).isEqualTo(0L); // missing → defaulted - } - } - - @Test - void allUnparseableRateLimitHeadersAreIgnoredAsAbsent() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "not-a-number"}, - new String[] {"x-api-ratelimit-remaining", "still-not"}, - new String[] {"x-api-ratelimit-reset", "??"}, - new String[] {"x-api-ratelimit-consumed", "wat"})); - - try (var client = newClient()) { - client.markets().status(); - assertThat(client.getRateLimits()).isNull(); - } - } - - /** - * Regression for Issue #4: a successful request that arrives without rate-limit headers must not - * clobber the previously-cached snapshot. The API's rate-limit middleware can silently swallow - * its own errors and serve the response without headers; if we overwrote the snapshot with {@code - * null} on each such response, the user-visible {@code getRateLimits()} would flicker between - * populated and {@code null} across consecutive successful calls. Spec §8 mandates " update - * client-level snapshot" — implicitly only when there's something to update. - */ - @Test - void successWithoutHeadersDoesNotClobberPreviousSnapshot() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "50000"}, - new String[] {"x-api-ratelimit-remaining", "49000"}, - new String[] {"x-api-ratelimit-reset", "1735689600"}, - new String[] {"x-api-ratelimit-consumed", "1000"})); - - try (var client = newClient()) { - client.markets().status(); - RateLimits before = client.getRateLimits(); - assertThat(before).isNotNull(); - assertThat(before.remaining()).isEqualTo(49000L); - - // Same client, second successful call — but the server didn't include rate-limit headers - // this time (e.g. middleware glitch on the API side). - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); - client.markets().status(); - - RateLimits after = client.getRateLimits(); - assertThat(after) - .as("snapshot must retain the last known rate-limit data, not reset to null") - .isNotNull(); - assertThat(after.remaining()).isEqualTo(49000L); - } - } - - @Test - void errorResponseWithoutCfRayProducesNullRequestId() { - handler.setResponse(401, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(AuthenticationError.class) - .satisfies(t -> assertThat(((AuthenticationError) t).getRequestId()).isNull()); - } - } - - @Test - void errorResponseWithCfRayPropagatesRequestId() { - handler.setResponse(401, "{}", List.of(new String[] {"cf-ray", "abc123-XYZ"})); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(AuthenticationError.class) - .satisfies( - t -> assertThat(((AuthenticationError) t).getRequestId()).isEqualTo("abc123-XYZ")); - } - } - - // ---------- network failure (connect refused — fast-failing proxy for timeout class) ---------- - - /** - * The 99-second per-request timeout is fixed by SDK requirements §10. Forcing a real timeout in a - * test would block for ~99 s, which we don't want. Instead we exercise the {@link NetworkError} - * path by pointing the client at a port nothing is listening on (TCP RST → fast failure). This - * proves the transport surfaces transport-level failures as a typed exception rather than letting - * raw {@code IOException}s leak. - */ - @ParameterizedTest - @EnumSource(CallMode.class) - void connectionRefusedProducesNetworkError(CallMode mode) throws IOException { - // Bind to an ephemeral port and immediately close — the OS guarantees that connecting to a - // recently-closed local port produces a fast RST (Linux/macOS) or ConnectException (Windows) - // rather than the long timeouts some hardened sandboxes serve on the historically-privileged - // port 1. The narrow window where another process could grab the port before our connect - // attempt is theoretical on CI. - int closedPort; - try (java.net.ServerSocket probe = - new java.net.ServerSocket(0, 0, java.net.InetAddress.getByName("127.0.0.1"))) { - closedPort = probe.getLocalPort(); - } - - try (var client = - new MarketDataClient("test-key", "http://127.0.0.1:" + closedPort, null, false)) { - - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(NetworkError.class) - .satisfies( - t -> { - NetworkError ne = (NetworkError) t; - assertThat(ne.getCause()).isNotNull(); - assertThat(ne.getRequestUrl()).contains("127.0.0.1:" + closedPort); - }); - } - } - - // ---------- helpers ---------- - - // CallMode (sync vs async dispatcher) lives in its own file so the integration-test source set - // can reuse it. See CallMode.java in this same package. - - private static String[] rateLimitHeader(String suffix, String value) { - return new String[] {"x-api-ratelimit-" + suffix, value}; - } - - private record RecordedRequest(String path, String query, java.net.http.HttpHeaders headers) {} - - private final class RouteHandler implements HttpHandler { - private int statusCode = 200; - private String body = "{}"; - private List extraHeaders = List.of(); - - void setResponse(int code, String body, List extraHeaders) { - this.statusCode = code; - this.body = body; - this.extraHeaders = extraHeaders; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - // Snapshot request shape for assertions. - URI uri = exchange.getRequestURI(); - var headerMap = new java.util.HashMap>(); - exchange.getRequestHeaders().forEach((k, v) -> headerMap.put(k, new ArrayList<>(v))); - lastRequest.set( - new RecordedRequest( - uri.getPath(), - uri.getRawQuery(), - java.net.http.HttpHeaders.of(headerMap, (a, b) -> true))); - - for (String[] h : extraHeaders) { - exchange.getResponseHeaders().add(h[0], h[1]); - } - byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(statusCode, bodyBytes.length); - exchange.getResponseBody().write(bodyBytes); - exchange.getResponseBody().close(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java deleted file mode 100644 index b3074f5..0000000 --- a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.net.URI; -import java.net.http.HttpHeaders; -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import org.junit.jupiter.api.Test; - -class RateLimitHeadersTest { - - // ---------- helpers ---------- - - /** - * Builds an immutable {@link HttpHeaders} from a flat key→value map. The JDK only exposes - * builders via {@link java.net.http.HttpClient}; this is the canonical workaround using {@link - * HttpHeaders#of}. - */ - private static HttpHeaders headersOf(Map entries) { - Map> multi = new TreeMap<>(); - entries.forEach((k, v) -> multi.put(k, List.of(v))); - return HttpHeaders.of(multi, (a, b) -> true); - } - - // ---------- happy path ---------- - - @Test - void parsesAllFourHeaders() { - HttpHeaders headers = - headersOf( - Map.of( - "x-api-ratelimit-limit", "1000", - "x-api-ratelimit-remaining", "987", - "x-api-ratelimit-reset", "1714867200", - "x-api-ratelimit-consumed", "13")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(1000L); - assertThat(rl.remaining()).isEqualTo(987L); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1714867200L)); - assertThat(rl.consumed()).isEqualTo(13L); - } - - // ---------- the all-null short-circuit ---------- - - @Test - void returnsNullWhenNoRateLimitHeadersPresent() { - // With every header absent the long `&&` chain in `parse()` evaluates each side fully — - // covers the "all four are null" branches. - HttpHeaders headers = headersOf(Map.of("content-type", "application/json")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNull(); - } - - // ---------- partial headers (one present, others missing) ---------- - - @Test - void onlyLimitPresentZerosTheOthers() { - // Covers the `null` branch of three of the four `x != null ? x : 0L` ternaries while - // keeping `limit` non-null (the all-null short-circuit doesn't apply). - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", "500")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(500L); - assertThat(rl.remaining()).isZero(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(0L)); - assertThat(rl.consumed()).isZero(); - } - - @Test - void onlyConsumedPresentZerosTheOthers() { - // Covers the case where the head of the && chain is null but the tail is not — exercises - // a different short-circuit path than onlyLimitPresent. - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-consumed", "42")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.consumed()).isEqualTo(42L); - assertThat(rl.limit()).isZero(); - } - - @Test - void onlyRemainingPresentExitsAtSecondCondition() { - // Forces the && chain past `limit == null` and stops at `remaining == null`. Without this - // test, the false-branch of the second condition is never evaluated. - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-remaining", "1234")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.remaining()).isEqualTo(1234L); - assertThat(rl.limit()).isZero(); - } - - @Test - void onlyResetPresentExitsAtThirdCondition() { - // Forces the && chain past `limit` and `remaining` to evaluate `reset == null` as false. - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-reset", "1735689600")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1735689600L)); - assertThat(rl.limit()).isZero(); - assertThat(rl.remaining()).isZero(); - assertThat(rl.consumed()).isZero(); - } - - // ---------- malformed values ---------- - - @Test - void malformedNumberIsTreatedAsAbsent() { - // readLong's catch(NumberFormatException) returns null; the header is then treated as - // missing. With every header malformed the result must be null, same as none-present. - HttpHeaders headers = - headersOf( - Map.of( - "x-api-ratelimit-limit", "not-a-number", - "x-api-ratelimit-remaining", "also-broken")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNull(); - } - - @Test - void valuesAreTrimmedBeforeParsing() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", " 1000 ")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(1000L); - } - - // ---------- sanity: parse() doesn't depend on URI/method ---------- - - @Test - void parseIgnoresNonRateLimitHeaders() { - URI dummy = URI.create("https://example/"); - HttpHeaders headers = - headersOf( - Map.of( - "cf-ray", "abc", - "content-type", "application/json", - "x-api-ratelimit-limit", "100")); - - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(100L); - assertThat(dummy).isNotNull(); // silence unused - } -} diff --git a/src/test/java/com/marketdata/sdk/RateLimitsTest.java b/src/test/java/com/marketdata/sdk/RateLimitsTest.java deleted file mode 100644 index 84a7b41..0000000 --- a/src/test/java/com/marketdata/sdk/RateLimitsTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Instant; -import org.junit.jupiter.api.Test; - -class RateLimitsTest { - - @Test - void recordExposesAllFields() { - Instant reset = Instant.parse("2026-05-04T12:00:00Z"); - var rl = new RateLimits(50_000L, 49_500L, reset, 1L); - - assertThat(rl.limit()).isEqualTo(50_000L); - assertThat(rl.remaining()).isEqualTo(49_500L); - assertThat(rl.reset()).isEqualTo(reset); - assertThat(rl.consumed()).isEqualTo(1L); - } -} diff --git a/src/test/java/com/marketdata/sdk/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java deleted file mode 100644 index c192322..0000000 --- a/src/test/java/com/marketdata/sdk/RequestSpecTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class RequestSpecTest { - - @Test - void buildPreservesPathAndOmitsNullQueryParams() { - // Covers both branches of `if (value != null)` in Builder.query: the null branch is - // exercised by .query("ignored", null), the non-null branch by .query("date", "2024-05-01"). - RequestSpec spec = - RequestSpec.get("markets/status") - .query("date", "2024-05-01") - .query("ignored", null) - .query("from", "2024-01-01") - .build(); - - assertThat(spec.path()).isEqualTo("markets/status"); - assertThat(spec.queryParams()) - .containsExactly( - java.util.Map.entry("date", "2024-05-01"), java.util.Map.entry("from", "2024-01-01")); - assertThat(spec.queryParams()).doesNotContainKey("ignored"); - } - - @Test - void buildWithNoQueryParamsProducesEmptyMap() { - RequestSpec spec = RequestSpec.get("markets/status").build(); - - assertThat(spec.path()).isEqualTo("markets/status"); - assertThat(spec.queryParams()).isEmpty(); - } - - @Test - void queryParamsAreImmutable() { - RequestSpec spec = RequestSpec.get("markets/status").query("date", "2024-05-01").build(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> spec.queryParams().put("hacked", "value")) - .isInstanceOf(UnsupportedOperationException.class); - } - - @Test - void queryConvertsNonStringValuesViaToString() { - // value.toString() is called when value is non-null. Numbers, enums, etc. should serialise - // through their toString(). - RequestSpec spec = - RequestSpec.get("markets/candles") - .query("countback", 5) - .query("limit", Long.valueOf(100L)) - .build(); - - assertThat(spec.queryParams()).containsEntry("countback", "5").containsEntry("limit", "100"); - } -} diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java deleted file mode 100644 index dae21b5..0000000 --- a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.ErrorContext; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.NotFoundError; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import java.time.Duration; -import org.junit.jupiter.api.Test; - -class RetryPolicyTest { - - private static final RetryPolicy DEFAULTS = RetryPolicy.defaults(); - - // ---------- shouldRetry: which errors are retriable ---------- - - @Test - void networkErrorsWithIoCauseAreRetriable() { - // The canonical "real network failure" shape: NetworkError wraps an IOException (or - // subtype like ConnectException / HttpTimeoutException). - NetworkError err = - new NetworkError( - "connect refused", ErrorContext.empty(), new java.io.IOException("connect refused")); - assertThat(DEFAULTS.shouldRetry(err, 0)).isTrue(); - } - - @Test - void networkErrorsWithoutCauseAreNotRetriable() { - // A NetworkError with no cause has no signal that it was an actual network failure. - // Better to surface immediately than burn 3 attempts on a possibly-deterministic bug. - assertThat(DEFAULTS.shouldRetry(new NetworkError("boom", ErrorContext.empty()), 0)).isFalse(); - } - - @Test - void networkErrorsWrappingNonIoCauseAreNotRetriable() { - // Sync-throws from httpClient.sendAsync() (malformed request, internal NPE, - // IllegalArgumentException, etc.) get wrapped as NetworkError in `dispatch`, but they're - // deterministic — retrying just wastes the 1s+2s backoff for the same crash. - NetworkError syncThrow = - new NetworkError( - "Request failed before dispatch", - ErrorContext.empty(), - new IllegalArgumentException("malformed URI")); - assertThat(DEFAULTS.shouldRetry(syncThrow, 0)).isFalse(); - } - - @Test - void status500IsNotRetriable() { - ServerError err = new ServerError("500", new ErrorContext(null, "u", 500)); - assertThat(DEFAULTS.shouldRetry(err, 0)).isFalse(); - } - - @Test - void status501Through599AreRetriable() { - for (int code : new int[] {501, 502, 503, 504, 599}) { - ServerError err = new ServerError("err", new ErrorContext(null, "u", code)); - assertThat(DEFAULTS.shouldRetry(err, 0)).as("status %d should be retriable", code).isTrue(); - } - } - - @Test - void authenticationErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new AuthenticationError("a", ErrorContext.empty()), 0)) - .isFalse(); - } - - @Test - void badRequestErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new BadRequestError("b", ErrorContext.empty()), 0)).isFalse(); - } - - @Test - void rateLimitErrorIsNotRetriable() { - // Spec §9: "Never retry 4xx or rate limit errors." Even though 429 carries Retry-After in - // some protocols, the SDK contract is to surface RateLimitError to the caller immediately. - assertThat(DEFAULTS.shouldRetry(new RateLimitError("r", ErrorContext.empty()), 0)).isFalse(); - } - - @Test - void notFoundErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new NotFoundError("n", ErrorContext.empty()), 0)).isFalse(); - } - - @Test - void parseErrorIsNotRetriable() { - // A bad-shape body is deterministic — retrying produces the same broken decode. - assertThat(DEFAULTS.shouldRetry(new ParseError("p", ErrorContext.empty()), 0)).isFalse(); - } - - @Test - void unknownThrowableIsNotRetriable() { - // Conservative default for non-MarketDataException causes: don't retry. Better to surface - // the unknown failure than to silently hammer the API. - assertThat(DEFAULTS.shouldRetry(new RuntimeException("?"), 0)).isFalse(); - } - - // ---------- shouldRetry: respect max attempts ---------- - - @Test - void retriesStopAfterMaxAttempts() { - NetworkError retriable = - new NetworkError("net", ErrorContext.empty(), new java.io.IOException("transport down")); - // Defaults: maxAttempts = 4 → attempts 0, 1, 2 are eligible to be followed by a retry - // (attempt 3 was the fourth try; no fifth attempt allowed). - assertThat(DEFAULTS.shouldRetry(retriable, 0)).isTrue(); - assertThat(DEFAULTS.shouldRetry(retriable, 1)).isTrue(); - assertThat(DEFAULTS.shouldRetry(retriable, 2)).isTrue(); - assertThat(DEFAULTS.shouldRetry(retriable, 3)).isFalse(); - assertThat(DEFAULTS.shouldRetry(retriable, 99)).isFalse(); - } - - // ---------- backoffDelay: exponential with cap ---------- - - @Test - void backoffStartsAtInitialAndDoubles() { - assertThat(DEFAULTS.backoffDelay(0)).isEqualTo(Duration.ofSeconds(1)); - assertThat(DEFAULTS.backoffDelay(1)).isEqualTo(Duration.ofSeconds(2)); - assertThat(DEFAULTS.backoffDelay(2)).isEqualTo(Duration.ofSeconds(4)); - assertThat(DEFAULTS.backoffDelay(3)).isEqualTo(Duration.ofSeconds(8)); - } - - @Test - void backoffCapsAtMaxBackoff() { - // 2^5 = 32 > 30 cap; 2^10 way over. - assertThat(DEFAULTS.backoffDelay(5)).isEqualTo(Duration.ofSeconds(30)); - assertThat(DEFAULTS.backoffDelay(10)).isEqualTo(Duration.ofSeconds(30)); - } - - @Test - void backoffSaturatesOnExtremeAttemptIndices() { - // The shift `1L << attempt` is undefined for attempt >= 63 (the shift count is masked to - // the bottom 6 bits, wrapping silently); the implementation guards against this by - // capping at maxBackoff once the multiplier would overflow. - assertThat(DEFAULTS.backoffDelay(62)).isEqualTo(Duration.ofSeconds(30)); - assertThat(DEFAULTS.backoffDelay(70)).isEqualTo(Duration.ofSeconds(30)); - assertThat(DEFAULTS.backoffDelay(Integer.MAX_VALUE)).isEqualTo(Duration.ofSeconds(30)); - } - - // ---------- custom-tuned policy (used by tests that need fast retries) ---------- - - @Test - void customConstructorWiresValuesThrough() { - RetryPolicy tiny = - new RetryPolicy(/* maxAttempts */ 5, Duration.ofMillis(1), Duration.ofMillis(10)); - - NetworkError net = - new NetworkError("n", ErrorContext.empty(), new java.io.IOException("transport down")); - assertThat(tiny.shouldRetry(net, 3)).isTrue(); - assertThat(tiny.shouldRetry(net, 4)).isFalse(); - assertThat(tiny.backoffDelay(0)).isEqualTo(Duration.ofMillis(1)); - assertThat(tiny.backoffDelay(1)).isEqualTo(Duration.ofMillis(2)); - assertThat(tiny.backoffDelay(20)).isEqualTo(Duration.ofMillis(10)); - } -} diff --git a/src/test/java/com/marketdata/sdk/TokensTest.java b/src/test/java/com/marketdata/sdk/TokensTest.java deleted file mode 100644 index 4be730c..0000000 --- a/src/test/java/com/marketdata/sdk/TokensTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class TokensTest { - - @Test - void redactsLongTokenKeepingLastFourChars() { - String redacted = Tokens.redact("0123456789abcdefghijklmnopqrstuvwxyzYKT0"); - assertThat(redacted).endsWith("YKT0"); - assertThat(redacted).matches("\\*+YKT0"); - assertThat(redacted).hasSize(40); - } - - @Test - void padsShortTokensToMinimumMaskLength() { - // 10-char token: 4 visible, 6 hidden — but mask floor is 32. - String redacted = Tokens.redact("ABCDEF1234"); - assertThat(redacted).endsWith("1234"); - assertThat(redacted).hasSize(36); // 32 asterisks + 4 visible - } - - @Test - void tokenShorterThanFourCharsIsFullyMasked() { - assertThat(Tokens.redact("abc")).isEqualTo("***"); - } - - @Test - void blankOrNullTokenRendersAsNone() { - assertThat(Tokens.redact(null)).isEqualTo("(none)"); - assertThat(Tokens.redact("")).isEqualTo("(none)"); - assertThat(Tokens.redact(" ")).isEqualTo("(none)"); - } -} diff --git a/src/test/java/com/marketdata/sdk/VersionTest.java b/src/test/java/com/marketdata/sdk/VersionTest.java deleted file mode 100644 index 8ab9742..0000000 --- a/src/test/java/com/marketdata/sdk/VersionTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class VersionTest { - - // ---------- resolve: covers all 4 branch outcomes of the `!= null && !isBlank()` chain - // ---------- - - @Test - void resolveReturnsDetectedVersionWhenPresent() { - assertThat(Version.resolve("1.2.3")).isEqualTo("1.2.3"); - } - - @Test - void resolveFallsBackWhenDetectedIsNull() { - assertThat(Version.resolve(null)).isEqualTo(Version.FALLBACK); - } - - @Test - void resolveFallsBackWhenDetectedIsEmpty() { - assertThat(Version.resolve("")).isEqualTo(Version.FALLBACK); - } - - @Test - void resolveFallsBackWhenDetectedIsBlank() { - // Exercises the second condition independently (`!isBlank()` evaluated `false` on whitespace). - assertThat(Version.resolve(" ")).isEqualTo(Version.FALLBACK); - } - - // ---------- current: lives at the package boundary; only asserts the contract ---------- - - @Test - void currentNeverReturnsNullOrBlank() { - // From class files in tests, the manifest has no Implementation-Version so current() - // exercises the fallback path. From a published JAR it would return the manifest value. - // Either way the contract holds. - String v = Version.current(); - assertThat(v).isNotNull().isNotBlank(); - } -} diff --git a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java deleted file mode 100644 index 16c7d39..0000000 --- a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.marketdata.sdk.exception; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; -import org.junit.jupiter.api.Test; - -class MarketDataExceptionTest { - - @Test - void emptyContextLeavesFieldsNull() { - var error = new BadRequestError("symbol must not be blank", ErrorContext.empty()); - - assertThat(error.getRequestId()).isNull(); - assertThat(error.getRequestUrl()).isNull(); - assertThat(error.getStatusCode()).isNull(); - assertThat(error.getTimestamp()).isNotNull(); - assertThat(error.getExceptionType()).isEqualTo("BadRequestError"); - } - - @Test - void carriesContextFields() { - var ctx = - new ErrorContext( - "8a1b2c3d4e5f6g7h-SJC", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); - - var error = new RateLimitError("Rate limit exceeded", ctx); - - assertThat(error.getRequestId()).isEqualTo("8a1b2c3d4e5f6g7h-SJC"); - assertThat(error.getStatusCode()).isEqualTo(429); - assertThat(error.getExceptionType()).isEqualTo("RateLimitError"); - } - - @Test - void supportInfoIncludesAllRequiredFields() { - var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); - var error = new RateLimitError("Rate limit exceeded", ctx); - - String supportInfo = error.getSupportInfo(); - - assertThat(supportInfo) - .contains("RateLimitError") - .contains("Rate limit exceeded") - .contains("429") - .contains("RAY-1") - .contains("https://api.marketdata.app/v1/stocks/quotes/AAPL/") - .contains("US/Eastern"); - } - - @Test - void allSubtypesCarryContextAndCause() { - var ctx = new ErrorContext("RAY-X", "https://api.marketdata.app/v1/test/", 500); - var cause = new RuntimeException("root cause"); - - // The four subtypes not exercised by the other tests in this file. - var net = new NetworkError("network down", ctx, cause); - var nf = new NotFoundError("not found", ctx); - var pe = new ParseError("bad json", ctx, cause); - var se = new ServerError("internal", ctx); - - assertThat(net.getExceptionType()).isEqualTo("NetworkError"); - assertThat(net.getCause()).isSameAs(cause); - assertThat(nf.getExceptionType()).isEqualTo("NotFoundError"); - assertThat(nf.getCause()).isNull(); - assertThat(pe.getExceptionType()).isEqualTo("ParseError"); - assertThat(pe.getCause()).isSameAs(cause); - assertThat(se.getExceptionType()).isEqualTo("ServerError"); - assertThat(se.getCause()).isNull(); - - for (MarketDataException ex : List.of(net, nf, pe, se)) { - assertThat(ex.getStatusCode()).isEqualTo(500); - assertThat(ex.getRequestId()).isEqualTo("RAY-X"); - assertThat(ex.getRequestUrl()).isEqualTo("https://api.marketdata.app/v1/test/"); - assertThat(ex.getTimestamp()).isNotNull(); - } - } - - @Test - void everySubtypeExposesBothConstructors() { - var ctx = ErrorContext.empty(); - var cause = new RuntimeException("cause"); - - // Each subtype has two constructors: (msg, ctx) and (msg, ctx, cause). - // Exercise the one that the other tests in this file don't already hit. - List exhaustive = - List.of( - new AuthenticationError("a", ctx, cause), - new BadRequestError("b", ctx, cause), - new NotFoundError("n", ctx, cause), - new RateLimitError("r", ctx, cause), - new ServerError("s", ctx, cause), - new NetworkError("net", ctx), // cause-less variant - new ParseError("p", ctx)); // cause-less variant - - for (MarketDataException ex : exhaustive) { - assertThat(ex.getMessage()).isNotBlank(); - assertThat(ex.getTimestamp()).isNotNull(); - } - } - - @Test - void supportInfoFormatsNullContextAsNotApplicable() { - // When the exception is built from ErrorContext.empty() (e.g. client-side validation - // errors that fire before any HTTP request), getSupportInfo() must render each null - // field as "(n/a)" instead of literal "null". Covers the null-branches of the three - // ternaries in MarketDataException.getSupportInfo. - var error = new BadRequestError("symbol must not be blank", ErrorContext.empty()); - - String supportInfo = error.getSupportInfo(); - - assertThat(supportInfo) - .contains("BadRequestError") - .contains("symbol must not be blank") - .contains("Status code: (n/a)") - .contains("Request ID: (n/a)") - .contains("Request URL: (n/a)") - .doesNotContain("null"); - } - - @Test - void supportInfoNeverContainsSensitiveData() { - // The exception itself never receives the token; we just - // double-check that the canonical message+URL form doesn't leak. - var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/user/", 401); - var error = new AuthenticationError("Invalid token", ctx); - - assertThat(error.getSupportInfo()).doesNotContain("token=").doesNotContain("Bearer "); - } -} From f5e95cb3e2db771061c62bf7d5f3e9ebae2e35b1 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 15:19:33 -0300 Subject: [PATCH 02/57] config cascade --- .../com/marketdata/sdk/Configuration.java | 58 +++++ .../java/com/marketdata/sdk/DotEnvLoader.java | 49 +++++ src/main/java/com/marketdata/sdk/EnvVars.java | 18 ++ .../java/com/marketdata/sdk/package-info.java | 4 + .../com/marketdata/sdk/ConfigurationTest.java | 202 ++++++++++++++++++ .../com/marketdata/sdk/DotEnvLoaderTest.java | 145 +++++++++++++ 6 files changed, 476 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/Configuration.java create mode 100644 src/main/java/com/marketdata/sdk/DotEnvLoader.java create mode 100644 src/main/java/com/marketdata/sdk/EnvVars.java create mode 100644 src/main/java/com/marketdata/sdk/package-info.java create mode 100644 src/test/java/com/marketdata/sdk/ConfigurationTest.java create mode 100644 src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java new file mode 100644 index 0000000..d6d2fd2 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -0,0 +1,58 @@ +package com.marketdata.sdk; + +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +record Configuration( + @Nullable String apiKey, + String baseUrl, + String apiVersion, + @Nullable String loggingLevel, + @Nullable String dateFormat) { + + static final String DEFAULT_BASE_URL = "https://api.marketdata.app"; + static final String DEFAULT_API_VERSION = "v1"; + static final Path DEFAULT_DOTENV_PATH = Path.of(".env"); + + static Configuration resolve( + @Nullable String explicitApiKey, + @Nullable String explicitBaseUrl, + @Nullable String explicitApiVersion, + Function env, + Path dotEnvPath) { + Map dotEnv = DotEnvLoader.load(dotEnvPath); + String apiKey = pickFirst(explicitApiKey, env.apply(EnvVars.TOKEN), dotEnv.get(EnvVars.TOKEN)); + String baseUrl = + pickFirstOrDefault( + DEFAULT_BASE_URL, + explicitBaseUrl, + env.apply(EnvVars.BASE_URL), + dotEnv.get(EnvVars.BASE_URL)); + String apiVersion = + pickFirstOrDefault( + DEFAULT_API_VERSION, + explicitApiVersion, + env.apply(EnvVars.API_VERSION), + dotEnv.get(EnvVars.API_VERSION)); + String loggingLevel = + pickFirst(env.apply(EnvVars.LOGGING_LEVEL), dotEnv.get(EnvVars.LOGGING_LEVEL)); + String dateFormat = pickFirst(env.apply(EnvVars.DATE_FORMAT), dotEnv.get(EnvVars.DATE_FORMAT)); + return new Configuration(apiKey, baseUrl, apiVersion, loggingLevel, dateFormat); + } + + private static @Nullable String pickFirst(@Nullable String... candidates) { + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + return candidate; + } + } + return null; + } + + private static String pickFirstOrDefault(String fallback, @Nullable String... candidates) { + String picked = pickFirst(candidates); + return picked != null ? picked : fallback; + } +} diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java new file mode 100644 index 0000000..0de84ec --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -0,0 +1,49 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +final class DotEnvLoader { + + static Map load(Path path) { + if (!Files.isReadable(path)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + try { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + String value = stripQuotes(trimmed.substring(eq + 1).trim()); + result.put(key, value); + } + } catch (IOException e) { + return Map.of(); + } + return Map.copyOf(result); + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private DotEnvLoader() {} +} diff --git a/src/main/java/com/marketdata/sdk/EnvVars.java b/src/main/java/com/marketdata/sdk/EnvVars.java new file mode 100644 index 0000000..dbdfab4 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/EnvVars.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk; + +import java.util.function.Function; + +final class EnvVars { + + static final String TOKEN = "MARKETDATA_TOKEN"; + static final String BASE_URL = "MARKETDATA_BASE_URL"; + static final String API_VERSION = "MARKETDATA_API_VERSION"; + static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; + static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; + + static Function systemLookup() { + return System::getenv; + } + + private EnvVars() {} +} diff --git a/src/main/java/com/marketdata/sdk/package-info.java b/src/main/java/com/marketdata/sdk/package-info.java new file mode 100644 index 0000000..8e30625 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.marketdata.sdk; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java new file mode 100644 index 0000000..f24a123 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -0,0 +1,202 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ConfigurationTest { + + private static final Function NO_ENV = key -> null; + + private static Function envOf(Map values) { + return values::get; + } + + private static Path noDotEnv(@TempDir Path tmp) { + return tmp.resolve("missing.env"); + } + + @Test + void resolve_uses_explicit_values_when_provided(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + "explicit-key", + "https://explicit.example", + "v9", + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v0")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("explicit-key"); + assertThat(config.baseUrl()).isEqualTo("https://explicit.example"); + assertThat(config.apiVersion()).isEqualTo("v9"); + } + + @Test + void resolve_falls_back_to_env_when_explicit_missing(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v2")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("env-key"); + assertThat(config.baseUrl()).isEqualTo("https://env.example"); + assertThat(config.apiVersion()).isEqualTo("v2"); + } + + @Test + void resolve_falls_back_to_dotenv_when_explicit_and_env_missing(@TempDir Path tmp) + throws IOException { + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + MARKETDATA_API_VERSION=v3 + """); + + Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(config.apiKey()).isEqualTo("dotenv-key"); + assertThat(config.baseUrl()).isEqualTo("https://dotenv.example"); + assertThat(config.apiVersion()).isEqualTo("v3"); + } + + @Test + void resolve_uses_defaults_for_base_url_and_api_version_when_nothing_provided(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.baseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); + assertThat(config.apiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); + } + + @Test + void resolve_leaves_api_key_null_when_nothing_provided(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.apiKey()).isNull(); + } + + @Test + void resolve_treats_blank_explicit_as_missing(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + " ", + "", + "\t", + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v2")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("env-key"); + assertThat(config.baseUrl()).isEqualTo("https://env.example"); + assertThat(config.apiVersion()).isEqualTo("v2"); + } + + @Test + void resolve_explicit_beats_env_beats_dotenv_beats_default(@TempDir Path tmp) throws IOException { + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + """); + + Configuration withExplicit = + Configuration.resolve( + "explicit-key", + "https://explicit.example", + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example")), + dotEnv); + + assertThat(withExplicit.apiKey()).isEqualTo("explicit-key"); + assertThat(withExplicit.baseUrl()).isEqualTo("https://explicit.example"); + + Configuration withoutExplicit = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example")), + dotEnv); + + assertThat(withoutExplicit.apiKey()).isEqualTo("env-key"); + assertThat(withoutExplicit.baseUrl()).isEqualTo("https://env.example"); + + Configuration onlyDotEnv = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(onlyDotEnv.apiKey()).isEqualTo("dotenv-key"); + assertThat(onlyDotEnv.baseUrl()).isEqualTo("https://dotenv.example"); + } + + @Test + void resolve_picks_up_logging_level_and_date_format_from_env(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.LOGGING_LEVEL, "DEBUG", + EnvVars.DATE_FORMAT, "unix")), + noDotEnv(tmp)); + + assertThat(config.loggingLevel()).isEqualTo("DEBUG"); + assertThat(config.dateFormat()).isEqualTo("unix"); + } + + @Test + void resolve_leaves_logging_level_and_date_format_null_when_unset(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.loggingLevel()).isNull(); + assertThat(config.dateFormat()).isNull(); + } + + @Test + void resolve_env_lookup_returning_blank_is_treated_as_missing(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, " ", + EnvVars.BASE_URL, "")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isNull(); + assertThat(config.baseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); + } +} diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java new file mode 100644 index 0000000..64ad43d --- /dev/null +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -0,0 +1,145 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DotEnvLoaderTest { + + @Test + void load_returns_empty_when_file_missing(@TempDir Path tmp) { + Path missing = tmp.resolve("does-not-exist.env"); + + Map result = DotEnvLoader.load(missing); + + assertThat(result).isEmpty(); + } + + @Test + void load_returns_empty_for_empty_file(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), ""); + + assertThat(DotEnvLoader.load(file)).isEmpty(); + } + + @Test + void load_parses_simple_key_value_pairs(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=abc123 + MARKETDATA_BASE_URL=https://example.com + """); + + Map result = DotEnvLoader.load(file); + + assertThat(result) + .containsEntry("MARKETDATA_TOKEN", "abc123") + .containsEntry("MARKETDATA_BASE_URL", "https://example.com"); + } + + @Test + void load_strips_double_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc 123\"\n"); + + assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_strips_single_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN='abc 123'\n"); + + assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_does_not_strip_mismatched_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc'\n"); + + assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "\"abc'"); + } + + @Test + void load_ignores_comment_lines(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + # a comment + TOKEN=abc + #TOKEN=should-be-ignored + """); + + Map result = DotEnvLoader.load(file); + + assertThat(result).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + + TOKEN=abc + + BASE_URL=https://x + + """); + + assertThat(DotEnvLoader.load(file)) + .containsEntry("TOKEN", "abc") + .containsEntry("BASE_URL", "https://x"); + } + + @Test + void load_keeps_equals_signs_in_value(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=a=b=c\n"); + + assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "a=b=c"); + } + + @Test + void load_skips_lines_without_equals(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + this-is-not-a-pair + TOKEN=abc + also-not-a-pair + """); + + assertThat(DotEnvLoader.load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_skips_lines_starting_with_equals(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "=novalue\nTOKEN=abc\n"); + + assertThat(DotEnvLoader.load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_trims_whitespace_around_key_and_value(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), " TOKEN = abc \n"); + + assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc"); + } + + @Test + void load_returns_immutable_map(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + + Map result = DotEnvLoader.load(file); + + assertThat(result).isUnmodifiable(); + } +} From f2183aec4402408c8d11e47a28797e3185f3b966 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 15:24:50 -0300 Subject: [PATCH 03/57] add tokens class --- src/main/java/com/marketdata/sdk/Tokens.java | 17 +++++ .../java/com/marketdata/sdk/TokensTest.java | 63 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/Tokens.java create mode 100644 src/test/java/com/marketdata/sdk/TokensTest.java diff --git a/src/main/java/com/marketdata/sdk/Tokens.java b/src/main/java/com/marketdata/sdk/Tokens.java new file mode 100644 index 0000000..e6f4cd8 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Tokens.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk; + +import org.jspecify.annotations.Nullable; + +final class Tokens { + + private static final String REDACTED = "***…***"; + + static String redact(@Nullable String token) { + if (token == null || token.length() < 4) { + return REDACTED; + } + return REDACTED + token.substring(token.length() - 4); + } + + private Tokens() {} +} diff --git a/src/test/java/com/marketdata/sdk/TokensTest.java b/src/test/java/com/marketdata/sdk/TokensTest.java new file mode 100644 index 0000000..fcaf9d5 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/TokensTest.java @@ -0,0 +1,63 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class TokensTest { + + private static final String REDACTED = "***…***"; + + @Test + void redact_returns_marker_for_null() { + assertThat(Tokens.redact(null)).isEqualTo(REDACTED); + } + + @Test + void redact_returns_marker_for_empty_string() { + assertThat(Tokens.redact("")).isEqualTo(REDACTED); + } + + @Test + void redact_returns_marker_for_tokens_shorter_than_four_chars() { + assertThat(Tokens.redact("a")).isEqualTo(REDACTED); + assertThat(Tokens.redact("ab")).isEqualTo(REDACTED); + assertThat(Tokens.redact("abc")).isEqualTo(REDACTED); + } + + @Test + void redact_appends_full_token_when_exactly_four_chars() { + assertThat(Tokens.redact("abcd")).isEqualTo(REDACTED + "abcd"); + } + + @Test + void redact_appends_last_four_chars_for_normal_token() { + assertThat(Tokens.redact("MARKETDATA_TOKEN_VALUE_YKT0")).isEqualTo(REDACTED + "YKT0"); + } + + @Test + void redact_never_contains_token_prefix_for_normal_token() { + String token = "supersecrettoken1234567890"; + + String redacted = Tokens.redact(token); + + assertThat(redacted).doesNotContain("supersecret"); + assertThat(redacted).doesNotContain("token12345"); + assertThat(redacted).endsWith("7890"); + } + + @Test + void redact_handles_tokens_with_special_characters() { + assertThat(Tokens.redact("abc.def-ghi/jklMNOP")).isEqualTo(REDACTED + "MNOP"); + } + + @Test + void redact_handles_unicode_token() { + assertThat(Tokens.redact("token-ñöùéABCD")).isEqualTo(REDACTED + "ABCD"); + } + + @Test + void redact_returns_marker_unchanged_for_blank_strings_shorter_than_four() { + assertThat(Tokens.redact(" ")).isEqualTo(REDACTED); + } +} From 7f49c94544e590668c75cb949d23dc1781625a32 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 15:27:34 -0300 Subject: [PATCH 04/57] add version class --- src/main/java/com/marketdata/sdk/Version.java | 19 ++++++++ .../java/com/marketdata/sdk/VersionTest.java | 48 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/Version.java create mode 100644 src/test/java/com/marketdata/sdk/VersionTest.java diff --git a/src/main/java/com/marketdata/sdk/Version.java b/src/main/java/com/marketdata/sdk/Version.java new file mode 100644 index 0000000..d7b3b8c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Version.java @@ -0,0 +1,19 @@ +package com.marketdata.sdk; + +import org.jspecify.annotations.Nullable; + +final class Version { + + static final String FALLBACK = "0.0.0-dev"; + + static String sdkVersion() { + Package pkg = Version.class.getPackage(); + return resolve(pkg == null ? null : pkg.getImplementationVersion()); + } + + static String resolve(@Nullable String rawVersion) { + return (rawVersion == null || rawVersion.isBlank()) ? FALLBACK : rawVersion; + } + + private Version() {} +} diff --git a/src/test/java/com/marketdata/sdk/VersionTest.java b/src/test/java/com/marketdata/sdk/VersionTest.java new file mode 100644 index 0000000..ad4f2b5 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/VersionTest.java @@ -0,0 +1,48 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class VersionTest { + + @Test + void resolve_returns_fallback_for_null() { + assertThat(Version.resolve(null)).isEqualTo(Version.FALLBACK); + } + + @Test + void resolve_returns_fallback_for_empty_string() { + assertThat(Version.resolve("")).isEqualTo(Version.FALLBACK); + } + + @Test + void resolve_returns_fallback_for_blank_string() { + assertThat(Version.resolve(" ")).isEqualTo(Version.FALLBACK); + } + + @Test + void resolve_returns_value_when_present() { + assertThat(Version.resolve("1.2.3")).isEqualTo("1.2.3"); + } + + @Test + void resolve_returns_snapshot_value_unchanged() { + assertThat(Version.resolve("0.1.0-SNAPSHOT")).isEqualTo("0.1.0-SNAPSHOT"); + } + + @Test + void sdk_version_returns_fallback_when_loaded_from_classpath_not_jar() { + String version = Version.sdkVersion(); + + assertThat(version).isEqualTo(Version.FALLBACK); + } + + @Test + void sdk_version_never_returns_null_or_blank() { + String version = Version.sdkVersion(); + + assertThat(version).isNotNull(); + assertThat(version.isBlank()).isFalse(); + } +} From cf6c22c4850fee24e66c170710206c75356adf0a Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 15:32:24 -0300 Subject: [PATCH 05/57] adds RateLimitSnapshot & DemoMode --- .../java/com/marketdata/sdk/DemoMode.java | 11 +++++ .../com/marketdata/sdk/RateLimitSnapshot.java | 8 ++++ .../java/com/marketdata/sdk/DemoModeTest.java | 41 +++++++++++++++++++ .../marketdata/sdk/RateLimitSnapshotTest.java | 40 ++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/DemoMode.java create mode 100644 src/main/java/com/marketdata/sdk/RateLimitSnapshot.java create mode 100644 src/test/java/com/marketdata/sdk/DemoModeTest.java create mode 100644 src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java diff --git a/src/main/java/com/marketdata/sdk/DemoMode.java b/src/main/java/com/marketdata/sdk/DemoMode.java new file mode 100644 index 0000000..0a02ac5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DemoMode.java @@ -0,0 +1,11 @@ +package com.marketdata.sdk; + +final class DemoMode { + + static boolean isDemo(Configuration config) { + String apiKey = config.apiKey(); + return apiKey == null || apiKey.isBlank(); + } + + private DemoMode() {} +} diff --git a/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java new file mode 100644 index 0000000..88bcad7 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java @@ -0,0 +1,8 @@ +package com.marketdata.sdk; + +import java.time.Instant; + +public record RateLimitSnapshot(int limit, int remaining, Instant reset, int consumed) { + + public static final RateLimitSnapshot EMPTY = new RateLimitSnapshot(0, 0, Instant.EPOCH, 0); +} diff --git a/src/test/java/com/marketdata/sdk/DemoModeTest.java b/src/test/java/com/marketdata/sdk/DemoModeTest.java new file mode 100644 index 0000000..06fae6c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/DemoModeTest.java @@ -0,0 +1,41 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DemoModeTest { + + @Test + void is_demo_when_api_key_is_null() { + Configuration config = configWithApiKey(null); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_demo_when_api_key_is_empty() { + Configuration config = configWithApiKey(""); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_demo_when_api_key_is_blank() { + Configuration config = configWithApiKey(" "); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_not_demo_when_api_key_present() { + Configuration config = configWithApiKey("real-token-YKT0"); + + assertThat(DemoMode.isDemo(config)).isFalse(); + } + + private static Configuration configWithApiKey(String apiKey) { + return new Configuration( + apiKey, Configuration.DEFAULT_BASE_URL, Configuration.DEFAULT_API_VERSION, null, null); + } +} diff --git a/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java new file mode 100644 index 0000000..9c1658c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java @@ -0,0 +1,40 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RateLimitSnapshotTest { + + @Test + void exposes_all_fields() { + Instant reset = Instant.parse("2026-05-15T12:00:00Z"); + + RateLimitSnapshot snapshot = new RateLimitSnapshot(1000, 750, reset, 250); + + assertThat(snapshot.limit()).isEqualTo(1000); + assertThat(snapshot.remaining()).isEqualTo(750); + assertThat(snapshot.reset()).isEqualTo(reset); + assertThat(snapshot.consumed()).isEqualTo(250); + } + + @Test + void empty_snapshot_has_zero_values_and_epoch_reset() { + assertThat(RateLimitSnapshot.EMPTY.limit()).isZero(); + assertThat(RateLimitSnapshot.EMPTY.remaining()).isZero(); + assertThat(RateLimitSnapshot.EMPTY.consumed()).isZero(); + assertThat(RateLimitSnapshot.EMPTY.reset()).isEqualTo(Instant.EPOCH); + } + + @Test + void records_with_same_values_are_equal() { + Instant reset = Instant.parse("2026-05-15T12:00:00Z"); + + RateLimitSnapshot a = new RateLimitSnapshot(100, 50, reset, 50); + RateLimitSnapshot b = new RateLimitSnapshot(100, 50, reset, 50); + + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } +} From 59ea39a753d56f1b1bf4168efcceaa02bc5c5563 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 15 May 2026 16:02:57 -0300 Subject: [PATCH 06/57] adds MarketDataClient --- src/main/java/com/marketdata/sdk/EnvVars.java | 3 +- .../com/marketdata/sdk/MarketDataClient.java | 65 +++++++++ .../marketdata/sdk/MarketDataClientTest.java | 138 ++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/marketdata/sdk/MarketDataClient.java create mode 100644 src/test/java/com/marketdata/sdk/MarketDataClientTest.java diff --git a/src/main/java/com/marketdata/sdk/EnvVars.java b/src/main/java/com/marketdata/sdk/EnvVars.java index dbdfab4..e56dbd3 100644 --- a/src/main/java/com/marketdata/sdk/EnvVars.java +++ b/src/main/java/com/marketdata/sdk/EnvVars.java @@ -1,6 +1,7 @@ package com.marketdata.sdk; import java.util.function.Function; +import org.jspecify.annotations.Nullable; final class EnvVars { @@ -10,7 +11,7 @@ final class EnvVars { static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; - static Function systemLookup() { + static Function systemLookup() { return System::getenv; } diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java new file mode 100644 index 0000000..98449cb --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -0,0 +1,65 @@ +package com.marketdata.sdk; + +import java.nio.file.Path; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +public final class MarketDataClient implements AutoCloseable { + + private final Configuration config; + private volatile RateLimitSnapshot rateLimits; + + public MarketDataClient() { + this(null, null, null, true); + } + + public MarketDataClient( + @Nullable String apiKey, + @Nullable String baseUrl, + @Nullable String apiVersion, + boolean validateOnStartup) { + this( + apiKey, + baseUrl, + apiVersion, + validateOnStartup, + EnvVars.systemLookup(), + Configuration.DEFAULT_DOTENV_PATH, + () -> {}); + } + + MarketDataClient( + @Nullable String apiKey, + @Nullable String baseUrl, + @Nullable String apiVersion, + boolean validateOnStartup, + Function env, + Path dotEnvPath, + Runnable startupValidator) { + this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); + this.rateLimits = RateLimitSnapshot.EMPTY; + if (validateOnStartup) { + startupValidator.run(); + } + } + + public RateLimitSnapshot getRateLimits() { + return rateLimits; + } + + @Override + public void close() {} + + @Override + public String toString() { + return "MarketDataClient[baseUrl=" + + config.baseUrl() + + ", apiVersion=" + + config.apiVersion() + + ", apiKey=" + + Tokens.redact(config.apiKey()) + + ", demoMode=" + + DemoMode.isDemo(config) + + "]"; + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java new file mode 100644 index 0000000..41e194a --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -0,0 +1,138 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MarketDataClientTest { + + private static final Function NO_ENV = key -> null; + private static final Runnable NO_VALIDATION = () -> {}; + + private static Function envOf(Map values) { + return values::get; + } + + private static Path noDotEnv(Path tmp) { + return tmp.resolve("missing.env"); + } + + @Test + void no_arg_constructor_resolves_defaults_and_returns_empty_rate_limits(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + } + } + + @Test + void four_arg_constructor_uses_explicit_values(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient( + "explicit-key", + "https://explicit.example", + "v9", + false, + NO_ENV, + noDotEnv(tmp), + NO_VALIDATION)) { + assertThat(client.toString()) + .contains("baseUrl=https://explicit.example") + .contains("apiVersion=v9") + .contains("demoMode=false") + .doesNotContain("explicit-key"); + } + } + + @Test + void to_string_redacts_token(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient( + "supersecret-token-YKT0", null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + String repr = client.toString(); + + assertThat(repr).doesNotContain("supersecret-token-YKT0"); + assertThat(repr).contains("***…***YKT0"); + } + } + + @Test + void to_string_shows_demo_mode_when_no_api_key(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + assertThat(client.toString()).contains("demoMode=true"); + } + } + + @Test + void validate_on_startup_true_invokes_validator(@TempDir Path tmp) { + AtomicInteger calls = new AtomicInteger(); + + try (MarketDataClient client = + new MarketDataClient( + "key", null, null, true, NO_ENV, noDotEnv(tmp), calls::incrementAndGet)) { + assertThat(calls.get()).isEqualTo(1); + assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + } + } + + @Test + void validate_on_startup_false_does_not_invoke_validator(@TempDir Path tmp) { + AtomicInteger calls = new AtomicInteger(); + + try (MarketDataClient client = + new MarketDataClient( + "key", null, null, false, NO_ENV, noDotEnv(tmp), calls::incrementAndGet)) { + assertThat(calls.get()).isZero(); + assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + } + } + + @Test + void resolves_token_from_env_when_not_provided_explicitly(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient( + null, + null, + null, + false, + envOf(Map.of(EnvVars.TOKEN, "env-token-ABCD")), + noDotEnv(tmp), + NO_VALIDATION)) { + assertThat(client.toString()).contains("***…***ABCD").contains("demoMode=false"); + } + } + + @Test + void close_is_idempotent(@TempDir Path tmp) { + MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION); + + client.close(); + client.close(); + } + + @Test + void quick_start_usage_resolves_real_environment_and_never_leaks_token() { + try (MarketDataClient client = new MarketDataClient()) { + assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + assertThat(client.toString()).startsWith("MarketDataClient[").endsWith("]"); + + String envToken = System.getenv(EnvVars.TOKEN); + if (envToken != null && !envToken.isBlank()) { + assertThat(client.toString()).doesNotContain(envToken); + } + + String envBaseUrl = System.getenv(EnvVars.BASE_URL); + if (envBaseUrl == null || envBaseUrl.isBlank()) { + assertThat(client.toString()).contains("baseUrl=" + Configuration.DEFAULT_BASE_URL); + } + } + } +} From cd74c021570afcd3da66f017a5f39c66cdf55554 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 09:32:15 -0300 Subject: [PATCH 07/57] add Sealed exception hierarchy --- .../com/marketdata/sdk/HttpStatusMapper.java | 34 ++++ .../sdk/exception/AuthenticationError.java | 16 ++ .../sdk/exception/BadRequestError.java | 16 ++ .../sdk/exception/ErrorContext.java | 17 ++ .../sdk/exception/MarketDataException.java | 71 ++++++++ .../sdk/exception/NetworkError.java | 16 ++ .../sdk/exception/NotFoundError.java | 16 ++ .../marketdata/sdk/exception/ParseError.java | 16 ++ .../sdk/exception/RateLimitError.java | 16 ++ .../marketdata/sdk/exception/ServerError.java | 16 ++ .../sdk/exception/package-info.java | 4 + .../marketdata/sdk/HttpStatusMapperTest.java | 79 +++++++++ .../sdk/exception/ErrorContextTest.java | 43 +++++ .../exception/MarketDataExceptionTest.java | 161 ++++++++++++++++++ .../sdk/exception/SealedHierarchyTest.java | 37 ++++ 15 files changed, 558 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/HttpStatusMapper.java create mode 100644 src/main/java/com/marketdata/sdk/exception/AuthenticationError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/BadRequestError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/ErrorContext.java create mode 100644 src/main/java/com/marketdata/sdk/exception/MarketDataException.java create mode 100644 src/main/java/com/marketdata/sdk/exception/NetworkError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/NotFoundError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/ParseError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/RateLimitError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/ServerError.java create mode 100644 src/main/java/com/marketdata/sdk/exception/package-info.java create mode 100644 src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java create mode 100644 src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java create mode 100644 src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java create mode 100644 src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java new file mode 100644 index 0000000..2954298 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -0,0 +1,34 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import org.jspecify.annotations.Nullable; + +final class HttpStatusMapper { + + static @Nullable MarketDataException map(int statusCode, ErrorContext context) { + if (statusCode >= 200 && statusCode < 300) { + return null; + } + return switch (statusCode) { + case 400 -> new BadRequestError("Bad request", context); + case 401 -> new AuthenticationError("Authentication failed", context); + case 404 -> new NotFoundError("Not found", context); + case 429 -> new RateLimitError("Rate limit exceeded", context); + case 500 -> new ServerError("Server error: 500", context); + default -> { + if (statusCode >= 501 && statusCode <= 599) { + yield new ServerError("Server error: " + statusCode, context); + } + yield new BadRequestError("Unexpected status code: " + statusCode, context); + } + }; + } + + private HttpStatusMapper() {} +} diff --git a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java new file mode 100644 index 0000000..85c3fbb --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class AuthenticationError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public AuthenticationError(String message, ErrorContext context) { + this(message, context, null); + } + + public AuthenticationError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java new file mode 100644 index 0000000..fd30e6f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class BadRequestError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public BadRequestError(String message, ErrorContext context) { + this(message, context, null); + } + + public BadRequestError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/ErrorContext.java b/src/main/java/com/marketdata/sdk/exception/ErrorContext.java new file mode 100644 index 0000000..074e81c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/ErrorContext.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk.exception; + +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +public record ErrorContext( + @Nullable String requestId, String requestUrl, int statusCode, Instant timestamp) { + + public static ErrorContext forResponse( + String requestUrl, int statusCode, @Nullable String requestId, Instant timestamp) { + return new ErrorContext(requestId, requestUrl, statusCode, timestamp); + } + + public static ErrorContext forNoResponse(String requestUrl, Instant timestamp) { + return new ErrorContext(null, requestUrl, 0, timestamp); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java new file mode 100644 index 0000000..2915c2e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java @@ -0,0 +1,71 @@ +package com.marketdata.sdk.exception; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import org.jspecify.annotations.Nullable; + +public abstract sealed class MarketDataException extends RuntimeException + permits AuthenticationError, + BadRequestError, + NotFoundError, + RateLimitError, + ServerError, + NetworkError, + ParseError { + + private static final long serialVersionUID = 1L; + + private final ErrorContext context; + + protected MarketDataException(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, cause); + this.context = context; + } + + public ErrorContext getContext() { + return context; + } + + public @Nullable String getRequestId() { + return context.requestId(); + } + + public String getRequestUrl() { + return context.requestUrl(); + } + + public int getStatusCode() { + return context.statusCode(); + } + + public Instant getTimestamp() { + return context.timestamp(); + } + + public String getExceptionType() { + return getClass().getSimpleName(); + } + + public String getSupportInfo() { + String requestId = getRequestId(); + String message = getMessage(); + return String.join( + System.lineSeparator(), + "--- MARKET DATA SUPPORT INFO ---", + formatField("request_id:", requestId == null ? "(not provided)" : requestId), + formatField("request_url:", getRequestUrl()), + formatField("status_code:", String.valueOf(getStatusCode())), + formatField("timestamp:", EASTERN_FORMATTER.format(getTimestamp())), + formatField("message:", message == null ? "" : message), + formatField("exception_type:", getExceptionType()), + "--------------------------------"); + } + + private static final DateTimeFormatter EASTERN_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("America/New_York")); + + private static String formatField(String name, String value) { + return String.format("%-16s%s", name, value); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/NetworkError.java b/src/main/java/com/marketdata/sdk/exception/NetworkError.java new file mode 100644 index 0000000..3cefa0f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/NetworkError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class NetworkError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public NetworkError(String message, ErrorContext context) { + this(message, context, null); + } + + public NetworkError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java b/src/main/java/com/marketdata/sdk/exception/NotFoundError.java new file mode 100644 index 0000000..9f5acac --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/NotFoundError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class NotFoundError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public NotFoundError(String message, ErrorContext context) { + this(message, context, null); + } + + public NotFoundError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/ParseError.java b/src/main/java/com/marketdata/sdk/exception/ParseError.java new file mode 100644 index 0000000..c58fd32 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/ParseError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class ParseError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public ParseError(String message, ErrorContext context) { + this(message, context, null); + } + + public ParseError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java new file mode 100644 index 0000000..80056ee --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class RateLimitError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public RateLimitError(String message, ErrorContext context) { + this(message, context, null); + } + + public RateLimitError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/ServerError.java b/src/main/java/com/marketdata/sdk/exception/ServerError.java new file mode 100644 index 0000000..7a7d646 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/ServerError.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +public final class ServerError extends MarketDataException { + + private static final long serialVersionUID = 1L; + + public ServerError(String message, ErrorContext context) { + this(message, context, null); + } + + public ServerError(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/package-info.java b/src/main/java/com/marketdata/sdk/exception/package-info.java new file mode 100644 index 0000000..70d7169 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java new file mode 100644 index 0000000..662e5a4 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java @@ -0,0 +1,79 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.time.Instant; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class HttpStatusMapperTest { + + private static final Instant TS = Instant.parse("2026-05-15T12:00:00Z"); + + private static ErrorContext context(int statusCode) { + return ErrorContext.forResponse("https://api.example", statusCode, "req-1", TS); + } + + @ParameterizedTest + @ValueSource(ints = {200, 201, 203, 204, 299}) + void returns_null_for_two_xx_status(int statusCode) { + assertThat(HttpStatusMapper.map(statusCode, context(statusCode))).isNull(); + } + + @ParameterizedTest + @MethodSource("statusToExceptionType") + void maps_status_code_to_expected_subtype( + int statusCode, Class expectedType) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(expectedType); + assertThat(exception.getStatusCode()).isEqualTo(statusCode); + } + + static Stream statusToExceptionType() { + return Stream.of( + arguments(400, BadRequestError.class), + arguments(401, AuthenticationError.class), + arguments(404, NotFoundError.class), + arguments(429, RateLimitError.class), + arguments(500, ServerError.class), + arguments(501, ServerError.class), + arguments(502, ServerError.class), + arguments(503, ServerError.class), + arguments(599, ServerError.class)); + } + + @ParameterizedTest + @ValueSource(ints = {402, 403, 405, 418}) + void maps_unhandled_four_xx_to_bad_request_error(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + } + + @Test + void error_carries_the_full_context() { + ErrorContext ctx = context(401); + + MarketDataException exception = HttpStatusMapper.map(401, ctx); + + assertThat(exception).isNotNull(); + assertThat(exception.getContext()).isEqualTo(ctx); + assertThat(exception.getRequestUrl()).isEqualTo("https://api.example"); + assertThat(exception.getRequestId()).isEqualTo("req-1"); + assertThat(exception.getTimestamp()).isEqualTo(TS); + } +} diff --git a/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java b/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java new file mode 100644 index 0000000..f3fd991 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java @@ -0,0 +1,43 @@ +package com.marketdata.sdk.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ErrorContextTest { + + @Test + void for_response_carries_all_fields() { + Instant ts = Instant.parse("2026-05-15T12:00:00Z"); + + ErrorContext ctx = + ErrorContext.forResponse("https://api.example/v1/markets/status", 401, "req-1", ts); + + assertThat(ctx.requestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(ctx.statusCode()).isEqualTo(401); + assertThat(ctx.requestId()).isEqualTo("req-1"); + assertThat(ctx.timestamp()).isEqualTo(ts); + } + + @Test + void for_response_allows_null_request_id() { + ErrorContext ctx = + ErrorContext.forResponse( + "https://api.example", 500, null, Instant.parse("2026-05-15T12:00:00Z")); + + assertThat(ctx.requestId()).isNull(); + } + + @Test + void for_no_response_uses_zero_status_and_null_request_id() { + Instant ts = Instant.parse("2026-05-15T12:00:00Z"); + + ErrorContext ctx = ErrorContext.forNoResponse("https://api.example/v1/markets/status", ts); + + assertThat(ctx.statusCode()).isZero(); + assertThat(ctx.requestId()).isNull(); + assertThat(ctx.requestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(ctx.timestamp()).isEqualTo(ts); + } +} diff --git a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java new file mode 100644 index 0000000..92c02db --- /dev/null +++ b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java @@ -0,0 +1,161 @@ +package com.marketdata.sdk.exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class MarketDataExceptionTest { + + private static final Instant TS = Instant.parse("2026-05-15T12:00:00Z"); + + private static ErrorContext sampleContext() { + return ErrorContext.forResponse("https://api.example/v1/markets/status", 401, "req-abc", TS); + } + + @Test + void exposes_all_context_fields_via_getters() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + assertThat(error.getMessage()).isEqualTo("Token invalid"); + assertThat(error.getStatusCode()).isEqualTo(401); + assertThat(error.getRequestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(error.getRequestId()).isEqualTo("req-abc"); + assertThat(error.getTimestamp()).isEqualTo(TS); + assertThat(error.getExceptionType()).isEqualTo("AuthenticationError"); + assertThat(error.getContext()).isEqualTo(sampleContext()); + } + + @Test + void preserves_cause_when_provided() { + IOException cause = new IOException("connection refused"); + + NetworkError error = new NetworkError("Network failure", sampleContext(), cause); + + assertThat(error.getCause()).isSameAs(cause); + } + + @Test + void support_info_matches_spec_format() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + assertThat(info) + .contains("--- MARKET DATA SUPPORT INFO ---") + .contains("--------------------------------") + .contains("request_id:") + .contains("request_url:") + .contains("status_code:") + .contains("timestamp:") + .contains("message:") + .contains("exception_type:") + .contains("AuthenticationError") + .contains("Token invalid") + .contains("401") + .contains("https://api.example/v1/markets/status") + .contains("req-abc"); + } + + @Test + void support_info_renders_timestamp_in_us_eastern() { + // 2026-05-15T12:00:00Z is during EDT (UTC-4): expected 2026-05-15 08:00:00 + AuthenticationError summer = new AuthenticationError("x", sampleContext()); + assertThat(summer.getSupportInfo()).contains("2026-05-15 08:00:00"); + + // 2026-01-15T12:00:00Z is during EST (UTC-5): expected 2026-01-15 07:00:00 + ErrorContext winterCtx = + ErrorContext.forResponse( + "https://api.example", 500, "r", Instant.parse("2026-01-15T12:00:00Z")); + ServerError winter = new ServerError("x", winterCtx); + assertThat(winter.getSupportInfo()).contains("2026-01-15 07:00:00"); + } + + @Test + void support_info_preserves_field_order_per_spec() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + assertThat(info.indexOf("request_id:")).isLessThan(info.indexOf("request_url:")); + assertThat(info.indexOf("request_url:")).isLessThan(info.indexOf("status_code:")); + assertThat(info.indexOf("status_code:")).isLessThan(info.indexOf("timestamp:")); + assertThat(info.indexOf("timestamp:")).isLessThan(info.indexOf("message:")); + assertThat(info.indexOf("message:")).isLessThan(info.indexOf("exception_type:")); + } + + @Test + void support_info_handles_missing_request_id() { + ErrorContext ctx = ErrorContext.forResponse("https://api.example", 500, null, TS); + ServerError error = new ServerError("Server boom", ctx); + + String info = error.getSupportInfo(); + + assertThat(info).contains("request_id: (not provided)"); + assertThat(info).doesNotContain("request_id: null"); + } + + @Test + void support_info_handles_no_response_context() { + ErrorContext ctx = ErrorContext.forNoResponse("https://api.example", TS); + NetworkError error = new NetworkError("Connection refused", ctx); + + String info = error.getSupportInfo(); + + assertThat(info) + .contains("exception_type: NetworkError") + .contains("status_code: 0") + .contains("request_id: (not provided)"); + } + + @Test + void support_info_uses_sixteen_char_column_padding() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + // exception_type: is 15 chars + 1 space = 16; value starts at column 16 + assertThat(info).contains("exception_type: AuthenticationError"); + // request_id: is 11 chars + 5 spaces = 16 + assertThat(info).contains("request_id: req-abc"); + } + + @Test + void can_be_thrown_and_caught_as_market_data_exception() { + assertThatThrownBy( + () -> { + throw new RateLimitError("Quota exceeded", sampleContext()); + }) + .isInstanceOf(MarketDataException.class) + .isInstanceOf(RateLimitError.class) + .hasMessage("Quota exceeded"); + } + + @Test + void supports_instanceof_dispatch_over_sealed_hierarchy() { + MarketDataException exception = new RateLimitError("rate limited", sampleContext()); + + String label; + if (exception instanceof AuthenticationError) { + label = "auth"; + } else if (exception instanceof BadRequestError) { + label = "bad"; + } else if (exception instanceof NotFoundError) { + label = "notfound"; + } else if (exception instanceof RateLimitError) { + label = "rate"; + } else if (exception instanceof ServerError) { + label = "server"; + } else if (exception instanceof NetworkError) { + label = "network"; + } else if (exception instanceof ParseError) { + label = "parse"; + } else { + label = "unknown"; + } + + assertThat(label).isEqualTo("rate"); + } +} diff --git a/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java new file mode 100644 index 0000000..eb6cace --- /dev/null +++ b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java @@ -0,0 +1,37 @@ +package com.marketdata.sdk.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SealedHierarchyTest { + + @Test + void permits_exactly_the_seven_canonical_subtypes() { + Class[] permitted = MarketDataException.class.getPermittedSubclasses(); + + assertThat(permitted) + .containsExactlyInAnyOrder( + AuthenticationError.class, + BadRequestError.class, + NotFoundError.class, + RateLimitError.class, + ServerError.class, + NetworkError.class, + ParseError.class); + } + + @Test + void base_class_is_sealed() { + assertThat(MarketDataException.class.isSealed()).isTrue(); + } + + @Test + void all_subtypes_are_final() { + for (Class subtype : MarketDataException.class.getPermittedSubclasses()) { + assertThat(java.lang.reflect.Modifier.isFinal(subtype.getModifiers())) + .as("Subtype %s must be final", subtype.getSimpleName()) + .isTrue(); + } + } +} From 9ae56db80f799d5ee92826d9de49fdba4c27a7db Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 09:53:15 -0300 Subject: [PATCH 08/57] add asycnsemaphore --- .../com/marketdata/sdk/AsyncSemaphore.java | 102 ++++++++ .../marketdata/sdk/AsyncSemaphoreTest.java | 230 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/AsyncSemaphore.java create mode 100644 src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java diff --git a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java new file mode 100644 index 0000000..7945108 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java @@ -0,0 +1,102 @@ +package com.marketdata.sdk; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.CompletableFuture; + +/** + * Async-safe concurrency limiter. Replaces {@link java.util.concurrent.Semaphore} in the HTTP path + * so that {@code executeAsync} never parks the caller's thread when the pool is at capacity — it + * returns a {@link CompletableFuture} that completes when a permit is released by an in-flight + * request. See ADR-007 for the rationale. + * + *

Two invariants: + * + *

    + *
  1. Every permit is accounted for exactly once — it is either in {@link #availablePermits()} + * (free), held by an in-flight caller (and will be released via {@link #release()}), or + * pending in the waiter queue (and will be released by completing the waiter's future). + *
  2. {@link CompletableFuture#complete} of a transferred permit always runs outside the + * lock. Completing a future runs the caller's attached callbacks synchronously on the + * releasing thread, and we never want those running while our lock is held. + *
+ * + *

Cancelled or otherwise-completed waiters are skipped on {@link #release()} so a cancelled + * {@code acquire} doesn't burn a permit. + */ +final class AsyncSemaphore { + + private final Object lock = new Object(); + private final Deque> waiters = new ArrayDeque<>(); + private int available; + + AsyncSemaphore(int permits) { + if (permits < 0) { + throw new IllegalArgumentException("permits must be >= 0, was " + permits); + } + this.available = permits; + } + + /** + * Asynchronously claim a permit. + * + *

Fast path: a permit is available, returns an already-completed future. Slow path: pool is + * exhausted, returns a pending future enqueued FIFO; it completes when some in-flight caller + * calls {@link #release()}. Either way, the caller's thread is never parked. + */ + CompletableFuture acquire() { + synchronized (lock) { + if (available > 0) { + available--; + return CompletableFuture.completedFuture(null); + } + CompletableFuture waiter = new CompletableFuture<>(); + waiters.addLast(waiter); + return waiter; + } + } + + /** + * Release a permit. If a live waiter is enqueued, the permit is transferred to it (its future is + * completed) without going through the counter. Otherwise the counter is incremented. + */ + void release() { + // Outer loop handles the TOCTOU window between pollFirst (inside the lock) and + // complete (outside): if the waiter is cancelled in that gap, complete(null) returns + // false and the permit hasn't actually been transferred. Retry with the next waiter, + // or fall through to the counter when the queue runs out of live waiters. + while (true) { + CompletableFuture next = null; + synchronized (lock) { + while (!waiters.isEmpty()) { + CompletableFuture w = waiters.pollFirst(); + if (!w.isDone()) { + next = w; + break; + } + } + if (next == null) { + available++; + return; + } + } + if (next.complete(null)) { + return; + } + } + } + + /** Permits not currently held nor pending in the queue. */ + int availablePermits() { + synchronized (lock) { + return available; + } + } + + /** Number of pending waiters on the slow path. Useful for diagnostics and tests. */ + int queueLength() { + synchronized (lock) { + return waiters.size(); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java new file mode 100644 index 0000000..a15d572 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -0,0 +1,230 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +class AsyncSemaphoreTest { + + // ---------- fast path ---------- + + @Test + void acquireReturnsCompletedFutureWhenPermitsAvailable() { + AsyncSemaphore sem = new AsyncSemaphore(3); + + CompletableFuture a = sem.acquire(); + CompletableFuture b = sem.acquire(); + CompletableFuture c = sem.acquire(); + + assertThat(a).isCompleted(); + assertThat(b).isCompleted(); + assertThat(c).isCompleted(); + assertThat(sem.availablePermits()).isZero(); + assertThat(sem.queueLength()).isZero(); + } + + // ---------- slow path ---------- + + @Test + void acquireReturnsPendingFutureWhenPoolExhausted() { + AsyncSemaphore sem = new AsyncSemaphore(2); + sem.acquire(); + sem.acquire(); + + CompletableFuture waiter = sem.acquire(); + + assertThat(waiter).isNotCompleted(); + assertThat(sem.availablePermits()).isZero(); + assertThat(sem.queueLength()).isOne(); + } + + @Test + void releaseTransfersPermitDirectlyToFirstWaiter() { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); // pool empty + + CompletableFuture w1 = sem.acquire(); + CompletableFuture w2 = sem.acquire(); + + sem.release(); + + // The permit goes from the in-flight caller straight to w1 — never re-counted. + assertThat(w1).isCompleted(); + assertThat(w2).isNotCompleted(); + assertThat(sem.availablePermits()).isZero(); + assertThat(sem.queueLength()).isOne(); + + sem.release(); + + assertThat(w2).isCompleted(); + assertThat(sem.availablePermits()).isZero(); + assertThat(sem.queueLength()).isZero(); + } + + @Test + void releaseWithNoWaitersIncrementsCounter() { + AsyncSemaphore sem = new AsyncSemaphore(2); + sem.acquire(); + sem.acquire(); + + sem.release(); + assertThat(sem.availablePermits()).isOne(); + + sem.release(); + assertThat(sem.availablePermits()).isEqualTo(2); + } + + // ---------- cancellation ---------- + + @Test + void cancelledWaiterIsSkippedOnRelease() { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); // pool empty + + CompletableFuture cancelled = sem.acquire(); + CompletableFuture alive = sem.acquire(); + cancelled.cancel(false); + + sem.release(); + + // The cancelled waiter is skipped; the next live one gets the permit. + assertThat(alive).isCompleted(); + assertThat(sem.queueLength()).isZero(); + assertThat(sem.availablePermits()).isZero(); + } + + @Test + void releaseWhenAllWaitersCancelledFallsBackToCounter() { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); + + sem.acquire().cancel(false); + sem.acquire().cancel(false); + + sem.release(); + + // No live waiter — the permit goes back to the pool. + assertThat(sem.availablePermits()).isOne(); + assertThat(sem.queueLength()).isZero(); + } + + // ---------- ordering ---------- + + @Test + void waitersAreServedFifo() { + AsyncSemaphore sem = new AsyncSemaphore(0); + List completionOrder = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + int id = i; + sem.acquire().thenRun(() -> completionOrder.add(id)); + } + + for (int i = 0; i < 10; i++) { + sem.release(); + } + + assertThat(completionOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); + } + + // ---------- race between release() and waiter cancellation ---------- + + /** + * Regression for the TOCTOU race in {@link AsyncSemaphore#release()} between {@code pollFirst()} + * (inside the lock) and {@code complete(null)} (outside the lock). If the polled waiter is + * cancelled in that window, {@code complete(null)} returns false and — without the retry loop — + * the permit would be silently lost: it was already removed from the counter when release() + * "transferred" it, and the cancelled waiter never delivers it anywhere. + * + *

The race is timing-sensitive; we coordinate two threads through a {@link CyclicBarrier} and + * repeat the scenario many times so at least some iterations hit the bad window. The invariant we + * assert is permit-conservation: + * + *

    + *
  • If the canceller won the race, the waiter is cancelled and {@code release()} must have + * found an alternative home for the permit — either the next live waiter, or the + * available-permits counter. + *
  • If the releaser won the race, the waiter completes normally and the counter stays at 0. + *
+ * + * Either way, the permit is never lost. + */ + @RepeatedTest(200) + void releaseDoesNotLosePermitWhenWaiterIsCancelledMidRelease() throws Exception { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); // pool now empty + + CompletableFuture waiter = sem.acquire(); // queued + + CyclicBarrier barrier = new CyclicBarrier(2); + + Thread releaser = + new Thread( + () -> { + awaitBarrier(barrier); + sem.release(); + }); + Thread canceller = + new Thread( + () -> { + awaitBarrier(barrier); + waiter.cancel(false); + }); + + releaser.start(); + canceller.start(); + releaser.join(); + canceller.join(); + + assertThat(sem.queueLength()).as("queue must be drained").isZero(); + + if (waiter.isCancelled()) { + // Canceller observed (or won) the race. Whatever release() did, the permit must have + // landed somewhere — and with no other waiter present, that "somewhere" is the counter. + assertThat(sem.availablePermits()) + .as("permit must return to the pool when the only waiter is cancelled") + .isEqualTo(1); + } else { + // Releaser completed the waiter before cancel arrived. waiter must be done-normally, + // and the permit is considered "held" by the (notional) downstream consumer of the waiter. + assertThat(waiter) + .as("if not cancelled, waiter must be completed normally") + .isCompletedWithValue(null); + assertThat(sem.availablePermits()).isZero(); + } + } + + private static void awaitBarrier(CyclicBarrier barrier) { + try { + barrier.await(); + } catch (Exception e) { + throw new AssertionError("barrier interrupted", e); + } + } + + // ---------- argument validation ---------- + + @Test + void rejectsNegativeInitialPermits() { + assertThatThrownBy(() -> new AsyncSemaphore(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("permits"); + } + + @Test + void zeroInitialPermitsIsValidAndForcesSlowPath() { + AsyncSemaphore sem = new AsyncSemaphore(0); + + CompletableFuture w = sem.acquire(); + assertThat(w).isNotCompleted(); + + sem.release(); + assertThat(w).isCompleted(); + } +} From fa2d20e1e35830c51b257b69133f6bd654d94c1c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 10:10:05 -0300 Subject: [PATCH 09/57] ADD retry pilicies --- .../java/com/marketdata/sdk/RetryPolicy.java | 107 ++++++++++ .../com/marketdata/sdk/RetryPolicyTest.java | 184 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/RetryPolicy.java create mode 100644 src/test/java/com/marketdata/sdk/RetryPolicyTest.java diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java new file mode 100644 index 0000000..d134617 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -0,0 +1,107 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.ServerError; +import java.io.IOException; +import java.time.Duration; + +/** + * Decides which failures get retried and how long to wait between attempts. Per SDK requirements + * §9.3: max 3 retries (yielding 4 total attempts) with exponential backoff {@code initial * + * 2^retry} starting at 1s, capped at 30s. Network errors (only when wrapping an {@link + * IOException}-shaped cause — see {@link #shouldRetry}) and HTTP 501–599 are retriable; 500 + * specifically is not, and 4xx (including 401/429) surfaces immediately. + * + *

Worst-case wall-clock per {@code executeAsync} call (defaults): 4 attempts × + * 99s per-request timeout + 1s + 2s + 4s backoff ≈ 6.75 minutes. SDK requirements §10 only mandates + * the per-request timeout, not an overall deadline, so this is compliant — but callers in + * latency-sensitive contexts may want to wrap calls with their own {@code orTimeout} cap. + * + *

The constructor accepts custom values so tests can drive retries with sub-millisecond delays + * without waiting on real wall-clock backoffs. + * + *

TODO §9: this policy is purely about whether a cause is retriable in principle. The {@code + * /status/} cache pre-check (skip a 5xx retry when the server is marked down) and the {@code + * Retry-After} header override (replace the calculated backoff with the server-specified delay) are + * deliberately handled by {@code HttpTransport}, not here — they depend on external runtime state + * and response headers that this class doesn't see. See {@code CLAUDE.md} "Known latent gaps". + */ +final class RetryPolicy { + + private final int maxAttempts; + private final Duration initialBackoff; + private final Duration maxBackoff; + + RetryPolicy(int maxAttempts, Duration initialBackoff, Duration maxBackoff) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be >= 1, was " + maxAttempts); + } + this.maxAttempts = maxAttempts; + this.initialBackoff = initialBackoff; + this.maxBackoff = maxBackoff; + } + + /** Defaults: 4 attempts, 1s → 30s exponential. */ + static RetryPolicy defaults() { + return new RetryPolicy(4, Duration.ofSeconds(1), Duration.ofSeconds(30)); + } + + /** + * Whether the SDK should retry after {@code cause}, given that {@code attempt} attempts have + * already been spent (zero-indexed: {@code attempt == 0} means the original call just failed and + * we're considering the first retry). + */ + boolean shouldRetry(Throwable cause, int attempt) { + if (attempt + 1 >= maxAttempts) { + return false; + } + return isRetriable(cause); + } + + /** + * Backoff before the next attempt. {@code attempt == 0} means "before the first retry", i.e. the + * delay applied right after the original call failed. + */ + Duration backoffDelay(int attempt) { + long base = initialBackoff.toMillis(); + long max = maxBackoff.toMillis(); + // Two saturation points: (1) for large attempt indices, the shift `1L << N` would silently + // wrap once N >= 63 (Java masks the shift count to its low 6 bits), and (2) for moderate + // indices, `base * 2^attempt` can overflow Long before we get a chance to cap. (1) is + // handled by the early return; (2) by the rearranged inequality + // `base > max / multiplier ⇔ base * multiplier > max`, which detects overflow without + // actually overflowing. + if (attempt >= 62) { + return Duration.ofMillis(max); + } + long multiplier = 1L << Math.max(attempt, 0); + long delay = (base > max / multiplier) ? max : base * multiplier; + return Duration.ofMillis(delay); + } + + private static boolean isRetriable(Throwable cause) { + if (!(cause instanceof MarketDataException)) { + // Conservative: unknown failure types don't get retried. The caller sees the original + // exception rather than an amplified series of identical hits. + return false; + } + if (cause instanceof NetworkError net) { + // NetworkError wraps two shapes: actual transport failures (IOException + subtypes: + // ConnectException, HttpTimeoutException, ...) and sync-throws from httpClient.sendAsync + // (NPE, IllegalArgumentException — bugs, not network). Retry only the former; the latter + // is deterministic and just burns the backoff for the same crash. + return net.getCause() instanceof IOException; + } + if (cause instanceof ServerError server) { + int status = server.getStatusCode(); + // Spec §9: 500 is not retriable; 501–599 are. The 0 sentinel comes from + // ErrorContext.forNoResponse — a ServerError without a real HTTP code — and falls outside + // the range, so the same check excludes it naturally. + return status >= 501 && status <= 599; + } + // AuthenticationError, BadRequestError, RateLimitError, NotFoundError, ParseError: §9 says + // never retry 4xx, and ParseError is deterministic. + return false; + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java new file mode 100644 index 0000000..ca31ff8 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java @@ -0,0 +1,184 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RetryPolicyTest { + + private static final RetryPolicy DEFAULTS = RetryPolicy.defaults(); + + private static ErrorContext ctxNoResponse() { + return ErrorContext.forNoResponse("https://example/u", Instant.EPOCH); + } + + private static ErrorContext ctxWithStatus(int status) { + return ErrorContext.forResponse("https://example/u", status, null, Instant.EPOCH); + } + + // ---------- shouldRetry: which errors are retriable ---------- + + @Test + void networkErrorsWithIoCauseAreRetriable() { + // The canonical "real network failure" shape: NetworkError wraps an IOException (or + // subtype like ConnectException / HttpTimeoutException). + NetworkError err = + new NetworkError( + "connect refused", ctxNoResponse(), new java.io.IOException("connect refused")); + assertThat(DEFAULTS.shouldRetry(err, 0)).isTrue(); + } + + @Test + void networkErrorsWithoutCauseAreNotRetriable() { + // A NetworkError with no cause has no signal that it was an actual network failure. + // Better to surface immediately than burn 3 attempts on a possibly-deterministic bug. + assertThat(DEFAULTS.shouldRetry(new NetworkError("boom", ctxNoResponse()), 0)).isFalse(); + } + + @Test + void networkErrorsWrappingNonIoCauseAreNotRetriable() { + // Sync-throws from httpClient.sendAsync() (malformed request, internal NPE, + // IllegalArgumentException, etc.) get wrapped as NetworkError in `dispatch`, but they're + // deterministic — retrying just wastes the 1s+2s backoff for the same crash. + NetworkError syncThrow = + new NetworkError( + "Request failed before dispatch", + ctxNoResponse(), + new IllegalArgumentException("malformed URI")); + assertThat(DEFAULTS.shouldRetry(syncThrow, 0)).isFalse(); + } + + @Test + void status500IsNotRetriable() { + ServerError err = new ServerError("500", ctxWithStatus(500)); + assertThat(DEFAULTS.shouldRetry(err, 0)).isFalse(); + } + + @Test + void status501Through599AreRetriable() { + for (int code : new int[] {501, 502, 503, 504, 599}) { + ServerError err = new ServerError("err", ctxWithStatus(code)); + assertThat(DEFAULTS.shouldRetry(err, 0)).as("status %d should be retriable", code).isTrue(); + } + } + + @Test + void serverErrorWithoutHttpStatusIsNotRetriable() { + // ErrorContext.forNoResponse sets statusCode = 0 — the synthetic-path sentinel for a + // ServerError that wasn't backed by a real HTTP response. Falls outside 501–599, so it's + // not retried. + ServerError synthetic = new ServerError("no response", ctxNoResponse()); + assertThat(DEFAULTS.shouldRetry(synthetic, 0)).isFalse(); + } + + @Test + void authenticationErrorIsNotRetriable() { + assertThat(DEFAULTS.shouldRetry(new AuthenticationError("a", ctxWithStatus(401)), 0)).isFalse(); + } + + @Test + void badRequestErrorIsNotRetriable() { + assertThat(DEFAULTS.shouldRetry(new BadRequestError("b", ctxWithStatus(400)), 0)).isFalse(); + } + + @Test + void rateLimitErrorIsNotRetriable() { + // Spec §9: "Never retry 4xx or rate limit errors." Even though 429 carries Retry-After in + // some protocols, the SDK contract is to surface RateLimitError to the caller immediately. + assertThat(DEFAULTS.shouldRetry(new RateLimitError("r", ctxWithStatus(429)), 0)).isFalse(); + } + + @Test + void notFoundErrorIsNotRetriable() { + assertThat(DEFAULTS.shouldRetry(new NotFoundError("n", ctxWithStatus(404)), 0)).isFalse(); + } + + @Test + void parseErrorIsNotRetriable() { + // A bad-shape body is deterministic — retrying produces the same broken decode. + assertThat(DEFAULTS.shouldRetry(new ParseError("p", ctxNoResponse()), 0)).isFalse(); + } + + @Test + void unknownThrowableIsNotRetriable() { + // Conservative default for non-MarketDataException causes: don't retry. Better to surface + // the unknown failure than to silently hammer the API. + assertThat(DEFAULTS.shouldRetry(new RuntimeException("?"), 0)).isFalse(); + } + + // ---------- shouldRetry: respect max attempts ---------- + + @Test + void retriesStopAfterMaxAttempts() { + NetworkError retriable = + new NetworkError("net", ctxNoResponse(), new java.io.IOException("transport down")); + // Defaults: maxAttempts = 4 → attempts 0, 1, 2 are eligible to be followed by a retry + // (attempt 3 was the fourth try; no fifth attempt allowed). + assertThat(DEFAULTS.shouldRetry(retriable, 0)).isTrue(); + assertThat(DEFAULTS.shouldRetry(retriable, 1)).isTrue(); + assertThat(DEFAULTS.shouldRetry(retriable, 2)).isTrue(); + assertThat(DEFAULTS.shouldRetry(retriable, 3)).isFalse(); + assertThat(DEFAULTS.shouldRetry(retriable, 99)).isFalse(); + } + + // ---------- backoffDelay: exponential with cap ---------- + + @Test + void backoffStartsAtInitialAndDoubles() { + assertThat(DEFAULTS.backoffDelay(0)).isEqualTo(Duration.ofSeconds(1)); + assertThat(DEFAULTS.backoffDelay(1)).isEqualTo(Duration.ofSeconds(2)); + assertThat(DEFAULTS.backoffDelay(2)).isEqualTo(Duration.ofSeconds(4)); + assertThat(DEFAULTS.backoffDelay(3)).isEqualTo(Duration.ofSeconds(8)); + } + + @Test + void backoffCapsAtMaxBackoff() { + // 2^5 = 32 > 30 cap; 2^10 way over. + assertThat(DEFAULTS.backoffDelay(5)).isEqualTo(Duration.ofSeconds(30)); + assertThat(DEFAULTS.backoffDelay(10)).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void backoffSaturatesOnExtremeAttemptIndices() { + // The shift `1L << attempt` is undefined for attempt >= 63 (the shift count is masked to + // the bottom 6 bits, wrapping silently); the implementation guards against this by + // capping at maxBackoff once the multiplier would overflow. + assertThat(DEFAULTS.backoffDelay(62)).isEqualTo(Duration.ofSeconds(30)); + assertThat(DEFAULTS.backoffDelay(70)).isEqualTo(Duration.ofSeconds(30)); + assertThat(DEFAULTS.backoffDelay(Integer.MAX_VALUE)).isEqualTo(Duration.ofSeconds(30)); + } + + // ---------- custom-tuned policy (used by tests that need fast retries) ---------- + + @Test + void customConstructorWiresValuesThrough() { + RetryPolicy tiny = + new RetryPolicy(/* maxAttempts */ 5, Duration.ofMillis(1), Duration.ofMillis(10)); + + NetworkError net = + new NetworkError("n", ctxNoResponse(), new java.io.IOException("transport down")); + assertThat(tiny.shouldRetry(net, 3)).isTrue(); + assertThat(tiny.shouldRetry(net, 4)).isFalse(); + assertThat(tiny.backoffDelay(0)).isEqualTo(Duration.ofMillis(1)); + assertThat(tiny.backoffDelay(1)).isEqualTo(Duration.ofMillis(2)); + assertThat(tiny.backoffDelay(20)).isEqualTo(Duration.ofMillis(10)); + } + + @Test + void rejectsNonPositiveMaxAttempts() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new RetryPolicy(0, Duration.ofMillis(1), Duration.ofMillis(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAttempts"); + } +} From 52e9540d2e2c52bac4fa2e10919231301b455fe1 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 11:19:12 -0300 Subject: [PATCH 10/57] add HttpTransport --- .../java/com/marketdata/sdk/DateFormat.java | 27 ++ src/main/java/com/marketdata/sdk/Format.java | 44 +++ .../com/marketdata/sdk/HttpDispatcher.java | 111 ++++++ .../marketdata/sdk/HttpResponseEnvelope.java | 25 ++ .../com/marketdata/sdk/HttpTransport.java | 232 +++++++++++++ src/main/java/com/marketdata/sdk/Mode.java | 27 ++ .../com/marketdata/sdk/RateLimitHeaders.java | 52 +++ .../java/com/marketdata/sdk/RequestSpec.java | 118 +++++++ .../com/marketdata/sdk/RetryExecutor.java | 104 ++++++ .../marketdata/sdk/HttpDispatcherTest.java | 154 ++++++++ .../com/marketdata/sdk/HttpTransportTest.java | 328 ++++++++++++++++++ .../marketdata/sdk/RateLimitHeadersTest.java | 144 ++++++++ .../com/marketdata/sdk/RequestSpecTest.java | 139 ++++++++ .../com/marketdata/sdk/RetryExecutorTest.java | 189 ++++++++++ .../com/marketdata/sdk/TestHttpClients.java | 205 +++++++++++ 15 files changed, 1899 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/DateFormat.java create mode 100644 src/main/java/com/marketdata/sdk/Format.java create mode 100644 src/main/java/com/marketdata/sdk/HttpDispatcher.java create mode 100644 src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java create mode 100644 src/main/java/com/marketdata/sdk/HttpTransport.java create mode 100644 src/main/java/com/marketdata/sdk/Mode.java create mode 100644 src/main/java/com/marketdata/sdk/RateLimitHeaders.java create mode 100644 src/main/java/com/marketdata/sdk/RequestSpec.java create mode 100644 src/main/java/com/marketdata/sdk/RetryExecutor.java create mode 100644 src/test/java/com/marketdata/sdk/HttpDispatcherTest.java create mode 100644 src/test/java/com/marketdata/sdk/HttpTransportTest.java create mode 100644 src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java create mode 100644 src/test/java/com/marketdata/sdk/RequestSpecTest.java create mode 100644 src/test/java/com/marketdata/sdk/RetryExecutorTest.java create mode 100644 src/test/java/com/marketdata/sdk/TestHttpClients.java diff --git a/src/main/java/com/marketdata/sdk/DateFormat.java b/src/main/java/com/marketdata/sdk/DateFormat.java new file mode 100644 index 0000000..8211440 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DateFormat.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk; + +/** + * Date/time serialization format for response payloads. Controlled via {@code ?dateformat=}. + * + *

    + *
  • {@link #UNIX} — epoch seconds (default). + *
  • {@link #TIMESTAMP} — ISO-8601-style timestamp string. + *
  • {@link #SPREADSHEET} — Excel/Sheets-compatible serial date number. + *
+ */ +public enum DateFormat { + UNIX("unix"), + TIMESTAMP("timestamp"), + SPREADSHEET("spreadsheet"); + + private final String wireValue; + + DateFormat(String wireValue) { + this.wireValue = wireValue; + } + + /** The value sent in the {@code ?dateformat=} query parameter. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/Format.java b/src/main/java/com/marketdata/sdk/Format.java new file mode 100644 index 0000000..2c042e6 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Format.java @@ -0,0 +1,44 @@ +package com.marketdata.sdk; + +/** + * Wire response format negotiated via the {@code ?format=} query parameter. + * + *

Package-private by design. SDK consumers never reference this enum directly — + * resource façades expose a method per format (e.g. {@code stocks.candles(...)} returns a decoded + * record from a JSON response; {@code stocks.candlesAsCsv(...)} returns the raw CSV). That keeps + * the format choice surfaced as a method selection rather than a parameter the user has to import + * {@code Format} for. + * + *

Why {@link #HTML} is here even though the server doesn't return it today: the SDK is supposed + * to be ready. Plumbing for {@code text/html} responses lives in the transport pipeline — Accept + * header, {@code ?format=html}, and round-trip-through-{@link HttpResponseEnvelope} — so the day an + * endpoint flips it on, the only change required is a resource façade exposing a matching {@code + * ...AsHtml(...)} method. No transport edits. + * + *

The server's renderer set today is JSON + CSV; {@code ?format=html} is currently a no-op + * server-side (the response falls back to the default renderer). Internal callers that pass {@link + * #HTML} should expect that until the server lights it up. + */ +enum Format { + JSON("json", "application/json"), + CSV("csv", "text/csv"), + HTML("html", "text/html"); + + private final String wireValue; + private final String mediaType; + + Format(String wireValue, String mediaType) { + this.wireValue = wireValue; + this.mediaType = mediaType; + } + + /** The value sent in the {@code ?format=} query parameter. */ + String wireValue() { + return wireValue; + } + + /** The media type sent in the {@code Accept} request header. */ + String mediaType() { + return mediaType; + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java new file mode 100644 index 0000000..5780a81 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -0,0 +1,111 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.NetworkError; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Instant; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Single-shot HTTP dispatch with global concurrency limiting. + * + *

One {@code HttpDispatcher} per {@link MarketDataClient}. Owns the {@link HttpClient} and the + * 50-permit {@link AsyncSemaphore} (SDK requirements §12). Each call to {@link #dispatch} acquires + * a permit, sends the request, and releases the permit exactly once — whether the response + * succeeds, fails, or the caller cancels the returned future. + * + *

Failures that originate inside {@code HttpClient.sendAsync} (transport errors, sync-thrown + * bugs) are mapped to {@link NetworkError} so the upstream retry layer sees a single, typed shape. + * Status-code interpretation lives in {@link HttpTransport}, not here — this class is below the + * "what does HTTP 4xx mean" abstraction. + */ +final class HttpDispatcher { + + private final HttpClient httpClient; + private final AsyncSemaphore permits; + + HttpDispatcher(HttpClient httpClient, int concurrencyLimit) { + this.httpClient = httpClient; + this.permits = new AsyncSemaphore(concurrencyLimit); + } + + /** + * Send one request. The returned future completes with the raw response on success, or fails with + * a {@link NetworkError} for transport-level problems. Cancellation of the returned future + * propagates to the underlying send and, if the dispatch hasn't started yet because we're queued + * behind the concurrency pool, removes the waiter from the semaphore. + */ + CompletableFuture> dispatch(HttpRequest request) { + CompletableFuture permit = permits.acquire(); + CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); + + // Cancellation of `dispatched` doesn't propagate to `permit` by default, so a slow-path + // waiter would stay live in the semaphore queue; release() would later "transfer" the + // permit by completing the waiter, but thenCompose's function wouldn't run (its dependent + // is already cancelled), and send — which registers whenComplete(release) — would never + // fire. Cancelling `permit` here makes AsyncSemaphore.release skip the waiter. + dispatched.whenComplete( + (r, t) -> { + if (t instanceof CancellationException) { + permit.cancel(false); + } + }); + + return dispatched; + } + + private CompletableFuture> send(HttpRequest request) { + CompletableFuture> sendFuture; + try { + sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray()); + } catch (Throwable t) { + // sendAsync threw synchronously (malformed request, internal NPE, OOM). The future + // never formed, so the whenComplete below would never fire — release the permit here + // to prevent a permanent leak that would degrade the pool to deadlock. + permits.release(); + if (t instanceof Error err) { + throw err; + } + return CompletableFuture.failedFuture( + new NetworkError( + "Request to " + request.uri() + " failed before dispatch: " + t.getMessage(), + ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), + t)); + } + + return sendFuture + .whenComplete((r, t) -> permits.release()) + .handle( + (response, error) -> { + if (error != null) { + Throwable root = unwrap(error); + throw new CompletionException( + new NetworkError( + "Request to " + request.uri() + " failed: " + root.getMessage(), + ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), + root)); + } + return response; + }); + } + + /** Permits not currently held nor queued. Exposed for diagnostics and tests. */ + int availablePermits() { + return permits.availablePermits(); + } + + /** Number of pending waiters on the semaphore's slow path. */ + int queueLength() { + return permits.queueLength(); + } + + private static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java b/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java new file mode 100644 index 0000000..b3145cd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java @@ -0,0 +1,25 @@ +package com.marketdata.sdk; + +import java.net.URI; +import java.net.http.HttpHeaders; +import org.jspecify.annotations.Nullable; + +/** + * Format-agnostic envelope returned by {@link HttpTransport} to resources. + * + *

The transport's job ends here: it confirms the response was a success-shaped HTTP status + * (200/203/404 per the API contract), maps real errors (4xx/5xx) to typed exceptions, and hands the + * raw bytes back. Whether the body is JSON, CSV, or HTML is the resource's decision — the transport + * does not parse it. + * + * @param body raw response bytes, exactly as received from the wire. May be empty. + * @param statusCode the HTTP status code (one of 200, 203, 404). + * @param requestId server-provided request id (e.g. Cloudflare {@code cf-ray}), {@code null} when + * the response did not carry one. Useful when the resource's own parser fails and needs to + * build an {@code ErrorContext}. + * @param headers full response headers, in case a resource needs to read content-type, encoding, + * pagination, etc. + * @param url the absolute URL the response came from. Useful for error contexts. + */ +record HttpResponseEnvelope( + byte[] body, int statusCode, @Nullable String requestId, HttpHeaders headers, URI url) {} diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java new file mode 100644 index 0000000..36418e1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -0,0 +1,232 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NetworkError; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; + +/** + * The single point of contact between resource façades and the network. + * + *

Owned by {@link MarketDataClient}, instantiated once per client. Composes the URL/request + * builders (private helpers below) with {@link HttpDispatcher} (concurrency + send) and {@link + * RetryExecutor} (retry orchestration), and applies the API's HTTP-level status routing (200/203/ + * 404 → success envelope; 4xx/5xx → typed exception via {@link HttpStatusMapper}). + * + *

The transport is deliberately agnostic to response format. It hands back an + * {@link HttpResponseEnvelope} of raw bytes plus metadata; resources decide whether to decode as + * JSON, CSV, HTML, or return raw. + * + *

Per ADR-006 the design is async-first: {@link #executeAsync} is the canonical path; {@link + * #executeSync} is a thin wrapper that calls {@link CompletableFuture#join()} and unwraps any + * {@link CompletionException} so the caller sees the underlying {@link MarketDataException} + * directly. + */ +final class HttpTransport implements AutoCloseable { + + /** SDK requirements §10: fixed 99-second per-request timeout. */ + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); + + /** SDK requirements §10: fixed 2-second connect timeout. */ + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); + + /** SDK requirements §12: 50-permit global concurrency pool. */ + static final int CONCURRENCY_LIMIT = 50; + + private static final String CF_RAY = "cf-ray"; + + private final HttpDispatcher dispatcher; + private final RetryExecutor retryExecutor; + private final AtomicReference<@Nullable RateLimitSnapshot> latestRateLimits = + new AtomicReference<>(); + + private final String baseUrl; + private final String apiVersion; + private final String userAgent; + private final @Nullable String token; + + HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) { + this( + baseUrl, + apiVersion, + userAgent, + token, + new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), + new RetryExecutor(RetryPolicy.defaults())); + } + + // Package-private constructor for tests: inject a stubbed dispatcher and/or a fast retry + // policy so tests don't hit the wire and don't wait on real backoffs. + HttpTransport( + String baseUrl, + String apiVersion, + String userAgent, + @Nullable String token, + HttpDispatcher dispatcher, + RetryExecutor retryExecutor) { + this.baseUrl = baseUrl; + this.apiVersion = apiVersion; + this.userAgent = userAgent; + this.token = token; + this.dispatcher = dispatcher; + this.retryExecutor = retryExecutor; + } + + private static HttpClient defaultHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * Latest client-level rate-limit snapshot, or {@code null} if no rate-limit-bearing response has + * arrived yet. Successful responses without rate-limit headers do not clear it. + */ + @Nullable RateLimitSnapshot getLatestRateLimits() { + return latestRateLimits.get(); + } + + /** + * Async-first request execution with retry. Returns the raw response envelope on success (HTTP + * 200/203/404); 4xx and 5xx responses surface as the corresponding {@link MarketDataException} + * subtype via {@link HttpStatusMapper}, possibly after retries. + */ + CompletableFuture executeAsync(RequestSpec spec) { + URI uri = buildUri(spec); + HttpRequest request = buildHttpRequest(uri, spec.format()); + return retryExecutor.execute( + () -> dispatcher.dispatch(request).thenApply(response -> routeAndEnvelope(response, uri))); + } + + /** + * Sync wrapper around {@link #executeAsync}. Per ADR-006, calls {@code .join()} and unwraps + * {@link CompletionException} so callers see the underlying {@link MarketDataException}. + */ + HttpResponseEnvelope executeSync(RequestSpec spec) { + try { + return executeAsync(spec).join(); + } catch (CompletionException e) { + throw asRuntime(e.getCause()); + } catch (CancellationException e) { + throw asRuntime(e); + } + } + + @Override + public void close() { + // java.net.http.HttpClient gained explicit close() in JDK 21; until the SDK's minimum + // bumps to 21+ this is a no-op (ADR-002). + } + + // ---------- private helpers ---------- + + /** + * Status routing + rate-limit snapshot update. Runs inside the retry supplier so a 5xx that we'd + * retry surfaces here as a thrown exception that {@link RetryExecutor} can catch and pass to the + * policy. + */ + private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI uri) { + // Only overwrite when the response carried parseable rate-limit headers — the API + // occasionally responds without them on its own internal errors; clobbering with null + // would make `getLatestRateLimits()` flicker. + RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); + if (parsed != null) { + latestRateLimits.set(parsed); + } + + int status = response.statusCode(); + String requestId = response.headers().firstValue(CF_RAY).orElse(null); + + // Any 2xx is treated as success — the API uses 200 and 203 today, but a future endpoint + // returning 201/204 should still hand the body to the resource. 404 is the API's + // no_data convention: the body still carries a typed payload the resource interprets. + if ((status >= 200 && status < 300) || status == 404) { + return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); + } + ErrorContext context = + ErrorContext.forResponse(uri.toString(), status, requestId, Instant.now()); + MarketDataException ex = HttpStatusMapper.map(status, context); + if (ex != null) { + throw ex; + } + // Mapper only returns null for 2xx, which the branch above already handled. Belt & + // suspenders for the impossible case so a future mapper edit can't silently swallow. + throw new com.marketdata.sdk.exception.ServerError( + "Unmapped status " + status + " from " + uri, context); + } + + private URI buildUri(RequestSpec spec) { + // RequestSpec's Javadoc says path has no leading slash, but a caller mistake would produce + // baseUrl/v1//markets/status (double slash). Strip defensively so the URL stays well-formed + // regardless of which side of the contract the bug is on. + String path = spec.path(); + if (path.startsWith("/")) { + path = path.substring(1); + } + StringBuilder sb = new StringBuilder(); + sb.append(baseUrl).append('/').append(apiVersion).append('/').append(path); + if (!path.endsWith("/")) { + sb.append('/'); + } + Map params = spec.queryParams(); + if (!params.isEmpty()) { + sb.append('?'); + boolean first = true; + for (Map.Entry e : params.entrySet()) { + if (!first) { + sb.append('&'); + } + sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + first = false; + } + } + return URI.create(sb.toString()); + } + + private HttpRequest buildHttpRequest(URI uri, Format format) { + HttpRequest.Builder b = + HttpRequest.newBuilder(uri) + .GET() + .timeout(REQUEST_TIMEOUT) + .header("User-Agent", userAgent) + .header("Accept", format.mediaType()); + if (token != null) { + b.header("Authorization", "Bearer " + token); + } + return b.build(); + } + + // Visible for tests: under the current SDK design, executeAsync always wraps failures as + // MarketDataException so the MDE branch is the only one reached from the public surface. + // The other two branches are defensive guardrails — extracted so they can be exercised + // directly by tests rather than relying on a synthetic public-API path. + static RuntimeException asRuntime(@Nullable Throwable cause) { + if (cause instanceof MarketDataException mde) { + return mde; + } + if (cause instanceof RuntimeException re) { + return re; + } + return new NetworkError( + "Unexpected failure invoking SDK", + ErrorContext.forNoResponse("(unknown)", Instant.now()), + cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/Mode.java b/src/main/java/com/marketdata/sdk/Mode.java new file mode 100644 index 0000000..19530ee --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Mode.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk; + +/** + * Data-freshness tier requested for the response. Controlled via {@code ?mode=}. + * + *

    + *
  • {@link #LIVE} — current market data (default). + *
  • {@link #DELAYED} — exchange-delayed data, typically 15 minutes. + *
  • {@link #CACHED} — last cached snapshot; lowest cost, highest staleness. + *
+ */ +public enum Mode { + LIVE("live"), + DELAYED("delayed"), + CACHED("cached"); + + private final String wireValue; + + Mode(String wireValue) { + this.wireValue = wireValue; + } + + /** The value sent in the {@code ?mode=} query parameter. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java new file mode 100644 index 0000000..2a0ed87 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java @@ -0,0 +1,52 @@ +package com.marketdata.sdk; + +import java.net.http.HttpHeaders; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +/** + * Parses the {@code x-api-ratelimit-*} response headers that the API sets on every successful + * request (SDK requirements §8.2) into a {@link RateLimitSnapshot}. + * + *

Returns {@code null} when none of the relevant headers are present, which happens during a + * rate-limit-tracking outage on the server side (the API silently swallows the error and keeps + * serving the request). + */ +final class RateLimitHeaders { + + private static final String LIMIT = "x-api-ratelimit-limit"; + private static final String REMAINING = "x-api-ratelimit-remaining"; + private static final String RESET = "x-api-ratelimit-reset"; + private static final String CONSUMED = "x-api-ratelimit-consumed"; + + private RateLimitHeaders() {} + + static @Nullable RateLimitSnapshot parse(HttpHeaders headers) { + Long limit = readLong(headers, LIMIT); + Long remaining = readLong(headers, REMAINING); + Long reset = readLong(headers, RESET); + Long consumed = readLong(headers, CONSUMED); + if (limit == null && remaining == null && reset == null && consumed == null) { + return null; + } + return new RateLimitSnapshot( + limit != null ? limit.intValue() : 0, + remaining != null ? remaining.intValue() : 0, + Instant.ofEpochSecond(reset != null ? reset : 0L), + consumed != null ? consumed.intValue() : 0); + } + + private static @Nullable Long readLong(HttpHeaders headers, String name) { + return headers + .firstValue(name) + .map( + v -> { + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + return null; + } + }) + .orElse(null); + } +} diff --git a/src/main/java/com/marketdata/sdk/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java new file mode 100644 index 0000000..0cf56c0 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -0,0 +1,118 @@ +package com.marketdata.sdk; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Declarative description of an HTTP GET request the SDK wants to make. + * + *

Resources build instances of this and hand them to {@link HttpTransport}; the transport is the + * only code that knows about base URLs, auth headers, timeouts, and the like. The transport stays + * agnostic to response format — the {@code format} field is what tells it which {@code Accept} + * header to send (a courtesy; {@code ?format=} on the query string is the source of truth, since + * that is the path exercised by the backend's own test suite). + * + *

Universal query parameters (per SDK requirements §3) are surfaced as typed builder methods so + * resources don't reach for {@link Builder#query} for the common cross-cutting cases. + * + * @param path API-relative path with no leading {@code /v1/} prefix and no trailing slash, e.g. + * {@code "markets/status"}. The transport adds the base URL, version prefix, and trailing + * slash. + * @param queryParams ordered query parameters (insertion order preserved for predictable URLs in + * tests). Values are URL-encoded by the transport. + * @param format wire response format. The transport mirrors this in the {@code Accept} request + * header; the {@code ?format=} query param is also written into {@code queryParams} by the + * builder when set. + */ +record RequestSpec(String path, Map queryParams, Format format) { + + static final Format DEFAULT_FORMAT = Format.JSON; + + RequestSpec { + // Preserve insertion order — Map.copyOf would defensively copy but + // strip the iteration order, which breaks predictable URLs in tests + // and in any caller that cares about query-param order on the wire. + queryParams = Collections.unmodifiableMap(new LinkedHashMap<>(queryParams)); + } + + static Builder get(String path) { + return new Builder(path); + } + + static final class Builder { + private final String path; + private final Map queryParams = new LinkedHashMap<>(); + private Format format = DEFAULT_FORMAT; + + private Builder(String path) { + this.path = path; + } + + /** Adds an arbitrary query parameter; skipped if {@code value} is null. */ + Builder query(String key, Object value) { + if (value != null) { + queryParams.put(key, value.toString()); + } + return this; + } + + /** Sets the wire response format ({@code ?format=}) and the matching Accept header. */ + Builder format(Format format) { + this.format = format; + queryParams.put("format", format.wireValue()); + return this; + } + + /** Sets {@code ?dateformat=} controlling date/time serialization. */ + Builder dateformat(DateFormat fmt) { + queryParams.put("dateformat", fmt.wireValue()); + return this; + } + + /** Sets {@code ?mode=} controlling data freshness tier. */ + Builder mode(Mode mode) { + queryParams.put("mode", mode.wireValue()); + return this; + } + + /** Sets {@code ?headers=true|false} controlling the CSV header row. */ + Builder headers(boolean include) { + queryParams.put("headers", String.valueOf(include)); + return this; + } + + /** Sets {@code ?human=true|false} for human-readable attribute names. */ + Builder human(boolean human) { + queryParams.put("human", String.valueOf(human)); + return this; + } + + /** Sets {@code ?columns=...} as a comma-joined list. No-op when {@code cols} is empty. */ + Builder columns(List cols) { + if (!cols.isEmpty()) { + queryParams.put("columns", String.join(",", cols)); + } + return this; + } + + /** Sets {@code ?limit=}. */ + Builder limit(int limit) { + queryParams.put("limit", String.valueOf(limit)); + return this; + } + + /** Sets {@code ?offset=}. */ + Builder offset(int offset) { + queryParams.put("offset", String.valueOf(offset)); + return this; + } + + RequestSpec build() { + // Pass the raw LinkedHashMap — the record's compact constructor defensively copies and + // wraps it as unmodifiable, so wrapping here too would just rebuild a redundant view. + return new RequestSpec(path, queryParams, format); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java new file mode 100644 index 0000000..58f6556 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -0,0 +1,104 @@ +package com.marketdata.sdk; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; + +/** + * Wraps a {@link Supplier} of {@link CompletableFuture}s with retry-on-failure semantics governed + * by {@link RetryPolicy}. Knows nothing about HTTP, JSON, or {@code MarketDataException} subtypes — + * it just observes which causes the policy says are retriable and schedules subsequent attempts + * after the policy's backoff. + * + *

Backoffs run on {@link CompletableFuture#delayedExecutor} so we don't own a scheduled-thread + * pool that needs lifecycle management. The thread that runs each retry comes from {@code + * ForkJoinPool.commonPool} after the delay elapses. + * + *

Cancellation of the outer result propagates to the in-flight attempt: if the caller cancels + * mid-flight or mid-backoff, the next attempt is not scheduled and the current one (if any) is + * cancelled. + */ +final class RetryExecutor { + + private final RetryPolicy policy; + + RetryExecutor(RetryPolicy policy) { + this.policy = policy; + } + + /** + * Drive {@code supplier} with retry. Each invocation of the supplier represents one attempt; if + * the resulting future fails with a retriable cause, {@code supplier} is invoked again after the + * policy-determined backoff. + */ + CompletableFuture execute(Supplier> supplier) { + CompletableFuture result = new CompletableFuture<>(); + // One cancellation handler installed once: whichever attempt is currently in flight is + // tracked in `currentAttempt`; cancelling `result` cancels that. Previous attempts are + // already done by the time the next one overwrites the reference, so this avoids + // accumulating one handler per attempt. + AtomicReference<@Nullable CompletableFuture> currentAttempt = new AtomicReference<>(); + result.whenComplete( + (r, t) -> { + if (t instanceof CancellationException) { + CompletableFuture inFlight = currentAttempt.get(); + if (inFlight != null && !inFlight.isDone()) { + inFlight.cancel(false); + } + } + }); + attempt(supplier, 0, result, currentAttempt); + return result; + } + + private void attempt( + Supplier> supplier, + int attemptIdx, + CompletableFuture result, + AtomicReference<@Nullable CompletableFuture> currentAttempt) { + if (result.isDone()) { + // Caller cancelled (or completed exceptionally from a previous attempt's whenComplete). + // Don't invoke the supplier again. + return; + } + CompletableFuture dispatched = supplier.get(); + currentAttempt.set(dispatched); + + // If the caller cancelled `result` between attempts (during a backoff window), the handler + // installed in execute() has fired but `currentAttempt` was either null or pointing to + // the previous (already-done) attempt — so the new attempt was never cancelled. Check + // here and propagate immediately. + if (result.isCancelled() && !dispatched.isDone()) { + dispatched.cancel(false); + return; + } + + dispatched.whenComplete( + (value, error) -> { + if (result.isDone()) { + return; + } + if (error == null) { + result.complete(value); + return; + } + Throwable cause = unwrap(error); + if (policy.shouldRetry(cause, attemptIdx)) { + long delayMs = policy.backoffDelay(attemptIdx).toMillis(); + CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) + .execute(() -> attempt(supplier, attemptIdx + 1, result, currentAttempt)); + } else { + result.completeExceptionally(cause); + } + }); + } + + // Package-private so the unwrap-when-nested-and-when-not branches are reachable from tests. + static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } +} diff --git a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java new file mode 100644 index 0000000..adf2147 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java @@ -0,0 +1,154 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.NetworkError; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +class HttpDispatcherTest { + + private static final int LIMIT = 4; + + private static HttpRequest req() { + return HttpRequest.newBuilder(URI.create("http://localhost/ping")).GET().build(); + } + + // ---------- happy path ---------- + + @Test + void dispatchReturnsResponseAndReleasesPermit() { + HttpClient client = + new TestHttpClients.StubHttpClient() { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + HttpResponse resp = + TestHttpClients.response( + 200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true), request.uri()); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + }; + HttpDispatcher dispatcher = new HttpDispatcher(client, LIMIT); + + HttpResponse resp = dispatcher.dispatch(req()).join(); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(new String(resp.body())).isEqualTo("ok"); + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + assertThat(dispatcher.queueLength()).isZero(); + } + + // ---------- sync-throw guard ---------- + + /** + * If {@code sendAsync} throws synchronously (malformed request, internal NPE, OOM), the future + * never forms; without explicit permit release in the catch block, every such failure leaks a + * permit. With {@code LIMIT + extras} calls against a stub that always throws, a leak would + * deadlock the pool once {@code LIMIT} requests had accumulated. + */ + @Test + void permitReleasedWhenSendAsyncThrowsSynchronously() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.SyncThrowing(), LIMIT); + + int n = LIMIT + 3; + for (int i = 0; i < n; i++) { + CompletableFuture> f = dispatcher.dispatch(req()); + assertThat(f).isCompletedExceptionally(); + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class) + .hasMessageContaining("before dispatch"); + } + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + /** + * Sync-thrown {@link Error} (e.g. simulated OOM) must surface with its type preserved; wrapping + * it as {@link NetworkError} would mask a JVM-level crash. Permit must still be released. + */ + @Test + void errorThrownSynchronouslyIsPreservedAsRootCause() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.ErrorThrowing(), LIMIT); + + CompletableFuture> f = dispatcher.dispatch(req()); + + assertThat(f).isCompletedExceptionally(); + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasRootCauseInstanceOf(OutOfMemoryError.class); + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + // ---------- async failure mapped to NetworkError ---------- + + @Test + void asyncIoExceptionMappedToNetworkError() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, LIMIT); + + CompletableFuture> f = dispatcher.dispatch(req()); + client.failAll(new IOException("connect refused")); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class); + + NetworkError err = (NetworkError) f.handle((r, e) -> e.getCause()).join(); + assertThat(err.getCause()).isInstanceOf(IOException.class); + assertThat(err.getMessage()).contains("connect refused"); + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + // ---------- slow-path cancellation ---------- + + /** + * When the pool is saturated, an extra {@code dispatch} call's permit acquire queues a waiter in + * the semaphore. The thing {@link HttpDispatcher} adds on top of {@link AsyncSemaphore} here is + * propagating cancellation: when the caller cancels the dispatch future, the waiter must be + * marked cancelled so a later {@code release} skips it instead of transferring a permit into the + * void. + */ + @Test + void cancellingQueuedDispatchMarksWaiterAndPermitReturnsToPool() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, /* concurrencyLimit */ 1); + + CompletableFuture> inflight = dispatcher.dispatch(req()); + assertThat(dispatcher.availablePermits()).isZero(); + + CompletableFuture> queued = dispatcher.dispatch(req()); + assertThat(dispatcher.queueLength()).isOne(); + + queued.cancel(false); + + HttpResponse ok = + TestHttpClients.response( + 200, + "ok".getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/ping")); + client.completeAll(ok); + + assertThat(inflight).isCompleted(); + assertThat(queued).isCancelled(); + + // The cancelled waiter was skipped on release; the permit returns to the pool rather + // than being silently lost. + assertThat(dispatcher.queueLength()).isZero(); + assertThat(dispatcher.availablePermits()).isOne(); + } +} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java new file mode 100644 index 0000000..cea9000 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -0,0 +1,328 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +class HttpTransportTest { + + /** RetryPolicy with a single attempt so each test's HTTP-call count is unambiguous. */ + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + + private static HttpTransport newTransport(HttpClient client) { + return new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY)); + } + + // ---------- URL & header composition ---------- + + @Test + void buildsUrlWithBaseVersionPathTrailingSlashAndEncodedQuery() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport + .executeAsync( + RequestSpec.get("markets/status") + .query("date", "2024-05-01") + .query("country", "US") + .build()) + .join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.uri().toString()) + .isEqualTo("http://localhost/v1/markets/status/?date=2024-05-01&country=US"); + } + + @Test + void sendsAuthorizationUserAgentAndAcceptHeaders() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").format(Format.CSV).build()).join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.headers().firstValue("Authorization")).contains("Bearer secret-token"); + assertThat(sent.headers().firstValue("User-Agent")).contains("test/0.0"); + assertThat(sent.headers().firstValue("Accept")).contains("text/csv"); + assertThat(sent.timeout()).contains(HttpTransport.REQUEST_TIMEOUT); + } + + @Test + void noAuthorizationHeaderWhenTokenIsNull() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + null, + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY)); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void leadingSlashInPathIsStripped() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("/markets/status").build()).join(); + + // Defensive strip — no double slash even when the resource accidentally prepends one. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/markets/status/"); + } + + // ---------- response envelope ---------- + + @Test + void successReturnsEnvelopeWithBodyStatusAndRequestId() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("cf-ray", "abc-123")); + CapturingClient client = new CapturingClient(200, "payload".getBytes(), headers); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(new String(env.body())).isEqualTo("payload"); + assertThat(env.statusCode()).isEqualTo(200); + assertThat(env.requestId()).isEqualTo("abc-123"); + assertThat(env.url().toString()).isEqualTo("http://localhost/v1/markets/status/"); + } + + @Test + void status203AlsoReturnsEnvelope() { + CapturingClient client = + new CapturingClient(203, "cached".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(env.statusCode()).isEqualTo(203); + assertThat(new String(env.body())).isEqualTo("cached"); + } + + @Test + void status404AlsoReturnsEnvelope() { + // The API uses 404 for "no_data" responses; the body still carries a payload that resources + // need to inspect. + CapturingClient client = + new CapturingClient( + 404, "{\"s\":\"no_data\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(env.statusCode()).isEqualTo(404); + assertThat(new String(env.body())).isEqualTo("{\"s\":\"no_data\"}"); + } + + // ---------- status routing to typed exceptions ---------- + + @Test + void status401ThrowsAuthenticationError() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(AuthenticationError.class); + } + + @Test + void status400ThrowsBadRequestError() { + CapturingClient client = + new CapturingClient(400, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(BadRequestError.class); + } + + @Test + void status429ThrowsRateLimitError() { + CapturingClient client = + new CapturingClient(429, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); + } + + @Test + void status500ThrowsServerError() { + CapturingClient client = + new CapturingClient(500, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + } + + @Test + void status418ThrowsNotFoundFallbackOrServerError() { + // Sanity: unmapped 4xx falls through to HttpStatusMapper's catch-all; we don't pin to + // a specific type here, only that it surfaces as SOME MarketDataException. + CapturingClient client = + new CapturingClient(418, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.MarketDataException.class); + } + + @Test + void notFoundStatusIsNotThrownBecauseTheApiUsesItForNoData() { + // Sanity: 404 must NOT route to NotFoundError — it carries a no_data body. The status + // routing's "if 200/203/404 return envelope" branch covers this. + CapturingClient client = + new CapturingClient(404, "{}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(env.statusCode()).isEqualTo(404); + // Compiler-only: ensure NotFoundError exists so test wouldn't compile if removed. + @SuppressWarnings("unused") + Class noisy = NotFoundError.class; + } + + // ---------- rate-limit snapshot ---------- + + @Test + void rateLimitSnapshotUpdatesWhenHeadersPresent() { + HttpHeaders headers = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), headers); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + RateLimitSnapshot snap = transport.getLatestRateLimits(); + assertThat(snap).isNotNull(); + assertThat(snap.limit()).isEqualTo(1000); + assertThat(snap.remaining()).isEqualTo(987); + assertThat(snap.consumed()).isEqualTo(13); + } + + @Test + void rateLimitSnapshotNotClearedByResponseWithoutHeaders() { + // First call sets a snapshot; second call returns no headers; snapshot must remain + // populated (vs flickering to null). + HttpHeaders withRl = TestHttpClients.headersOf(Map.of("x-api-ratelimit-limit", "500")); + HttpHeaders empty = HttpHeaders.of(Map.of(), (a, b) -> true); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), withRl); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + RateLimitSnapshot before = transport.getLatestRateLimits(); + assertThat(before).isNotNull(); + + client.nextHeaders = empty; + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(transport.getLatestRateLimits()).isSameAs(before); + } + + // ---------- sync bridge ---------- + + @Test + void executeSyncReturnsEnvelopeOnSuccess() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = transport.executeSync(RequestSpec.get("markets/status").build()); + + assertThat(env.statusCode()).isEqualTo(200); + } + + @Test + void executeSyncUnwrapsCompletionExceptionToCause() { + CapturingClient client = + new CapturingClient(500, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("markets/status").build())) + .isInstanceOf(ServerError.class); // not CompletionException, not wrapped + } + + // ---------- stub HttpClient ---------- + + /** + * Captures every {@link HttpRequest} that flows through {@code sendAsync} and replies with a + * canned {@link HttpResponse}. Tests can mutate {@code nextHeaders}/{@code nextBody}/{@code + * nextStatus} between calls to drive different responses across requests. + */ + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + int nextStatus; + byte[] nextBody; + HttpHeaders nextHeaders; + + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.nextStatus = status; + this.nextBody = body; + this.nextHeaders = headers; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response( + nextStatus, nextBody, nextHeaders, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java new file mode 100644 index 0000000..4bfb12a --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java @@ -0,0 +1,144 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpHeaders; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +class RateLimitHeadersTest { + + /** + * Builds an immutable {@link HttpHeaders} from a flat key→value map. The JDK only exposes + * builders via {@link java.net.http.HttpClient}; this is the canonical workaround using {@link + * HttpHeaders#of}. + */ + private static HttpHeaders headersOf(Map entries) { + Map> multi = new TreeMap<>(); + entries.forEach((k, v) -> multi.put(k, List.of(v))); + return HttpHeaders.of(multi, (a, b) -> true); + } + + // ---------- happy path ---------- + + @Test + void parsesAllFourHeaders() { + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(1000); + assertThat(rl.remaining()).isEqualTo(987); + assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1714867200L)); + assertThat(rl.consumed()).isEqualTo(13); + } + + // ---------- the all-null short-circuit ---------- + + @Test + void returnsNullWhenNoRateLimitHeadersPresent() { + HttpHeaders headers = headersOf(Map.of("content-type", "application/json")); + + assertThat(RateLimitHeaders.parse(headers)).isNull(); + } + + // ---------- partial headers ---------- + + @Test + void onlyLimitPresentZerosTheOthers() { + HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", "500")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(500); + assertThat(rl.remaining()).isZero(); + assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(0L)); + assertThat(rl.consumed()).isZero(); + } + + @Test + void onlyConsumedPresentZerosTheOthers() { + HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-consumed", "42")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.consumed()).isEqualTo(42); + assertThat(rl.limit()).isZero(); + } + + @Test + void onlyRemainingPresent() { + HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-remaining", "1234")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.remaining()).isEqualTo(1234); + assertThat(rl.limit()).isZero(); + } + + @Test + void onlyResetPresent() { + HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-reset", "1735689600")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1735689600L)); + assertThat(rl.limit()).isZero(); + assertThat(rl.remaining()).isZero(); + assertThat(rl.consumed()).isZero(); + } + + // ---------- malformed values ---------- + + @Test + void malformedNumberIsTreatedAsAbsent() { + // readLong's catch(NumberFormatException) returns null; the header is then treated as + // missing. With every header malformed the result must be null, same as none-present. + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", "not-a-number", + "x-api-ratelimit-remaining", "also-broken")); + + assertThat(RateLimitHeaders.parse(headers)).isNull(); + } + + @Test + void valuesAreTrimmedBeforeParsing() { + HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", " 1000 ")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(1000); + } + + @Test + void parseIgnoresNonRateLimitHeaders() { + HttpHeaders headers = + headersOf( + Map.of( + "cf-ray", "abc", + "content-type", "application/json", + "x-api-ratelimit-limit", "100")); + + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(100); + } +} diff --git a/src/test/java/com/marketdata/sdk/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java new file mode 100644 index 0000000..e0e5643 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RequestSpecTest.java @@ -0,0 +1,139 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RequestSpecTest { + + @Test + void buildPreservesPathAndOmitsNullQueryParams() { + // Covers both branches of `if (value != null)` in Builder.query. + RequestSpec spec = + RequestSpec.get("markets/status") + .query("date", "2024-05-01") + .query("ignored", null) + .query("from", "2024-01-01") + .build(); + + assertThat(spec.path()).isEqualTo("markets/status"); + assertThat(spec.queryParams()) + .containsExactly( + java.util.Map.entry("date", "2024-05-01"), java.util.Map.entry("from", "2024-01-01")); + assertThat(spec.queryParams()).doesNotContainKey("ignored"); + } + + @Test + void buildWithNoQueryParamsProducesEmptyMap() { + RequestSpec spec = RequestSpec.get("markets/status").build(); + + assertThat(spec.path()).isEqualTo("markets/status"); + assertThat(spec.queryParams()).isEmpty(); + } + + @Test + void queryParamsAreImmutable() { + RequestSpec spec = RequestSpec.get("markets/status").query("date", "2024-05-01").build(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> spec.queryParams().put("hacked", "value")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void queryConvertsNonStringValuesViaToString() { + RequestSpec spec = + RequestSpec.get("markets/candles") + .query("countback", 5) + .query("limit", Long.valueOf(100L)) + .build(); + + assertThat(spec.queryParams()).containsEntry("countback", "5").containsEntry("limit", "100"); + } + + // ---------- universal params ---------- + + @Test + void defaultFormatIsJsonAndNotWrittenToQuery() { + // No-op default: an explicit `?format=json` is redundant and adds query noise. + RequestSpec spec = RequestSpec.get("markets/status").build(); + assertThat(spec.format()).isEqualTo(Format.JSON); + assertThat(spec.queryParams()).doesNotContainKey("format"); + } + + @Test + void formatSetterWritesQueryParamAndUpdatesField() { + RequestSpec spec = RequestSpec.get("stocks/candles").format(Format.CSV).build(); + assertThat(spec.format()).isEqualTo(Format.CSV); + assertThat(spec.queryParams()).containsEntry("format", "csv"); + } + + @Test + void htmlFormatWiresThroughEvenThoughItIsNotUserVisible() { + // Format.HTML is package-private — no SDK consumer can reference it — but the transport + // pipeline must accept it end-to-end so the day the server lights up HTML responses, the + // only change is exposing a `...AsHtml()` method on the relevant resource. + RequestSpec spec = RequestSpec.get("stocks/candles").format(Format.HTML).build(); + assertThat(spec.format()).isEqualTo(Format.HTML); + assertThat(spec.queryParams()).containsEntry("format", "html"); + assertThat(Format.HTML.mediaType()).isEqualTo("text/html"); + } + + @Test + void dateformatWritesQueryParam() { + RequestSpec spec = RequestSpec.get("stocks/candles").dateformat(DateFormat.SPREADSHEET).build(); + assertThat(spec.queryParams()).containsEntry("dateformat", "spreadsheet"); + } + + @Test + void modeWritesQueryParam() { + RequestSpec spec = RequestSpec.get("stocks/quotes").mode(Mode.DELAYED).build(); + assertThat(spec.queryParams()).containsEntry("mode", "delayed"); + } + + @Test + void headersAndHumanWriteBooleansAsStrings() { + RequestSpec spec = RequestSpec.get("stocks/candles").headers(false).human(true).build(); + assertThat(spec.queryParams()).containsEntry("headers", "false").containsEntry("human", "true"); + } + + @Test + void columnsCommaJoinsTheList() { + RequestSpec spec = + RequestSpec.get("stocks/quotes").columns(List.of("symbol", "last", "volume")).build(); + assertThat(spec.queryParams()).containsEntry("columns", "symbol,last,volume"); + } + + @Test + void columnsEmptyListIsNoOp() { + // Sending `?columns=` (empty value) would risk the server interpreting it as "no columns" + // rather than "all columns". Easier to omit. + RequestSpec spec = RequestSpec.get("stocks/quotes").columns(List.of()).build(); + assertThat(spec.queryParams()).doesNotContainKey("columns"); + } + + @Test + void limitAndOffsetWriteInts() { + RequestSpec spec = RequestSpec.get("stocks/news").limit(50).offset(100).build(); + assertThat(spec.queryParams()).containsEntry("limit", "50").containsEntry("offset", "100"); + } + + @Test + void universalParamsAccumulateAlongsideArbitraryQueryParams() { + // The universal-setter API does not replace `.query(...)` — both coexist, ordered by + // insertion. + RequestSpec spec = + RequestSpec.get("stocks/candles") + .query("symbol", "AAPL") + .format(Format.CSV) + .dateformat(DateFormat.UNIX) + .build(); + + assertThat(spec.queryParams()) + .containsExactly( + java.util.Map.entry("symbol", "AAPL"), + java.util.Map.entry("format", "csv"), + java.util.Map.entry("dateformat", "unix")); + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java new file mode 100644 index 0000000..bb444af --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -0,0 +1,189 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.ServerError; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RetryExecutorTest { + + // Sub-millisecond backoffs so tests don't wait on real wall-clock. + private static final RetryPolicy FAST_RETRY = + new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(2)); + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + + private static ErrorContext ctx() { + return ErrorContext.forNoResponse("https://example/u", Instant.EPOCH); + } + + private static NetworkError retriableNet() { + return new NetworkError("net", ctx(), new IOException("transport down")); + } + + private static ServerError retriable5xx() { + return new ServerError( + "503", ErrorContext.forResponse("https://example/u", 503, null, Instant.EPOCH)); + } + + private static ServerError nonRetriable500() { + return new ServerError( + "500", ErrorContext.forResponse("https://example/u", 500, null, Instant.EPOCH)); + } + + // ---------- success on first attempt ---------- + + @Test + void firstAttemptSucceedsNoRetry() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + String result = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.completedFuture("ok"); + }) + .join(); + + assertThat(result).isEqualTo("ok"); + assertThat(calls).hasValue(1); + } + + // ---------- retries until success ---------- + + @Test + void retriesUntilSuccess() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + String result = + exec.execute( + () -> { + int n = calls.incrementAndGet(); + if (n < 3) { + return CompletableFuture.failedFuture(retriableNet()); + } + return CompletableFuture.completedFuture("ok"); + }) + .join(); + + assertThat(result).isEqualTo("ok"); + assertThat(calls).hasValue(3); + } + + // ---------- exhausts attempts ---------- + + @Test + void exhaustsAttemptsAndSurfacesLastCause() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriable5xx()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + assertThat(calls).hasValue(4); // 1 initial + 3 retries + } + + // ---------- non-retriable surfaces immediately ---------- + + @Test + void nonRetriableCauseStopsImmediately() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(nonRetriable500()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + assertThat(calls).hasValue(1); + } + + // ---------- cancellation propagation ---------- + + @Test + void cancelOfResultCancelsInFlightAttempt() { + AtomicReference> handle = new AtomicReference<>(); + RetryExecutor exec = new RetryExecutor(NO_RETRY); + + CompletableFuture result = + exec.execute( + () -> { + CompletableFuture f = new CompletableFuture<>(); + handle.set(f); + return f; + }); + + assertThat(handle.get()).isNotNull(); + result.cancel(false); + + assertThat(handle.get()).isCancelled(); + } + + @Test + void cancelDuringBackoffPreventsNextAttempt() throws Exception { + AtomicInteger calls = new AtomicInteger(); + // Slow enough backoff that we can cancel between attempts; the wall-clock cost is bounded + // by the cancel firing before 50 ms elapses. + RetryPolicy slow = new RetryPolicy(4, Duration.ofMillis(50), Duration.ofMillis(50)); + RetryExecutor exec = new RetryExecutor(slow); + + CompletableFuture result = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); + + // Wait until the first attempt has fired and we're presumably in the backoff window. + Thread.sleep(10); + result.cancel(false); + + // Give the (cancelled) scheduler a window to NOT fire a second attempt. + Thread.sleep(150); + + assertThat(result).isCancelled(); + assertThat(calls.get()).as("should not have started a second attempt").isOne(); + } + + // ---------- result shape ---------- + + @Test + void resultFutureCarriesCancellationException() { + RetryExecutor exec = new RetryExecutor(NO_RETRY); + + CompletableFuture result = + exec.execute(CompletableFuture::new); // never-completing supplier + + result.cancel(false); + + assertThatThrownBy(result::join).isInstanceOf(CancellationException.class); + } +} diff --git a/src/test/java/com/marketdata/sdk/TestHttpClients.java b/src/test/java/com/marketdata/sdk/TestHttpClients.java new file mode 100644 index 0000000..5f8002c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/TestHttpClients.java @@ -0,0 +1,205 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +/** + * Shared {@link HttpClient} stubs used across transport-layer tests. The JDK only ships abstract + * implementations of {@code HttpClient}; subclassing it forces stubs for ~12 methods we don't use, + * so we centralize the noise here. + */ +final class TestHttpClients { + + private TestHttpClients() {} + + /** {@code HttpHeaders} from a flat key→value map. */ + static HttpHeaders headersOf(Map entries) { + Map> multi = new TreeMap<>(); + entries.forEach((k, v) -> multi.put(k, List.of(v))); + return HttpHeaders.of(multi, (a, b) -> true); + } + + /** A canned successful {@link HttpResponse} with the given status, body, and headers. */ + static HttpResponse response(int status, byte[] body, HttpHeaders headers, URI uri) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return HttpRequest.newBuilder(uri).build(); + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public byte[] body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return uri; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_2; + } + }; + } + + /** Bare-bones {@link HttpClient} with the abstract surface stubbed. */ + abstract static class StubHttpClient extends HttpClient { + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + throw new UnsupportedOperationException(); + } + + @Override + public SSLParameters sslParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler bh) + throws IOException, InterruptedException { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler bh, + HttpResponse.PushPromiseHandler ph) { + throw new UnsupportedOperationException(); + } + + @Override + public WebSocket.Builder newWebSocketBuilder() { + throw new UnsupportedOperationException(); + } + } + + /** Always throws {@link IllegalArgumentException} synchronously from {@code sendAsync}. */ + static final class SyncThrowing extends StubHttpClient { + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + throw new IllegalArgumentException("simulated synchronous throw from sendAsync"); + } + } + + /** Throws an {@link Error} (e.g. simulated OOM) synchronously. */ + static final class ErrorThrowing extends StubHttpClient { + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + throw new OutOfMemoryError("simulated synchronous Error from sendAsync"); + } + } + + /** Returns fresh pending futures from {@code sendAsync}; the test controls completion. */ + static final class Controllable extends StubHttpClient { + private final List>> pending = new ArrayList<>(); + + @SuppressWarnings("unchecked") + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + CompletableFuture> f = new CompletableFuture<>(); + pending.add((CompletableFuture>) (CompletableFuture) f); + return f; + } + + int pendingCount() { + return pending.size(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + void completeAll(HttpResponse response) { + // Completing a future can trigger downstream callbacks that re-enter sendAsync (e.g. a + // queued waiter receiving its permit and dispatching), which would CME on `pending`. + // Snapshot first; any sends that happen as a side effect get scheduled into the next call. + List>> snapshot = new ArrayList<>(pending); + for (CompletableFuture f : snapshot) { + f.complete(response); + } + } + + void failAll(Throwable t) { + List>> snapshot = new ArrayList<>(pending); + for (CompletableFuture> f : snapshot) { + f.completeExceptionally(t); + } + } + } +} From 422c87eee18c506c783ebdf4b75a681143d0a454 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 11:25:09 -0300 Subject: [PATCH 11/57] wire transport to MarketDataClient --- .../com/marketdata/sdk/MarketDataClient.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 98449cb..1c9ec01 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -7,7 +7,7 @@ public final class MarketDataClient implements AutoCloseable { private final Configuration config; - private volatile RateLimitSnapshot rateLimits; + private final HttpTransport transport; public MarketDataClient() { this(null, null, null, true); @@ -37,18 +37,30 @@ public MarketDataClient( Path dotEnvPath, Runnable startupValidator) { this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); - this.rateLimits = RateLimitSnapshot.EMPTY; + this.transport = + new HttpTransport( + config.baseUrl(), + config.apiVersion(), + "marketdata-sdk-java/" + Version.sdkVersion(), + config.apiKey()); if (validateOnStartup) { startupValidator.run(); } } + /** + * Latest rate-limit snapshot recorded from any successful response. Returns {@link + * RateLimitSnapshot#EMPTY} until the first rate-limit-bearing response has arrived; never null. + */ public RateLimitSnapshot getRateLimits() { - return rateLimits; + RateLimitSnapshot latest = transport.getLatestRateLimits(); + return latest != null ? latest : RateLimitSnapshot.EMPTY; } @Override - public void close() {} + public void close() { + transport.close(); + } @Override public String toString() { From e5671d15f6d9af29c8382775f3815524035995a3 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 15:29:15 -0300 Subject: [PATCH 12/57] unversioned added --- .../com/marketdata/sdk/HttpTransport.java | 6 ++++- .../java/com/marketdata/sdk/RequestSpec.java | 24 +++++++++++++++---- .../com/marketdata/sdk/HttpTransportTest.java | 13 ++++++++++ .../com/marketdata/sdk/RequestSpecTest.java | 17 +++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 36418e1..dca7efe 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -179,7 +179,11 @@ private URI buildUri(RequestSpec spec) { path = path.substring(1); } StringBuilder sb = new StringBuilder(); - sb.append(baseUrl).append('/').append(apiVersion).append('/').append(path); + sb.append(baseUrl).append('/'); + if (spec.versioned()) { + sb.append(apiVersion).append('/'); + } + sb.append(path); if (!path.endsWith("/")) { sb.append('/'); } diff --git a/src/main/java/com/marketdata/sdk/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java index 0cf56c0..45daef8 100644 --- a/src/main/java/com/marketdata/sdk/RequestSpec.java +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -18,15 +18,19 @@ * resources don't reach for {@link Builder#query} for the common cross-cutting cases. * * @param path API-relative path with no leading {@code /v1/} prefix and no trailing slash, e.g. - * {@code "markets/status"}. The transport adds the base URL, version prefix, and trailing - * slash. + * {@code "markets/status"}. The transport adds the base URL, version prefix (when {@link + * #versioned} is true), and trailing slash. * @param queryParams ordered query parameters (insertion order preserved for predictable URLs in * tests). Values are URL-encoded by the transport. * @param format wire response format. The transport mirrors this in the {@code Accept} request * header; the {@code ?format=} query param is also written into {@code queryParams} by the * builder when set. + * @param versioned when true, the transport interpolates the API version segment between base URL + * and path (the default, used by every {@code /v1/...} endpoint); when false, the path is + * appended directly to the base URL. The handful of system endpoints documented at the API root + * — {@code /status/}, {@code /headers/} — opt into the unversioned form. */ -record RequestSpec(String path, Map queryParams, Format format) { +record RequestSpec(String path, Map queryParams, Format format, boolean versioned) { static final Format DEFAULT_FORMAT = Format.JSON; @@ -45,6 +49,7 @@ static final class Builder { private final String path; private final Map queryParams = new LinkedHashMap<>(); private Format format = DEFAULT_FORMAT; + private boolean versioned = true; private Builder(String path) { this.path = path; @@ -58,6 +63,17 @@ Builder query(String key, Object value) { return this; } + /** + * Marks this request as targeting the unversioned root of the API ({@code + * https://api.marketdata.app/path/}), rather than the default {@code + * https://api.marketdata.app/v1/path/}. Only a handful of system endpoints — {@code /status/} + * and {@code /headers/} — live there. + */ + Builder unversioned() { + this.versioned = false; + return this; + } + /** Sets the wire response format ({@code ?format=}) and the matching Accept header. */ Builder format(Format format) { this.format = format; @@ -112,7 +128,7 @@ Builder offset(int offset) { RequestSpec build() { // Pass the raw LinkedHashMap — the record's compact constructor defensively copies and // wraps it as unmodifiable, so wrapping here too would just rebuild a redundant view. - return new RequestSpec(path, queryParams, format); + return new RequestSpec(path, queryParams, format, versioned); } } } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index cea9000..e736d62 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -91,6 +91,19 @@ void noAuthorizationHeaderWhenTokenIsNull() { assertThat(client.captured.get(0).headers().firstValue("Authorization")).isEmpty(); } + @Test + void unversionedSpecOmitsTheVersionSegment() { + // /status/ and /headers/ are documented at the API root, not under /v1/. The transport must + // honor the spec's unversioned flag so those system endpoints reach the right URL. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("status").unversioned().build()).join(); + + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/status/"); + } + @Test void leadingSlashInPathIsStripped() { CapturingClient client = diff --git a/src/test/java/com/marketdata/sdk/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java index e0e5643..2397706 100644 --- a/src/test/java/com/marketdata/sdk/RequestSpecTest.java +++ b/src/test/java/com/marketdata/sdk/RequestSpecTest.java @@ -119,6 +119,23 @@ void limitAndOffsetWriteInts() { assertThat(spec.queryParams()).containsEntry("limit", "50").containsEntry("offset", "100"); } + // ---------- versioned / unversioned ---------- + + @Test + void specsAreVersionedByDefault() { + // Every business endpoint lives under /v1/, so the default has to be the common case. + RequestSpec spec = RequestSpec.get("markets/status").build(); + assertThat(spec.versioned()).isTrue(); + } + + @Test + void unversionedFlipsThePrefixOff() { + // /status/ and /headers/ live at the API root, not under /v1/. + RequestSpec spec = RequestSpec.get("status").unversioned().build(); + assertThat(spec.versioned()).isFalse(); + assertThat(spec.path()).isEqualTo("status"); + } + @Test void universalParamsAccumulateAlongsideArbitraryQueryParams() { // The universal-setter API does not replace `.query(...)` — both coexist, ordered by From 5b9104b21a566c5ba96406e2279b26b06ed77e89 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 15:39:18 -0300 Subject: [PATCH 13/57] Utilities added --- .../marketdata/sdk/JsonResponseParser.java | 50 +++++++ .../com/marketdata/sdk/MarketDataClient.java | 8 ++ .../sdk/RequestHeadersDeserializer.java | 27 ++++ .../com/marketdata/sdk/UtilitiesResource.java | 49 +++++++ .../sdk/utilities/RequestHeaders.java | 20 +++ .../sdk/utilities/package-info.java | 7 + .../sdk/JsonResponseParserTest.java | 70 ++++++++++ .../marketdata/sdk/UtilitiesResourceTest.java | 125 ++++++++++++++++++ 8 files changed, 356 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/JsonResponseParser.java create mode 100644 src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/UtilitiesResource.java create mode 100644 src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java create mode 100644 src/main/java/com/marketdata/sdk/utilities/package-info.java create mode 100644 src/test/java/com/marketdata/sdk/JsonResponseParserTest.java create mode 100644 src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java new file mode 100644 index 0000000..7db57b0 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -0,0 +1,50 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.io.IOException; +import java.time.Instant; + +/** + * Decodes {@link HttpResponseEnvelope} bodies into typed records. + * + *

Owns one {@link ObjectMapper} per {@link MarketDataClient} (Jackson mappers are thread-safe + * and expensive to construct, so we build and reuse). Per ADR-007, wire-format deserializers are + * registered programmatically on a package-private {@link SimpleModule} here — response records + * never carry {@code @JsonDeserialize} annotations. + * + *

Resources that need raw bytes (CSV, HTML) skip this class entirely and read {@link + * HttpResponseEnvelope#body()} directly. + */ +final class JsonResponseParser { + + private final ObjectMapper mapper; + + JsonResponseParser() { + ObjectMapper m = new ObjectMapper(); + SimpleModule wireModule = new SimpleModule("marketdata-wire"); + wireModule.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); + m.registerModule(wireModule); + this.mapper = m; + } + + /** + * Decode an envelope's body into the requested type. Throws {@link ParseError} when Jackson + * cannot read the body — the error context carries the envelope's url, status, and request id for + * the consumer's diagnostics. + */ + T parse(HttpResponseEnvelope env, Class type) { + try { + return mapper.readValue(env.body(), type); + } catch (IOException e) { + ErrorContext context = + ErrorContext.forResponse( + env.url().toString(), env.statusCode(), env.requestId(), Instant.now()); + throw new ParseError( + "Failed to decode response from " + env.url() + ": " + e.getMessage(), context, e); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 1c9ec01..34b4cb7 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -8,6 +8,7 @@ public final class MarketDataClient implements AutoCloseable { private final Configuration config; private final HttpTransport transport; + private final UtilitiesResource utilities; public MarketDataClient() { this(null, null, null, true); @@ -43,11 +44,18 @@ public MarketDataClient( config.apiVersion(), "marketdata-sdk-java/" + Version.sdkVersion(), config.apiKey()); + JsonResponseParser parser = new JsonResponseParser(); + this.utilities = new UtilitiesResource(transport, parser); if (validateOnStartup) { startupValidator.run(); } } + /** System endpoints documented at the API root: {@code /headers/} (and more to come). */ + public UtilitiesResource utilities() { + return utilities; + } + /** * Latest rate-limit snapshot recorded from any successful response. Returns {@link * RateLimitSnapshot#EMPTY} until the first rate-limit-bearing response has arrived; never null. diff --git a/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java new file mode 100644 index 0000000..011207d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.io.IOException; +import java.util.Map; + +/** + * Wire-format deserializer for {@link RequestHeaders}. The server returns a flat JSON object — + * {@code {"accept":"*\/*","cf-ray":"abc-123",...}} — and we wrap it in a {@code RequestHeaders} + * record at the Jackson layer rather than via an annotation on the record (per ADR-007: response + * records don't carry {@code @JsonDeserialize}; deserializers register programmatically on the + * parser's {@code ObjectMapper}). + */ +final class RequestHeadersDeserializer extends JsonDeserializer { + + private static final TypeReference> MAP_OF_STRINGS = new TypeReference<>() {}; + + @Override + public RequestHeaders deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + Map raw = p.readValueAs(MAP_OF_STRINGS); + return new RequestHeaders(raw); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java new file mode 100644 index 0000000..6192f63 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -0,0 +1,49 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * System endpoints documented at {@code https://api.marketdata.app/docs/api/utilities/}. None of + * them are versioned ({@code /v1/}); they live at the API root. + * + *

Constructed once per {@link MarketDataClient}; the consumer reaches it through {@code + * client.utilities()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. + */ +public final class UtilitiesResource { + + private final HttpTransport transport; + private final JsonResponseParser parser; + + UtilitiesResource(HttpTransport transport, JsonResponseParser parser) { + this.transport = transport; + this.parser = parser; + } + + /** + * Async: fetch the request headers the server received for this call, with sensitive values (e.g. + * {@code Authorization}) redacted server-side. Useful for diagnosing auth issues from a deployed + * consumer. + */ + public CompletableFuture headersAsync() { + RequestSpec spec = RequestSpec.get("headers").unversioned().build(); + return transport.executeAsync(spec).thenApply(env -> parser.parse(env, RequestHeaders.class)); + } + + /** + * Sync wrapper for {@link #headersAsync()}. Per ADR-006, calls {@code .join()} and unwraps {@link + * CompletionException} so the caller sees the underlying {@link MarketDataException} directly. + */ + public RequestHeaders headers() { + try { + return headersAsync().join(); + } catch (CompletionException e) { + throw HttpTransport.asRuntime(e.getCause()); + } catch (CancellationException e) { + throw HttpTransport.asRuntime(e); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java new file mode 100644 index 0000000..4907df1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java @@ -0,0 +1,20 @@ +package com.marketdata.sdk.utilities; + +import java.util.Map; + +/** + * Response shape for {@code GET /headers/} — the request headers echoed back by the server (with + * sensitive values like {@code Authorization} redacted server-side). + * + *

Used for diagnosing auth and routing issues; the SDK does not interpret the contents, it just + * surfaces them. + * + * @param headers all headers the server received, lower-cased keys to values. The map is + * defensively copied and immutable. + */ +public record RequestHeaders(Map headers) { + + public RequestHeaders { + headers = Map.copyOf(headers); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/package-info.java b/src/main/java/com/marketdata/sdk/utilities/package-info.java new file mode 100644 index 0000000..986e54d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records for the {@code utilities} resource — {@link + * com.marketdata.sdk.utilities.RequestHeaders} for the {@code /headers/} endpoint, with more to + * land as the resource grows. + */ +@org.jspecify.annotations.NullMarked +package com.marketdata.sdk.utilities; diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java new file mode 100644 index 0000000..9de35c5 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -0,0 +1,70 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonResponseParserTest { + + private static HttpResponseEnvelope env(String body) { + return new HttpResponseEnvelope( + body.getBytes(), + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/headers/")); + } + + @Test + void parsesRequestHeadersFromFlatJsonObject() { + JsonResponseParser parser = new JsonResponseParser(); + + RequestHeaders rh = + parser.parse( + env("{\"accept\":\"*/*\",\"cf-ray\":\"abc-123\",\"user-agent\":\"java/0\"}"), + RequestHeaders.class); + + assertThat(rh.headers()) + .containsEntry("accept", "*/*") + .containsEntry("cf-ray", "abc-123") + .containsEntry("user-agent", "java/0"); + } + + @Test + void emptyJsonObjectProducesEmptyHeaders() { + JsonResponseParser parser = new JsonResponseParser(); + RequestHeaders rh = parser.parse(env("{}"), RequestHeaders.class); + assertThat(rh.headers()).isEmpty(); + } + + @Test + void requestHeadersMapIsImmutable() { + JsonResponseParser parser = new JsonResponseParser(); + RequestHeaders rh = parser.parse(env("{\"a\":\"1\"}"), RequestHeaders.class); + + assertThatThrownBy(() -> rh.headers().put("hacked", "value")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void malformedJsonRaisesParseErrorCarryingResponseContext() { + JsonResponseParser parser = new JsonResponseParser(); + + assertThatThrownBy(() -> parser.parse(env("{not-json"), RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .satisfies( + t -> { + ParseError err = (ParseError) t; + assertThat(err.getRequestUrl()).isEqualTo("http://localhost/headers/"); + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + assertThat(err.getCause()).isNotNull(); + }); + } +} diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java new file mode 100644 index 0000000..79c7d79 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -0,0 +1,125 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class UtilitiesResourceTest { + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + + /** Mints a fresh transport + resource pair against the given canned HTTP client. */ + private static UtilitiesResource resourceWith(HttpClient client) { + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY)); + return new UtilitiesResource(transport, new JsonResponseParser()); + } + + // ---------- URL & verb ---------- + + @Test + void headersHitsTheUnversionedRootEndpoint() { + CapturingClient client = + new CapturingClient(200, "{}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.headersAsync().join(); + + HttpRequest sent = client.captured.get(0); + // No /v1/ prefix — /headers/ is documented at the API root. + assertThat(sent.uri().toString()).isEqualTo("http://localhost/headers/"); + assertThat(sent.method()).isEqualTo("GET"); + } + + // ---------- response decoding ---------- + + @Test + void headersAsyncReturnsDecodedRecord() { + String body = + "{\"accept\":\"*/*\",\"authorization\":\"Bearer ***REDACTED***\"," + + "\"cf-ray\":\"abc-123-xyz\"}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + RequestHeaders rh = utilities.headersAsync().join(); + + assertThat(rh.headers()) + .containsEntry("accept", "*/*") + .containsEntry("authorization", "Bearer ***REDACTED***") + .containsEntry("cf-ray", "abc-123-xyz"); + } + + @Test + void headersSyncMirrorsHeadersAsync() { + CapturingClient client = + new CapturingClient( + 200, "{\"x\":\"1\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + RequestHeaders rh = utilities.headers(); + + assertThat(rh.headers()).containsEntry("x", "1"); + } + + // ---------- error surfacing through sync ---------- + + /** + * Per ADR-006: sync wrappers must unwrap {@code CompletionException} so consumers catch the + * underlying {@link com.marketdata.sdk.exception.MarketDataException} subtype directly. A 401 + * from the server must reach the caller as {@link AuthenticationError}, not wrapped. + */ + @Test + void headersSyncUnwrapsAuthenticationFailureFromCompletionException() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::headers).isInstanceOf(AuthenticationError.class); + } + + // ---------- stub HttpClient ---------- + + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final int status; + final byte[] body; + final HttpHeaders headers; + + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.status = status; + this.body = body; + this.headers = headers; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response(status, body, headers, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } +} From 5b620a6c706a42cd006250ee2ba9c97bb11506ad Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 15:45:09 -0300 Subject: [PATCH 14/57] add Utilities.user() --- .../marketdata/sdk/JsonResponseParser.java | 2 + .../com/marketdata/sdk/UserDeserializer.java | 34 +++++++++ .../com/marketdata/sdk/UtilitiesResource.java | 25 +++++++ .../com/marketdata/sdk/utilities/User.java | 16 +++++ .../sdk/JsonResponseParserTest.java | 50 ++++++++++++++ .../marketdata/sdk/UtilitiesResourceTest.java | 69 +++++++++++++++++++ 6 files changed, 196 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/UserDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/utilities/User.java diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 7db57b0..3896ab6 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -5,6 +5,7 @@ import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.User; import java.io.IOException; import java.time.Instant; @@ -27,6 +28,7 @@ final class JsonResponseParser { ObjectMapper m = new ObjectMapper(); SimpleModule wireModule = new SimpleModule("marketdata-wire"); wireModule.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); + wireModule.addDeserializer(User.class, new UserDeserializer()); m.registerModule(wireModule); this.mapper = m; } diff --git a/src/main/java/com/marketdata/sdk/UserDeserializer.java b/src/main/java/com/marketdata/sdk/UserDeserializer.java new file mode 100644 index 0000000..3f08547 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UserDeserializer.java @@ -0,0 +1,34 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.utilities.User; +import java.io.IOException; + +/** + * Wire-format deserializer for {@link User}. The server uses HTTP-header-styled keys in the JSON + * body ({@code "x-ratelimit-requests-remaining"} etc.); this class maps them to the record's + * camelCase fields here rather than via {@code @JsonProperty} on the record, keeping all wire + * coupling out of the public response type (ADR-007). + * + *

Missing fields default leniently — {@code 0} for ints, empty string for {@code + * optionsDataPermissions}. The server always sends all three keys today, so a missing one is either + * a backend regression or a partial response we'd rather not blow up on. + */ +final class UserDeserializer extends JsonDeserializer { + + private static final String REMAINING_KEY = "x-ratelimit-requests-remaining"; + private static final String LIMIT_KEY = "x-ratelimit-requests-limit"; + private static final String OPTIONS_PERMS_KEY = "x-options-data-permissions"; + + @Override + public User deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + int remaining = root.path(REMAINING_KEY).asInt(0); + int limit = root.path(LIMIT_KEY).asInt(0); + String optionsPerms = root.path(OPTIONS_PERMS_KEY).asText(""); + return new User(remaining, limit, optionsPerms); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 6192f63..307ce22 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -2,6 +2,7 @@ import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.User; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -46,4 +47,28 @@ public RequestHeaders headers() { throw HttpTransport.asRuntime(e); } } + + /** + * Async: fetch the caller's current quota state and data-tier permissions. Returns a 401 (as + * {@link com.marketdata.sdk.exception.AuthenticationError}) when no billing plan is associated + * with the token — the typical use case for {@code validateOnStartup}. + */ + public CompletableFuture userAsync() { + RequestSpec spec = RequestSpec.get("user").build(); + return transport.executeAsync(spec).thenApply(env -> parser.parse(env, User.class)); + } + + /** + * Sync wrapper for {@link #userAsync()}; same {@link CompletionException}-unwrapping semantics as + * {@link #headers()}. + */ + public User user() { + try { + return userAsync().join(); + } catch (CompletionException e) { + throw HttpTransport.asRuntime(e.getCause()); + } catch (CancellationException e) { + throw HttpTransport.asRuntime(e); + } + } } diff --git a/src/main/java/com/marketdata/sdk/utilities/User.java b/src/main/java/com/marketdata/sdk/utilities/User.java new file mode 100644 index 0000000..a2f396a --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/User.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.utilities; + +/** + * Response shape for {@code GET /v1/user/} — the caller's current quota and data-tier permissions. + * + *

The numeric fields duplicate information that arrives on every response via the {@code + * x-api-ratelimit-*} headers (see {@link com.marketdata.sdk.RateLimitSnapshot}); the dedicated + * endpoint is mostly useful for a quota check that doesn't consume a request against the more + * expensive business endpoints. + * + * @param requestsRemaining how many requests the caller can still make in the current quota window. + * @param requestsLimit total requests allowed in the current quota window. + * @param optionsDataPermissions data-tier label for options — empty string for real-time access, + * {@code "OPRA data delayed 15 minutes"} otherwise. + */ +public record User(int requestsRemaining, int requestsLimit, String optionsDataPermissions) {} diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 9de35c5..3603792 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -5,6 +5,7 @@ import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpHeaders; import java.util.Map; @@ -52,6 +53,55 @@ void requestHeadersMapIsImmutable() { .isInstanceOf(UnsupportedOperationException.class); } + // ---------- User: hyphenated wire keys → camelCase record ---------- + + @Test + void parsesUserMappingHyphenatedKeysToCamelCase() { + JsonResponseParser parser = new JsonResponseParser(); + + User u = + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":5421," + + "\"x-ratelimit-requests-limit\":100000," + + "\"x-options-data-permissions\":\"OPRA data delayed 15 minutes\"}"), + User.class); + + assertThat(u.requestsRemaining()).isEqualTo(5421); + assertThat(u.requestsLimit()).isEqualTo(100000); + assertThat(u.optionsDataPermissions()).isEqualTo("OPRA data delayed 15 minutes"); + } + + @Test + void parsesUserWithEmptyOptionsPermissionsAsRealTimeMarker() { + // Empty string is the server's convention for "real-time access"; the SDK preserves it + // verbatim so consumers can detect realTime via `permissions.isEmpty()`. + JsonResponseParser parser = new JsonResponseParser(); + + User u = + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":10," + + "\"x-ratelimit-requests-limit\":10," + + "\"x-options-data-permissions\":\"\"}"), + User.class); + + assertThat(u.optionsDataPermissions()).isEmpty(); + } + + @Test + void missingUserFieldsDefaultLeniently() { + // The server sends all three fields today, but if a future regression drops one, the SDK + // should produce a populated record with defaults rather than blow up parsing. + JsonResponseParser parser = new JsonResponseParser(); + + User u = parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class); + + assertThat(u.requestsRemaining()).isZero(); + assertThat(u.requestsLimit()).isEqualTo(500); + assertThat(u.optionsDataPermissions()).isEmpty(); + } + @Test void malformedJsonRaisesParseErrorCarryingResponseContext() { JsonResponseParser parser = new JsonResponseParser(); diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 79c7d79..432a4c7 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -5,6 +5,7 @@ import com.marketdata.sdk.exception.AuthenticationError; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; @@ -82,6 +83,74 @@ void headersSyncMirrorsHeadersAsync() { assertThat(rh.headers()).containsEntry("x", "1"); } + // ---------- /v1/user/ endpoint ---------- + + @Test + void userHitsVersionedEndpoint() { + // Contrast with /headers/ — /v1/user/ is under the versioned prefix. + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":2," + + "\"x-options-data-permissions\":\"\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.userAsync().join(); + + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/v1/user/"); + } + + @Test + void userAsyncReturnsDecodedRecord() { + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":42,\"x-ratelimit-requests-limit\":100," + + "\"x-options-data-permissions\":\"OPRA data delayed 15 minutes\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + User u = utilities.userAsync().join(); + + assertThat(u.requestsRemaining()).isEqualTo(42); + assertThat(u.requestsLimit()).isEqualTo(100); + assertThat(u.optionsDataPermissions()).isEqualTo("OPRA data delayed 15 minutes"); + } + + @Test + void userSyncMirrorsAsync() { + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":7,\"x-ratelimit-requests-limit\":7," + + "\"x-options-data-permissions\":\"\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + User u = utilities.user(); + + assertThat(u.requestsRemaining()).isEqualTo(7); + } + + /** + * The {@code /v1/user/} endpoint's typical failure mode is "no billing plan" — surfaces as 401. + * The sync method must unwrap it to {@link AuthenticationError} directly so {@code + * validateOnStartup} (when wired) can catch it without digging through {@code + * CompletionException}. + */ + @Test + void user401SurfacesAuthenticationErrorDirectly() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::user).isInstanceOf(AuthenticationError.class); + } + // ---------- error surfacing through sync ---------- /** From 22189f89900cf7d6046e1020f270f281932d995d Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 15:51:23 -0300 Subject: [PATCH 15/57] add validateonstartup --- .../com/marketdata/sdk/MarketDataClient.java | 29 ++++++++++++++++++- .../marketdata/sdk/MarketDataClientTest.java | 5 +++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 34b4cb7..974288a 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -19,14 +19,20 @@ public MarketDataClient( @Nullable String baseUrl, @Nullable String apiVersion, boolean validateOnStartup) { + // Delegate with validateOnStartup=false so the inner ctor's runnable seam stays a no-op on + // this path — we run the real validation below, where `this.utilities` is reachable. + // Tests still drive the runnable seam directly via the 7-arg ctor. this( apiKey, baseUrl, apiVersion, - validateOnStartup, + /* validateOnStartup */ false, EnvVars.systemLookup(), Configuration.DEFAULT_DOTENV_PATH, () -> {}); + if (validateOnStartup) { + runStartupValidation(); + } } MarketDataClient( @@ -56,6 +62,27 @@ public UtilitiesResource utilities() { return utilities; } + /** + * Fire a single call to {@code GET /v1/user/} to confirm the token is accepted and a billing plan + * is attached (SDK requirements §5). A 401 surfaces as {@link + * com.marketdata.sdk.exception.AuthenticationError} directly via the sync wrapper. On any failure + * we close the transport before re-throwing so a partially-constructed client doesn't leak its + * HttpClient — the caller's try-with-resources is never triggered if the constructor itself + * fails. + */ + private void runStartupValidation() { + try { + utilities.user(); + } catch (Throwable t) { + try { + close(); + } catch (Throwable closeFailure) { + t.addSuppressed(closeFailure); + } + throw t; + } + } + /** * Latest rate-limit snapshot recorded from any successful response. Returns {@link * RateLimitSnapshot#EMPTY} until the first rate-limit-bearing response has arrived; never null. diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 41e194a..545213b 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -120,7 +120,10 @@ void close_is_idempotent(@TempDir Path tmp) { @Test void quick_start_usage_resolves_real_environment_and_never_leaks_token() { - try (MarketDataClient client = new MarketDataClient()) { + // The no-arg public ctor now hits /v1/user/ for startup validation (§5). Don't exercise + // that path here — this test asserts config resolution and token redaction, not the live + // call. Use the 4-arg variant with validateOnStartup=false to keep this a pure unit test. + try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); assertThat(client.toString()).startsWith("MarketDataClient[").endsWith("]"); From a1f0c1d336aa3e7363125033c7de2f40b346b3f1 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 15:59:26 -0300 Subject: [PATCH 16/57] add utilities.status() --- .../marketdata/sdk/ApiStatusDeserializer.java | 97 +++++++++++++++++ .../marketdata/sdk/JsonResponseParser.java | 2 + .../com/marketdata/sdk/UtilitiesResource.java | 25 +++++ .../marketdata/sdk/utilities/ApiStatus.java | 22 ++++ .../sdk/utilities/ServiceStatus.java | 26 +++++ .../sdk/JsonResponseParserTest.java | 103 ++++++++++++++++++ .../marketdata/sdk/UtilitiesResourceTest.java | 55 ++++++++++ 7 files changed, 330 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/utilities/ApiStatus.java create mode 100644 src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java diff --git a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java new file mode 100644 index 0000000..95f535a --- /dev/null +++ b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java @@ -0,0 +1,97 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Wire-format deserializer for {@link ApiStatus}. The server uses the API's standard + * parallel-arrays shape: {@code service}, {@code status}, {@code online}, {@code uptimePct30d}, + * {@code uptimePct90d}, {@code updated} are six arrays of equal length. This class zips them into a + * list of {@link ServiceStatus} records so consumers iterate naturally. + * + *

Error cases — {@code s == "error"} payloads, missing arrays, mismatched lengths — bubble up as + * {@link JsonMappingException}, which the parent {@link JsonResponseParser} turns into a {@link + * com.marketdata.sdk.exception.ParseError} with the response context attached. + */ +final class ApiStatusDeserializer extends JsonDeserializer { + + private static final String S = "s"; + private static final String ERRMSG = "errmsg"; + private static final String SERVICE = "service"; + private static final String STATUS = "status"; + private static final String ONLINE = "online"; + private static final String UPTIME_30 = "uptimePct30d"; + private static final String UPTIME_90 = "uptimePct90d"; + private static final String UPDATED = "updated"; + + @Override + public ApiStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + + String s = root.path(S).asText(""); + if ("error".equals(s)) { + String errmsg = root.path(ERRMSG).asText("(no errmsg field)"); + throw new JsonMappingException(p, "API status reported error: " + errmsg); + } + + JsonNode services = requireArray(p, root, SERVICE); + JsonNode statuses = requireArray(p, root, STATUS); + JsonNode onlines = requireArray(p, root, ONLINE); + JsonNode up30 = requireArray(p, root, UPTIME_30); + JsonNode up90 = requireArray(p, root, UPTIME_90); + JsonNode updated = requireArray(p, root, UPDATED); + + int n = services.size(); + if (statuses.size() != n + || onlines.size() != n + || up30.size() != n + || up90.size() != n + || updated.size() != n) { + throw new JsonMappingException( + p, + "API status arrays have mismatched lengths: service=" + + n + + ", status=" + + statuses.size() + + ", online=" + + onlines.size() + + ", uptimePct30d=" + + up30.size() + + ", uptimePct90d=" + + up90.size() + + ", updated=" + + updated.size()); + } + + List rows = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + rows.add( + new ServiceStatus( + services.get(i).asText(""), + statuses.get(i).asText(""), + onlines.get(i).asBoolean(false), + up30.get(i).asDouble(0.0), + up90.get(i).asDouble(0.0), + Instant.ofEpochSecond(updated.get(i).asLong(0L)))); + } + return new ApiStatus(rows); + } + + private static JsonNode requireArray(JsonParser p, JsonNode root, String field) + throws JsonMappingException { + JsonNode node = root.get(field); + if (node == null || !node.isArray()) { + throw new JsonMappingException(p, "API status missing or non-array field: " + field); + } + return node; + } +} diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 3896ab6..fa038da 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; import java.io.IOException; @@ -29,6 +30,7 @@ final class JsonResponseParser { SimpleModule wireModule = new SimpleModule("marketdata-wire"); wireModule.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); wireModule.addDeserializer(User.class, new UserDeserializer()); + wireModule.addDeserializer(ApiStatus.class, new ApiStatusDeserializer()); m.registerModule(wireModule); this.mapper = m; } diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 307ce22..18be3c9 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -1,6 +1,7 @@ package com.marketdata.sdk; import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; import java.util.concurrent.CancellationException; @@ -71,4 +72,28 @@ public User user() { throw HttpTransport.asRuntime(e); } } + + /** + * Async: fetch the per-service health snapshot of the API. Unversioned ({@code /status/} lives at + * the API root) and public — works without a token. The server refreshes the snapshot every five + * minutes; polling more often than that is wasted work. + */ + public CompletableFuture statusAsync() { + RequestSpec spec = RequestSpec.get("status").unversioned().build(); + return transport.executeAsync(spec).thenApply(env -> parser.parse(env, ApiStatus.class)); + } + + /** + * Sync wrapper for {@link #statusAsync()}; same {@link CompletionException}-unwrapping semantics + * as {@link #headers()} and {@link #user()}. + */ + public ApiStatus status() { + try { + return statusAsync().join(); + } catch (CompletionException e) { + throw HttpTransport.asRuntime(e.getCause()); + } catch (CancellationException e) { + throw HttpTransport.asRuntime(e); + } + } } diff --git a/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java b/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java new file mode 100644 index 0000000..051e14e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.utilities; + +import java.util.List; + +/** + * Response shape for {@code GET /status/} — the per-service health snapshot of the Market Data API. + * + *

The wire format is parallel arrays; the SDK zips them into a {@code List} here + * so the abstraction the consumer sees matches the natural "one row per service" model. + * + *

The status data is updated every 5 minutes server-side; clients that poll more frequently than + * that are wasting requests on cached results. + * + * @param services one entry per service the API exposes. Empty when the server has no services to + * report. + */ +public record ApiStatus(List services) { + + public ApiStatus { + services = List.copyOf(services); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java new file mode 100644 index 0000000..2a7e028 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java @@ -0,0 +1,26 @@ +package com.marketdata.sdk.utilities; + +import java.time.Instant; + +/** + * Health of a single API service, as reported by {@code GET /status/}. + * + *

The {@code /status/} endpoint returns the parallel-arrays wire format used across the Market + * Data API; the SDK's deserializer zips those arrays into a record per service so consumers iterate + * naturally instead of indexing into six parallel collections. + * + * @param service service path the API exposes, e.g. {@code "/v1/stocks/quotes/"}. + * @param status status label as a string (today: {@code "online"} or {@code "offline"}; left + * stringly-typed so a future tier the server adds doesn't break this enum). + * @param online convenience boolean parallel to {@link #status} — server-supplied, not derived. + * @param uptimePct30d uptime fraction in the last 30 days, in the range {@code [0.0, 1.0]}. + * @param uptimePct90d uptime fraction in the last 90 days, in the range {@code [0.0, 1.0]}. + * @param updated when this entry was last refreshed server-side. + */ +public record ServiceStatus( + String service, + String status, + boolean online, + double uptimePct30d, + double uptimePct90d, + Instant updated) {} diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 3603792..5a1b78e 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -4,10 +4,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpHeaders; +import java.time.Instant; import java.util.Map; import org.junit.jupiter.api.Test; @@ -102,6 +105,106 @@ void missingUserFieldsDefaultLeniently() { assertThat(u.optionsDataPermissions()).isEmpty(); } + // ---------- ApiStatus: parallel-arrays wire format zipped into List ---------- + + @Test + void parsesApiStatusByZippingParallelArrays() { + // Canonical happy-path payload — six arrays of equal length plus the leading "s":"ok". + String body = + "{" + + "\"s\":\"ok\"," + + "\"service\":[\"/v1/stocks/quotes/\",\"/v1/options/chain/\"]," + + "\"status\":[\"online\",\"offline\"]," + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,0.9961]," + + "\"uptimePct90d\":[0.99828,0.95]," + + "\"updated\":[1734036832,1734036833]" + + "}"; + + ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + + assertThat(status.services()).hasSize(2); + ServiceStatus first = status.services().get(0); + assertThat(first.service()).isEqualTo("/v1/stocks/quotes/"); + assertThat(first.status()).isEqualTo("online"); + assertThat(first.online()).isTrue(); + assertThat(first.uptimePct30d()).isEqualTo(1.0); + assertThat(first.uptimePct90d()).isEqualTo(0.99828); + assertThat(first.updated()).isEqualTo(Instant.ofEpochSecond(1734036832L)); + + ServiceStatus second = status.services().get(1); + assertThat(second.service()).isEqualTo("/v1/options/chain/"); + assertThat(second.online()).isFalse(); + assertThat(second.uptimePct30d()).isEqualTo(0.9961); + } + + @Test + void parsesApiStatusWithEmptyArrays() { + String body = + "{\"s\":\"ok\",\"service\":[],\"status\":[],\"online\":[]," + + "\"uptimePct30d\":[],\"uptimePct90d\":[],\"updated\":[]}"; + + ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + + @Test + void apiStatusServicesListIsImmutable() { + String body = + "{\"s\":\"ok\",\"service\":[\"a\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[0]}"; + ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + + assertThatThrownBy(() -> status.services().add(null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void apiStatusServerSideErrorBecomesParseError() { + // `s: "error"` is the server's soft-error path — the body is valid JSON but doesn't carry + // the usable arrays. Surface as ParseError so it doesn't masquerade as an empty success. + String body = "{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("database connection refused"); + } + + @Test + void apiStatusMismatchedArrayLengthsBecomeParseError() { + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\",\"b\"]," + + "\"status\":[\"online\"]," // 1 vs 2 + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,1.0]," + + "\"uptimePct90d\":[1.0,1.0]," + + "\"updated\":[0,0]}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("mismatched lengths"); + } + + @Test + void apiStatusMissingArrayBecomesParseError() { + // No `online` array — could happen if a backend refactor drops a field; better to fail + // loudly than silently default booleans to false for every row. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\"]," + + "\"status\":[\"online\"]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("online"); + } + @Test void malformedJsonRaisesParseErrorCarryingResponseContext() { JsonResponseParser parser = new JsonResponseParser(); diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 432a4c7..6dee37e 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; import java.net.URI; @@ -151,6 +152,60 @@ void user401SurfacesAuthenticationErrorDirectly() { assertThatThrownBy(utilities::user).isInstanceOf(AuthenticationError.class); } + // ---------- /status/ endpoint ---------- + + @Test + void statusHitsTheUnversionedRootEndpoint() { + String body = + "{\"s\":\"ok\",\"service\":[\"/v1/x/\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1700000000]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.statusAsync().join(); + + // /status/ is at the API root, not under /v1/. + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/status/"); + } + + @Test + void statusAsyncReturnsZippedServiceList() { + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"/v1/a/\",\"/v1/b/\"]," + + "\"status\":[\"online\",\"offline\"]," + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,0.9]," + + "\"uptimePct90d\":[1.0,0.95]," + + "\"updated\":[1700000000,1700000001]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + ApiStatus status = utilities.statusAsync().join(); + + assertThat(status.services()).hasSize(2); + assertThat(status.services().get(0).service()).isEqualTo("/v1/a/"); + assertThat(status.services().get(0).online()).isTrue(); + assertThat(status.services().get(1).service()).isEqualTo("/v1/b/"); + assertThat(status.services().get(1).online()).isFalse(); + } + + @Test + void statusSyncMirrorsAsync() { + String body = + "{\"s\":\"ok\",\"service\":[\"/v1/x/\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1700000000]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + ApiStatus status = utilities.status(); + + assertThat(status.services()).hasSize(1); + } + // ---------- error surfacing through sync ---------- /** From b5eb2baf0282623d1cdf0486057e9ff1e5c8d882 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 16:12:30 -0300 Subject: [PATCH 17/57] status pre-check --- .../com/marketdata/sdk/HttpTransport.java | 49 +++- .../com/marketdata/sdk/MarketDataClient.java | 13 +- .../com/marketdata/sdk/RetryExecutor.java | 26 +- .../java/com/marketdata/sdk/StatusCache.java | 135 +++++++++ .../com/marketdata/sdk/HttpTransportTest.java | 82 ++++++ .../com/marketdata/sdk/RetryExecutorTest.java | 44 +++ .../com/marketdata/sdk/StatusCacheTest.java | 264 ++++++++++++++++++ 7 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/StatusCache.java create mode 100644 src/test/java/com/marketdata/sdk/StatusCacheTest.java diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index dca7efe..3ddb2f5 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -16,6 +16,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; /** @@ -50,6 +51,7 @@ final class HttpTransport implements AutoCloseable { private final HttpDispatcher dispatcher; private final RetryExecutor retryExecutor; + private final Supplier<@Nullable StatusCache> statusCacheSupplier; private final AtomicReference<@Nullable RateLimitSnapshot> latestRateLimits = new AtomicReference<>(); @@ -59,17 +61,30 @@ final class HttpTransport implements AutoCloseable { private final @Nullable String token; HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) { + this(baseUrl, apiVersion, userAgent, token, () -> null); + } + + // Default-infra ctor with a status-cache supplier. MarketDataClient uses this so the §9.5 + // gate is wired without each caller having to assemble the dispatcher + retry executor. + HttpTransport( + String baseUrl, + String apiVersion, + String userAgent, + @Nullable String token, + Supplier<@Nullable StatusCache> statusCacheSupplier) { this( baseUrl, apiVersion, userAgent, token, new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), - new RetryExecutor(RetryPolicy.defaults())); + new RetryExecutor(RetryPolicy.defaults()), + statusCacheSupplier); } // Package-private constructor for tests: inject a stubbed dispatcher and/or a fast retry - // policy so tests don't hit the wire and don't wait on real backoffs. + // policy. The status cache is omitted on this path (no §9.5 gate) so existing tests don't + // need to thread one through; tests that exercise the gate use the 7-arg overload. HttpTransport( String baseUrl, String apiVersion, @@ -77,12 +92,27 @@ final class HttpTransport implements AutoCloseable { @Nullable String token, HttpDispatcher dispatcher, RetryExecutor retryExecutor) { + this(baseUrl, apiVersion, userAgent, token, dispatcher, retryExecutor, () -> null); + } + + // Full ctor with status-cache supplier. The supplier is consulted on every executeAsync call + // so MarketDataClient can construct the cache AFTER the transport (the cache's fetcher uses + // the transport via UtilitiesResource — chicken-and-egg resolved by a deferred reference). + HttpTransport( + String baseUrl, + String apiVersion, + String userAgent, + @Nullable String token, + HttpDispatcher dispatcher, + RetryExecutor retryExecutor, + Supplier<@Nullable StatusCache> statusCacheSupplier) { this.baseUrl = baseUrl; this.apiVersion = apiVersion; this.userAgent = userAgent; this.token = token; this.dispatcher = dispatcher; this.retryExecutor = retryExecutor; + this.statusCacheSupplier = statusCacheSupplier; } private static HttpClient defaultHttpClient() { @@ -109,8 +139,21 @@ private static HttpClient defaultHttpClient() { CompletableFuture executeAsync(RequestSpec spec) { URI uri = buildUri(spec); HttpRequest request = buildHttpRequest(uri, spec.format()); + RetryPolicy policy = retryExecutor.policy(); return retryExecutor.execute( - () -> dispatcher.dispatch(request).thenApply(response -> routeAndEnvelope(response, uri))); + () -> dispatcher.dispatch(request).thenApply(response -> routeAndEnvelope(response, uri)), + // §9.5: gate retries on retryable server errors through the /status/ cache. Even if the + // policy says yes, an "offline" cache entry for this URI's service blocks the retry so + // the caller fails fast instead of hammering a known-down service. + (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); + } + + private boolean cacheAllowsRetry(URI uri) { + StatusCache cache = statusCacheSupplier.get(); + if (cache == null) { + return true; // pre-wire state or test setup without a cache + } + return cache.check(uri) == StatusCache.Decision.ALLOW; } /** diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 974288a..a5fea7a 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -1,6 +1,8 @@ package com.marketdata.sdk; import java.nio.file.Path; +import java.time.Clock; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import org.jspecify.annotations.Nullable; @@ -44,14 +46,23 @@ public MarketDataClient( Path dotEnvPath, Runnable startupValidator) { this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); + + // §9.5: the status cache pre-checks /status/ before retrying 5xx. The cache's fetcher uses + // `utilities.statusAsync()`, which goes through this transport — a chicken-and-egg. We + // resolve it with a deferred reference: the transport reads the cache through a supplier, + // which returns null until the cache is constructed (just below this transport instance). + AtomicReference cacheRef = new AtomicReference<>(); this.transport = new HttpTransport( config.baseUrl(), config.apiVersion(), "marketdata-sdk-java/" + Version.sdkVersion(), - config.apiKey()); + config.apiKey(), + cacheRef::get); JsonResponseParser parser = new JsonResponseParser(); this.utilities = new UtilitiesResource(transport, parser); + cacheRef.set(new StatusCache(utilities::statusAsync, Clock.systemUTC())); + if (validateOnStartup) { startupValidator.run(); } diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java index 58f6556..6e82ad0 100644 --- a/src/main/java/com/marketdata/sdk/RetryExecutor.java +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -5,6 +5,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiPredicate; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -30,12 +31,27 @@ final class RetryExecutor { this.policy = policy; } + /** Visible to callers that need to compose their own retry predicate. */ + RetryPolicy policy() { + return policy; + } + /** * Drive {@code supplier} with retry. Each invocation of the supplier represents one attempt; if * the resulting future fails with a retriable cause, {@code supplier} is invoked again after the - * policy-determined backoff. + * policy-determined backoff. The retry decision uses the policy's own {@code shouldRetry}. */ CompletableFuture execute(Supplier> supplier) { + return execute(supplier, policy::shouldRetry); + } + + /** + * Like {@link #execute(Supplier)}, but the caller supplies a custom retry predicate. Used when an + * external gate (e.g. the {@code /status/} pre-check from §9.5) needs to veto a retry the policy + * would otherwise allow. {@link RetryPolicy#backoffDelay} still controls timing. + */ + CompletableFuture execute( + Supplier> supplier, BiPredicate shouldRetry) { CompletableFuture result = new CompletableFuture<>(); // One cancellation handler installed once: whichever attempt is currently in flight is // tracked in `currentAttempt`; cancelling `result` cancels that. Previous attempts are @@ -51,12 +67,13 @@ CompletableFuture execute(Supplier> supplier) { } } }); - attempt(supplier, 0, result, currentAttempt); + attempt(supplier, shouldRetry, 0, result, currentAttempt); return result; } private void attempt( Supplier> supplier, + BiPredicate shouldRetry, int attemptIdx, CompletableFuture result, AtomicReference<@Nullable CompletableFuture> currentAttempt) { @@ -87,10 +104,11 @@ private void attempt( return; } Throwable cause = unwrap(error); - if (policy.shouldRetry(cause, attemptIdx)) { + if (shouldRetry.test(cause, attemptIdx)) { long delayMs = policy.backoffDelay(attemptIdx).toMillis(); CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) - .execute(() -> attempt(supplier, attemptIdx + 1, result, currentAttempt)); + .execute( + () -> attempt(supplier, shouldRetry, attemptIdx + 1, result, currentAttempt)); } else { result.completeExceptionally(cause); } diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java new file mode 100644 index 0000000..2fae80b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -0,0 +1,135 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; + +/** + * Client-side cache of the {@code /status/} endpoint used to gate retries against services the API + * has reported offline (SDK requirements §9.5). + * + *

The TTL is a stale-while-revalidate window: + * + *

    + *
  • {@code age < 270s} — serve the cached snapshot, no refresh. + *
  • {@code 270s ≤ age < 300s} — serve the cached snapshot AND fire an async refresh. + *
  • {@code age ≥ 300s} or no cache — treat the service as {@code unknown} (which allows + * retries) AND fire an async refresh. + *
+ * + *

The refresh fetcher returns a {@link CompletableFuture}, so refreshes never block the caller. + * If a refresh fails, the previous snapshot survives — there is no fallback "assume online"; the + * SDK simply continues using what it knows until the next successful refresh. + * + *

A single {@link AtomicBoolean} guards refreshes so concurrent retries on different services + * don't fire N refreshes against the same {@code /status/} endpoint. + * + *

Decision matrix per {@link Decision}: {@code offline} services {@link Decision#BLOCK} retries; + * everything else (including the {@code unknown} that comes from a stale or empty cache) {@link + * Decision#ALLOW}s them, per §9.5. + */ +final class StatusCache { + + static final Duration REFRESH_THRESHOLD = Duration.ofSeconds(270); + static final Duration EXPIRY = Duration.ofSeconds(300); + + private final Supplier> fetcher; + private final Clock clock; + private final AtomicReference<@Nullable Snapshot> snapshot = new AtomicReference<>(); + private final AtomicBoolean refreshInFlight = new AtomicBoolean(false); + + StatusCache(Supplier> fetcher, Clock clock) { + this.fetcher = fetcher; + this.clock = clock; + } + + /** Whether retrying on {@code uri} is allowed by the cache. */ + Decision check(URI uri) { + Snapshot snap = snapshot.get(); + Instant now = clock.instant(); + + boolean refreshNeeded = + snap == null || Duration.between(snap.fetchedAt, now).compareTo(REFRESH_THRESHOLD) >= 0; + if (refreshNeeded) { + triggerRefresh(); + } + + boolean usable = snap != null && Duration.between(snap.fetchedAt, now).compareTo(EXPIRY) < 0; + if (!usable) { + // Stale or empty → "unknown" → allow per §9.5. + return Decision.ALLOW; + } + + String status = lookupService(snap, uri); + return "offline".equals(status) ? Decision.BLOCK : Decision.ALLOW; + } + + /** Manually trigger a refresh. Visible for tests; production calls only via {@link #check}. */ + void triggerRefresh() { + if (!refreshInFlight.compareAndSet(false, true)) { + return; // already refreshing + } + CompletableFuture future; + try { + future = fetcher.get(); + } catch (Throwable t) { + refreshInFlight.set(false); + return; + } + future.whenComplete( + (apiStatus, err) -> { + try { + if (err == null && apiStatus != null) { + snapshot.set(Snapshot.from(apiStatus, clock.instant())); + } + // On error: cache persists — §9.5 "Cache persists across failed refresh attempts". + } finally { + refreshInFlight.set(false); + } + }); + } + + /** + * Find the cached status for the service whose path is the longest prefix of {@code uri}'s path. + * Returns {@code null} when no service matches. + */ + private static @Nullable String lookupService(Snapshot snap, URI uri) { + String path = uri.getPath(); + String bestKey = null; + for (String key : snap.serviceToStatus.keySet()) { + if (path.startsWith(key) && (bestKey == null || key.length() > bestKey.length())) { + bestKey = key; + } + } + return bestKey == null ? null : snap.serviceToStatus.get(bestKey); + } + + /** Decision the gate returns to the retry executor. */ + enum Decision { + /** Cache permits a retry: service is online, unknown, or out-of-scope. */ + ALLOW, + /** Cache marked the affected service offline — fail immediately without retrying. */ + BLOCK + } + + /** Immutable snapshot of one /status/ response, indexed by service path. */ + private record Snapshot(Instant fetchedAt, Map serviceToStatus) { + static Snapshot from(ApiStatus apiStatus, Instant fetchedAt) { + Map map = new HashMap<>(apiStatus.services().size()); + for (ServiceStatus s : apiStatus.services()) { + map.put(s.service(), s.status()); + } + return new Snapshot(fetchedAt, Map.copyOf(map)); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index e736d62..34f5143 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -308,6 +308,88 @@ void executeSyncUnwrapsCompletionExceptionToCause() { .isInstanceOf(ServerError.class); // not CompletionException, not wrapped } + // ---------- §9.5 status-cache gate ---------- + + /** + * Even with a 5xx that the policy would retry, an "offline" entry in the cache must veto the + * retry. The dispatcher should see exactly one call: the original attempt; no retries are + * scheduled. + */ + @Test + void cacheOfflineEntryVetoesA5xxRetry() throws Exception { + com.marketdata.sdk.utilities.ApiStatus offlineForService = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/status/", "offline", false, 0.5, 0.5, java.time.Instant.EPOCH))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(offlineForService), + java.time.Clock.systemUTC()); + cache.triggerRefresh(); + // Wait for the snapshot to land — the fetcher returns a completed future, so the + // whenComplete fires synchronously on the same thread, but be defensive. + Thread.sleep(20); + + // Allow 4 retries so we'd retry on a 5xx — IF the cache didn't veto. + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + // The cache vetoed: exactly one HTTP dispatch, no retries scheduled. + assertThat(client.captured).hasSize(1); + } + + /** When the cache says online (or no entry matches), retries proceed normally. */ + @Test + void cacheOnlineEntryAllowsNormalRetryFlow() throws Exception { + com.marketdata.sdk.utilities.ApiStatus online = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/status/", "online", true, 1.0, 1.0, java.time.Instant.EPOCH))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(online), java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + // 4 attempts: initial + 3 retries (policy allows; cache doesn't veto). + assertThat(client.captured).hasSize(4); + } + // ---------- stub HttpClient ---------- /** diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java index bb444af..b8ca803 100644 --- a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -175,6 +175,50 @@ void cancelDuringBackoffPreventsNextAttempt() throws Exception { // ---------- result shape ---------- + // ---------- custom retry predicate overload ---------- + + /** + * The overload that accepts a custom {@code BiPredicate} is the seam HttpTransport uses to AND + * the policy with a status-cache veto (§9.5). Verify that when the predicate returns false even + * though the policy would have said true, no retry happens. + */ + @Test + void customPredicateCanVetoARetryThePolicyWouldHaveAllowed() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }, + /* shouldRetry */ (cause, attempt) -> false); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.NetworkError.class); + assertThat(calls).hasValue(1); // policy would have allowed; predicate vetoed + } + + @Test + void customPredicateReceivesUnwrappedCauseAndAttemptIndex() { + java.util.List seenAttempts = new java.util.ArrayList<>(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + exec.execute( + () -> CompletableFuture.failedFuture(retriableNet()), + (cause, attempt) -> { + seenAttempts.add(attempt); + // Allow first two retries, then veto. + return attempt < 2; + }) + .exceptionally(e -> null) + .join(); + + assertThat(seenAttempts).containsExactly(0, 1, 2); + } + @Test void resultFutureCarriesCancellationException() { RetryExecutor exec = new RetryExecutor(NO_RETRY); diff --git a/src/test/java/com/marketdata/sdk/StatusCacheTest.java b/src/test/java/com/marketdata/sdk/StatusCacheTest.java new file mode 100644 index 0000000..0d3681b --- /dev/null +++ b/src/test/java/com/marketdata/sdk/StatusCacheTest.java @@ -0,0 +1,264 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class StatusCacheTest { + + /** Test clock whose instant can be advanced step-by-step. */ + private static final class FixedClock extends Clock { + private Instant now; + + FixedClock(Instant start) { + this.now = start; + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + + void advance(java.time.Duration by) { + now = now.plus(by); + } + } + + private static ApiStatus snapshot(String service, String status) { + return new ApiStatus( + List.of( + new ServiceStatus(service, status, "online".equals(status), 1.0, 1.0, Instant.EPOCH))); + } + + // ---------- empty cache ---------- + + @Test + void emptyCacheAllowsAndTriggersRefresh() { + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + calls.incrementAndGet(); + return CompletableFuture.completedFuture(snapshot("/v1/x/", "online")); + }, + new FixedClock(Instant.now())); + + assertThat(cache.check(URI.create("http://api/v1/x/AAPL/"))) + .isEqualTo(StatusCache.Decision.ALLOW); + assertThat(calls).hasValue(1); + } + + // ---------- fresh cache ---------- + + @Test + void freshCacheReturnsOfflineBlock() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + Supplier> fetcher = + () -> CompletableFuture.completedFuture(snapshot("/v1/stocks/quotes/", "offline")); + StatusCache cache = new StatusCache(fetcher, clock); + cache.triggerRefresh(); + + clock.advance(java.time.Duration.ofSeconds(10)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void freshCacheReturnsOnlineAllow() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/markets/status/", "online")), + clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/markets/status/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } + + // ---------- aging cache: serve + refresh ---------- + + @Test + void agingCacheServesAndKicksAsyncRefresh() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger refreshCalls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + refreshCalls.incrementAndGet(); + return CompletableFuture.completedFuture(snapshot("/v1/x/", "online")); + }, + clock); + cache.triggerRefresh(); // initial fill + assertThat(refreshCalls).hasValue(1); + + // Move time to 280s — past refresh threshold, before expiry. + clock.advance(java.time.Duration.ofSeconds(280)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); // served from cache + assertThat(refreshCalls).hasValue(2); // refresh fired + } + + // ---------- expired cache ---------- + + @Test + void expiredCacheReturnsAllowAndRefreshes() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger refreshCalls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + refreshCalls.incrementAndGet(); + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + }, + clock); + cache.triggerRefresh(); + assertThat(refreshCalls).hasValue(1); + + // 310s — past expiry. Cache is stale → treat as unknown → ALLOW even though the cached + // entry says offline. The async refresh runs simultaneously. + clock.advance(java.time.Duration.ofSeconds(310)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + assertThat(refreshCalls).hasValue(2); + } + + // ---------- in-flight guard ---------- + + @Test + void overlappingRefreshesAreDeduplicatedByInFlightGuard() { + // The fetcher returns a future that never completes; the in-flight guard must prevent + // the second/third/fourth check from kicking additional refreshes while the first is open. + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger fetcherInvocations = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + fetcherInvocations.incrementAndGet(); + return new CompletableFuture<>(); // never completes + }, + clock); + + for (int i = 0; i < 5; i++) { + cache.check(URI.create("http://api/v1/x/")); + } + + assertThat(fetcherInvocations).hasValue(1); + } + + // ---------- failure handling ---------- + + @Test + void failedRefreshLeavesPreviousSnapshotIntact() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + int n = calls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + // Second call fails — simulate /status/ briefly down. + return CompletableFuture.failedFuture(new RuntimeException("status endpoint down")); + }, + clock); + cache.triggerRefresh(); + // Cache now has /v1/x/ -> offline. + + // Trigger refresh: it fails. + clock.advance(java.time.Duration.ofSeconds(280)); + cache.check(URI.create("http://api/v1/x/")); + + // Cache still serves the previous snapshot. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void fetcherThrowingSyncAlsoLeavesCacheIntact() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + int n = calls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + throw new IllegalStateException("synchronous failure"); + }, + clock); + cache.triggerRefresh(); + + clock.advance(java.time.Duration.ofSeconds(280)); + cache.check(URI.create("http://api/v1/x/")); // triggers refresh; sync throws + // The in-flight guard must reset so subsequent refresh attempts can proceed. + + clock.advance(java.time.Duration.ofSeconds(50)); + cache.check(URI.create("http://api/v1/x/")); // age > 300, triggers again + assertThat(calls).hasValue(3); // initial fill + 2 failed refreshes + } + + // ---------- URI → service matching ---------- + + @Test + void uriMatchesLongestServicePrefix() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> + CompletableFuture.completedFuture( + new ApiStatus( + List.of( + new ServiceStatus("/v1/", "online", true, 1.0, 1.0, Instant.EPOCH), + new ServiceStatus( + "/v1/stocks/quotes/", "offline", false, 0.5, 0.6, Instant.EPOCH)))), + clock); + cache.triggerRefresh(); + + // /v1/stocks/quotes/AAPL/ matches both /v1/ and /v1/stocks/quotes/ — longest wins. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void uriWithNoMatchingServiceTreatsAsUnknown() { + // The /status/ endpoint itself has no matching service entry — its own call must not + // recurse on offline lookups. Test that the URI of /status/ falls through to ALLOW. + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")), clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/status/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } +} From 58176050ac9408f8533b408bebf8476a52f5cb2c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 18 May 2026 16:19:11 -0300 Subject: [PATCH 18/57] add retry-after --- .../com/marketdata/sdk/HttpStatusMapper.java | 15 ++++- .../com/marketdata/sdk/HttpTransport.java | 12 +++- .../com/marketdata/sdk/RetryAfterHeader.java | 64 ++++++++++++++++++ .../com/marketdata/sdk/RetryExecutor.java | 2 +- .../java/com/marketdata/sdk/RetryPolicy.java | 25 +++++-- .../marketdata/sdk/exception/ServerError.java | 28 +++++++- .../com/marketdata/sdk/HttpTransportTest.java | 41 ++++++++++++ .../marketdata/sdk/RetryAfterHeaderTest.java | 67 +++++++++++++++++++ .../com/marketdata/sdk/RetryPolicyTest.java | 32 +++++++++ 9 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/RetryAfterHeader.java create mode 100644 src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java index 2954298..2fec442 100644 --- a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -7,11 +7,22 @@ import com.marketdata.sdk.exception.NotFoundError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; import org.jspecify.annotations.Nullable; final class HttpStatusMapper { static @Nullable MarketDataException map(int statusCode, ErrorContext context) { + return map(statusCode, context, null); + } + + /** + * Maps an HTTP status to its typed exception. When {@code retryAfter} is non-null, it is attached + * to the resulting {@link ServerError} so the retry policy can honor §9.4. The other subtypes + * ignore it — only server errors retry, and only retries care about Retry-After. + */ + static @Nullable MarketDataException map( + int statusCode, ErrorContext context, @Nullable Duration retryAfter) { if (statusCode >= 200 && statusCode < 300) { return null; } @@ -20,10 +31,10 @@ final class HttpStatusMapper { case 401 -> new AuthenticationError("Authentication failed", context); case 404 -> new NotFoundError("Not found", context); case 429 -> new RateLimitError("Rate limit exceeded", context); - case 500 -> new ServerError("Server error: 500", context); + case 500 -> new ServerError("Server error: 500", context, null, retryAfter); default -> { if (statusCode >= 501 && statusCode <= 599) { - yield new ServerError("Server error: " + statusCode, context); + yield new ServerError("Server error: " + statusCode, context, null, retryAfter); } yield new BadRequestError("Unexpected status code: " + statusCode, context); } diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 3ddb2f5..55d9005 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -201,9 +201,15 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI if ((status >= 200 && status < 300) || status == 404) { return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); } - ErrorContext context = - ErrorContext.forResponse(uri.toString(), status, requestId, Instant.now()); - MarketDataException ex = HttpStatusMapper.map(status, context); + Instant now = Instant.now(); + ErrorContext context = ErrorContext.forResponse(uri.toString(), status, requestId, now); + java.time.Duration retryAfter = + response + .headers() + .firstValue("Retry-After") + .flatMap(v -> RetryAfterHeader.parse(v, now)) + .orElse(null); + MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); if (ex != null) { throw ex; } diff --git a/src/main/java/com/marketdata/sdk/RetryAfterHeader.java b/src/main/java/com/marketdata/sdk/RetryAfterHeader.java new file mode 100644 index 0000000..8e65f94 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RetryAfterHeader.java @@ -0,0 +1,64 @@ +package com.marketdata.sdk; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +/** + * Parses the HTTP {@code Retry-After} response header. Per RFC 7231 §7.1.3 the value is either: + * + *

    + *
  • delta-seconds — an unsigned integer like {@code 120}, OR + *
  • HTTP-date — an RFC 1123 timestamp like {@code Wed, 21 Oct 2025 07:28:00 GMT}. + *
+ * + *

The parser accepts both forms. Past dates and negative seconds clamp to {@link Duration#ZERO} + * ("retry immediately"). Malformed values yield {@link Optional#empty()}, which lets the caller + * fall back to its calculated backoff per SDK requirements §9.4 ("respect server-specified delay" + * is silent on malformed inputs). + * + *

This parser intentionally does not cap the result at any upper bound — the + * spec says "override calculated backoff with server value", taking the server's directive at face + * value. A future revision can introduce a cap if pathological values become an operational + * concern. + */ +final class RetryAfterHeader { + + private RetryAfterHeader() {} + + /** Parse the header value against {@code now} (used only when the value is an HTTP-date). */ + static Optional parse(String value, Instant now) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + Optional asSeconds = parseSeconds(trimmed); + if (asSeconds.isPresent()) { + return asSeconds; + } + return parseHttpDate(trimmed, now); + } + + private static Optional parseSeconds(String value) { + try { + long seconds = Long.parseLong(value); + // Negative deltas violate the spec but pop up in the wild; treat as "retry now". + return Optional.of(Duration.ofSeconds(Math.max(0L, seconds))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private static Optional parseHttpDate(String value, Instant now) { + try { + Instant target = DateTimeFormatter.RFC_1123_DATE_TIME.parse(value, Instant::from); + long delaySeconds = ChronoUnit.SECONDS.between(now, target); + return Optional.of(Duration.ofSeconds(Math.max(0L, delaySeconds))); + } catch (DateTimeParseException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java index 6e82ad0..1193098 100644 --- a/src/main/java/com/marketdata/sdk/RetryExecutor.java +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -105,7 +105,7 @@ private void attempt( } Throwable cause = unwrap(error); if (shouldRetry.test(cause, attemptIdx)) { - long delayMs = policy.backoffDelay(attemptIdx).toMillis(); + long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) .execute( () -> attempt(supplier, shouldRetry, attemptIdx + 1, result, currentAttempt)); diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java index d134617..444087d 100644 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -21,11 +21,11 @@ *

The constructor accepts custom values so tests can drive retries with sub-millisecond delays * without waiting on real wall-clock backoffs. * - *

TODO §9: this policy is purely about whether a cause is retriable in principle. The {@code - * /status/} cache pre-check (skip a 5xx retry when the server is marked down) and the {@code - * Retry-After} header override (replace the calculated backoff with the server-specified delay) are - * deliberately handled by {@code HttpTransport}, not here — they depend on external runtime state - * and response headers that this class doesn't see. See {@code CLAUDE.md} "Known latent gaps". + *

§9.4 {@code Retry-After} override: when the failing cause is a {@link ServerError} that + * carries a server-supplied delay, {@link #backoffDelay(Throwable, int)} returns that delay + * verbatim instead of the exponential calculation. §9.5 {@code /status/} cache pre-check is handled + * at the {@code HttpTransport} layer via {@code StatusCache}, not here — that gate depends on + * external runtime state this class doesn't see. */ final class RetryPolicy { @@ -59,6 +59,21 @@ boolean shouldRetry(Throwable cause, int attempt) { return isRetriable(cause); } + /** + * Backoff before the next attempt, honoring a server-supplied {@code Retry-After} when the cause + * is a {@link ServerError} that carried one (§9.4). Otherwise falls back to the exponential + * calculation from {@link #backoffDelay(int)}. + */ + Duration backoffDelay(Throwable cause, int attempt) { + if (cause instanceof ServerError server) { + Duration override = server.getRetryAfter().orElse(null); + if (override != null) { + return override; + } + } + return backoffDelay(attempt); + } + /** * Backoff before the next attempt. {@code attempt == 0} means "before the first retry", i.e. the * delay applied right after the original call failed. diff --git a/src/main/java/com/marketdata/sdk/exception/ServerError.java b/src/main/java/com/marketdata/sdk/exception/ServerError.java index 7a7d646..57ea5d3 100644 --- a/src/main/java/com/marketdata/sdk/exception/ServerError.java +++ b/src/main/java/com/marketdata/sdk/exception/ServerError.java @@ -1,16 +1,42 @@ package com.marketdata.sdk.exception; +import java.time.Duration; +import java.util.Optional; import org.jspecify.annotations.Nullable; public final class ServerError extends MarketDataException { private static final long serialVersionUID = 1L; + private final @Nullable Duration retryAfter; + public ServerError(String message, ErrorContext context) { - this(message, context, null); + this(message, context, null, null); } public ServerError(String message, ErrorContext context, @Nullable Throwable cause) { + this(message, context, cause, null); + } + + /** + * Construct a server error that carries the server-specified {@code Retry-After} hint (SDK + * requirements §9.4). When present, the retry policy uses this value instead of the calculated + * exponential backoff before the next attempt. + */ + public ServerError( + String message, + ErrorContext context, + @Nullable Throwable cause, + @Nullable Duration retryAfter) { super(message, context, cause); + this.retryAfter = retryAfter; + } + + /** + * The value parsed from the server's {@code Retry-After} response header, when present. Otherwise + * empty (the policy falls back to its calculated backoff). + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); } } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 34f5143..c488914 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -308,6 +308,47 @@ void executeSyncUnwrapsCompletionExceptionToCause() { .isInstanceOf(ServerError.class); // not CompletionException, not wrapped } + // ---------- §9.4 Retry-After header ---------- + + /** + * When the server attaches a {@code Retry-After} header to a 5xx response, the resulting {@link + * ServerError} must carry the parsed {@link Duration} so the retry policy can override its + * calculated backoff with the server's directive. + */ + @Test + void serverErrorCarriesParsedRetryAfterDuration() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("Retry-After", "7")); + CapturingClient client = new CapturingClient(503, new byte[0], headers); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class) + .satisfies( + t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).contains(Duration.ofSeconds(7)); + }); + } + + @Test + void serverErrorRetryAfterIsEmptyWhenHeaderAbsent() { + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class) + .satisfies( + t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).isEmpty(); + }); + } + // ---------- §9.5 status-cache gate ---------- /** diff --git a/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java b/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java new file mode 100644 index 0000000..1b964f9 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java @@ -0,0 +1,67 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RetryAfterHeaderTest { + + private static final Instant NOW = Instant.parse("2026-05-18T12:00:00Z"); + + // ---------- delta-seconds form ---------- + + @Test + void parsesPositiveSeconds() { + assertThat(RetryAfterHeader.parse("120", NOW)).contains(Duration.ofSeconds(120)); + } + + @Test + void parsesZeroSeconds() { + assertThat(RetryAfterHeader.parse("0", NOW)).contains(Duration.ZERO); + } + + @Test + void negativeSecondsClampToZero() { + // Spec doesn't allow negatives but clients have spotted them in the wild — treat as + // "retry now" rather than blow up parsing. + assertThat(RetryAfterHeader.parse("-5", NOW)).contains(Duration.ZERO); + } + + @Test + void valueIsTrimmedBeforeParsing() { + assertThat(RetryAfterHeader.parse(" 30 ", NOW)).contains(Duration.ofSeconds(30)); + } + + // ---------- HTTP-date form (RFC 1123) ---------- + + @Test + void parsesHttpDateInTheFuture() { + // 5 minutes after NOW. + String header = "Mon, 18 May 2026 12:05:00 GMT"; + assertThat(RetryAfterHeader.parse(header, NOW)).contains(Duration.ofMinutes(5)); + } + + @Test + void httpDateInThePastClampsToZero() { + // 10 seconds before NOW. + String header = "Mon, 18 May 2026 11:59:50 GMT"; + assertThat(RetryAfterHeader.parse(header, NOW)).contains(Duration.ZERO); + } + + // ---------- malformed ---------- + + @Test + void emptyHeaderProducesEmpty() { + assertThat(RetryAfterHeader.parse("", NOW)).isEmpty(); + assertThat(RetryAfterHeader.parse(" ", NOW)).isEmpty(); + } + + @Test + void garbageHeaderProducesEmpty() { + // Neither a valid integer nor a parseable HTTP-date. Caller falls back to its own backoff. + assertThat(RetryAfterHeader.parse("not-a-thing", NOW)).isEmpty(); + assertThat(RetryAfterHeader.parse("2026-05-18", NOW)).isEmpty(); // wrong date format + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java index ca31ff8..fa9171e 100644 --- a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java +++ b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java @@ -10,6 +10,7 @@ import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import org.junit.jupiter.api.Test; @@ -174,6 +175,37 @@ void customConstructorWiresValuesThrough() { assertThat(tiny.backoffDelay(20)).isEqualTo(Duration.ofMillis(10)); } + // ---------- backoffDelay(cause, attempt) honors Retry-After ---------- + + @Test + void backoffWithCauseFallsBackToExponentialWhenCauseHasNoRetryAfter() { + // ServerError without Retry-After → exponential as before. + ServerError noRetryAfter = new ServerError("503", ctxWithStatus(503)); + assertThat(DEFAULTS.backoffDelay(noRetryAfter, 0)).isEqualTo(Duration.ofSeconds(1)); + assertThat(DEFAULTS.backoffDelay(noRetryAfter, 3)).isEqualTo(Duration.ofSeconds(8)); + } + + @Test + void backoffWithCauseHonorsRetryAfterOnServerError() { + // The server's Retry-After completely replaces the calculated exponential — even when the + // exponential would have been smaller (server knows better). + ServerError withRetryAfter = + new ServerError( + "503", ctxWithStatus(503), /* cause */ null, /* retryAfter */ Duration.ofSeconds(45)); + + // Attempt 0 would normally be 1s; Retry-After overrides to 45s. + assertThat(DEFAULTS.backoffDelay(withRetryAfter, 0)).isEqualTo(Duration.ofSeconds(45)); + // Attempt 5 would normally cap at 30s; Retry-After still wins with 45s. + assertThat(DEFAULTS.backoffDelay(withRetryAfter, 5)).isEqualTo(Duration.ofSeconds(45)); + } + + @Test + void backoffWithCauseIgnoresRetryAfterOnNonServerErrorCauses() { + // NetworkError doesn't carry Retry-After at all → exponential math. + NetworkError net = new NetworkError("n", ctxNoResponse(), new IOException("down")); + assertThat(DEFAULTS.backoffDelay(net, 1)).isEqualTo(Duration.ofSeconds(2)); + } + @Test void rejectsNonPositiveMaxAttempts() { org.assertj.core.api.Assertions.assertThatThrownBy( From 8f9d618879ed6f64929cb173559a85d36d127570 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 09:22:48 -0300 Subject: [PATCH 19/57] add pre-fligth --- .../com/marketdata/sdk/HttpTransport.java | 33 ++++++++- .../com/marketdata/sdk/HttpTransportTest.java | 72 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 55d9005..96e5240 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -3,6 +3,7 @@ import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.RateLimitError; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -141,13 +142,43 @@ CompletableFuture executeAsync(RequestSpec spec) { HttpRequest request = buildHttpRequest(uri, spec.format()); RetryPolicy policy = retryExecutor.policy(); return retryExecutor.execute( - () -> dispatcher.dispatch(request).thenApply(response -> routeAndEnvelope(response, uri)), + () -> { + // §10.3: pre-flight gate — if our latest snapshot says credits are exhausted, fail + // fast without hitting the wire. RateLimitError is non-retriable per §11.2, so the + // retry executor will surface it directly. + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) { + return CompletableFuture.failedFuture(preflight); + } + return dispatcher + .dispatch(request) + .thenApply(response -> routeAndEnvelope(response, uri)); + }, // §9.5: gate retries on retryable server errors through the /status/ cache. Even if the // policy says yes, an "offline" cache entry for this URI's service blocks the retry so // the caller fails fast instead of hammering a known-down service. (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); } + /** + * Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits; + * {@code null} when the request is allowed (either credits are available or no snapshot has been + * taken yet — the first request must reach the server to populate one). + * + *

Treats {@code remaining == 0} as exhausted regardless of whether {@code reset} has passed. + * The snapshot only refreshes on response headers, so we have no fresh data to justify letting + * the request through; the server will fail us anyway if quotas haven't actually reset. + */ + private @Nullable RateLimitError checkRateLimitPreflight(URI uri) { + RateLimitSnapshot snap = latestRateLimits.get(); + if (snap == null || snap.remaining() > 0) { + return null; + } + ErrorContext context = ErrorContext.forNoResponse(uri.toString(), Instant.now()); + return new RateLimitError( + "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); + } + private boolean cacheAllowsRetry(URI uri) { StatusCache cache = statusCacheSupplier.get(); if (cache == null) { diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index c488914..22aca6c 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -270,7 +270,10 @@ void rateLimitSnapshotUpdatesWhenHeadersPresent() { void rateLimitSnapshotNotClearedByResponseWithoutHeaders() { // First call sets a snapshot; second call returns no headers; snapshot must remain // populated (vs flickering to null). - HttpHeaders withRl = TestHttpClients.headersOf(Map.of("x-api-ratelimit-limit", "500")); + // Real data has remaining > 0 — otherwise the §10.3 pre-flight would block the second call. + HttpHeaders withRl = + TestHttpClients.headersOf( + Map.of("x-api-ratelimit-limit", "500", "x-api-ratelimit-remaining", "100")); HttpHeaders empty = HttpHeaders.of(Map.of(), (a, b) -> true); CapturingClient client = new CapturingClient(200, "ok".getBytes(), withRl); HttpTransport transport = newTransport(client); @@ -308,6 +311,73 @@ void executeSyncUnwrapsCompletionExceptionToCause() { .isInstanceOf(ServerError.class); // not CompletionException, not wrapped } + // ---------- §10.3 pre-flight rate-limit check ---------- + + private static HttpHeaders rateLimitHeaders(int remaining) { + return TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", String.valueOf(remaining), + "x-api-ratelimit-reset", "1734036832", + "x-api-ratelimit-consumed", "1")); + } + + /** + * After a response that exhausts credits, the next call must fail fast with {@link + * RateLimitError} and never reach the HttpClient. Without §10.3 we'd waste a real request to + * discover the same answer the snapshot already gave us. + */ + @Test + void preflightRejectsWhenSnapshotShowsZeroRemaining() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), rateLimitHeaders(/* remaining */ 0)); + HttpTransport transport = newTransport(client); + + // First call populates the snapshot (remaining=0) and succeeds normally. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + // Second call should be vetoed by the pre-flight; HttpClient must not see it. + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.RateLimitError.class); + + assertThat(client.captured).hasSize(1); + } + + @Test + void preflightAllowsWhenSnapshotShowsCreditsRemaining() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), rateLimitHeaders(/* remaining */ 42)); + HttpTransport transport = newTransport(client); + + // First call populates the snapshot. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + // Second call should proceed — credits still available. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured).hasSize(2); + } + + /** + * Before any rate-limit-bearing response has arrived, the snapshot is {@code null} — the first + * request must NOT be blocked despite there being "zero" remaining in the EMPTY sentinel. The + * pre-flight gate has to distinguish "no data yet" from "actually exhausted". + */ + @Test + void preflightAllowsTheFirstRequestWhenNoSnapshotExistsYet() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + // No prior response → no snapshot → request proceeds. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured).hasSize(1); + assertThat(transport.getLatestRateLimits()).isNull(); + } + // ---------- §9.4 Retry-After header ---------- /** From 02906e38f5da7bdadace6df74f42f697caad7ab4 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 09:48:15 -0300 Subject: [PATCH 20/57] add logger --- .../marketdata/sdk/CanonicalLogFormatter.java | 60 ++++++++++++ .../com/marketdata/sdk/HttpDispatcher.java | 27 ++++++ .../com/marketdata/sdk/HttpTransport.java | 5 + .../com/marketdata/sdk/MarketDataClient.java | 14 +++ .../com/marketdata/sdk/MarketDataLogging.java | 88 ++++++++++++++++++ .../sdk/CanonicalLogFormatterTest.java | 78 ++++++++++++++++ .../marketdata/sdk/MarketDataLoggingTest.java | 91 +++++++++++++++++++ 7 files changed, 363 insertions(+) create mode 100644 src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java create mode 100644 src/main/java/com/marketdata/sdk/MarketDataLogging.java create mode 100644 src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java create mode 100644 src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java diff --git a/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java new file mode 100644 index 0000000..bb5ad73 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java @@ -0,0 +1,60 @@ +package com.marketdata.sdk; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.logging.Formatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +/** + * JUL {@link Formatter} producing the canonical SDK log format mandated by §9.1: + * + *

{@code
+ * {timestamp} - {logger_name} - {level} - {message}
+ * }
+ * + *

Two normalizations matter: + * + *

    + *
  • Timestamp: rendered in {@code America/New_York} with millisecond precision + * and the offset, matching the date-handling convention from §13.4. Looks like {@code + * 2026-05-19T14:23:45.123-04:00}. + *
  • Level: JUL's native level names ({@code FINE}, {@code SEVERE}) are mapped + * back to the spec's vocabulary ({@code DEBUG}, {@code ERROR}). Anything below {@link + * Level#FINE} also collapses to {@code DEBUG}; anything above {@link Level#SEVERE} to {@code + * ERROR}. + *
+ */ +final class CanonicalLogFormatter extends Formatter { + + static final ZoneId ZONE = ZoneId.of("America/New_York"); + private static final DateTimeFormatter TS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); + + @Override + public String format(LogRecord record) { + String timestamp = TS_FORMAT.format(record.getInstant().atZone(ZONE)); + return timestamp + + " - " + + record.getLoggerName() + + " - " + + levelLabel(record.getLevel()) + + " - " + + formatMessage(record) + + System.lineSeparator(); + } + + static String levelLabel(Level level) { + int n = level.intValue(); + if (n <= Level.FINE.intValue()) { + return "DEBUG"; + } + if (n < Level.WARNING.intValue()) { + return "INFO"; + } + if (n < Level.SEVERE.intValue()) { + return "WARNING"; + } + return "ERROR"; + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index 5780a81..25abec5 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -6,10 +6,12 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; import java.time.Instant; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.logging.Logger; /** * Single-shot HTTP dispatch with global concurrency limiting. @@ -26,6 +28,8 @@ */ final class HttpDispatcher { + private static final Logger LOGGER = Logger.getLogger(HttpDispatcher.class.getName()); + private final HttpClient httpClient; private final AsyncSemaphore permits; @@ -61,6 +65,9 @@ CompletableFuture> dispatch(HttpRequest request) { } private CompletableFuture> send(HttpRequest request) { + LOGGER.fine(() -> "GET " + request.uri()); + Instant start = Instant.now(); + CompletableFuture> sendFuture; try { sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray()); @@ -69,6 +76,8 @@ private CompletableFuture> send(HttpRequest request) { // never formed, so the whenComplete below would never fire — release the permit here // to prevent a permanent leak that would degrade the pool to deadlock. permits.release(); + LOGGER.warning( + () -> "Request to " + request.uri() + " failed before dispatch: " + t.getMessage()); if (t instanceof Error err) { throw err; } @@ -83,14 +92,32 @@ private CompletableFuture> send(HttpRequest request) { .whenComplete((r, t) -> permits.release()) .handle( (response, error) -> { + long elapsedMs = Duration.between(start, Instant.now()).toMillis(); if (error != null) { Throwable root = unwrap(error); + LOGGER.warning( + () -> + "Request to " + + request.uri() + + " failed after " + + elapsedMs + + "ms: " + + root.getMessage()); throw new CompletionException( new NetworkError( "Request to " + request.uri() + " failed: " + root.getMessage(), ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), root)); } + LOGGER.fine( + () -> + "Response " + + response.statusCode() + + " from " + + request.uri() + + " in " + + elapsedMs + + "ms"); return response; }); } diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 96e5240..41a55e1 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -18,6 +18,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; +import java.util.logging.Logger; import org.jspecify.annotations.Nullable; /** @@ -39,6 +40,8 @@ */ final class HttpTransport implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(HttpTransport.class.getName()); + /** SDK requirements §10: fixed 99-second per-request timeout. */ static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); @@ -242,6 +245,8 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI .orElse(null); MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); if (ex != null) { + LOGGER.warning( + () -> "Request to " + uri + " returned HTTP " + status + ": " + ex.getMessage()); throw ex; } // Mapper only returns null for 2xx, which the branch above already handled. Belt & diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index a5fea7a..3d5df69 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -4,10 +4,13 @@ import java.time.Clock; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import java.util.logging.Logger; import org.jspecify.annotations.Nullable; public final class MarketDataClient implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(MarketDataClient.class.getName()); + private final Configuration config; private final HttpTransport transport; private final UtilitiesResource utilities; @@ -46,6 +49,17 @@ public MarketDataClient( Path dotEnvPath, Runnable startupValidator) { this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); + MarketDataLogging.configure(config.loggingLevel()); + LOGGER.info( + () -> + "MarketDataClient initialized: baseUrl=" + + config.baseUrl() + + ", apiVersion=" + + config.apiVersion() + + ", token=" + + Tokens.redact(config.apiKey()) + + ", demoMode=" + + DemoMode.isDemo(config)); // §9.5: the status cache pre-checks /status/ before retrying 5xx. The cache's fetcher uses // `utilities.statusAsync()`, which goes through this transport — a chicken-and-egg. We diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java new file mode 100644 index 0000000..8bcdf68 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -0,0 +1,88 @@ +package com.marketdata.sdk; + +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jspecify.annotations.Nullable; + +/** + * Global, idempotent JUL configuration for the SDK (§9). Installs one {@link ConsoleHandler} with + * {@link CanonicalLogFormatter} on the SDK root logger ({@code com.marketdata.sdk}) and applies the + * level resolved from the {@code MARKETDATA_LOGGING_LEVEL} env var (default {@code INFO}). + * + *

Sets {@code useParentHandlers=false} on the SDK logger so the canonical format is guaranteed — + * the JDK's default root handler would otherwise re-emit every record with {@code + * SimpleFormatter}'s shape, duplicating output. + * + *

Consumers that want to capture SDK logs into their own system (Logback, SLF4J bridges, file + * appenders) should attach handlers directly to the {@code com.marketdata.sdk} logger. Their + * handlers will see {@link java.util.logging.LogRecord} instances and can format / route them + * however they like — the canonical formatter only applies to the handler this class installs. + * + *

The first {@link #configure(String)} call wins; subsequent calls are no-ops. This avoids + * doubling handlers when multiple {@code MarketDataClient} instances are created in the same + * process and avoids surprising config-flips when the second client passes a different level. + */ +final class MarketDataLogging { + + static final String SDK_LOGGER_NAME = "com.marketdata.sdk"; + static final Level DEFAULT_LEVEL = Level.INFO; + + private static final AtomicBoolean configured = new AtomicBoolean(false); + + private MarketDataLogging() {} + + /** + * Install the SDK's handler + formatter on the SDK root logger. Idempotent — first call wins; + * subsequent calls are no-ops. + * + * @param levelSpec a level string from {@code MARKETDATA_LOGGING_LEVEL} ({@code DEBUG}, {@code + * INFO}, {@code WARNING}, {@code ERROR}, case-insensitive), or {@code null} for the default + * {@link #DEFAULT_LEVEL}. + */ + static void configure(@Nullable String levelSpec) { + if (!configured.compareAndSet(false, true)) { + return; + } + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new CanonicalLogFormatter()); + // ConsoleHandler defaults its own filter to INFO; lower it so the logger's level is the + // single source of truth for what gets emitted. + handler.setLevel(Level.ALL); + sdkLogger.addHandler(handler); + sdkLogger.setUseParentHandlers(false); + sdkLogger.setLevel(parseLevel(levelSpec)); + } + + static Level parseLevel(@Nullable String levelSpec) { + if (levelSpec == null) { + return DEFAULT_LEVEL; + } + return switch (levelSpec.trim().toUpperCase(Locale.ROOT)) { + case "DEBUG" -> Level.FINE; + case "INFO" -> Level.INFO; + case "WARNING" -> Level.WARNING; + case "ERROR" -> Level.SEVERE; + default -> DEFAULT_LEVEL; // unknown spec → fall back to default rather than throw + }; + } + + /** + * Test-only seam: clear the installed handler and the idempotency flag so subsequent tests can + * {@link #configure(String)} with different levels. Not part of the public contract; not + * thread-safe. + */ + static void resetForTests() { + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + for (Handler h : sdkLogger.getHandlers()) { + sdkLogger.removeHandler(h); + } + sdkLogger.setUseParentHandlers(true); + sdkLogger.setLevel(null); + configured.set(false); + } +} diff --git a/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java new file mode 100644 index 0000000..72be7fe --- /dev/null +++ b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java @@ -0,0 +1,78 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import org.junit.jupiter.api.Test; + +class CanonicalLogFormatterTest { + + private static LogRecord recordAt(Level level, String logger, String message, Instant when) { + LogRecord r = new LogRecord(level, message); + r.setLoggerName(logger); + r.setInstant(when); + return r; + } + + @Test + void formatProducesCanonicalShape() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + LogRecord r = + recordAt( + Level.INFO, + "com.marketdata.sdk.HttpTransport", + "Sending GET to https://api/v1/markets/status/", + Instant.parse("2026-05-19T18:00:00Z")); + + String out = fmt.format(r); + + // {timestamp} - {logger_name} - {level} - {message}\n + String[] parts = out.split(" - ", 4); + assertThat(parts).hasSize(4); + assertThat(parts[1]).isEqualTo("com.marketdata.sdk.HttpTransport"); + assertThat(parts[2]).isEqualTo("INFO"); + assertThat(parts[3]).startsWith("Sending GET to https://api/v1/markets/status/"); + assertThat(out).endsWith(System.lineSeparator()); + } + + @Test + void timestampIsRenderedInEasternZone() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + // 18:00 UTC → 14:00 Eastern (EDT in May). + Instant when = Instant.parse("2026-05-19T18:00:00Z"); + LogRecord r = recordAt(Level.INFO, "logger", "msg", when); + + String out = fmt.format(r); + String timestamp = out.substring(0, out.indexOf(" - ")); + + ZonedDateTime parsed = ZonedDateTime.parse(timestamp, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + assertThat(parsed.toInstant()).isEqualTo(when); + // Eastern in May is UTC-04:00 (DST). + assertThat(timestamp).endsWith("-04:00"); + assertThat(timestamp).startsWith("2026-05-19T14:00:00.000"); + } + + @Test + void julLevelsMapToSpecVocabulary() { + assertThat(CanonicalLogFormatter.levelLabel(Level.FINEST)).isEqualTo("DEBUG"); + assertThat(CanonicalLogFormatter.levelLabel(Level.FINE)).isEqualTo("DEBUG"); + assertThat(CanonicalLogFormatter.levelLabel(Level.CONFIG)).isEqualTo("INFO"); + assertThat(CanonicalLogFormatter.levelLabel(Level.INFO)).isEqualTo("INFO"); + assertThat(CanonicalLogFormatter.levelLabel(Level.WARNING)).isEqualTo("WARNING"); + assertThat(CanonicalLogFormatter.levelLabel(Level.SEVERE)).isEqualTo("ERROR"); + } + + @Test + void allFourSpecLevelsRoundTripThroughTheFormatter() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + Instant now = Instant.now(); + assertThat(fmt.format(recordAt(Level.FINE, "x", "m", now))).contains(" - DEBUG - "); + assertThat(fmt.format(recordAt(Level.INFO, "x", "m", now))).contains(" - INFO - "); + assertThat(fmt.format(recordAt(Level.WARNING, "x", "m", now))).contains(" - WARNING - "); + assertThat(fmt.format(recordAt(Level.SEVERE, "x", "m", now))).contains(" - ERROR - "); + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java new file mode 100644 index 0000000..f025de1 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java @@ -0,0 +1,91 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MarketDataLoggingTest { + + private static Logger sdkLogger() { + return Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + } + + @BeforeEach + void reset() { + MarketDataLogging.resetForTests(); + } + + @AfterEach + void resetAfter() { + MarketDataLogging.resetForTests(); + } + + // ---------- parseLevel ---------- + + @Test + void parseLevelMapsSpecVocabularyToJulLevels() { + assertThat(MarketDataLogging.parseLevel("DEBUG")).isEqualTo(Level.FINE); + assertThat(MarketDataLogging.parseLevel("INFO")).isEqualTo(Level.INFO); + assertThat(MarketDataLogging.parseLevel("WARNING")).isEqualTo(Level.WARNING); + assertThat(MarketDataLogging.parseLevel("ERROR")).isEqualTo(Level.SEVERE); + } + + @Test + void parseLevelIsCaseAndWhitespaceInsensitive() { + assertThat(MarketDataLogging.parseLevel(" debug ")).isEqualTo(Level.FINE); + assertThat(MarketDataLogging.parseLevel("Info")).isEqualTo(Level.INFO); + } + + @Test + void parseLevelFallsBackToDefaultWhenNullOrUnknown() { + assertThat(MarketDataLogging.parseLevel(null)).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + assertThat(MarketDataLogging.parseLevel("VERBOSE")).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + assertThat(MarketDataLogging.parseLevel("")).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + } + + // ---------- configure ---------- + + @Test + void configureInstallsExactlyOneHandlerWithTheCanonicalFormatter() { + MarketDataLogging.configure("INFO"); + + Handler[] handlers = sdkLogger().getHandlers(); + assertThat(handlers).hasSize(1); + assertThat(handlers[0].getFormatter()).isInstanceOf(CanonicalLogFormatter.class); + } + + @Test + void configureSetsLevelOnSdkLogger() { + MarketDataLogging.configure("DEBUG"); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + } + + @Test + void configureDisablesParentHandlersToAvoidDuplicateEmission() { + MarketDataLogging.configure(null); + assertThat(sdkLogger().getUseParentHandlers()).isFalse(); + } + + @Test + void configureIsIdempotentAcrossCalls() { + // Multiple MarketDataClient instances must not pile up handlers; the first call wins. + MarketDataLogging.configure("DEBUG"); + MarketDataLogging.configure("ERROR"); + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getHandlers()).hasSize(1); + // First call's level stands (DEBUG → FINE), not the subsequent ones. + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + } + + @Test + void defaultLevelWhenSpecIsNullIsInfo() { + MarketDataLogging.configure(null); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); + } +} From ffb0ba3c2431e064d891253e0adc943248de2e2a Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 10:07:17 -0300 Subject: [PATCH 21/57] add date handling --- .../marketdata/sdk/ApiStatusDeserializer.java | 3 +- .../marketdata/sdk/CanonicalLogFormatter.java | 4 +- .../com/marketdata/sdk/MarketDataDates.java | 37 +++++++++++++++++++ .../sdk/utilities/ServiceStatus.java | 7 ++-- .../com/marketdata/sdk/HttpTransportTest.java | 14 ++++++- .../sdk/JsonResponseParserTest.java | 3 +- .../com/marketdata/sdk/StatusCacheTest.java | 23 ++++++++++-- 7 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/MarketDataDates.java diff --git a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java index 95f535a..6a55a87 100644 --- a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java +++ b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java @@ -8,7 +8,6 @@ import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.ServiceStatus; import java.io.IOException; -import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -81,7 +80,7 @@ public ApiStatus deserialize(JsonParser p, DeserializationContext ctxt) throws I onlines.get(i).asBoolean(false), up30.get(i).asDouble(0.0), up90.get(i).asDouble(0.0), - Instant.ofEpochSecond(updated.get(i).asLong(0L)))); + MarketDataDates.marketTimeFromEpochSecond(updated.get(i).asLong(0L)))); } return new ApiStatus(rows); } diff --git a/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java index bb5ad73..94e737e 100644 --- a/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java +++ b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java @@ -1,6 +1,5 @@ package com.marketdata.sdk; -import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.logging.Formatter; import java.util.logging.Level; @@ -27,13 +26,12 @@ */ final class CanonicalLogFormatter extends Formatter { - static final ZoneId ZONE = ZoneId.of("America/New_York"); private static final DateTimeFormatter TS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); @Override public String format(LogRecord record) { - String timestamp = TS_FORMAT.format(record.getInstant().atZone(ZONE)); + String timestamp = TS_FORMAT.format(record.getInstant().atZone(MarketDataDates.MARKET_ZONE)); return timestamp + " - " + record.getLoggerName() diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java new file mode 100644 index 0000000..8627eb5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -0,0 +1,37 @@ +package com.marketdata.sdk; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + * Conventions for date/time fields surfaced to SDK consumers (§13.4). + * + *

Two distinct buckets in the codebase, intentional and worth documenting: + * + *

    + *
  • Market-data timestamps — anything that comes from the wire and represents + * a moment in market time (quote times, candle bars, service uptime updates, etc.). Surfaced + * as {@link ZonedDateTime} in {@link #MARKET_ZONE} so the consumer sees the moment the way a + * trader thinks about it without converting. {@link #marketTimeFromEpochSecond} is the + * canonical conversion from the API's Unix-seconds wire format. + *
  • SDK-internal timestamps — when the SDK constructed an error, when a log + * record was emitted, etc. These stay as {@link Instant} (zone-neutral) because they aren't + * market data and converting them to Eastern would imply a semantic they don't have. Display + * layers ({@code MarketDataException.getSupportInfo()}, {@link CanonicalLogFormatter}) render + * those internal timestamps in {@link #MARKET_ZONE} for presentation consistency, but the + * canonical value remains an {@code Instant}. + *
+ */ +final class MarketDataDates { + + /** America/New_York — the zone the API uses for market hours and the SDK surfaces. */ + static final ZoneId MARKET_ZONE = ZoneId.of("America/New_York"); + + private MarketDataDates() {} + + /** Convert a Unix epoch-second timestamp (the API's wire format) to market-zone time. */ + static ZonedDateTime marketTimeFromEpochSecond(long epochSecond) { + return Instant.ofEpochSecond(epochSecond).atZone(MARKET_ZONE); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java index 2a7e028..c8fd6d5 100644 --- a/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java +++ b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java @@ -1,6 +1,6 @@ package com.marketdata.sdk.utilities; -import java.time.Instant; +import java.time.ZonedDateTime; /** * Health of a single API service, as reported by {@code GET /status/}. @@ -15,7 +15,8 @@ * @param online convenience boolean parallel to {@link #status} — server-supplied, not derived. * @param uptimePct30d uptime fraction in the last 30 days, in the range {@code [0.0, 1.0]}. * @param uptimePct90d uptime fraction in the last 90 days, in the range {@code [0.0, 1.0]}. - * @param updated when this entry was last refreshed server-side. + * @param updated when this entry was last refreshed server-side, in {@code America/New_York} (the + * SDK's canonical market-data zone per §13.4 — see {@code MarketDataDates}). */ public record ServiceStatus( String service, @@ -23,4 +24,4 @@ public record ServiceStatus( boolean online, double uptimePct30d, double uptimePct90d, - Instant updated) {} + ZonedDateTime updated) {} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 22aca6c..44ca557 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -432,7 +432,12 @@ void cacheOfflineEntryVetoesA5xxRetry() throws Exception { new com.marketdata.sdk.utilities.ApiStatus( java.util.List.of( new com.marketdata.sdk.utilities.ServiceStatus( - "/v1/markets/status/", "offline", false, 0.5, 0.5, java.time.Instant.EPOCH))); + "/v1/markets/status/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); StatusCache cache = new StatusCache( () -> CompletableFuture.completedFuture(offlineForService), @@ -472,7 +477,12 @@ void cacheOnlineEntryAllowsNormalRetryFlow() throws Exception { new com.marketdata.sdk.utilities.ApiStatus( java.util.List.of( new com.marketdata.sdk.utilities.ServiceStatus( - "/v1/markets/status/", "online", true, 1.0, 1.0, java.time.Instant.EPOCH))); + "/v1/markets/status/", + "online", + true, + 1.0, + 1.0, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); StatusCache cache = new StatusCache( () -> CompletableFuture.completedFuture(online), java.time.Clock.systemUTC()); diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 5a1b78e..9e54d22 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -10,7 +10,6 @@ import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpHeaders; -import java.time.Instant; import java.util.Map; import org.junit.jupiter.api.Test; @@ -130,7 +129,7 @@ void parsesApiStatusByZippingParallelArrays() { assertThat(first.online()).isTrue(); assertThat(first.uptimePct30d()).isEqualTo(1.0); assertThat(first.uptimePct90d()).isEqualTo(0.99828); - assertThat(first.updated()).isEqualTo(Instant.ofEpochSecond(1734036832L)); + assertThat(first.updated()).isEqualTo(MarketDataDates.marketTimeFromEpochSecond(1734036832L)); ServiceStatus second = status.services().get(1); assertThat(second.service()).isEqualTo("/v1/options/chain/"); diff --git a/src/test/java/com/marketdata/sdk/StatusCacheTest.java b/src/test/java/com/marketdata/sdk/StatusCacheTest.java index 0d3681b..71f77aa 100644 --- a/src/test/java/com/marketdata/sdk/StatusCacheTest.java +++ b/src/test/java/com/marketdata/sdk/StatusCacheTest.java @@ -47,7 +47,13 @@ void advance(java.time.Duration by) { private static ApiStatus snapshot(String service, String status) { return new ApiStatus( List.of( - new ServiceStatus(service, status, "online".equals(status), 1.0, 1.0, Instant.EPOCH))); + new ServiceStatus( + service, + status, + "online".equals(status), + 1.0, + 1.0, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); } // ---------- empty cache ---------- @@ -235,9 +241,20 @@ void uriMatchesLongestServicePrefix() { CompletableFuture.completedFuture( new ApiStatus( List.of( - new ServiceStatus("/v1/", "online", true, 1.0, 1.0, Instant.EPOCH), new ServiceStatus( - "/v1/stocks/quotes/", "offline", false, 0.5, 0.6, Instant.EPOCH)))), + "/v1/", + "online", + true, + 1.0, + 1.0, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)), + new ServiceStatus( + "/v1/stocks/quotes/", + "offline", + false, + 0.5, + 0.6, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE))))), clock); cache.triggerRefresh(); From 5abcda4a93dd1acd384bbf2f9623cc11154537c5 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 10:33:12 -0300 Subject: [PATCH 22/57] test added --- .gitignore | 3 +++ build.gradle.kts | 4 ++++ .../sdk/JsonResponseParserTest.java | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/.gitignore b/.gitignore index a958c90..0e5a3de 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db *.log hs_err_pid* replay_pid* + + +.claude/reviews/ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 24a5a70..6823eb4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,6 +138,10 @@ tasks.register("jacocoAggregateReport") { // main's last value) is enforced in CI — see .github/workflows/pull-request.yml // and .github/scripts/check-coverage-delta.py. Not enforced locally so that // dev iteration isn't blocked while coverage is in flux. +// +// SDK requirements §15.3 mandates 100% line coverage with explicit ignore +// comments on untestable lines. Target deferred until business resources land +// and the defensive-guards cleanup pass can run together. spotless { java { diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 9e54d22..76aadd7 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -186,6 +186,25 @@ void apiStatusMismatchedArrayLengthsBecomeParseError() { .hasMessageContaining("mismatched lengths"); } + @Test + void apiStatusNonArrayFieldBecomesParseError() { + // Field exists in the response but is not an array (e.g. a string). The "missing or + // non-array" guard treats this as malformed. + String body = + "{\"s\":\"ok\"," + + "\"service\":\"not-an-array\"," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("service"); + } + @Test void apiStatusMissingArrayBecomesParseError() { // No `online` array — could happen if a backend refactor drops a field; better to fail From 6ec5d87a39d1634bf22466f912798c656329fcac Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 11:57:49 -0300 Subject: [PATCH 23/57] runStartupValidation() skips demo mode --- .../com/marketdata/sdk/MarketDataClient.java | 13 ++++++++++++- .../com/marketdata/sdk/MarketDataClientTest.java | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 3d5df69..a3adabd 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -94,8 +94,19 @@ public UtilitiesResource utilities() { * we close the transport before re-throwing so a partially-constructed client doesn't leak its * HttpClient — the caller's try-with-resources is never triggered if the constructor itself * fails. + * + *

Skipped in demo mode: there is no token to validate, and {@code /v1/user/} would + * deterministically return 401, breaking construction for any consumer who instantiates the SDK + * without a token configured (the "I want to kick the tires" path). + * + *

Package-private so the demo-mode skip can be tested hermetically (i.e. without depending on + * whether {@code MARKETDATA_TOKEN} is set in the runner's environment). */ - private void runStartupValidation() { + void runStartupValidation() { + if (DemoMode.isDemo(config)) { + LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); + return; + } try { utilities.user(); } catch (Throwable t) { diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 545213b..3208a05 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -4,10 +4,12 @@ import java.nio.file.Path; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.io.TempDir; class MarketDataClientTest { @@ -118,6 +120,20 @@ void close_is_idempotent(@TempDir Path tmp) { client.close(); } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { + // §5: when apiKey is unresolvable (demo mode), runStartupValidation must not hit /v1/user/ — + // the server would return 401, breaking construction for any consumer who tries to "kick + // the tires" without a token. The @Timeout guards against regression: if the skip ever + // breaks, the test fails in 5s instead of hanging on the full retry budget (~6.75 min). + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + assertThat(client.toString()).contains("demoMode=true"); + client.runStartupValidation(); // must return immediately, not make a network call + } + } + @Test void quick_start_usage_resolves_real_environment_and_never_leaks_token() { // The no-arg public ctor now hits /v1/user/ for startup validation (§5). Don't exercise From 63c87e5d1c092abe6f444e7f8178ca8cd6065810 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 15:05:25 -0300 Subject: [PATCH 24/57] runStartupValidation do not consume retry budget --- .../com/marketdata/sdk/HttpTransport.java | 19 +++++++++-- .../com/marketdata/sdk/MarketDataClient.java | 4 ++- .../java/com/marketdata/sdk/RetryPolicy.java | 10 ++++++ .../com/marketdata/sdk/UtilitiesResource.java | 25 +++++++++++++++ .../marketdata/sdk/MarketDataClientTest.java | 32 +++++++++++++++++++ .../com/marketdata/sdk/RetryExecutorTest.java | 24 ++++++++++++++ .../com/marketdata/sdk/RetryPolicyTest.java | 17 ++++++++++ 7 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 41a55e1..545a23c 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -141,10 +141,25 @@ private static HttpClient defaultHttpClient() { * subtype via {@link HttpStatusMapper}, possibly after retries. */ CompletableFuture executeAsync(RequestSpec spec) { + return executeAsync(spec, retryExecutor); + } + + /** + * Like {@link #executeAsync(RequestSpec)}, but uses the caller's {@link RetryPolicy} instead of + * the transport's default. Used by callers that need a different retry budget for one specific + * call — e.g. {@link MarketDataClient}'s startup validation, which uses {@link + * RetryPolicy#noRetry()} so a slow/down API surfaces immediately to the constructor. + */ + CompletableFuture executeAsync(RequestSpec spec, RetryPolicy policy) { + return executeAsync(spec, new RetryExecutor(policy)); + } + + private CompletableFuture executeAsync( + RequestSpec spec, RetryExecutor executor) { URI uri = buildUri(spec); HttpRequest request = buildHttpRequest(uri, spec.format()); - RetryPolicy policy = retryExecutor.policy(); - return retryExecutor.execute( + RetryPolicy policy = executor.policy(); + return executor.execute( () -> { // §10.3: pre-flight gate — if our latest snapshot says credits are exhausted, fail // fast without hitting the wire. RateLimitError is non-retriable per §11.2, so the diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index a3adabd..7f9e30f 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -107,8 +107,10 @@ void runStartupValidation() { LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); return; } + // Single-attempt: see RetryPolicy#noRetry for why startup doesn't use the default budget + // (the consumer would otherwise wait up to ~6.75 min if the API is unreachable). try { - utilities.user(); + utilities.user(RetryPolicy.noRetry()); } catch (Throwable t) { try { close(); diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java index 444087d..b1137a6 100644 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -47,6 +47,16 @@ static RetryPolicy defaults() { return new RetryPolicy(4, Duration.ofSeconds(1), Duration.ofSeconds(30)); } + /** + * Single-attempt policy: {@code shouldRetry} always returns {@code false}. Useful for calls where + * retrying does more harm than failing fast — e.g. the startup validation in {@link + * MarketDataClient}, where a slow/down API should surface to the constructor within seconds + * rather than burning the full ~6.75 min default retry budget before throwing. + */ + static RetryPolicy noRetry() { + return new RetryPolicy(1, Duration.ZERO, Duration.ZERO); + } + /** * Whether the SDK should retry after {@code cause}, given that {@code attempt} attempts have * already been spent (zero-indexed: {@code attempt == 0} means the original call just failed and diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 18be3c9..fabc1c8 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -59,6 +59,17 @@ public CompletableFuture userAsync() { return transport.executeAsync(spec).thenApply(env -> parser.parse(env, User.class)); } + /** + * Like {@link #userAsync()}, but driven by a caller-supplied {@link RetryPolicy}. Package-private + * because the only consumer today is {@link MarketDataClient#runStartupValidation()}, which wants + * a single-attempt call so a slow/down API doesn't burn the full retry budget before the + * constructor returns. + */ + CompletableFuture userAsync(RetryPolicy policy) { + RequestSpec spec = RequestSpec.get("user").build(); + return transport.executeAsync(spec, policy).thenApply(env -> parser.parse(env, User.class)); + } + /** * Sync wrapper for {@link #userAsync()}; same {@link CompletionException}-unwrapping semantics as * {@link #headers()}. @@ -73,6 +84,20 @@ public User user() { } } + /** + * Sync wrapper for {@link #userAsync(RetryPolicy)}; package-private. Same companion as {@link + * #user()} for callers that need a custom retry policy. + */ + User user(RetryPolicy policy) { + try { + return userAsync(policy).join(); + } catch (CompletionException e) { + throw HttpTransport.asRuntime(e.getCause()); + } catch (CancellationException e) { + throw HttpTransport.asRuntime(e); + } + } + /** * Async: fetch the per-service health snapshot of the API. Unversioned ({@code /status/} lives at * the API root) and public — works without a token. The server refreshes the snapshot every five diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 3208a05..3035634 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -1,7 +1,10 @@ package com.marketdata.sdk; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.marketdata.sdk.exception.MarketDataException; +import java.net.ServerSocket; import java.nio.file.Path; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -120,6 +123,35 @@ void close_is_idempotent(@TempDir Path tmp) { client.close(); } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void run_startup_validation_fails_fast_when_api_unreachable(@TempDir Path tmp) throws Exception { + // §5 + retry policy: startup validation must use a single-attempt policy so a slow/down API + // doesn't burn the full retry budget (~6.75 min worst case with defaults) before the + // constructor returns. Drive a real connection-refused (closed local port) and assert the + // failure surfaces well below even one default-policy retry would. + int closedPort; + try (ServerSocket s = new ServerSocket(0)) { + closedPort = s.getLocalPort(); + } + String unreachable = "http://127.0.0.1:" + closedPort; + + try (MarketDataClient client = + new MarketDataClient( + "any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + long start = System.nanoTime(); + assertThatThrownBy(client::runStartupValidation).isInstanceOf(MarketDataException.class); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + // With the default retry policy this would have taken ~7 s minimum (1 s + 2 s + 4 s + // backoffs between four attempts). A single-attempt run is bounded by connect-refused + // latency, well under 2 s on any reasonable runner. + assertThat(elapsedMs) + .as("startup validation should not burn the retry budget") + .isLessThan(2000); + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java index b8ca803..4f08d8b 100644 --- a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -105,6 +105,30 @@ void exhaustsAttemptsAndSurfacesLastCause() { assertThat(calls).hasValue(4); // 1 initial + 3 retries } + // ---------- noRetry policy: exactly one attempt regardless of cause ---------- + + @Test + void noRetryPolicyInvokesSupplierExactlyOnceOnFailure() { + // RetryPolicy.noRetry() is the policy MarketDataClient#runStartupValidation uses to ensure + // a slow/down API can't burn the full retry budget before the constructor returns. Verify + // the supplier is invoked exactly once even for the most retriable failure shape. + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(RetryPolicy.noRetry()); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class); + + assertThat(calls).hasValue(1); + } + // ---------- non-retriable surfaces immediately ---------- @Test diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java index fa9171e..c15c37d 100644 --- a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java +++ b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java @@ -213,4 +213,21 @@ void rejectsNonPositiveMaxAttempts() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("maxAttempts"); } + + // ---------- noRetry factory ---------- + + @Test + void noRetryFactoryNeverAllowsRetry() { + // The factory caters to callers (startup validation today) that need a single-attempt call. + // shouldRetry must return false for any retriable cause from attempt 0 onward — otherwise + // the caller's deadline assumption is wrong. + RetryPolicy single = RetryPolicy.noRetry(); + + NetworkError retriable = + new NetworkError("net", ctxNoResponse(), new java.io.IOException("transport down")); + ServerError retriable5xx = new ServerError("503", ctxWithStatus(503)); + + assertThat(single.shouldRetry(retriable, 0)).isFalse(); + assertThat(single.shouldRetry(retriable5xx, 0)).isFalse(); + } } From 9333a9dbfefbb6812d486744dd88ceebd7a5ca16 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 15:20:20 -0300 Subject: [PATCH 25/57] close() now dains waiters --- .../com/marketdata/sdk/AsyncSemaphore.java | 44 ++++++++++++ .../com/marketdata/sdk/HttpDispatcher.java | 18 ++++- .../com/marketdata/sdk/HttpTransport.java | 6 +- .../marketdata/sdk/AsyncSemaphoreTest.java | 69 +++++++++++++++++++ .../marketdata/sdk/HttpDispatcherTest.java | 66 ++++++++++++++++++ 5 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java index 7945108..b58b240 100644 --- a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java +++ b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java @@ -1,7 +1,10 @@ package com.marketdata.sdk; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; /** @@ -29,6 +32,7 @@ final class AsyncSemaphore { private final Object lock = new Object(); private final Deque> waiters = new ArrayDeque<>(); private int available; + private boolean closed; AsyncSemaphore(int permits) { if (permits < 0) { @@ -43,9 +47,15 @@ final class AsyncSemaphore { *

Fast path: a permit is available, returns an already-completed future. Slow path: pool is * exhausted, returns a pending future enqueued FIFO; it completes when some in-flight caller * calls {@link #release()}. Either way, the caller's thread is never parked. + * + *

After {@link #close()} every acquire fails immediately with {@link CancellationException}; + * waiters queued before the close were already drained with the same exception. */ CompletableFuture acquire() { synchronized (lock) { + if (closed) { + return CompletableFuture.failedFuture(closedException()); + } if (available > 0) { available--; return CompletableFuture.completedFuture(null); @@ -99,4 +109,38 @@ int queueLength() { return waiters.size(); } } + + /** + * Drain the waiter queue and reject future {@link #acquire()} calls. All currently-queued waiters + * are completed exceptionally with {@link CancellationException} so the {@code thenCompose} chain + * downstream of the dispatcher fails cleanly instead of leaving futures pending forever when the + * owning client is closed mid-flight. + * + *

Idempotent: subsequent calls are no-ops. Permits already held by in-flight callers can still + * be {@link #release()}d (the counter accepts it harmlessly) — this matters because cancellation + * of a dispatched future cancels its permit, and that cancel-then-release path must continue to + * work even after close. + * + *

Completion of drained waiters runs outside the lock for the same reason {@link + * #release()} does it that way: completing a future runs callbacks synchronously, and we never + * want those running with our lock held. + */ + void close() { + List> drained; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + drained = new ArrayList<>(waiters); + waiters.clear(); + } + for (CompletableFuture w : drained) { + w.completeExceptionally(closedException()); + } + } + + private static CancellationException closedException() { + return new CancellationException("AsyncSemaphore is closed"); + } } diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index 25abec5..ab1be25 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -26,7 +26,7 @@ * Status-code interpretation lives in {@link HttpTransport}, not here — this class is below the * "what does HTTP 4xx mean" abstraction. */ -final class HttpDispatcher { +final class HttpDispatcher implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(HttpDispatcher.class.getName()); @@ -132,6 +132,22 @@ int queueLength() { return permits.queueLength(); } + /** + * Drains the semaphore's waiter queue and rejects subsequent {@link #dispatch} calls; waiters + * fail with {@link java.util.concurrent.CancellationException} so the chained future of every + * pending caller resolves cleanly instead of leaking forever. + * + *

Does not cancel in-flight HTTP sends: those run inside {@code HttpClient}, which + * has no {@code close()} until JDK 21 (ADR-002). When the SDK bumps to JDK 21+ this method should + * also close the {@code HttpClient}. + * + *

Idempotent. + */ + @Override + public void close() { + permits.close(); + } + private static Throwable unwrap(Throwable t) { return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; } diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 545a23c..022950c 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -221,8 +221,10 @@ HttpResponseEnvelope executeSync(RequestSpec spec) { @Override public void close() { - // java.net.http.HttpClient gained explicit close() in JDK 21; until the SDK's minimum - // bumps to 21+ this is a no-op (ADR-002). + // Drains the dispatcher's semaphore so pending waiters surface CancellationException instead + // of hanging forever. java.net.http.HttpClient gained explicit close() in JDK 21; until the + // SDK's minimum bumps to 21+ in-flight HTTP sends still run to completion (ADR-002). + dispatcher.close(); } // ---------- private helpers ---------- diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java index a15d572..1c9c3b6 100644 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CyclicBarrier; import org.junit.jupiter.api.RepeatedTest; @@ -208,6 +209,74 @@ private static void awaitBarrier(CyclicBarrier barrier) { } } + // ---------- close ---------- + + @Test + void closeCompletesAllQueuedWaitersWithCancellation() { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); // pool empty + + CompletableFuture w1 = sem.acquire(); + CompletableFuture w2 = sem.acquire(); + CompletableFuture w3 = sem.acquire(); + + sem.close(); + + // CompletableFuture#join unwraps CancellationException specifically: it surfaces directly + // rather than being wrapped in CompletionException. That's the same propagation downstream + // observers see, so we assert the bare exception shape here. + for (CompletableFuture w : List.of(w1, w2, w3)) { + assertThat(w).isCompletedExceptionally(); + assertThatThrownBy(w::join) + .isInstanceOf(CancellationException.class) + .hasMessageContaining("closed"); + } + assertThat(sem.queueLength()).isZero(); + } + + @Test + void acquireAfterCloseReturnsFailedFutureImmediately() { + AsyncSemaphore sem = new AsyncSemaphore(5); + sem.close(); + + CompletableFuture failed = sem.acquire(); + + assertThat(failed).isCompletedExceptionally(); + assertThatThrownBy(failed::join) + .isInstanceOf(CancellationException.class) + .hasMessageContaining("closed"); + assertThat(sem.queueLength()).isZero(); + } + + @Test + void closeIsIdempotent() { + AsyncSemaphore sem = new AsyncSemaphore(1); + CompletableFuture waiter = sem.acquire(); // takes the only permit + CompletableFuture queued = sem.acquire(); + + sem.close(); + sem.close(); // must be safe + + // First close completed the queued waiter; the second close has nothing to do. + assertThat(queued).isCompletedExceptionally(); + // And the in-flight holder of the permit can still release without exploding. + assertThat(waiter).isCompleted(); + sem.release(); + } + + @Test + void releaseAfterCloseDoesNotExplode() { + // After close the queue is empty, so release() falls through to the counter. Critical for + // the cancel-permit-after-close path: HttpDispatcher cancels the permit when its dispatched + // future is cancelled, and that cancellation may race close(). + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); + sem.close(); + + sem.release(); + assertThat(sem.availablePermits()).isOne(); + } + // ---------- argument validation ---------- @Test diff --git a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java index adf2147..a72cca6 100644 --- a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java +++ b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java @@ -11,6 +11,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Map; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.junit.jupiter.api.Test; @@ -151,4 +152,69 @@ void cancellingQueuedDispatchMarksWaiterAndPermitReturnsToPool() { assertThat(dispatcher.queueLength()).isZero(); assertThat(dispatcher.availablePermits()).isOne(); } + + // ---------- close drains queued waiters ---------- + + /** + * Without {@code close()} drain, a queued waiter sits in the semaphore forever when the owning + * client is shut down — the {@code thenCompose} chain hanging off it never resolves and the + * caller's future is leaked. After close, every queued future must fail with {@link + * CancellationException} so the consumer's await unblocks cleanly. + */ + @Test + void closeDrainsQueuedDispatchesWithCancellation() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + // Concurrency = 1 so the second dispatch is guaranteed to queue. + HttpDispatcher dispatcher = new HttpDispatcher(client, 1); + + CompletableFuture> inflight = dispatcher.dispatch(req()); + CompletableFuture> queued = dispatcher.dispatch(req()); + + assertThat(dispatcher.queueLength()).isOne(); + + dispatcher.close(); + + // The queued waiter was sitting in the semaphore, downstream of a thenCompose. Closing the + // semaphore completes it with CancellationException; the queued future surfaces it as a + // CompletionException -> CancellationException, matching how a cancelled future propagates + // through the rest of the pipeline. + assertThat(queued).isCompletedExceptionally(); + // The semaphore-level future failed with CancellationException directly, but the dispatcher + // chains it through thenCompose: that propagation wraps in CompletionException (per + // CompletionStage contract — only the original cancel propagates "bare"). + assertThatThrownBy(queued::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(CancellationException.class); + + // The in-flight send remains running (HttpClient.close() is JDK 21+). We let it complete so + // the test doesn't leave an orphan future. + HttpResponse ok = + TestHttpClients.response( + 200, + "ok".getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/ping")); + client.completeAll(ok); + assertThat(inflight).isCompleted(); + } + + @Test + void dispatchAfterCloseFailsImmediately() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.Controllable(), 4); + dispatcher.close(); + + CompletableFuture> failed = dispatcher.dispatch(req()); + + assertThat(failed).isCompletedExceptionally(); + assertThatThrownBy(failed::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(CancellationException.class); + } + + @Test + void closeIsIdempotent() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.Controllable(), 4); + dispatcher.close(); + dispatcher.close(); // must be safe + } } From 9190a13d3c5b4e0b7d59bdbabf8a43029a6fdac0 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 15:35:52 -0300 Subject: [PATCH 26/57] HttpTransport ctrs reduce --- .../com/marketdata/sdk/HttpTransport.java | 69 +++++++++---------- .../com/marketdata/sdk/MarketDataClient.java | 2 +- .../com/marketdata/sdk/HttpTransportTest.java | 6 +- .../marketdata/sdk/UtilitiesResourceTest.java | 3 +- 4 files changed, 38 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 022950c..a046bf6 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -64,44 +64,16 @@ final class HttpTransport implements AutoCloseable { private final String userAgent; private final @Nullable String token; - HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) { - this(baseUrl, apiVersion, userAgent, token, () -> null); - } - - // Default-infra ctor with a status-cache supplier. MarketDataClient uses this so the §9.5 - // gate is wired without each caller having to assemble the dispatcher + retry executor. - HttpTransport( - String baseUrl, - String apiVersion, - String userAgent, - @Nullable String token, - Supplier<@Nullable StatusCache> statusCacheSupplier) { - this( - baseUrl, - apiVersion, - userAgent, - token, - new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), - new RetryExecutor(RetryPolicy.defaults()), - statusCacheSupplier); - } - - // Package-private constructor for tests: inject a stubbed dispatcher and/or a fast retry - // policy. The status cache is omitted on this path (no §9.5 gate) so existing tests don't - // need to thread one through; tests that exercise the gate use the 7-arg overload. - HttpTransport( - String baseUrl, - String apiVersion, - String userAgent, - @Nullable String token, - HttpDispatcher dispatcher, - RetryExecutor retryExecutor) { - this(baseUrl, apiVersion, userAgent, token, dispatcher, retryExecutor, () -> null); - } - - // Full ctor with status-cache supplier. The supplier is consulted on every executeAsync call - // so MarketDataClient can construct the cache AFTER the transport (the cache's fetcher uses - // the transport via UtilitiesResource — chicken-and-egg resolved by a deferred reference). + /** + * Canonical constructor — all dependencies explicit. Production code uses {@link + * #withDefaults(String, String, String, String, Supplier)} which assembles real defaults; tests + * call this directly with stubs. + * + *

The {@code statusCacheSupplier} is consulted on every {@link #executeAsync} call so {@link + * MarketDataClient} can construct the cache after the transport (the cache's fetcher + * uses the transport via {@link UtilitiesResource} — the chicken-and-egg is resolved by a + * deferred reference). Pass {@code () -> null} when no §9.5 gate is desired (e.g. tests). + */ HttpTransport( String baseUrl, String apiVersion, @@ -119,6 +91,27 @@ final class HttpTransport implements AutoCloseable { this.statusCacheSupplier = statusCacheSupplier; } + /** + * Production factory: assembles a real {@link HttpDispatcher} (50-permit pool + JDK {@link + * HttpClient}) and a default {@link RetryExecutor} (4 attempts, exponential 1s→30s). Used by + * {@link MarketDataClient}. + */ + static HttpTransport withDefaults( + String baseUrl, + String apiVersion, + String userAgent, + @Nullable String token, + Supplier<@Nullable StatusCache> statusCacheSupplier) { + return new HttpTransport( + baseUrl, + apiVersion, + userAgent, + token, + new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), + new RetryExecutor(RetryPolicy.defaults()), + statusCacheSupplier); + } + private static HttpClient defaultHttpClient() { return HttpClient.newBuilder() .connectTimeout(CONNECT_TIMEOUT) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 7f9e30f..5b03da4 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -67,7 +67,7 @@ public MarketDataClient( // which returns null until the cache is constructed (just below this transport instance). AtomicReference cacheRef = new AtomicReference<>(); this.transport = - new HttpTransport( + HttpTransport.withDefaults( config.baseUrl(), config.apiVersion(), "marketdata-sdk-java/" + Version.sdkVersion(), diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 44ca557..553fdd6 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -34,7 +34,8 @@ private static HttpTransport newTransport(HttpClient client) { "test/0.0", "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), - new RetryExecutor(NO_RETRY)); + new RetryExecutor(NO_RETRY), + () -> null); } // ---------- URL & header composition ---------- @@ -84,7 +85,8 @@ void noAuthorizationHeaderWhenTokenIsNull() { "test/0.0", null, new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), - new RetryExecutor(NO_RETRY)); + new RetryExecutor(NO_RETRY), + () -> null); transport.executeAsync(RequestSpec.get("markets/status").build()).join(); diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 6dee37e..861d39b 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -33,7 +33,8 @@ private static UtilitiesResource resourceWith(HttpClient client) { "test/0.0", "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), - new RetryExecutor(NO_RETRY)); + new RetryExecutor(NO_RETRY), + () -> null); return new UtilitiesResource(transport, new JsonResponseParser()); } From 0cdc97adb5a6ff7c491707f2f9facfdf0d050868 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 15:41:16 -0300 Subject: [PATCH 27/57] MarketDataClient ctr rduce --- .../com/marketdata/sdk/MarketDataClient.java | 21 +++-- .../marketdata/sdk/MarketDataClientTest.java | 79 +++++++++---------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 5b03da4..52003f9 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -24,30 +24,27 @@ public MarketDataClient( @Nullable String baseUrl, @Nullable String apiVersion, boolean validateOnStartup) { - // Delegate with validateOnStartup=false so the inner ctor's runnable seam stays a no-op on - // this path — we run the real validation below, where `this.utilities` is reachable. - // Tests still drive the runnable seam directly via the 7-arg ctor. this( apiKey, baseUrl, apiVersion, - /* validateOnStartup */ false, + validateOnStartup, EnvVars.systemLookup(), - Configuration.DEFAULT_DOTENV_PATH, - () -> {}); - if (validateOnStartup) { - runStartupValidation(); - } + Configuration.DEFAULT_DOTENV_PATH); } + /** + * Package-private ctor with the env-lookup and dotEnv-path seams exposed so tests can drive the + * configuration cascade hermetically. The public 4-arg ctor delegates here with {@link + * EnvVars#systemLookup()} and {@link Configuration#DEFAULT_DOTENV_PATH}. + */ MarketDataClient( @Nullable String apiKey, @Nullable String baseUrl, @Nullable String apiVersion, boolean validateOnStartup, Function env, - Path dotEnvPath, - Runnable startupValidator) { + Path dotEnvPath) { this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); MarketDataLogging.configure(config.loggingLevel()); LOGGER.info( @@ -78,7 +75,7 @@ public MarketDataClient( cacheRef.set(new StatusCache(utilities::statusAsync, Clock.systemUTC())); if (validateOnStartup) { - startupValidator.run(); + runStartupValidation(); } } diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 3035634..4faf3c6 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -8,7 +8,6 @@ import java.nio.file.Path; import java.util.Map; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -18,7 +17,6 @@ class MarketDataClientTest { private static final Function NO_ENV = key -> null; - private static final Runnable NO_VALIDATION = () -> {}; private static Function envOf(Map values) { return values::get; @@ -28,10 +26,19 @@ private static Path noDotEnv(Path tmp) { return tmp.resolve("missing.env"); } + /** Reserves a fresh port and immediately releases it so connects target a known-closed socket. */ + private static String reserveClosedLocalUrl() throws Exception { + int port; + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + return "http://127.0.0.1:" + port; + } + @Test void no_arg_constructor_resolves_defaults_and_returns_empty_rate_limits(@TempDir Path tmp) { try (MarketDataClient client = - new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); } } @@ -40,13 +47,7 @@ void no_arg_constructor_resolves_defaults_and_returns_empty_rate_limits(@TempDir void four_arg_constructor_uses_explicit_values(@TempDir Path tmp) { try (MarketDataClient client = new MarketDataClient( - "explicit-key", - "https://explicit.example", - "v9", - false, - NO_ENV, - noDotEnv(tmp), - NO_VALIDATION)) { + "explicit-key", "https://explicit.example", "v9", false, NO_ENV, noDotEnv(tmp))) { assertThat(client.toString()) .contains("baseUrl=https://explicit.example") .contains("apiVersion=v9") @@ -58,8 +59,7 @@ void four_arg_constructor_uses_explicit_values(@TempDir Path tmp) { @Test void to_string_redacts_token(@TempDir Path tmp) { try (MarketDataClient client = - new MarketDataClient( - "supersecret-token-YKT0", null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + new MarketDataClient("supersecret-token-YKT0", null, null, false, NO_ENV, noDotEnv(tmp))) { String repr = client.toString(); assertThat(repr).doesNotContain("supersecret-token-YKT0"); @@ -70,32 +70,38 @@ void to_string_redacts_token(@TempDir Path tmp) { @Test void to_string_shows_demo_mode_when_no_api_key(@TempDir Path tmp) { try (MarketDataClient client = - new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { assertThat(client.toString()).contains("demoMode=true"); } } - @Test - void validate_on_startup_true_invokes_validator(@TempDir Path tmp) { - AtomicInteger calls = new AtomicInteger(); + // ---------- validateOnStartup wiring (end-to-end, no Runnable seam) ---------- - try (MarketDataClient client = - new MarketDataClient( - "key", null, null, true, NO_ENV, noDotEnv(tmp), calls::incrementAndGet)) { - assertThat(calls.get()).isEqualTo(1); - assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); - } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void validate_on_startup_true_attempts_validation(@TempDir Path tmp) throws Exception { + // With a non-demo token and an unreachable baseUrl, the ctor must attempt the /user/ call + // and surface the failure to the caller. If the validation hook ever gets disconnected from + // the ctor flow, this test fails because construction would succeed silently. + String unreachable = reserveClosedLocalUrl(); + + assertThatThrownBy( + () -> new MarketDataClient("any-token", unreachable, null, true, NO_ENV, noDotEnv(tmp))) + .isInstanceOf(MarketDataException.class); } @Test - void validate_on_startup_false_does_not_invoke_validator(@TempDir Path tmp) { - AtomicInteger calls = new AtomicInteger(); + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void validate_on_startup_false_skips_validation_even_with_token(@TempDir Path tmp) + throws Exception { + // Symmetric case: a non-demo token + unreachable baseUrl + validateOnStartup=false must + // construct cleanly. Any latent path that fires validation despite the flag would surface + // here as a thrown ctor. + String unreachable = reserveClosedLocalUrl(); try (MarketDataClient client = - new MarketDataClient( - "key", null, null, false, NO_ENV, noDotEnv(tmp), calls::incrementAndGet)) { - assertThat(calls.get()).isZero(); - assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + new MarketDataClient("any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp))) { + assertThat(client.toString()).contains("demoMode=false"); } } @@ -108,16 +114,14 @@ void resolves_token_from_env_when_not_provided_explicitly(@TempDir Path tmp) { null, false, envOf(Map.of(EnvVars.TOKEN, "env-token-ABCD")), - noDotEnv(tmp), - NO_VALIDATION)) { + noDotEnv(tmp))) { assertThat(client.toString()).contains("***…***ABCD").contains("demoMode=false"); } } @Test void close_is_idempotent(@TempDir Path tmp) { - MarketDataClient client = - new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION); + MarketDataClient client = new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp)); client.close(); client.close(); @@ -130,15 +134,10 @@ void run_startup_validation_fails_fast_when_api_unreachable(@TempDir Path tmp) t // doesn't burn the full retry budget (~6.75 min worst case with defaults) before the // constructor returns. Drive a real connection-refused (closed local port) and assert the // failure surfaces well below even one default-policy retry would. - int closedPort; - try (ServerSocket s = new ServerSocket(0)) { - closedPort = s.getLocalPort(); - } - String unreachable = "http://127.0.0.1:" + closedPort; + String unreachable = reserveClosedLocalUrl(); try (MarketDataClient client = - new MarketDataClient( - "any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + new MarketDataClient("any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp))) { long start = System.nanoTime(); assertThatThrownBy(client::runStartupValidation).isInstanceOf(MarketDataException.class); long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); @@ -160,7 +159,7 @@ void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { // the tires" without a token. The @Timeout guards against regression: if the skip ever // breaks, the test fails in 5s instead of hanging on the full retry budget (~6.75 min). try (MarketDataClient client = - new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp), NO_VALIDATION)) { + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { assertThat(client.toString()).contains("demoMode=true"); client.runStartupValidation(); // must return immediately, not make a network call } From 3f3746140d7cbec77998ad2b7f38c18cc944f94f Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 15:46:31 -0300 Subject: [PATCH 28/57] optimize executeSync --- .../com/marketdata/sdk/HttpTransport.java | 26 ++++++--- .../com/marketdata/sdk/UtilitiesResource.java | 55 +++---------------- 2 files changed, 27 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index a046bf6..96cf6a6 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -203,13 +203,7 @@ private boolean cacheAllowsRetry(URI uri) { * {@link CompletionException} so callers see the underlying {@link MarketDataException}. */ HttpResponseEnvelope executeSync(RequestSpec spec) { - try { - return executeAsync(spec).join(); - } catch (CompletionException e) { - throw asRuntime(e.getCause()); - } catch (CancellationException e) { - throw asRuntime(e); - } + return joinSync(executeAsync(spec)); } @Override @@ -312,6 +306,24 @@ private HttpRequest buildHttpRequest(URI uri, Format format) { return b.build(); } + /** + * Sync bridge for resource façades: waits on {@code future}, unwrapping {@link + * CompletionException} so the caller sees the underlying {@link MarketDataException} directly + * (ADR-006), and routing cancellations through {@link #asRuntime} so the surface is uniform. + * + *

One place to fix the sync semantics; every {@code public T xxx()} wrapper in a resource is + * just {@code return joinSync(xxxAsync())}. + */ + static T joinSync(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + throw asRuntime(e.getCause()); + } catch (CancellationException e) { + throw asRuntime(e); + } + } + // Visible for tests: under the current SDK design, executeAsync always wraps failures as // MarketDataException so the MDE branch is the only one reached from the public surface. // The other two branches are defensive guardrails — extracted so they can be exercised diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index fabc1c8..e1027aa 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -1,12 +1,9 @@ package com.marketdata.sdk; -import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; -import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; /** * System endpoints documented at {@code https://api.marketdata.app/docs/api/utilities/}. None of @@ -35,18 +32,9 @@ public CompletableFuture headersAsync() { return transport.executeAsync(spec).thenApply(env -> parser.parse(env, RequestHeaders.class)); } - /** - * Sync wrapper for {@link #headersAsync()}. Per ADR-006, calls {@code .join()} and unwraps {@link - * CompletionException} so the caller sees the underlying {@link MarketDataException} directly. - */ + /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ public RequestHeaders headers() { - try { - return headersAsync().join(); - } catch (CompletionException e) { - throw HttpTransport.asRuntime(e.getCause()); - } catch (CancellationException e) { - throw HttpTransport.asRuntime(e); - } + return HttpTransport.joinSync(headersAsync()); } /** @@ -70,32 +58,14 @@ CompletableFuture userAsync(RetryPolicy policy) { return transport.executeAsync(spec, policy).thenApply(env -> parser.parse(env, User.class)); } - /** - * Sync wrapper for {@link #userAsync()}; same {@link CompletionException}-unwrapping semantics as - * {@link #headers()}. - */ + /** Sync wrapper for {@link #userAsync()}. */ public User user() { - try { - return userAsync().join(); - } catch (CompletionException e) { - throw HttpTransport.asRuntime(e.getCause()); - } catch (CancellationException e) { - throw HttpTransport.asRuntime(e); - } + return HttpTransport.joinSync(userAsync()); } - /** - * Sync wrapper for {@link #userAsync(RetryPolicy)}; package-private. Same companion as {@link - * #user()} for callers that need a custom retry policy. - */ + /** Sync wrapper for {@link #userAsync(RetryPolicy)}; package-private. */ User user(RetryPolicy policy) { - try { - return userAsync(policy).join(); - } catch (CompletionException e) { - throw HttpTransport.asRuntime(e.getCause()); - } catch (CancellationException e) { - throw HttpTransport.asRuntime(e); - } + return HttpTransport.joinSync(userAsync(policy)); } /** @@ -108,17 +78,8 @@ public CompletableFuture statusAsync() { return transport.executeAsync(spec).thenApply(env -> parser.parse(env, ApiStatus.class)); } - /** - * Sync wrapper for {@link #statusAsync()}; same {@link CompletionException}-unwrapping semantics - * as {@link #headers()} and {@link #user()}. - */ + /** Sync wrapper for {@link #statusAsync()}. */ public ApiStatus status() { - try { - return statusAsync().join(); - } catch (CompletionException e) { - throw HttpTransport.asRuntime(e.getCause()); - } catch (CancellationException e) { - throw HttpTransport.asRuntime(e); - } + return HttpTransport.joinSync(statusAsync()); } } From efccef1fc3c26a83aaeb1e9109f7ccced2558a45 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 16:40:18 -0300 Subject: [PATCH 29/57] add response object and methods --- .../com/marketdata/sdk/MarketDataClient.java | 4 +- .../java/com/marketdata/sdk/Response.java | 138 ++++++++++++++++ .../com/marketdata/sdk/UtilitiesResource.java | 45 ++++-- .../java/com/marketdata/sdk/ResponseTest.java | 151 ++++++++++++++++++ .../marketdata/sdk/UtilitiesResourceTest.java | 37 ++++- 5 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/Response.java create mode 100644 src/test/java/com/marketdata/sdk/ResponseTest.java diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 52003f9..0216084 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -72,7 +72,9 @@ public MarketDataClient( cacheRef::get); JsonResponseParser parser = new JsonResponseParser(); this.utilities = new UtilitiesResource(transport, parser); - cacheRef.set(new StatusCache(utilities::statusAsync, Clock.systemUTC())); + cacheRef.set( + new StatusCache( + () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); if (validateOnStartup) { runStartupValidation(); diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java new file mode 100644 index 0000000..3431591 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -0,0 +1,138 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, + * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), + * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} + * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. + * + *

The {@link Format} enum is intentionally not exposed publicly (it has private values like + * {@code HTML} that consumers shouldn't depend on). Consumers query format via the boolean + * accessors. + * + *

Immutable. {@link #rawBody()} returns a defensive copy on every call. + * + * @param the typed deserialization of {@link #rawBody()}. + */ +public final class Response { + + private final T data; + private final byte[] rawBody; + private final Format format; + private final int statusCode; + private final @Nullable String requestId; + private final URI requestUrl; + + private Response( + T data, + byte[] rawBody, + Format format, + int statusCode, + @Nullable String requestId, + URI requestUrl) { + this.data = Objects.requireNonNull(data, "data"); + this.rawBody = Objects.requireNonNull(rawBody, "rawBody").clone(); + this.format = Objects.requireNonNull(format, "format"); + this.statusCode = statusCode; + this.requestId = requestId; + this.requestUrl = Objects.requireNonNull(requestUrl, "requestUrl"); + } + + /** + * Package-private factory used by resource façades. Resources parse the envelope's body to a + * typed {@code T}, then wrap. + */ + static Response wrap(T data, HttpResponseEnvelope envelope, Format format) { + return new Response<>( + data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); + } + + /** The typed deserialization. Never {@code null}. */ + public T data() { + return data; + } + + /** + * Defensive copy of the raw response bytes. Mutating the result does not affect this response. + */ + public byte[] rawBody() { + return rawBody.clone(); + } + + /** HTTP status code (currently one of 200, 203, 404). */ + public int statusCode() { + return statusCode; + } + + /** Absolute URL the response came from. */ + public URI requestUrl() { + return requestUrl; + } + + /** + * Server-provided request id (Cloudflare {@code cf-ray}). Empty when the response did not carry + * one — useful when correlating with the support team. + */ + public Optional requestId() { + return Optional.ofNullable(requestId); + } + + public boolean isJson() { + return format == Format.JSON; + } + + public boolean isCsv() { + return format == Format.CSV; + } + + public boolean isHtml() { + return format == Format.HTML; + } + + /** + * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 + * for that envelope (it is a successful "we have nothing for that query", not an error), so we + * gate on the status code rather than parsing the body. + */ + public boolean isNoData() { + return statusCode == 404; + } + + /** + * Write the raw body verbatim to {@code path}, creating or overwriting it. The on-disk content + * matches what the server sent — if you requested {@code ?format=csv}, you get CSV. Errors are + * rewrapped as {@link UncheckedIOException} so {@code saveToFile} fits naturally inside a fluent + * call chain. + */ + public void saveToFile(Path path) { + try { + Files.write(path, rawBody); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write response body to " + path, e); + } + } + + @Override + public String toString() { + return "Response[status=" + + statusCode + + ", format=" + + format.name().toLowerCase(java.util.Locale.ROOT) + + ", bytes=" + + rawBody.length + + ", url=" + + requestUrl + + ", data=" + + data + + "]"; + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index e1027aa..10b78b4 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -11,6 +11,10 @@ * *

Constructed once per {@link MarketDataClient}; the consumer reaches it through {@code * client.utilities()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. + * + *

Every endpoint returns a {@link Response} carrying both the typed model and the raw body so + * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without + * the resource caring about format choice. */ public final class UtilitiesResource { @@ -27,13 +31,13 @@ public final class UtilitiesResource { * {@code Authorization}) redacted server-side. Useful for diagnosing auth issues from a deployed * consumer. */ - public CompletableFuture headersAsync() { + public CompletableFuture> headersAsync() { RequestSpec spec = RequestSpec.get("headers").unversioned().build(); - return transport.executeAsync(spec).thenApply(env -> parser.parse(env, RequestHeaders.class)); + return executeAndWrap(spec, RequestHeaders.class); } /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ - public RequestHeaders headers() { + public Response headers() { return HttpTransport.joinSync(headersAsync()); } @@ -42,9 +46,8 @@ public RequestHeaders headers() { * {@link com.marketdata.sdk.exception.AuthenticationError}) when no billing plan is associated * with the token — the typical use case for {@code validateOnStartup}. */ - public CompletableFuture userAsync() { - RequestSpec spec = RequestSpec.get("user").build(); - return transport.executeAsync(spec).thenApply(env -> parser.parse(env, User.class)); + public CompletableFuture> userAsync() { + return executeAndWrap(RequestSpec.get("user").build(), User.class); } /** @@ -53,18 +56,17 @@ public CompletableFuture userAsync() { * a single-attempt call so a slow/down API doesn't burn the full retry budget before the * constructor returns. */ - CompletableFuture userAsync(RetryPolicy policy) { - RequestSpec spec = RequestSpec.get("user").build(); - return transport.executeAsync(spec, policy).thenApply(env -> parser.parse(env, User.class)); + CompletableFuture> userAsync(RetryPolicy policy) { + return executeAndWrap(RequestSpec.get("user").build(), policy, User.class); } /** Sync wrapper for {@link #userAsync()}. */ - public User user() { + public Response user() { return HttpTransport.joinSync(userAsync()); } /** Sync wrapper for {@link #userAsync(RetryPolicy)}; package-private. */ - User user(RetryPolicy policy) { + Response user(RetryPolicy policy) { return HttpTransport.joinSync(userAsync(policy)); } @@ -73,13 +75,28 @@ User user(RetryPolicy policy) { * the API root) and public — works without a token. The server refreshes the snapshot every five * minutes; polling more often than that is wasted work. */ - public CompletableFuture statusAsync() { + public CompletableFuture> statusAsync() { RequestSpec spec = RequestSpec.get("status").unversioned().build(); - return transport.executeAsync(spec).thenApply(env -> parser.parse(env, ApiStatus.class)); + return executeAndWrap(spec, ApiStatus.class); } /** Sync wrapper for {@link #statusAsync()}. */ - public ApiStatus status() { + public Response status() { return HttpTransport.joinSync(statusAsync()); } + + // ---------- internal helpers ---------- + + private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { + return transport + .executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + } + + private CompletableFuture> executeAndWrap( + RequestSpec spec, RetryPolicy policy, Class type) { + return transport + .executeAsync(spec, policy) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + } } diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java new file mode 100644 index 0000000..8eb428f --- /dev/null +++ b/src/test/java/com/marketdata/sdk/ResponseTest.java @@ -0,0 +1,151 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ResponseTest { + + private static HttpResponseEnvelope env(byte[] body, int status, String url) { + return new HttpResponseEnvelope( + body, status, "req-id-123", HttpHeaders.of(Map.of(), (a, b) -> true), URI.create(url)); + } + + // ---------- typed accessors ---------- + + @Test + void exposesDataStatusAndUrl() { + Response r = + Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); + + assertThat(r.data()).isEqualTo("payload"); + assertThat(r.statusCode()).isEqualTo(200); + assertThat(r.requestUrl()).isEqualTo(URI.create("http://x/y")); + assertThat(r.requestId()).contains("req-id-123"); + } + + @Test + void requestIdEmptyWhenServerOmitsIt() { + HttpResponseEnvelope e = + new HttpResponseEnvelope( + "x".getBytes(), + 200, + null, + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://x")); + Response r = Response.wrap("data", e, Format.JSON); + + assertThat(r.requestId()).isEmpty(); + } + + // ---------- format detection ---------- + + @Test + void formatDetectionExposesBooleansOnly() { + // The Format enum itself is package-private (HTML is hidden from consumers per ADR). + // Consumers only see the booleans. + Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); + Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); + Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); + + assertThat(json.isJson()).isTrue(); + assertThat(json.isCsv()).isFalse(); + assertThat(json.isHtml()).isFalse(); + + assertThat(csv.isCsv()).isTrue(); + assertThat(csv.isJson()).isFalse(); + + assertThat(html.isHtml()).isTrue(); + assertThat(html.isJson()).isFalse(); + } + + // ---------- no-data ---------- + + @Test + void isNoDataReflects404Convention() { + Response ok = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); + Response noData = Response.wrap("d", env("d".getBytes(), 404, "http://x"), Format.JSON); + + assertThat(ok.isNoData()).isFalse(); + assertThat(noData.isNoData()).isTrue(); + } + + // ---------- raw body immutability ---------- + + @Test + void rawBodyReturnsDefensiveCopy() { + byte[] source = "hello".getBytes(); + Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); + + byte[] firstCopy = r.rawBody(); + firstCopy[0] = 'X'; // mutate the returned array + byte[] secondCopy = r.rawBody(); + + assertThat(secondCopy[0]) + .as("internal state must not be affected by mutation") + .isEqualTo((byte) 'h'); + } + + @Test + void constructorCopiesIncomingRawBody() { + // Symmetric: the constructor must clone the input so mutations to the source after + // construction don't bleed into the Response. + byte[] source = "hello".getBytes(); + Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); + source[0] = 'X'; + + assertThat(r.rawBody()[0]).isEqualTo((byte) 'h'); + } + + // ---------- saveToFile ---------- + + @Test + void saveToFileWritesRawBodyVerbatim(@TempDir Path tmp) throws IOException { + byte[] body = "alpha,beta\n1,2\n".getBytes(); + Response r = Response.wrap("ignored", env(body, 200, "http://x"), Format.CSV); + + Path target = tmp.resolve("out.csv"); + r.saveToFile(target); + + assertThat(Files.readAllBytes(target)).isEqualTo(body); + } + + @Test + void saveToFileWrapsIoFailuresInUncheckedIoException(@TempDir Path tmp) { + Response r = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); + + // A non-existent parent directory triggers NoSuchFileException — the wrapper turns it into + // UncheckedIOException so the call fits in a fluent chain without checked-exception noise. + Path inaccessible = tmp.resolve("does-not-exist").resolve("out.txt"); + + assertThatThrownBy(() -> r.saveToFile(inaccessible)) + .isInstanceOf(UncheckedIOException.class) + .hasMessageContaining(inaccessible.toString()); + } + + // ---------- toString ---------- + + @Test + void toStringIncludesStatusFormatAndUrl() { + Response r = + Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); + + String repr = r.toString(); + + assertThat(repr) + .contains("status=200") + .contains("format=json") + .contains("bytes=4") + .contains("http://x/y") + .contains("payload"); + } +} diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 861d39b..264c235 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -65,7 +65,7 @@ void headersAsyncReturnsDecodedRecord() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headersAsync().join(); + RequestHeaders rh = utilities.headersAsync().join().data(); assertThat(rh.headers()) .containsEntry("accept", "*/*") @@ -80,7 +80,7 @@ void headersSyncMirrorsHeadersAsync() { 200, "{\"x\":\"1\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headers(); + RequestHeaders rh = utilities.headers().data(); assertThat(rh.headers()).containsEntry("x", "1"); } @@ -115,7 +115,7 @@ void userAsyncReturnsDecodedRecord() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.userAsync().join(); + User u = utilities.userAsync().join().data(); assertThat(u.requestsRemaining()).isEqualTo(42); assertThat(u.requestsLimit()).isEqualTo(100); @@ -133,7 +133,7 @@ void userSyncMirrorsAsync() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.user(); + User u = utilities.user().data(); assertThat(u.requestsRemaining()).isEqualTo(7); } @@ -184,7 +184,7 @@ void statusAsyncReturnsZippedServiceList() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.statusAsync().join(); + ApiStatus status = utilities.statusAsync().join().data(); assertThat(status.services()).hasSize(2); assertThat(status.services().get(0).service()).isEqualTo("/v1/a/"); @@ -202,11 +202,36 @@ void statusSyncMirrorsAsync() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.status(); + ApiStatus status = utilities.status().data(); assertThat(status.services()).hasSize(1); } + // ---------- Response wrapper composition ---------- + + /** + * The resource layer is responsible for composing typed model + raw body + format into a {@link + * Response}. This verifies the wiring end-to-end: the bytes from the wire reach {@code rawBody}, + * the request URL is preserved for support, and the format from the spec is reflected in the + * format accessors. + */ + @Test + void resourceWrapsTypedDataWithRawBodyAndMetadata() { + String body = "{\"x\":\"1\"}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + Response r = utilities.headers(); + + assertThat(r.data().headers()).containsEntry("x", "1"); + assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.statusCode()).isEqualTo(200); + assertThat(r.isJson()).isTrue(); + assertThat(r.isNoData()).isFalse(); + assertThat(r.requestUrl().toString()).isEqualTo("http://localhost/headers/"); + } + // ---------- error surfacing through sync ---------- /** From e365bd1fd4e92d5d585e648485813a9092c80c59 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 19 May 2026 16:53:16 -0300 Subject: [PATCH 30/57] parallel-arrays deserialization --- .../marketdata/sdk/ApiStatusDeserializer.java | 90 ++---- .../com/marketdata/sdk/ParallelArrays.java | 217 +++++++++++++ .../sdk/JsonResponseParserTest.java | 40 +++ .../marketdata/sdk/ParallelArraysTest.java | 284 ++++++++++++++++++ 4 files changed, 561 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/ParallelArrays.java create mode 100644 src/test/java/com/marketdata/sdk/ParallelArraysTest.java diff --git a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java index 6a55a87..d22299d 100644 --- a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java +++ b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java @@ -8,89 +8,39 @@ import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.ServiceStatus; import java.io.IOException; -import java.util.ArrayList; import java.util.List; /** * Wire-format deserializer for {@link ApiStatus}. The server uses the API's standard - * parallel-arrays shape: {@code service}, {@code status}, {@code online}, {@code uptimePct30d}, - * {@code uptimePct90d}, {@code updated} are six arrays of equal length. This class zips them into a - * list of {@link ServiceStatus} records so consumers iterate naturally. + * parallel-arrays shape: six equal-length arrays of column values plus the {@code "s"} envelope. + * {@link ParallelArrays#zip} handles the structural validation; this class only declares which + * columns are expected and how to materialize a {@link ServiceStatus} from one row. * - *

Error cases — {@code s == "error"} payloads, missing arrays, mismatched lengths — bubble up as + *

Error envelopes ({@code s == "error"}), missing arrays, and mismatched lengths bubble up as * {@link JsonMappingException}, which the parent {@link JsonResponseParser} turns into a {@link * com.marketdata.sdk.exception.ParseError} with the response context attached. */ final class ApiStatusDeserializer extends JsonDeserializer { - private static final String S = "s"; - private static final String ERRMSG = "errmsg"; - private static final String SERVICE = "service"; - private static final String STATUS = "status"; - private static final String ONLINE = "online"; - private static final String UPTIME_30 = "uptimePct30d"; - private static final String UPTIME_90 = "uptimePct90d"; - private static final String UPDATED = "updated"; + private static final List FIELDS = + List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"); @Override public ApiStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode root = p.readValueAsTree(); - - String s = root.path(S).asText(""); - if ("error".equals(s)) { - String errmsg = root.path(ERRMSG).asText("(no errmsg field)"); - throw new JsonMappingException(p, "API status reported error: " + errmsg); - } - - JsonNode services = requireArray(p, root, SERVICE); - JsonNode statuses = requireArray(p, root, STATUS); - JsonNode onlines = requireArray(p, root, ONLINE); - JsonNode up30 = requireArray(p, root, UPTIME_30); - JsonNode up90 = requireArray(p, root, UPTIME_90); - JsonNode updated = requireArray(p, root, UPDATED); - - int n = services.size(); - if (statuses.size() != n - || onlines.size() != n - || up30.size() != n - || up90.size() != n - || updated.size() != n) { - throw new JsonMappingException( - p, - "API status arrays have mismatched lengths: service=" - + n - + ", status=" - + statuses.size() - + ", online=" - + onlines.size() - + ", uptimePct30d=" - + up30.size() - + ", uptimePct90d=" - + up90.size() - + ", updated=" - + updated.size()); - } - - List rows = new ArrayList<>(n); - for (int i = 0; i < n; i++) { - rows.add( - new ServiceStatus( - services.get(i).asText(""), - statuses.get(i).asText(""), - onlines.get(i).asBoolean(false), - up30.get(i).asDouble(0.0), - up90.get(i).asDouble(0.0), - MarketDataDates.marketTimeFromEpochSecond(updated.get(i).asLong(0L)))); - } - return new ApiStatus(rows); - } - - private static JsonNode requireArray(JsonParser p, JsonNode root, String field) - throws JsonMappingException { - JsonNode node = root.get(field); - if (node == null || !node.isArray()) { - throw new JsonMappingException(p, "API status missing or non-array field: " + field); - } - return node; + List services = + ParallelArrays.zip( + p, + root, + FIELDS, + row -> + new ServiceStatus( + row.text("service"), + row.text("status"), + row.bool("online"), + row.dbl("uptimePct30d"), + row.dbl("uptimePct90d"), + MarketDataDates.marketTimeFromEpochSecond(row.lng("updated")))); + return new ApiStatus(services); } } diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java new file mode 100644 index 0000000..357974a --- /dev/null +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -0,0 +1,217 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Helper for deserializing the API's parallel-arrays wire format. Almost every endpoint that + * returns multiple rows uses this shape: N equal-length arrays of column values plus a leading + * {@code "s"} envelope status, e.g. + * + *

{@code
+ * { "s": "ok",
+ *   "symbol": ["AAPL", "MSFT"],
+ *   "price":  [150.0,   400.0] }
+ * }
+ * + *

{@link #zip} encapsulates the repeating boilerplate — envelope-error check, presence and + * length validation across columns, indexed iteration — leaving each deserializer to declare just + * which columns it expects and how to build one row from a {@link Row}. + * + *

Cell-level accessors on {@link Row} are strict by default: a {@code null} + * cell or a value of the wrong JSON type raises {@link JsonMappingException} (which surfaces as + * {@link com.marketdata.sdk.exception.ParseError} upstream). The previous lenient behavior — + * substituting {@code ""}, {@code false}, {@code 0.0}, {@code 0} for missing cells — masked real + * server bugs: e.g. a regression that dropped the {@code online} column would have silently flipped + * every service to {@code online=false}, propagating to {@link StatusCache} decisions and blocking + * retries across the board. If a future endpoint has legitimately-nullable columns, add explicit + * {@code textOr(field, default)} overloads then — not pre-emptively. + */ +final class ParallelArrays { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + + private ParallelArrays() {} + + /** + * Zip the parallel arrays under {@code root} into a list of rows via {@code rowBuilder}. + * + * @throws JsonMappingException if the envelope reports {@code "s":"error"}, a required field is + * absent or not an array, or arrays have mismatched lengths. + */ + static List zip(JsonParser p, JsonNode root, List fields, RowBuilder rowBuilder) + throws IOException { + + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if ("error".equals(envelopeStatus)) { + String errmsg = root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"); + throw new JsonMappingException(p, "API responded with error: " + errmsg); + } + + Map arrays = new LinkedHashMap<>(); + int expected = -1; + for (String field : fields) { + JsonNode node = root.get(field); + if (node == null || !node.isArray()) { + throw new JsonMappingException(p, "missing or non-array field: " + field); + } + if (expected == -1) { + expected = node.size(); + } else if (node.size() != expected) { + throw new JsonMappingException( + p, + "mismatched lengths: " + + field + + "=" + + node.size() + + " vs expected=" + + expected + + " (from first column " + + fields.get(0) + + ")"); + } + arrays.put(field, node); + } + + int rowCount = Math.max(expected, 0); + List rows = new ArrayList<>(rowCount); + for (int i = 0; i < rowCount; i++) { + rows.add(rowBuilder.build(new IndexedRow(arrays, i))); + } + return rows; + } + + /** + * Builds one row from the {@code Row} accessor at a fixed index. Allowed to throw {@link + * IOException} so {@link Row} accessors can surface {@link JsonMappingException} for strict cell + * validation. + */ + @FunctionalInterface + interface RowBuilder { + T build(Row row) throws IOException; + } + + /** + * Strict typed accessors over one row of the parallel arrays. Each accessor verifies that the + * cell is present and is of the expected JSON type; otherwise a {@link JsonMappingException} is + * raised so the row never silently degrades to a sentinel value. + */ + interface Row { + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON string. + */ + String text(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON boolean. + */ + boolean bool(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON number. + */ + double dbl(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON number. + */ + long lng(String field) throws JsonMappingException; + + /** + * Raw access for custom conversions (e.g. nested objects or non-trivial date parsing). Returns + * the node verbatim — the caller decides how to validate. + */ + JsonNode node(String field); + } + + private static final class IndexedRow implements Row { + private final Map arrays; + private final int index; + + IndexedRow(Map arrays, int index) { + this.arrays = arrays; + this.index = index; + } + + @Override + public String text(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isTextual()) { + throw typeMismatch(field, "string", cell); + } + return cell.asText(); + } + + @Override + public boolean bool(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isBoolean()) { + throw typeMismatch(field, "boolean", cell); + } + return cell.asBoolean(); + } + + @Override + public double dbl(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asDouble(); + } + + @Override + public long lng(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asLong(); + } + + @Override + public JsonNode node(String field) { + return cell(field); + } + + private JsonNode cell(String field) { + JsonNode array = arrays.get(field); + if (array == null) { + throw new IllegalArgumentException( + "Row accessor asked for unknown field '" + + field + + "'; declared columns are " + + arrays.keySet()); + } + return array.get(index); + } + + private JsonNode requirePresent(String field) throws JsonMappingException { + JsonNode cell = cell(field); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + throw new JsonMappingException(null, "null cell at field '" + field + "' row " + index); + } + return cell; + } + + private JsonMappingException typeMismatch(String field, String expected, JsonNode actual) { + return new JsonMappingException( + null, + "expected " + + expected + + " at field '" + + field + + "' row " + + index + + " but got " + + actual.getNodeType()); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 76aadd7..7dbafea 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -223,6 +223,46 @@ void apiStatusMissingArrayBecomesParseError() { .hasMessageContaining("online"); } + @Test + void apiStatusNullCellInOnlineArrayBecomesParseError() { + // Real-world regression scenario: the backend ships a build where `online` is sometimes + // null instead of a boolean. Before the strict-cell validation, this silently became + // online=false for every row → StatusCache marks services as offline → SDK blocks retries + // across the board. The strict accessor must surface the malformed cell as ParseError. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\",\"b\"]," + + "\"status\":[\"online\",\"online\"]," + + "\"online\":[true,null]," + + "\"uptimePct30d\":[1.0,1.0]," + + "\"uptimePct90d\":[1.0,1.0]," + + "\"updated\":[0,0]}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("null cell") + .hasMessageContaining("online"); + } + + @Test + void apiStatusWrongTypeInUptimeArrayBecomesParseError() { + // The backend swaps a number for a string (e.g. "1.0" instead of 1.0). Strict mode rejects + // it rather than relying on Jackson's lax string→number coercion. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[\"1.0\"]," // string instead of number + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("expected number") + .hasMessageContaining("uptimePct30d"); + } + @Test void malformedJsonRaisesParseErrorCarryingResponseContext() { JsonResponseParser parser = new JsonResponseParser(); diff --git a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java new file mode 100644 index 0000000..a92d8be --- /dev/null +++ b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java @@ -0,0 +1,284 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ParallelArraysTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static JsonNode parse(String json) throws IOException { + return MAPPER.readTree(json); + } + + // ---------- happy path: zip + typed accessors ---------- + + @Test + void zipsParallelArraysIntoRowsViaTypedAccessors() throws IOException { + JsonNode root = + parse( + "{\"s\":\"ok\"," + + "\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"price\":[150.0,400.0]," + + "\"active\":[true,false]," + + "\"updated\":[1700000000,1700000001]}"); + + List rows = + ParallelArrays.zip( + null, + root, + List.of("symbol", "price", "active", "updated"), + row -> + new Record( + row.text("symbol"), row.dbl("price"), row.bool("active"), row.lng("updated"))); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0)).isEqualTo(new Record("AAPL", 150.0, true, 1700000000L)); + assertThat(rows.get(1)).isEqualTo(new Record("MSFT", 400.0, false, 1700000001L)); + } + + @Test + void emptyArraysProduceEmptyListWithoutInvokingBuilder() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"a\":[],\"b\":[]}"); + + List rows = + ParallelArrays.zip( + null, + root, + List.of("a", "b"), + row -> { + throw new AssertionError("builder must not be invoked when arrays are empty"); + }); + + assertThat(rows).isEmpty(); + } + + // ---------- envelope-error short-circuit ---------- + + @Test + void serverSideErrorEnvelopeShortCircuitsBeforeFieldValidation() { + // s=error means the body intentionally omits the data arrays. The helper must surface the + // errmsg instead of complaining about missing fields downstream. + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"), + List.of("symbol"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("database connection refused"); + } + + @Test + void errorEnvelopeWithoutErrmsgYieldsPlaceholderText() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, parse("{\"s\":\"error\"}"), List.of("symbol"), row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("no errmsg field"); + } + + // ---------- presence and length validation ---------- + + @Test + void missingFieldFailsWithFieldName() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\"]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("price"); + } + + @Test + void nonArrayFieldFailsWithFieldName() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":\"AAPL\",\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("symbol"); + } + + @Test + void mismatchedLengthsFailWithDetail() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse( + "{\"s\":\"ok\"," + + "\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("mismatched lengths") + .hasMessageContaining("price=1") + .hasMessageContaining("expected=2"); + } + + // ---------- Row.node() for custom conversions ---------- + + @Test + void rowNodeExposesRawJsonNodeForCustomConversion() throws IOException { + // node() lets the builder do conversions the typed helpers don't cover — here we parse + // an array element directly so the test exercises that escape hatch. + JsonNode root = parse("{\"s\":\"ok\",\"nested\":[{\"k\":\"v1\"},{\"k\":\"v2\"}]}"); + + List rows = + ParallelArrays.zip( + null, root, List.of("nested"), row -> row.node("nested").get("k").asText()); + + assertThat(rows).containsExactly("v1", "v2"); + } + + // ---------- Row programming errors ---------- + + @Test + void rowAccessorRejectsUndeclaredField() throws IOException { + // Asking for a field that wasn't in the declared `fields` list is a programming bug in the + // builder lambda — surface it loudly rather than NPEing on a null array. + JsonNode root = parse("{\"s\":\"ok\",\"a\":[\"x\"]}"); + + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + root, + List.of("a"), + row -> row.text("nonexistent"))) // builder asks for an undeclared column + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nonexistent") + .hasMessageContaining("[a]"); + } + + // ---------- strict cell validation ---------- + + @Test + void textFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\",null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("symbol"), row -> row.text("symbol"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("symbol") + .hasMessageContaining("row 1"); + } + + @Test + void textFailsWhenCellIsNotAString() throws IOException { + // The server suddenly sending a number where a symbol is expected is the kind of regression + // we want to surface immediately, not silently coerce to "123" via Jackson's lax asText. + JsonNode root = parse("{\"s\":\"ok\",\"symbol\":[123]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("symbol"), row -> row.text("symbol"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected string") + .hasMessageContaining("symbol"); + } + + @Test + void boolFailsWhenCellIsJsonNull() throws IOException { + // The exact regression flagged in the review: a missing `online` cell silently becoming + // `false` would mass-block retries via StatusCache. Strict mode rejects it. + JsonNode root = parse("{\"s\":\"ok\",\"online\":[true,null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("online"), row -> row.bool("online"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("online"); + } + + @Test + void boolFailsWhenCellIsNotABoolean() throws IOException { + // "true" as a string is not the same as boolean true — Jackson's lax asBoolean would coerce. + JsonNode root = parse("{\"s\":\"ok\",\"online\":[\"true\"]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("online"), row -> row.bool("online"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected boolean") + .hasMessageContaining("online"); + } + + @Test + void dblFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"price\":[null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("price"), row -> row.dbl("price"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("price"); + } + + @Test + void dblFailsWhenCellIsNotANumber() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"price\":[\"150.0\"]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("price"), row -> row.dbl("price"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected number") + .hasMessageContaining("price"); + } + + @Test + void lngFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"updated\":[null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("updated"), row -> row.lng("updated"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("updated"); + } + + @Test + void lngFailsWhenCellIsNotANumber() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"updated\":[true]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("updated"), row -> row.lng("updated"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected number") + .hasMessageContaining("updated"); + } + + @Test + void nodeAccessorReturnsNullJsonNodeVerbatimForCustomHandling() throws IOException { + // Strict accessors fail on null; the raw `node()` escape hatch must NOT — callers that opt + // into raw access are responsible for handling null themselves (e.g. nested object fields). + JsonNode root = parse("{\"s\":\"ok\",\"raw\":[null]}"); + + List rows = + ParallelArrays.zip(null, root, List.of("raw"), row -> row.node("raw").isNull()); + + assertThat(rows).containsExactly(true); + } + + // ---------- helper record ---------- + + private record Record(String symbol, double price, boolean active, long updated) {} +} From a55d517017758b2788dd71f9461eae9174fee89b Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 09:37:23 -0300 Subject: [PATCH 31/57] avoid silence DotEnvLoader --- .../java/com/marketdata/sdk/DotEnvLoader.java | 25 ++++ .../com/marketdata/sdk/DotEnvLoaderTest.java | 111 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java index 0de84ec..e975cd2 100644 --- a/src/main/java/com/marketdata/sdk/DotEnvLoader.java +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -6,11 +6,32 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +/** + * Loads {@code .env} key=value pairs from disk. {@code .env} is the third tier of the configuration + * cascade (after explicit args and env vars), and is optional — a missing file is normal and never + * logs. However, an existing file that the SDK fails to read is suspicious: the user + * placed a {@code .env} expecting it to apply, and silently falling through to defaults would + * surface later as a confusing {@code AuthenticationError} with no breadcrumb. In that case we emit + * a WARNING and still degrade to an empty map, so {@link Configuration#resolve} can fall through + * the cascade rather than failing startup. + */ final class DotEnvLoader { + private static final Logger LOG = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + static Map load(Path path) { + if (!Files.exists(path)) { + return Map.of(); + } if (!Files.isReadable(path)) { + LOG.log( + Level.WARNING, + "Found .env at {0} but it is not readable (permission denied?) — falling back to env" + + " vars/defaults.", + path); return Map.of(); } Map result = new LinkedHashMap<>(); @@ -29,6 +50,10 @@ static Map load(Path path) { result.put(key, value); } } catch (IOException e) { + LOG.log( + Level.WARNING, + "Failed to read .env at " + path + " — falling back to env vars/defaults.", + e); return Map.of(); } return Map.copyOf(result); diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java index 64ad43d..68cf3bb 100644 --- a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -5,12 +5,35 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import java.util.Map; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; class DotEnvLoaderTest { + private CapturingHandler handler; + + @BeforeEach + void attachLogHandler() { + handler = new CapturingHandler(); + handler.setLevel(Level.ALL); + Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME).addHandler(handler); + } + + @AfterEach + void detachLogHandler() { + Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME).removeHandler(handler); + } + @Test void load_returns_empty_when_file_missing(@TempDir Path tmp) { Path missing = tmp.resolve("does-not-exist.env"); @@ -20,6 +43,64 @@ void load_returns_empty_when_file_missing(@TempDir Path tmp) { assertThat(result).isEmpty(); } + @Test + void load_missing_file_does_not_log(@TempDir Path tmp) { + // The cascade explicitly tolerates a missing .env — that's the common case, not an error. + // Emitting a WARNING here would spam every consumer that runs without a .env file. + Path missing = tmp.resolve("does-not-exist.env"); + + DotEnvLoader.load(missing); + + assertThat(handler.records).isEmpty(); + } + + @Test + @DisabledOnOs(OS.WINDOWS) // POSIX permissions are unreliable on Windows file systems + void load_unreadable_file_emits_warning_and_returns_empty(@TempDir Path tmp) throws IOException { + // Existing-but-unreadable is suspicious: the user dropped a .env expecting it to apply, but + // the SDK can't open it. Silent fallback would surface much later as a confusing + // AuthenticationError. Log a WARNING with the path so the breadcrumb is obvious. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("---------")); + try { + Map result = DotEnvLoader.load(file); + + assertThat(result).isEmpty(); + assertThat(handler.records) + .singleElement() + .satisfies( + r -> { + assertThat(r.getLevel()).isEqualTo(Level.WARNING); + assertThat(handler.formatLast()).contains("not readable").contains(file.toString()); + }); + } finally { + // Restore so @TempDir cleanup can delete the file. + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------")); + } + } + + @Test + void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) throws Exception { + // Files.exists + isReadable can pass and the actual read still fail (NFS drop, decoded-bytes + // encoding mismatch, etc.). A directory passed as the path is a portable way to make + // Files.readAllLines blow up after the readability check succeeds. + Path asDir = Files.createDirectory(tmp.resolve("env-as-dir")); + + Map result = DotEnvLoader.load(asDir); + + assertThat(result).isEmpty(); + assertThat(handler.records) + .singleElement() + .satisfies( + r -> { + assertThat(r.getLevel()).isEqualTo(Level.WARNING); + assertThat(r.getThrown()).isNotNull(); + assertThat(handler.formatLast()) + .contains("Failed to read .env") + .contains(asDir.toString()); + }); + } + @Test void load_returns_empty_for_empty_file(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), ""); @@ -142,4 +223,34 @@ void load_returns_immutable_map(@TempDir Path tmp) throws IOException { assertThat(result).isUnmodifiable(); } + + @Test + void load_successful_read_does_not_log(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + + DotEnvLoader.load(file); + + assertThat(handler.records).isEmpty(); + } + + /** Captures {@link LogRecord}s emitted on the SDK logger so tests can assert on them. */ + private static final class CapturingHandler extends Handler { + private final java.util.List records = new java.util.ArrayList<>(); + private final java.util.logging.Formatter fmt = new java.util.logging.SimpleFormatter(); + + @Override + public void publish(LogRecord r) { + records.add(r); + } + + @Override + public void flush() {} + + @Override + public void close() {} + + String formatLast() { + return fmt.format(records.get(records.size() - 1)); + } + } } From baf94002ef62ba777e7b9cf4ad7cb43a489eb13c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 09:49:19 -0300 Subject: [PATCH 32/57] improve url builder --- .../com/marketdata/sdk/Configuration.java | 130 ++++++++++- .../com/marketdata/sdk/HttpTransport.java | 17 +- .../com/marketdata/sdk/ConfigurationTest.java | 201 ++++++++++++++++++ .../com/marketdata/sdk/HttpTransportTest.java | 33 +++ 4 files changed, 378 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index d6d2fd2..4261276 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -1,8 +1,12 @@ package com.marketdata.sdk; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Map; +import java.util.Set; import java.util.function.Function; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; record Configuration( @@ -16,6 +20,9 @@ record Configuration( static final String DEFAULT_API_VERSION = "v1"; static final Path DEFAULT_DOTENV_PATH = Path.of(".env"); + private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); + private static final Pattern API_VERSION_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); + static Configuration resolve( @Nullable String explicitApiKey, @Nullable String explicitBaseUrl, @@ -39,7 +46,128 @@ static Configuration resolve( String loggingLevel = pickFirst(env.apply(EnvVars.LOGGING_LEVEL), dotEnv.get(EnvVars.LOGGING_LEVEL)); String dateFormat = pickFirst(env.apply(EnvVars.DATE_FORMAT), dotEnv.get(EnvVars.DATE_FORMAT)); - return new Configuration(apiKey, baseUrl, apiVersion, loggingLevel, dateFormat); + String normalizedBaseUrl = normalizeBaseUrl(baseUrl); + String normalizedApiVersion = normalizeApiVersion(apiVersion); + validateBaseUrl(normalizedBaseUrl); + validateApiVersion(normalizedApiVersion); + return new Configuration( + apiKey, normalizedBaseUrl, normalizedApiVersion, loggingLevel, dateFormat); + } + + /** + * Strip trailing slashes from {@code baseUrl} so {@code HttpTransport.buildUri} can append {@code + * "/" + apiVersion + "/" + path} unconditionally. A user-supplied {@code + * "https://api.marketdata.app/"} would otherwise produce a double-slash like {@code + * "https://api.marketdata.app//v1/..."} that some routers reject and others silently canonicalize + * — either way, an annoying source of "looks right but isn't" failures. + */ + static String normalizeBaseUrl(String raw) { + String trimmed = raw.trim(); + int end = trimmed.length(); + while (end > 0 && trimmed.charAt(end - 1) == '/') { + end--; + } + return trimmed.substring(0, end); + } + + /** + * Strip leading and trailing slashes from {@code apiVersion} so the segment composes cleanly + * regardless of how the user spelled it ({@code "v1"}, {@code "/v1"}, {@code "v1/"}, {@code + * "/v1/"}). + */ + static String normalizeApiVersion(String raw) { + String trimmed = raw.trim(); + int start = 0; + int end = trimmed.length(); + while (start < end && trimmed.charAt(start) == '/') { + start++; + } + while (end > start && trimmed.charAt(end - 1) == '/') { + end--; + } + return trimmed.substring(start, end); + } + + /** + * Validate that {@code baseUrl} (already normalized — no trailing slashes, no surrounding + * whitespace) is a usable HTTP origin. The point is to fail at construction with a clear message + * instead of letting {@link java.net.http.HttpClient} surface a cryptic {@code + * IllegalArgumentException} the first time a request is sent. + * + *

Rules: + * + *

    + *
  • Non-empty (post-normalize {@code "////"} collapses to empty). + *
  • Parseable as a {@link URI}. + *
  • Scheme is exactly {@code http} or {@code https} — schemes like {@code file:}, {@code + * ftp:}, or {@code javascript:} have no business here. + *
  • Host is present (rules out scheme-only inputs like {@code "https://"}). + *
  • No query, fragment, or user-info — those belong on a request, not the origin, and their + * presence is almost always a copy-paste mistake that would mangle the constructed URL. + *
+ */ + static void validateBaseUrl(String baseUrl) { + if (baseUrl.isEmpty()) { + throw new IllegalArgumentException( + "baseUrl must not be empty; expected an http or https URL like " + DEFAULT_BASE_URL); + } + URI uri; + try { + uri = new URI(baseUrl); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' is not a valid URI: " + e.getMessage(), e); + } + String scheme = uri.getScheme(); + if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(java.util.Locale.ROOT))) { + throw new IllegalArgumentException( + "baseUrl '" + + baseUrl + + "' must use scheme http or https (got " + + (scheme == null ? "" : scheme) + + ")"); + } + if (uri.getHost() == null) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' is missing a host (e.g. api.marketdata.app)"); + } + if (uri.getRawQuery() != null) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' must not contain a query string"); + } + if (uri.getRawFragment() != null) { + throw new IllegalArgumentException("baseUrl '" + baseUrl + "' must not contain a fragment"); + } + if (uri.getRawUserInfo() != null) { + throw new IllegalArgumentException( + "baseUrl '" + + baseUrl + + "' must not contain user-info — credentials belong on the" + + " request, not the origin"); + } + } + + /** + * Validate {@code apiVersion} (already normalized — no leading/trailing slashes, no surrounding + * whitespace) as a single, URL-safe path segment. Rejects anything outside {@code [A-Za-z0-9._-]} + * — that's permissive enough for {@code v1}, {@code v2}, {@code v1.0}, {@code beta-1}, etc., + * while ruling out embedded slashes ({@code "v1/extra"}), spaces, already percent-encoded values + * ({@code "%2Fv1"}), and path-traversal tokens ({@code ".."} fails because {@code .} alone is + * allowed but the result becomes a literal {@code ".."} segment — that's still legitimate enough + * to send and the server will reject it; the regex's job is just to keep us from emitting + * malformed URLs). + */ + static void validateApiVersion(String apiVersion) { + if (apiVersion.isEmpty()) { + throw new IllegalArgumentException( + "apiVersion must not be empty; expected a path segment like " + DEFAULT_API_VERSION); + } + if (!API_VERSION_PATTERN.matcher(apiVersion).matches()) { + throw new IllegalArgumentException( + "apiVersion '" + + apiVersion + + "' must match [A-Za-z0-9._-]+ (a single URL-safe path segment)"); + } } private static @Nullable String pickFirst(@Nullable String... candidates) { diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 96cf6a6..c243c3c 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -284,15 +284,28 @@ private URI buildUri(RequestSpec spec) { if (!first) { sb.append('&'); } - sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) + sb.append(encodeQueryComponent(e.getKey())) .append('=') - .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + .append(encodeQueryComponent(e.getValue())); first = false; } } return URI.create(sb.toString()); } + /** + * RFC 3986 percent-encoding for a query-string component. {@link URLEncoder} emits {@code + * application/x-www-form-urlencoded} bytes — i.e. spaces become {@code +} — which is the wrong + * dialect for query strings: strict servers treat {@code ?symbol=BRK A} and {@code + * ?symbol=BRK%20A} as equivalent but {@code ?symbol=BRK+A} as a literal {@code +}. Replacing + * {@code +} with {@code %20} after encoding is the canonical patch: {@link URLEncoder} only emits + * {@code +} for the space character (everything else that needs encoding is already {@code %XX}), + * so the substitution is unambiguous. + */ + private static String encodeQueryComponent(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("+", "%20"); + } + private HttpRequest buildHttpRequest(URI uri, Format format) { HttpRequest.Builder b = HttpRequest.newBuilder(uri) diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index f24a123..87ef309 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -1,6 +1,7 @@ package com.marketdata.sdk; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.io.IOException; import java.nio.file.Files; @@ -199,4 +200,204 @@ void resolve_env_lookup_returning_blank_is_treated_as_missing(@TempDir Path tmp) assertThat(config.apiKey()).isNull(); assertThat(config.baseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); } + + // ---------- normalization ---------- + + @Test + void resolve_strips_trailing_slashes_from_baseUrl(@TempDir Path tmp) { + // Single trailing slash from the user is the common copy-paste mistake; multiple slashes + // (e.g. "https://x///") are pathological but cheap to handle and avoid surprises. + Configuration single = + Configuration.resolve(null, "https://api.example.com/", null, NO_ENV, noDotEnv(tmp)); + Configuration many = + Configuration.resolve(null, "https://api.example.com///", null, NO_ENV, noDotEnv(tmp)); + Configuration whitespaced = + Configuration.resolve(null, " https://api.example.com/ ", null, NO_ENV, noDotEnv(tmp)); + + assertThat(single.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(many.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(whitespaced.baseUrl()).isEqualTo("https://api.example.com"); + } + + @Test + void resolve_strips_leading_and_trailing_slashes_from_apiVersion(@TempDir Path tmp) { + // "v1", "/v1", "v1/", and "/v1/" should all collapse to the same canonical form so URI + // composition is independent of the user's spelling. + Configuration leading = Configuration.resolve(null, null, "/v1", NO_ENV, noDotEnv(tmp)); + Configuration trailing = Configuration.resolve(null, null, "v1/", NO_ENV, noDotEnv(tmp)); + Configuration both = Configuration.resolve(null, null, "/v1/", NO_ENV, noDotEnv(tmp)); + Configuration whitespaced = + Configuration.resolve(null, null, " /v1/ ", NO_ENV, noDotEnv(tmp)); + + assertThat(leading.apiVersion()).isEqualTo("v1"); + assertThat(trailing.apiVersion()).isEqualTo("v1"); + assertThat(both.apiVersion()).isEqualTo("v1"); + assertThat(whitespaced.apiVersion()).isEqualTo("v1"); + } + + @Test + void resolve_default_baseUrl_already_has_no_trailing_slash(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.baseUrl()).doesNotEndWith("/"); + } + + // ---------- validation: baseUrl ---------- + + @Test + void resolve_rejects_baseUrl_without_scheme(@TempDir Path tmp) { + // The classic "I forgot https://" mistake — URI.create accepts it as a relative path, but + // HttpClient.send then surfaces a cryptic "URI is not absolute". Fail at construction. + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve(null, "api.marketdata.app", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("scheme http or https"); + } + + @Test + void resolve_rejects_baseUrl_with_disallowed_scheme(@TempDir Path tmp) { + // file://, ftp://, javascript: — schemes the SDK has no business opening. + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve(null, "file:///etc/passwd", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("scheme http or https"); + } + + @Test + void resolve_accepts_http_and_https(@TempDir Path tmp) { + Configuration https = + Configuration.resolve(null, "https://api.example.com", null, NO_ENV, noDotEnv(tmp)); + Configuration http = + Configuration.resolve(null, "http://localhost:9000", null, NO_ENV, noDotEnv(tmp)); + + assertThat(https.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(http.baseUrl()).isEqualTo("http://localhost:9000"); + } + + @Test + void resolve_accepts_baseUrl_with_path_prefix(@TempDir Path tmp) { + // Self-hosted / reverse-proxy setups: the API lives under /marketdata-proxy on a corp host. + Configuration config = + Configuration.resolve( + null, "https://corp.example.com/marketdata-proxy", null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.baseUrl()).isEqualTo("https://corp.example.com/marketdata-proxy"); + } + + @Test + void resolve_rejects_baseUrl_missing_host(@TempDir Path tmp) { + // Opaque URIs ({@code scheme:opaque}, no {@code //authority}) parse fine but expose a null + // host — those are the inputs the "missing a host" guard exists to catch. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "https:opaque", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("missing a host"); + } + + @Test + void resolve_rejects_baseUrl_with_invalid_syntax(@TempDir Path tmp) { + // "https://" normalizes to "https:" which fails URI.parse outright — the test verifies the + // "is not a valid URI" branch fires with a clear message rather than letting the syntax + // exception bubble up unwrapped. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "https://", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("is not a valid URI"); + } + + @Test + void resolve_rejects_baseUrl_with_query_string(@TempDir Path tmp) { + // Query belongs on requests, not the origin. Letting it through would corrupt every URL the + // transport composes (`?token=abc/v1/markets/status/`). + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://api.example.com?token=x", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("query string"); + } + + @Test + void resolve_rejects_baseUrl_with_fragment(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://api.example.com#frag", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("fragment"); + } + + @Test + void resolve_rejects_baseUrl_with_user_info(@TempDir Path tmp) { + // user:pass@host has Basic-auth semantics the SDK does not support — and would leak + // credentials into log lines that include the URL. + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://user:pass@api.example.com", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("user-info"); + } + + @Test + void resolve_rejects_baseUrl_that_normalizes_to_empty(@TempDir Path tmp) { + // "////" passes pickFirstOrDefault (not blank), normalizes to "", then validation must + // reject the empty result rather than silently falling through. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "////", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("must not be empty"); + } + + // ---------- validation: apiVersion ---------- + + @Test + void resolve_accepts_valid_apiVersion_shapes(@TempDir Path tmp) { + // Permissive enough for the realistic variants — semver-ish, branch-tagged, etc. + for (String version : new String[] {"v1", "v2", "v1.0", "v2.1.0", "beta-1", "alpha_2"}) { + Configuration config = Configuration.resolve(null, null, version, NO_ENV, noDotEnv(tmp)); + assertThat(config.apiVersion()).isEqualTo(version); + } + } + + @Test + void resolve_rejects_apiVersion_with_embedded_slash(@TempDir Path tmp) { + // Mid-string slashes survive the leading/trailing strip, but they'd inject extra path + // segments — "v1/extra" → /v1/extra/markets/status/ which the server treats as a different + // resource. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "v1/extra", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); + } + + @Test + void resolve_rejects_apiVersion_with_spaces(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "v 1", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); + } + + @Test + void resolve_rejects_apiVersion_already_percent_encoded(@TempDir Path tmp) { + // Double-encoding territory — "%2F" would become "%252F" on the wire and the server would + // see the literal text, not a slash. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "%2Fv1", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); + } + + @Test + void resolve_rejects_apiVersion_that_normalizes_to_empty(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "////", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("must not be empty"); + } + + @Test + void resolve_validates_values_from_dotenv_too(@TempDir Path tmp) throws IOException { + // The validator runs after the cascade picks a value — bad input from env vars or .env files + // must surface the same IAE, not slip through because the cascade source was non-explicit. + Path dotEnv = Files.writeString(tmp.resolve(".env"), "MARKETDATA_BASE_URL=not-a-url\n"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, null, NO_ENV, dotEnv)) + .withMessageContaining("scheme http or https"); + } } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 553fdd6..9e4536d 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -119,6 +119,39 @@ void leadingSlashInPathIsStripped() { .isEqualTo("http://localhost/v1/markets/status/"); } + @Test + void queryParamWithSpaceEncodesAsPercent20NotPlus() { + // URLEncoder defaults to form-encoding (spaces → "+"), which strict RFC-3986 servers treat + // as a literal "+" in the query string. The transport patches this to "%20" so endpoints + // taking arbitrary text (e.g. a multi-word symbol or description) round-trip correctly. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport + .executeAsync(RequestSpec.get("stocks/quotes").query("symbol", "BRK A").build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/stocks/quotes/?symbol=BRK%20A"); + } + + @Test + void queryParamWithReservedCharactersIsPercentEncoded() { + // Reserved characters like &, =, ?, # in a value must be percent-encoded so they aren't + // parsed as query-string delimiters. URLEncoder handles these correctly out of the box — + // this test just locks in that behavior so a future refactor of encodeQueryComponent + // doesn't accidentally regress it. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("stocks/quotes").query("q", "a&b=c?d#e").build()).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/stocks/quotes/?q=a%26b%3Dc%3Fd%23e"); + } + // ---------- response envelope ---------- @Test From d975a477a428b63d455b6c47b84bfb8114cd6b60 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 09:57:23 -0300 Subject: [PATCH 33/57] improe rate limits --- .../com/marketdata/sdk/RateLimitHeaders.java | 18 +-- .../com/marketdata/sdk/HttpTransportTest.java | 36 +++++- .../marketdata/sdk/RateLimitHeadersTest.java | 113 +++++++++++------- 3 files changed, 115 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java index 2a0ed87..b27a829 100644 --- a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java +++ b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java @@ -8,9 +8,14 @@ * Parses the {@code x-api-ratelimit-*} response headers that the API sets on every successful * request (SDK requirements §8.2) into a {@link RateLimitSnapshot}. * - *

Returns {@code null} when none of the relevant headers are present, which happens during a - * rate-limit-tracking outage on the server side (the API silently swallows the error and keeps - * serving the request). + *

Returns {@code null} when the four headers do not arrive together (all absent, partial, or any + * value unparseable). The §8.2 contract is that the four headers ship as a set; a partial delivery + * is a server-side rate-limit-tracking outage, not legitimate data. Returning {@code null} on + * partial responses preserves the caller's last-known-good snapshot in {@link + * HttpTransport#latestRateLimits} instead of clobbering it with phantom zeros — those would + * otherwise trip {@link HttpTransport#checkRateLimitPreflight} into blocking subsequent requests + * with a fake {@code remaining=0}, and would surface in {@code client.getRateLimits()} as a + * snapshot the consumer can't tell apart from a real one. */ final class RateLimitHeaders { @@ -26,14 +31,11 @@ private RateLimitHeaders() {} Long remaining = readLong(headers, REMAINING); Long reset = readLong(headers, RESET); Long consumed = readLong(headers, CONSUMED); - if (limit == null && remaining == null && reset == null && consumed == null) { + if (limit == null || remaining == null || reset == null || consumed == null) { return null; } return new RateLimitSnapshot( - limit != null ? limit.intValue() : 0, - remaining != null ? remaining.intValue() : 0, - Instant.ofEpochSecond(reset != null ? reset : 0L), - consumed != null ? consumed.intValue() : 0); + limit.intValue(), remaining.intValue(), Instant.ofEpochSecond(reset), consumed.intValue()); } private static @Nullable Long readLong(HttpHeaders headers, String name) { diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 9e4536d..9cc18d7 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -308,7 +308,11 @@ void rateLimitSnapshotNotClearedByResponseWithoutHeaders() { // Real data has remaining > 0 — otherwise the §10.3 pre-flight would block the second call. HttpHeaders withRl = TestHttpClients.headersOf( - Map.of("x-api-ratelimit-limit", "500", "x-api-ratelimit-remaining", "100")); + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "100", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "400")); HttpHeaders empty = HttpHeaders.of(Map.of(), (a, b) -> true); CapturingClient client = new CapturingClient(200, "ok".getBytes(), withRl); HttpTransport transport = newTransport(client); @@ -323,6 +327,36 @@ void rateLimitSnapshotNotClearedByResponseWithoutHeaders() { assertThat(transport.getLatestRateLimits()).isSameAs(before); } + @Test + void rateLimitSnapshotNotClobberedByPartialHeaders() { + // §8.2: the four x-api-ratelimit-* headers travel together. A response that only carries a + // subset is treated as "no rate-limit info" — we keep the last-known-good snapshot instead + // of stomping it with phantom zeros that would trip the §10.3 preflight. + HttpHeaders complete = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "100", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "400")); + HttpHeaders partial = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "99")); // missing reset + consumed + CapturingClient client = new CapturingClient(200, "ok".getBytes(), complete); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + RateLimitSnapshot before = transport.getLatestRateLimits(); + assertThat(before).isNotNull(); + + client.nextHeaders = partial; + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(transport.getLatestRateLimits()).isSameAs(before); + } + // ---------- sync bridge ---------- @Test diff --git a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java index 4bfb12a..3144580 100644 --- a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java +++ b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java @@ -55,71 +55,93 @@ void returnsNullWhenNoRateLimitHeadersPresent() { // ---------- partial headers ---------- @Test - void onlyLimitPresentZerosTheOthers() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", "500")); - - RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + void anyMissingHeaderReturnsNull() { + // §8.2: the four x-api-ratelimit-* headers travel together on every successful response. A + // partial response is a server-side bug — surfacing it as a snapshot with phantom zeros + // would flip the preflight gate into a false "exhausted" state and feed consumers a + // snapshot indistinguishable from a real one. Returning null instead lets the caller keep + // the last-known-good snapshot. + HttpHeaders missingRemaining = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingLimit = + headersOf( + Map.of( + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingReset = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingConsumed = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200")); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(500); - assertThat(rl.remaining()).isZero(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(0L)); - assertThat(rl.consumed()).isZero(); + assertThat(RateLimitHeaders.parse(missingRemaining)).isNull(); + assertThat(RateLimitHeaders.parse(missingLimit)).isNull(); + assertThat(RateLimitHeaders.parse(missingReset)).isNull(); + assertThat(RateLimitHeaders.parse(missingConsumed)).isNull(); } @Test - void onlyConsumedPresentZerosTheOthers() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-consumed", "42")); - - RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + void onlyOneHeaderPresentReturnsNull() { + // The complementary check — a single header doesn't carry enough information to be useful, + // so the all-or-nothing rule returns null whether 0 or 1 (or 2 or 3) headers are present. + HttpHeaders onlyLimit = headersOf(Map.of("x-api-ratelimit-limit", "500")); - assertThat(rl).isNotNull(); - assertThat(rl.consumed()).isEqualTo(42); - assertThat(rl.limit()).isZero(); + assertThat(RateLimitHeaders.parse(onlyLimit)).isNull(); } - @Test - void onlyRemainingPresent() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-remaining", "1234")); - - RateLimitSnapshot rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNotNull(); - assertThat(rl.remaining()).isEqualTo(1234); - assertThat(rl.limit()).isZero(); - } + // ---------- malformed values ---------- @Test - void onlyResetPresent() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-reset", "1735689600")); - - RateLimitSnapshot rl = RateLimitHeaders.parse(headers); + void anyMalformedValueReturnsNull() { + // A malformed value is treated as absent by readLong; with the all-or-nothing rule that + // makes the whole snapshot unreliable — same outcome as the header being missing entirely. + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "not-a-number", + "x-api-ratelimit-consumed", "13")); - assertThat(rl).isNotNull(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1735689600L)); - assertThat(rl.limit()).isZero(); - assertThat(rl.remaining()).isZero(); - assertThat(rl.consumed()).isZero(); + assertThat(RateLimitHeaders.parse(headers)).isNull(); } - // ---------- malformed values ---------- - @Test - void malformedNumberIsTreatedAsAbsent() { - // readLong's catch(NumberFormatException) returns null; the header is then treated as - // missing. With every header malformed the result must be null, same as none-present. + void allMalformedValuesReturnNull() { HttpHeaders headers = headersOf( Map.of( "x-api-ratelimit-limit", "not-a-number", - "x-api-ratelimit-remaining", "also-broken")); + "x-api-ratelimit-remaining", "also-broken", + "x-api-ratelimit-reset", "still-broken", + "x-api-ratelimit-consumed", "broken-too")); assertThat(RateLimitHeaders.parse(headers)).isNull(); } @Test void valuesAreTrimmedBeforeParsing() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", " 1000 ")); + // The complete-headers happy path; the trim guard applies to every value, exercised through + // the limit header here. + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", " 1000 ", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); RateLimitSnapshot rl = RateLimitHeaders.parse(headers); @@ -129,12 +151,17 @@ void valuesAreTrimmedBeforeParsing() { @Test void parseIgnoresNonRateLimitHeaders() { + // The four required headers are still present alongside unrelated ones — unrelated headers + // must not affect parsing. HttpHeaders headers = headersOf( Map.of( "cf-ray", "abc", "content-type", "application/json", - "x-api-ratelimit-limit", "100")); + "x-api-ratelimit-limit", "100", + "x-api-ratelimit-remaining", "99", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "1")); RateLimitSnapshot rl = RateLimitHeaders.parse(headers); From 08e9d73cefc80e61e2f5e1806dc5d2b4f9c4c761 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:19:57 -0300 Subject: [PATCH 34/57] improve http status mapper --- .../com/marketdata/sdk/HttpStatusMapper.java | 50 +++++++++++-- .../marketdata/sdk/HttpStatusMapperTest.java | 75 ++++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java index 2fec442..a4bf8da 100644 --- a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -20,6 +20,22 @@ final class HttpStatusMapper { * Maps an HTTP status to its typed exception. When {@code retryAfter} is non-null, it is attached * to the resulting {@link ServerError} so the retry policy can honor §9.4. The other subtypes * ignore it — only server errors retry, and only retries care about Retry-After. + * + *

Unmapped status codes are split by range rather than lumped into a single bucket: + * + *

    + *
  • 5xx → {@link ServerError} (retryable). + *
  • 4xx (other than 401/404/429) → {@link BadRequestError} with the status + * code in the message — the request itself was malformed for some endpoint-specific reason + * (403 forbidden, 422 unprocessable entity, etc.). + *
  • 3xx → {@link BadRequestError} with a "redirect" message. The transport's + * {@code HttpClient} follows redirects per {@code NORMAL} policy, so a 3xx escaping that + * means the redirect could not be followed (e.g., cross-protocol, max redirects). Surfaces + * as a non-retryable error — retrying would just hit the same redirect. + *
  • 1xx → {@link BadRequestError} defensively. {@code HttpClient} handles + * {@code 100 Continue} itself, so reaching here is a server-protocol oddity. + *
  • Anything else (negative, > 599, etc.) → {@link BadRequestError} with the raw status. + *
*/ static @Nullable MarketDataException map( int statusCode, ErrorContext context, @Nullable Duration retryAfter) { @@ -31,15 +47,35 @@ final class HttpStatusMapper { case 401 -> new AuthenticationError("Authentication failed", context); case 404 -> new NotFoundError("Not found", context); case 429 -> new RateLimitError("Rate limit exceeded", context); - case 500 -> new ServerError("Server error: 500", context, null, retryAfter); - default -> { - if (statusCode >= 501 && statusCode <= 599) { - yield new ServerError("Server error: " + statusCode, context, null, retryAfter); - } - yield new BadRequestError("Unexpected status code: " + statusCode, context); - } + default -> mapByRange(statusCode, context, retryAfter); }; } + private static MarketDataException mapByRange( + int statusCode, ErrorContext context, @Nullable Duration retryAfter) { + if (statusCode >= 500 && statusCode <= 599) { + return new ServerError("Server error: " + statusCode, context, null, retryAfter); + } + if (statusCode >= 400 && statusCode <= 499) { + return new BadRequestError("Client error: HTTP " + statusCode, context); + } + if (statusCode >= 300 && statusCode <= 399) { + // followRedirects(NORMAL) drains the standard cases; a 3xx surviving here means the + // redirect could not be followed (cross-protocol, max-redirects hit, etc.). Retrying + // would hit the same redirect, so route through the non-retryable BadRequestError + // bucket with a message that points at the likely culprit. + return new BadRequestError( + "Unhandled redirect: HTTP " + + statusCode + + " — the SDK follows standard redirects; this response was not followed." + + " Check baseUrl or proxy configuration.", + context); + } + if (statusCode >= 100 && statusCode <= 199) { + return new BadRequestError("Unexpected informational response: HTTP " + statusCode, context); + } + return new BadRequestError("Unexpected HTTP status: " + statusCode, context); + } + private HttpStatusMapper() {} } diff --git a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java index 662e5a4..871e217 100644 --- a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java +++ b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java @@ -57,13 +57,86 @@ static Stream statusToExceptionType() { } @ParameterizedTest - @ValueSource(ints = {402, 403, 405, 418}) + @ValueSource(ints = {402, 403, 405, 418, 422, 451}) void maps_unhandled_four_xx_to_bad_request_error(int statusCode) { @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); assertThat(exception).isExactlyInstanceOf(BadRequestError.class); } + @ParameterizedTest + @ValueSource(ints = {402, 403, 405, 418}) + void unhandled_four_xx_message_includes_the_status_code(int statusCode) { + // Previously the default branch produced a generic "Unexpected status code: 403" which read + // like an SDK bug rather than a server response. The message now identifies the bucket + // ("Client error") and the actual status, making it obvious what came back. + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isNotNull(); + assertThat(exception.getMessage()) + .contains("Client error") + .contains(String.valueOf(statusCode)); + } + + // ---------- 3xx redirects ---------- + + @ParameterizedTest + @ValueSource(ints = {301, 302, 303, 304, 307, 308}) + void maps_three_xx_to_bad_request_with_redirect_message(int statusCode) { + // HttpClient is configured with followRedirects(NORMAL); a 3xx escaping that means the + // redirect could not be followed (cross-protocol, max-redirects hit, etc.). Treat as + // BadRequestError so the retry layer does not loop on the same redirect, with a message + // that points the user at the likely cause. + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("Unhandled redirect") + .contains(String.valueOf(statusCode)) + .contains("baseUrl"); + assertThat(exception.getStatusCode()).isEqualTo(statusCode); + } + + // ---------- 1xx informational ---------- + + @ParameterizedTest + @ValueSource(ints = {100, 101, 102}) + void maps_one_xx_to_bad_request_with_informational_message(int statusCode) { + // HttpClient handles 100 Continue internally — reaching the mapper with a 1xx means the + // server is doing something protocol-weird. Surface with a clear "informational" message + // rather than the generic "Unexpected status code". + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("informational") + .contains(String.valueOf(statusCode)); + } + + // ---------- out-of-range fallback ---------- + + @ParameterizedTest + @ValueSource(ints = {0, -1, 600, 999}) + void maps_out_of_range_to_bad_request_with_unexpected_message(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("Unexpected HTTP status") + .contains(String.valueOf(statusCode)); + } + + // ---------- 5xx messages include the actual status ---------- + + @ParameterizedTest + @ValueSource(ints = {500, 502, 503, 504, 599}) + void server_error_message_includes_the_actual_status(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(ServerError.class); + assertThat(exception.getMessage()).contains(String.valueOf(statusCode)); + } + @Test void error_carries_the_full_context() { ErrorContext ctx = context(401); From 4243da44f6759916b2815f08db384e96eb867972 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:28:29 -0300 Subject: [PATCH 35/57] use clock on checkRateLimitPreflight --- .../com/marketdata/sdk/HttpTransport.java | 39 ++++-- .../com/marketdata/sdk/HttpTransportTest.java | 118 ++++++++++++++++-- .../marketdata/sdk/UtilitiesResourceTest.java | 4 +- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index c243c3c..277d2ee 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -10,6 +10,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Map; @@ -56,6 +57,7 @@ final class HttpTransport implements AutoCloseable { private final HttpDispatcher dispatcher; private final RetryExecutor retryExecutor; private final Supplier<@Nullable StatusCache> statusCacheSupplier; + private final Clock clock; private final AtomicReference<@Nullable RateLimitSnapshot> latestRateLimits = new AtomicReference<>(); @@ -73,6 +75,9 @@ final class HttpTransport implements AutoCloseable { * MarketDataClient} can construct the cache after the transport (the cache's fetcher * uses the transport via {@link UtilitiesResource} — the chicken-and-egg is resolved by a * deferred reference). Pass {@code () -> null} when no §9.5 gate is desired (e.g. tests). + * + *

The {@code clock} drives the §10.3 preflight's {@code reset}-window check; tests pass a + * fixed clock to assert the time-based gate behavior deterministically. */ HttpTransport( String baseUrl, @@ -81,7 +86,8 @@ final class HttpTransport implements AutoCloseable { @Nullable String token, HttpDispatcher dispatcher, RetryExecutor retryExecutor, - Supplier<@Nullable StatusCache> statusCacheSupplier) { + Supplier<@Nullable StatusCache> statusCacheSupplier, + Clock clock) { this.baseUrl = baseUrl; this.apiVersion = apiVersion; this.userAgent = userAgent; @@ -89,12 +95,13 @@ final class HttpTransport implements AutoCloseable { this.dispatcher = dispatcher; this.retryExecutor = retryExecutor; this.statusCacheSupplier = statusCacheSupplier; + this.clock = clock; } /** * Production factory: assembles a real {@link HttpDispatcher} (50-permit pool + JDK {@link - * HttpClient}) and a default {@link RetryExecutor} (4 attempts, exponential 1s→30s). Used by - * {@link MarketDataClient}. + * HttpClient}), a default {@link RetryExecutor} (4 attempts, exponential 1s→30s), and {@link + * Clock#systemUTC()} for the preflight reset-window check. */ static HttpTransport withDefaults( String baseUrl, @@ -109,7 +116,8 @@ static HttpTransport withDefaults( token, new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), new RetryExecutor(RetryPolicy.defaults()), - statusCacheSupplier); + statusCacheSupplier, + Clock.systemUTC()); } private static HttpClient defaultHttpClient() { @@ -172,20 +180,29 @@ private CompletableFuture executeAsync( } /** - * Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits; - * {@code null} when the request is allowed (either credits are available or no snapshot has been - * taken yet — the first request must reach the server to populate one). + * Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits + * and the snapshot's {@code reset} timestamp is still in the future. Returns {@code + * null} when the request is allowed (credits available, no snapshot yet, or the reset window has + * elapsed — the snapshot is stale and the next response's headers will refresh it). * - *

Treats {@code remaining == 0} as exhausted regardless of whether {@code reset} has passed. - * The snapshot only refreshes on response headers, so we have no fresh data to justify letting - * the request through; the server will fail us anyway if quotas haven't actually reset. + *

Without the reset check, a single response carrying {@code remaining=0} would freeze the + * client forever: the preflight would short-circuit every subsequent request, no request would + * reach the wire, and the snapshot would never refresh — even after the server replenished + * credits at the reset time. */ private @Nullable RateLimitError checkRateLimitPreflight(URI uri) { RateLimitSnapshot snap = latestRateLimits.get(); if (snap == null || snap.remaining() > 0) { return null; } - ErrorContext context = ErrorContext.forNoResponse(uri.toString(), Instant.now()); + Instant now = clock.instant(); + if (!now.isBefore(snap.reset())) { + // now >= reset → window has elapsed; let the request through so the response refreshes + // the snapshot. If the server hasn't actually replenished yet it will reject with 429, + // which costs one round-trip — strictly less harmful than locking out indefinitely. + return null; + } + ErrorContext context = ErrorContext.forNoResponse(uri.toString(), now); return new RateLimitError( "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 9cc18d7..813dc76 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -13,7 +13,10 @@ import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -28,6 +31,10 @@ class HttpTransportTest { new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); private static HttpTransport newTransport(HttpClient client) { + return newTransport(client, Clock.systemUTC()); + } + + private static HttpTransport newTransport(HttpClient client, Clock clock) { return new HttpTransport( "http://localhost", "v1", @@ -35,7 +42,8 @@ private static HttpTransport newTransport(HttpClient client) { "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), new RetryExecutor(NO_RETRY), - () -> null); + () -> null, + clock); } // ---------- URL & header composition ---------- @@ -86,7 +94,8 @@ void noAuthorizationHeaderWhenTokenIsNull() { null, new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), new RetryExecutor(NO_RETRY), - () -> null); + () -> null, + Clock.systemUTC()); transport.executeAsync(RequestSpec.get("markets/status").build()).join(); @@ -383,12 +392,19 @@ void executeSyncUnwrapsCompletionExceptionToCause() { // ---------- §10.3 pre-flight rate-limit check ---------- private static HttpHeaders rateLimitHeaders(int remaining) { + // Reset always in the future relative to Clock.systemUTC() so the preflight's reset-window + // guard treats the snapshot as "still exhausted" rather than "stale — let it through". + long resetEpoch = Instant.now().plus(Duration.ofHours(1)).getEpochSecond(); return TestHttpClients.headersOf( Map.of( - "x-api-ratelimit-limit", "1000", - "x-api-ratelimit-remaining", String.valueOf(remaining), - "x-api-ratelimit-reset", "1734036832", - "x-api-ratelimit-consumed", "1")); + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + String.valueOf(remaining), + "x-api-ratelimit-reset", + String.valueOf(resetEpoch), + "x-api-ratelimit-consumed", + "1")); } /** @@ -447,6 +463,90 @@ void preflightAllowsTheFirstRequestWhenNoSnapshotExistsYet() { assertThat(transport.getLatestRateLimits()).isNull(); } + /** + * The reset-window guard: once {@code reset} has elapsed the preflight must let the request + * through even though {@code remaining=0}. Without this guard a single response with {@code + * remaining=0} would lock the client out forever — no request reaches the wire, so the snapshot + * never refreshes from a fresh response. + */ + @Test + void preflightAllowsWhenResetWindowHasElapsed() { + // reset = 15:00:00 UTC; the test runs the second call at 15:00:10 — past the reset, so the + // preflight must allow the request even though remaining=0. + Instant resetTs = Instant.parse("2026-05-20T15:00:00Z"); + long resetEpoch = resetTs.getEpochSecond(); + Clock fixedAfterReset = Clock.fixed(resetTs.plus(Duration.ofSeconds(10)), ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetEpoch), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedAfterReset); + + // First call lands the exhausted snapshot. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + // Second call: remaining=0, but reset has already passed → preflight must allow. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(2); + } + + @Test + void preflightAllowsAtTheExactResetInstant() { + // Boundary check: "now == reset" is treated as window-elapsed (the server has presumably + // refreshed by the instant the reset timestamp names). + Instant resetTs = Instant.parse("2026-05-20T15:00:00Z"); + Clock fixedAtReset = Clock.fixed(resetTs, ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetTs.getEpochSecond()), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedAtReset); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured).hasSize(2); + } + + @Test + void preflightStillBlocksWhenResetIsInTheFuture() { + // reset is in the future → the snapshot's "exhausted" verdict is still current; the + // preflight must veto the second call. + Instant resetTs = Instant.parse("2026-05-20T15:30:00Z"); + Clock fixedBeforeReset = Clock.fixed(resetTs.minus(Duration.ofMinutes(5)), ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetTs.getEpochSecond()), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedBeforeReset); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); + + assertThat(client.captured).hasSize(1); + } + // ---------- §9.4 Retry-After header ---------- /** @@ -528,7 +628,8 @@ void cacheOfflineEntryVetoesA5xxRetry() throws Exception { "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), new RetryExecutor(fourAttempts), - () -> cache); + () -> cache, + Clock.systemUTC()); assertThatThrownBy( () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) @@ -569,7 +670,8 @@ void cacheOnlineEntryAllowsNormalRetryFlow() throws Exception { "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), new RetryExecutor(fourAttempts), - () -> cache); + () -> cache, + Clock.systemUTC()); assertThatThrownBy( () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 264c235..bcf69d0 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -34,7 +35,8 @@ private static UtilitiesResource resourceWith(HttpClient client) { "secret-token", new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), new RetryExecutor(NO_RETRY), - () -> null); + () -> null, + Clock.systemUTC()); return new UtilitiesResource(transport, new JsonResponseParser()); } From be1c1047dd7a6933626b07095c0f14386e94c89f Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:38:43 -0300 Subject: [PATCH 36/57] bypass cache when /status --- .../com/marketdata/sdk/HttpTransport.java | 17 ++++ .../com/marketdata/sdk/HttpTransportTest.java | 96 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 277d2ee..77e4d25 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -207,11 +207,28 @@ private CompletableFuture executeAsync( "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); } + /** + * Path of the {@code /status/} endpoint the {@link StatusCache} fetches. Hardcoded to the + * canonical no-prefix shape because today every {@link MarketDataClient} construction lands the + * endpoint at exactly this path; if a {@code baseUrl} with a path prefix ever ships (e.g. {@code + * https://corp/proxy}), the self-referential bypass below would silently stop applying and need + * to switch to a {@link URI} stored on the cache at construction time. + */ + private static final String STATUS_ENDPOINT_PATH = "/status/"; + private boolean cacheAllowsRetry(URI uri) { StatusCache cache = statusCacheSupplier.get(); if (cache == null) { return true; // pre-wire state or test setup without a cache } + // Self-referential bypass: the cache's own fetcher targets /status/. If we consulted the + // cache for retries of that fetch and the snapshot reported /status/ offline (or any + // wildcard match grazed it), the retry would be blocked — and because no successful fetch + // can land, the snapshot would stay frozen in that "offline" state forever. Skip the cache + // for /status/ so the §9.5 gate cannot trap its own refresh. + if (STATUS_ENDPOINT_PATH.equals(uri.getPath())) { + return true; + } return cache.check(uri) == StatusCache.Decision.ALLOW; } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 813dc76..fe42cef 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -682,6 +682,102 @@ void cacheOnlineEntryAllowsNormalRetryFlow() throws Exception { assertThat(client.captured).hasSize(4); } + /** + * Self-referential bypass: even when the cache reports the /status/ service offline, retries of + * the /status/ fetch itself must proceed — otherwise the cache could never refresh out of an + * "offline" snapshot and would stay frozen in that state indefinitely. + */ + @Test + void cacheDoesNotBlockRetriesOnTheStatusEndpointEvenWhenSnapshotMarksItOffline() + throws Exception { + com.marketdata.sdk.utilities.ApiStatus statusItselfOffline = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/status/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(statusItselfOffline), + java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + // Confirm the snapshot really would BLOCK /status/ retries if the bypass didn't exist. + assertThat(cache.check(URI.create("http://localhost/status/"))) + .isEqualTo(StatusCache.Decision.BLOCK); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("status").unversioned().build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + // All 4 attempts run: the bypass kicked in for the /status/ path, so the cache did not + // veto. Without the bypass this would be 1. + assertThat(client.captured).hasSize(4); + } + + @Test + void selfReferentialBypassDoesNotLeakToOtherEndpoints() throws Exception { + // Regression guard: the bypass for /status/ must NOT generalize. A different endpoint + // marked offline should still BLOCK retries as today. + com.marketdata.sdk.utilities.ApiStatus quotesOffline = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/quotes/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(quotesOffline), java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/quotes").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + // Cache vetoed: only 1 attempt, no retries. The bypass is /status/-specific. + assertThat(client.captured).hasSize(1); + } + // ---------- stub HttpClient ---------- /** From 0ef852847d9f41bcee3ee6aff3741c558f15e059 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:42:07 -0300 Subject: [PATCH 37/57] remove unnecessary fqn --- src/main/java/com/marketdata/sdk/HttpTransport.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 77e4d25..3d5f681 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -4,6 +4,7 @@ import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.exception.NetworkError; import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -275,7 +276,7 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI } Instant now = Instant.now(); ErrorContext context = ErrorContext.forResponse(uri.toString(), status, requestId, now); - java.time.Duration retryAfter = + Duration retryAfter = response .headers() .firstValue("Retry-After") @@ -289,8 +290,7 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI } // Mapper only returns null for 2xx, which the branch above already handled. Belt & // suspenders for the impossible case so a future mapper edit can't silently swallow. - throw new com.marketdata.sdk.exception.ServerError( - "Unmapped status " + status + " from " + uri, context); + throw new ServerError("Unmapped status " + status + " from " + uri, context); } private URI buildUri(RequestSpec spec) { From a89cb7d19ecba99e0855976cbdea871f953e5661 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:46:51 -0300 Subject: [PATCH 38/57] inmutable SDK logger --- .../com/marketdata/sdk/MarketDataLogging.java | 18 +++++- .../marketdata/sdk/MarketDataLoggingTest.java | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java index 8bcdf68..a660e35 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataLogging.java +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -25,6 +25,13 @@ *

The first {@link #configure(String)} call wins; subsequent calls are no-ops. This avoids * doubling handlers when multiple {@code MarketDataClient} instances are created in the same * process and avoids surprising config-flips when the second client passes a different level. + * + *

Consumer-config detection: the SDK logger lives in a JVM-wide registry, so a + * consumer (or another lib) may have already attached a handler or set a level on it before any + * {@link MarketDataClient} is constructed. {@link #configure(String)} detects that and backs off + * entirely — no handler added, no {@code useParentHandlers} flipped, no level overridden. This + * makes the constructor's logging side-effect conditional: install the spec-default behavior only + * when no other code has expressed an opinion. */ final class MarketDataLogging { @@ -37,7 +44,9 @@ private MarketDataLogging() {} /** * Install the SDK's handler + formatter on the SDK root logger. Idempotent — first call wins; - * subsequent calls are no-ops. + * subsequent calls are no-ops. Also backs off entirely when the SDK logger already carries a + * handler or an explicit level (see class docs): the consumer has taken control, the SDK respects + * it. * * @param levelSpec a level string from {@code MARKETDATA_LOGGING_LEVEL} ({@code DEBUG}, {@code * INFO}, {@code WARNING}, {@code ERROR}, case-insensitive), or {@code null} for the default @@ -48,6 +57,13 @@ static void configure(@Nullable String levelSpec) { return; } Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + if (sdkLogger.getHandlers().length > 0 || sdkLogger.getLevel() != null) { + // Consumer (or another library) already configured the SDK logger. Respect that + // entirely: don't add our ConsoleHandler (would double-emit), don't flip + // useParentHandlers (would break their parent-handler routing), don't overwrite the + // level they explicitly chose. + return; + } Handler handler = new ConsoleHandler(); handler.setFormatter(new CanonicalLogFormatter()); // ConsoleHandler defaults its own filter to INFO; lower it so the logger's level is the diff --git a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java index f025de1..41f33fe 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java @@ -88,4 +88,64 @@ void defaultLevelWhenSpecIsNullIsInfo() { MarketDataLogging.configure(null); assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); } + + // ---------- consumer-config detection ---------- + + @Test + void configureSkipsWhenConsumerAlreadyAttachedAHandler() { + // Consumer pre-attached their own handler (e.g. SLF4J bridge, Logback appender). The SDK + // must not add its ConsoleHandler on top — that would emit each log line twice. + Handler consumerHandler = new TestHandler(); + sdkLogger().addHandler(consumerHandler); + + MarketDataLogging.configure("DEBUG"); + + assertThat(sdkLogger().getHandlers()).containsExactly(consumerHandler); + // useParentHandlers must remain at its default (true) — flipping it would break the + // consumer's parent-handler routing. + assertThat(sdkLogger().getUseParentHandlers()).isTrue(); + // Level was not set by the SDK; remains null (inherits from parent). + assertThat(sdkLogger().getLevel()).isNull(); + } + + @Test + void configureSkipsWhenConsumerAlreadySetALevel() { + // Consumer explicitly chose a level (e.g. FINE for local debugging). The SDK's default + // INFO must not silently override it. + sdkLogger().setLevel(Level.FINE); + + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + // No handler added either — having any opinion at all on the logger counts as "consumer + // has taken control". + assertThat(sdkLogger().getHandlers()).isEmpty(); + } + + @Test + void configureRunsAgainAfterResetClearsConsumerState() { + // Defensive: resetForTests() wipes both the idempotency flag and the logger state, so a + // subsequent configure() must see a fresh slate and install the SDK defaults. + sdkLogger().setLevel(Level.FINE); // simulate consumer state + MarketDataLogging.configure("INFO"); + assertThat(sdkLogger().getHandlers()).isEmpty(); // skipped + + MarketDataLogging.resetForTests(); + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getHandlers()).hasSize(1); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); + } + + /** Minimal Handler stub used to simulate a consumer-attached handler. */ + private static final class TestHandler extends Handler { + @Override + public void publish(java.util.logging.LogRecord record) {} + + @Override + public void flush() {} + + @Override + public void close() {} + } } From e3d2228397701b594dd0f864e9412fb47dbc144c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:49:43 -0300 Subject: [PATCH 39/57] do not expose direct env --- src/main/java/com/marketdata/sdk/EnvVars.java | 15 +++++- .../java/com/marketdata/sdk/EnvVarsTest.java | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/marketdata/sdk/EnvVarsTest.java diff --git a/src/main/java/com/marketdata/sdk/EnvVars.java b/src/main/java/com/marketdata/sdk/EnvVars.java index e56dbd3..71ee2a5 100644 --- a/src/main/java/com/marketdata/sdk/EnvVars.java +++ b/src/main/java/com/marketdata/sdk/EnvVars.java @@ -1,5 +1,6 @@ package com.marketdata.sdk; +import java.util.Set; import java.util.function.Function; import org.jspecify.annotations.Nullable; @@ -11,8 +12,20 @@ final class EnvVars { static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; + static final Set ALLOWED_KEYS = + Set.of(TOKEN, BASE_URL, API_VERSION, LOGGING_LEVEL, DATE_FORMAT); + + /** + * Lookup function over the SDK-relevant environment variables. Restricts reads to {@link + * #ALLOWED_KEYS} so the {@link Function} can be passed around safely — any other key returns + * {@code null} without touching {@code System.getenv}. Today's only caller ({@link + * Configuration#resolve}) already invokes with just the {@code MARKETDATA_*} keys; the + * restriction is defensive: a future caller that accidentally tries to read {@code PATH} or + * {@code AWS_SECRET_ACCESS_KEY} through this seam would silently get {@code null} instead of + * leaking the value. + */ static Function systemLookup() { - return System::getenv; + return key -> ALLOWED_KEYS.contains(key) ? System.getenv(key) : null; } private EnvVars() {} diff --git a/src/test/java/com/marketdata/sdk/EnvVarsTest.java b/src/test/java/com/marketdata/sdk/EnvVarsTest.java new file mode 100644 index 0000000..944c3ea --- /dev/null +++ b/src/test/java/com/marketdata/sdk/EnvVarsTest.java @@ -0,0 +1,47 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class EnvVarsTest { + + @Test + void systemLookupReturnsNullForKeysOutsideTheAllowlist() { + // The classic risk: a Function handed to other code could be invoked with + // arbitrary keys and silently read AWS_SECRET_ACCESS_KEY or PATH from the process env. + // The lookup must refuse anything outside the MARKETDATA_* set, even if the real env has it. + Function lookup = EnvVars.systemLookup(); + + assertThat(lookup.apply("PATH")).isNull(); // PATH is virtually always set in real envs + assertThat(lookup.apply("HOME")).isNull(); + assertThat(lookup.apply("FOOBAR_DOES_NOT_EXIST")).isNull(); + assertThat(lookup.apply("")).isNull(); + } + + @Test + void systemLookupAllowsExactlyTheDeclaredMarketdataKeys() { + // Regression guard: if a new MARKETDATA_* constant is added to EnvVars, it must also be + // wired into ALLOWED_KEYS, or systemLookup() silently swallows reads of the new key. + assertThat(EnvVars.ALLOWED_KEYS) + .containsExactlyInAnyOrder( + EnvVars.TOKEN, + EnvVars.BASE_URL, + EnvVars.API_VERSION, + EnvVars.LOGGING_LEVEL, + EnvVars.DATE_FORMAT); + } + + @Test + void systemLookupForAllowedKeyMatchesSystemGetenv() { + // Best-effort sanity: for an allowed key the lookup must mirror System.getenv. We can't + // force a MARKETDATA_* var to be set on the test JVM, so just assert that whatever the + // process has (likely null) is what the lookup returns — i.e., no extra filtering or + // transformation is applied on top of System.getenv for permitted keys. + Function lookup = EnvVars.systemLookup(); + + assertThat(lookup.apply(EnvVars.TOKEN)).isEqualTo(System.getenv(EnvVars.TOKEN)); + assertThat(lookup.apply(EnvVars.BASE_URL)).isEqualTo(System.getenv(EnvVars.BASE_URL)); + } +} From 9d984422f68306e1b7934e4633d2dbb382ce9780 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:56:17 -0300 Subject: [PATCH 40/57] obfuscate URI parameters --- .../com/marketdata/sdk/HttpDispatcher.java | 36 +++++++++++++++---- .../com/marketdata/sdk/HttpTransport.java | 8 ++++- .../marketdata/sdk/HttpDispatcherTest.java | 35 ++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index ab1be25..81d384e 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -2,6 +2,7 @@ import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.NetworkError; +import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -65,7 +66,7 @@ CompletableFuture> dispatch(HttpRequest request) { } private CompletableFuture> send(HttpRequest request) { - LOGGER.fine(() -> "GET " + request.uri()); + LOGGER.fine(() -> "GET " + safeUri(request.uri())); Instant start = Instant.now(); CompletableFuture> sendFuture; @@ -77,13 +78,17 @@ private CompletableFuture> send(HttpRequest request) { // to prevent a permanent leak that would degrade the pool to deadlock. permits.release(); LOGGER.warning( - () -> "Request to " + request.uri() + " failed before dispatch: " + t.getMessage()); + () -> + "Request to " + + safeUri(request.uri()) + + " failed before dispatch: " + + t.getMessage()); if (t instanceof Error err) { throw err; } return CompletableFuture.failedFuture( new NetworkError( - "Request to " + request.uri() + " failed before dispatch: " + t.getMessage(), + "Request to " + safeUri(request.uri()) + " failed before dispatch: " + t.getMessage(), ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), t)); } @@ -98,14 +103,14 @@ private CompletableFuture> send(HttpRequest request) { LOGGER.warning( () -> "Request to " - + request.uri() + + safeUri(request.uri()) + " failed after " + elapsedMs + "ms: " + root.getMessage()); throw new CompletionException( new NetworkError( - "Request to " + request.uri() + " failed: " + root.getMessage(), + "Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(), ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), root)); } @@ -114,7 +119,7 @@ private CompletableFuture> send(HttpRequest request) { "Response " + response.statusCode() + " from " - + request.uri() + + safeUri(request.uri()) + " in " + elapsedMs + "ms"); @@ -122,6 +127,25 @@ private CompletableFuture> send(HttpRequest request) { }); } + /** + * Returns a log-safe rendition of {@code uri}: just the path, with a literal {@code "?…"} + * appended when the URI had a query string. The query is omitted so log lines never persist + * potentially-sensitive request parameters (PII like {@code account_id}, competitive-signal data + * like queried symbols, or a hypothetical future {@code ?token=}). + * + *

Exception context (via {@link ErrorContext}) still carries the full URI: that surface is for + * consumer code that has context to decide what to do with it; ambient logs are not. + */ + static String safeUri(URI uri) { + String path = uri.getPath(); + if (path == null) { + // Opaque URIs (scheme:opaque, no //authority) — defensive fallback. Won't happen for + // requests built by this SDK, but log-safety helpers must never throw. + return uri.toString(); + } + return uri.getRawQuery() != null ? path + "?…" : path; + } + /** Permits not currently held nor queued. Exposed for diagnostics and tests. */ int availablePermits() { return permits.availablePermits(); diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 3d5f681..4cad33f 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -285,7 +285,13 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); if (ex != null) { LOGGER.warning( - () -> "Request to " + uri + " returned HTTP " + status + ": " + ex.getMessage()); + () -> + "Request to " + + HttpDispatcher.safeUri(uri) + + " returned HTTP " + + status + + ": " + + ex.getMessage()); throw ex; } // Mapper only returns null for 2xx, which the branch above already handled. Belt & diff --git a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java index a72cca6..49f607f 100644 --- a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java +++ b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java @@ -217,4 +217,39 @@ void closeIsIdempotent() { dispatcher.close(); dispatcher.close(); // must be safe } + + // ---------- safeUri (log redaction) ---------- + + @Test + void safeUriReturnsPathWhenNoQueryString() { + assertThat(HttpDispatcher.safeUri(URI.create("http://localhost/v1/markets/status/"))) + .isEqualTo("/v1/markets/status/"); + } + + @Test + void safeUriOmitsQueryStringWithEllipsisMarker() { + // The "?…" suffix preserves the signal that the call carried params (useful for diagnostics) + // without persisting their values to logs. Symbols, account IDs, date ranges, hypothetical + // future "?token=" — all stay out of the log line. + assertThat( + HttpDispatcher.safeUri( + URI.create("http://localhost/v1/stocks/quotes/?symbol=AAPL&from=2024-01-01"))) + .isEqualTo("/v1/stocks/quotes/?…"); + } + + @Test + void safeUriHandlesEmptyQueryDefensively() { + // URI like "/foo?" — empty query but the marker character is present. URI.getRawQuery() + // returns "" (non-null) in that case, so the suffix still applies. + assertThat(HttpDispatcher.safeUri(URI.create("http://localhost/foo?"))).isEqualTo("/foo?…"); + } + + @Test + void safeUriFallsBackToToStringForOpaqueUri() { + // Opaque URIs (scheme:opaque, no //authority) have a null path. Won't be built by the SDK + // for real requests, but the helper must never throw — that would convert a log call into + // a runtime failure. + assertThat(HttpDispatcher.safeUri(URI.create("mailto:user@example.com"))) + .isEqualTo("mailto:user@example.com"); + } } From eabbb8e4b69703501d114c1c540dc8ec123c81f6 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 10:59:06 -0300 Subject: [PATCH 41/57] modify CLAUDE.md --- CLAUDE.md | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c89e15..6eca75a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository state -This repo currently contains **documentation only** — no Java sources, no build scripts. Branch `00_base_setup` is the pre-implementation phase: all foundational technical decisions are being captured as ADRs *before* code lands. There is therefore nothing to build, lint, or test yet. When implementation starts, the build will be Gradle (Kotlin DSL) per ADR-003 — see "Locked-in tech stack" below. +This repo contains a working Java SDK in active development on branch `clean-architecture-restart`. Gradle 9.0 (Kotlin DSL) build per ADR-003; ~48 main + ~28 test source files; full CI matrix per ADR-002. The transport, retry, rate-limit, status-cache, and exception layers are wired; only the `utilities` resource façade is implemented today — stocks/options/funds/markets resources are still to come (see "Deliberately deferred" below). Sibling repo: `../api/` is the backend (Python/Django). The Python SDK lives at `../../sdk-py/` (referenced from ADRs). The cross-language `sdk-requirements.md` is referenced as `../sdk-requirements.md` from inside `docs/`; it is canonical but not committed in this repo. @@ -62,14 +62,17 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( **Already wired in:** - §1.1 client object — `MarketDataClient` with two public constructors: a no-arg one for production (everything from the cascade) and a 4-arg `(apiKey, baseUrl, apiVersion, validateOnStartup)` for tests and short-lived runtimes. All fields `final` (immutable). Default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor. -- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). The 4-arg constructor's parameters feed step 1; the no-arg constructor skips it and starts at step 2. -- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). +- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). `baseUrl` and `apiVersion` are normalized (trailing/leading slashes stripped) and validated (scheme http/https, host present, no query/fragment/user-info on `baseUrl`; `[A-Za-z0-9._-]+` on `apiVersion`) at resolution time, so misconfigured cascade inputs fail at construction with a clear message instead of producing malformed URLs later. +- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). `runStartupValidation` fires a single `GET /v1/user/` via `utilities.user(RetryPolicy.noRetry())`, skipping in demo mode; the no-retry policy keeps construction snappy on a slow/down API. - §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`. +- §7 logging — `MarketDataLogging.configure(...)` installs `CanonicalLogFormatter` (the spec's `{timestamp} - {logger_name} - {level} - {message}` shape) on `com.marketdata.sdk` and applies the level from `MARKETDATA_LOGGING_LEVEL`. Detects consumer-pre-configured loggers (handler attached or level set) and backs off entirely so embedding the SDK doesn't clobber existing logging setups. +- §8 rate-limit tracking — `RateLimitHeaders.parse` reads the four `x-api-ratelimit-*` headers per response (all-or-nothing: a partial header set returns null rather than emitting a snapshot with phantom zeros); `HttpTransport.latestRateLimits` keeps the latest snapshot, exposed via `client.getRateLimits()`. §10.3 preflight (`HttpTransport.checkRateLimitPreflight`) fails fast on `remaining=0`, but only while `now < reset` — once the reset window elapses the request goes through so the next response refreshes the snapshot. +- §9 retry/backoff — `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not. §9.4 `Retry-After`: parsed from response headers via `RetryAfterHeader.parse` (supports both delta-seconds and HTTP-date), attached to `ServerError`, and honored by `RetryPolicy.backoffDelay` as an override of the calculated exponential delay. §9.5 `/status/` cache: `StatusCache` (stale-while-revalidate, 270s refresh / 300s expiry) gates retries on services reported offline; `HttpTransport.cacheAllowsRetry` has a self-referential bypass for `/status/` itself so the cache can never block its own refresh. - §10 timeouts: `REQUEST_TIMEOUT = 99s` and `CONNECT_TIMEOUT = 2s` exposed as constants on `MarketDataClient`. Connect timeout is wired into the `HttpClient`; the per-request 99 s timeout is applied via `HttpRequest.Builder#timeout` in `HttpTransport.buildRequest`. - §12 concurrency: 50-permit `AsyncSemaphore` on `HttpTransport` with acquire/release wired around every dispatch. The custom semaphore replaces `java.util.concurrent.Semaphore` so `executeAsync` never parks the caller's thread on a full pool (ADR-007). -- §9 retry/backoff: `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not. +- §13.5 response object — `Response` wrapper exposes typed `data()`, `rawBody()` (defensive copy), `statusCode()`, `requestUrl()`, `requestId()`, format predicates (`isJson()`/`isCsv()`/`isHtml()`), `isNoData()`, and `saveToFile(Path)`. Every resource endpoint returns `Response` so consumers get a uniform surface regardless of format. - §15 packaging: SemVer, MIT `LICENSE`, `CHANGELOG.md` in Keep a Changelog format, version auto-detected via JAR manifest (`Implementation-Version`). -- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option). +- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option); query strings stripped from log output (logged as `/path?…`) so request params never persist to logs — exception context retains the full URI for diagnostic use; `EnvVars.systemLookup()` restricts reads to the declared `MARKETDATA_*` keys. - ADR-002 CI: split into four workflows. - `.github/workflows/pull-request.yml` — runs on PR `opened`/`synchronize`/`reopened` (no pre-PR push trigger by design). JDK 17 only. Runs `./gradlew build` (unit tests + Spotless + JaCoCo) and uploads coverage to Codecov. **Does not** run integration tests — those are handled by the on-demand workflow below. - `.github/workflows/main.yml` — runs only on `push` to `main`. Two jobs: `verify` does the full forward-compat matrix `{17, 21, 25}` for unit tests via `-PtestJdk=N`; `integration-tests` does a parallel matrix `{17, 21, 25}` against the live API. Both are mandatory for the merge to be considered successful. The JDK 17 matrix entry of `verify` also uploads coverage to Codecov as the new baseline that PRs compare against. `integration-tests` fails the build if `MARKETDATA_TOKEN` secret is absent (it is required on main). @@ -79,21 +82,14 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - `-PtestJdk=N` is wired to **all** `Test` tasks (`test` and `integrationTest`) via `tasks.withType().configureEach { javaLauncher.set(...) }` in `build.gradle.kts`, so the matrix flag works uniformly across unit and integration tests. - Coverage ratchet lives in `codecov.yml`: project status with `target: auto, threshold: 5%` (cannot drop >5 pp vs base branch) plus a patch-coverage requirement of 70 % on new code. Requires a `CODECOV_TOKEN` repo secret — without it the upload step fails because workflows pass `fail_ci_if_error: true`. -**Deliberately deferred (require the request/endpoint layer to land first):** -- §1.2 resource groupings (`client.stocks`, `client.options`, `client.funds`, `client.markets`, `client.utilities`). -- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding. -- §5 actual `/user/` startup validation call (the `validateOnStartup` flag is the seam; the call itself comes with the request layer). -- §7 honoring `MARKETDATA_LOGGING_LEVEL` and the spec's exact `{timestamp} - {logger_name} - {level} - {message}` format. Currently the SDK uses `java.util.logging` with default formatting; consumers can attach their own handler. -- §8 rate-limit header parsing, pre-flight check, request-scoped attachment. -- §9 `/status/` cache workflow and `Retry-After` header override (retry/backoff itself lives in `RetryPolicy` and is wired; what is missing is the `/status/` pre-check before retrying 501–599 and respecting the server-specified `Retry-After` over the calculated exponential backoff). -- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold. +**Deliberately deferred (require the per-resource layer to land first):** +- §1.2 resource groupings — only `client.utilities()` is wired today; `client.stocks`, `client.options`, `client.funds`, `client.markets` still to come. +- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for resources beyond utilities. The plumbing (`ParallelArrays.zip` helper for the parallel-arrays shape, `JsonResponseParser`, `Response` wrapper) is in place — each new endpoint just declares its fields and row builder. +- §8 request-scoped rate-limit attachment — today the snapshot is client-level (via `client.getRateLimits()`); attaching a per-response snapshot to `Response` is a small follow-up when a consumer needs it. +- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until the resource layer lands so the threshold meaningfully ratchets functional code, not just scaffolding. When picking up new work, check this list before reaching for the SDK requirements doc — most foundational rules are already encoded in code; missing pieces are deferred deliberately, not by accident. -**Known latent gaps:** -- `HttpTransport.buildUri` URL-encodes query-param values with `URLEncoder.encode(..., UTF_8)`, which is form-encoding semantics: spaces become `+`, not `%20`. Fine for today's typed params (dates, numerics) but a future endpoint that takes an arbitrary string (e.g. `symbol="BRK A"`) would round-trip differently against an RFC-3986-strict server. Switch to a path/query-segment-aware encoder when the first such param lands. Tracked as Issue #10 of the 2026-05-11 review. -- `Retry-After` server header is parsed and respected by neither `RetryPolicy` nor `HttpTransport`. Today every retry uses the calculated exponential backoff (`min(1s × 2^N, 30s)`). Implementing the override needs the response headers to reach `RetryPolicy.backoffDelay`, which today only sees the attempt index — most natural path is to surface a `Duration` on `ServerError` (or thread it through a separate channel) when 5xx responses carry the header. Follow-up of the §9 work. - ## Acceptance checklist `docs/java-sdk-requirements.md` ends with an "Acceptance Checklist" mapping each Java-specific requirements section to verifiable items. Treat it as the definition of done for v1: when implementing, work toward making each box checkable, and use it as a self-review pass before declaring a section complete. From bfdc69bb996d33b492401e581552a43ff5d60f22 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 15:45:50 -0300 Subject: [PATCH 42/57] after review --- .gitignore | 3 +- .../com/marketdata/sdk/Configuration.java | 17 ++- .../java/com/marketdata/sdk/DotEnvLoader.java | 44 ++++--- src/main/java/com/marketdata/sdk/Format.java | 13 +- .../com/marketdata/sdk/HttpDispatcher.java | 15 ++- .../com/marketdata/sdk/HttpTransport.java | 70 +++++------ .../marketdata/sdk/JsonResponseParser.java | 10 +- .../com/marketdata/sdk/MarketDataClient.java | 30 +++-- .../com/marketdata/sdk/MarketDataLogging.java | 55 +++++++-- .../com/marketdata/sdk/RateLimitHeaders.java | 3 +- .../com/marketdata/sdk/RateLimitSnapshot.java | 5 +- .../java/com/marketdata/sdk/Response.java | 21 ++-- .../com/marketdata/sdk/RetryExecutor.java | 11 +- .../java/com/marketdata/sdk/StatusCache.java | 14 ++- .../com/marketdata/sdk/UserDeserializer.java | 32 ++++- .../com/marketdata/sdk/UtilitiesResource.java | 33 ++--- .../sdk/exception/MarketDataException.java | 27 ++++- .../com/marketdata/sdk/DotEnvLoaderTest.java | 114 +++++++----------- .../com/marketdata/sdk/HttpTransportTest.java | 11 ++ .../sdk/JsonResponseParserTest.java | 50 ++++++-- .../marketdata/sdk/MarketDataClientTest.java | 8 +- .../marketdata/sdk/RateLimitSnapshotTest.java | 8 -- .../com/marketdata/sdk/RequestSpecTest.java | 11 -- .../java/com/marketdata/sdk/ResponseTest.java | 14 +-- 24 files changed, 367 insertions(+), 252 deletions(-) diff --git a/.gitignore b/.gitignore index 0e5a3de..299483b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ hs_err_pid* replay_pid* -.claude/reviews/ \ No newline at end of file +.claude/reviews/ +.claude/prompts/ \ No newline at end of file diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index 4261276..83100e9 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -5,6 +5,7 @@ import java.nio.file.Path; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Function; import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; @@ -23,13 +24,27 @@ record Configuration( private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); private static final Pattern API_VERSION_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); + /** + * Convenience overload that discards any {@link DotEnvLoader.Warning}s. Used by tests and any + * call site that does not need to replay them through a freshly-configured logger. + */ static Configuration resolve( @Nullable String explicitApiKey, @Nullable String explicitBaseUrl, @Nullable String explicitApiVersion, Function env, Path dotEnvPath) { - Map dotEnv = DotEnvLoader.load(dotEnvPath); + return resolve(explicitApiKey, explicitBaseUrl, explicitApiVersion, env, dotEnvPath, w -> {}); + } + + static Configuration resolve( + @Nullable String explicitApiKey, + @Nullable String explicitBaseUrl, + @Nullable String explicitApiVersion, + Function env, + Path dotEnvPath, + Consumer warnings) { + Map dotEnv = DotEnvLoader.load(dotEnvPath, warnings); String apiKey = pickFirst(explicitApiKey, env.apply(EnvVars.TOKEN), dotEnv.get(EnvVars.TOKEN)); String baseUrl = pickFirstOrDefault( diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java index e975cd2..2162b84 100644 --- a/src/main/java/com/marketdata/sdk/DotEnvLoader.java +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -6,32 +6,41 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Consumer; import java.util.logging.Level; -import java.util.logging.Logger; +import org.jspecify.annotations.Nullable; /** * Loads {@code .env} key=value pairs from disk. {@code .env} is the third tier of the configuration * cascade (after explicit args and env vars), and is optional — a missing file is normal and never - * logs. However, an existing file that the SDK fails to read is suspicious: the user - * placed a {@code .env} expecting it to apply, and silently falling through to defaults would - * surface later as a confusing {@code AuthenticationError} with no breadcrumb. In that case we emit - * a WARNING and still degrade to an empty map, so {@link Configuration#resolve} can fall through - * the cascade rather than failing startup. + * reports a warning. However, an existing file that the SDK fails to read is suspicious: + * the user placed a {@code .env} expecting it to apply, and silently falling through to defaults + * would surface later as a confusing {@code AuthenticationError} with no breadcrumb. + * + *

Warnings are collected into a caller-supplied sink rather than emitted via the SDK logger + * directly. The loader runs inside {@link Configuration#resolve} which itself runs before + * {@link MarketDataLogging#configure}, so logging from here would land on an unconfigured JUL + * logger — wrong format, possibly invisible. {@link MarketDataClient} drains the sink after + * configuring logging, so the breadcrumb reaches its intended destination. */ final class DotEnvLoader { - private static final Logger LOG = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + /** Diagnostic emitted by the loader, replayed by {@link MarketDataClient} after logging setup. */ + record Warning(Level level, String message, @Nullable Throwable cause) {} - static Map load(Path path) { + static Map load(Path path, Consumer warnings) { if (!Files.exists(path)) { return Map.of(); } if (!Files.isReadable(path)) { - LOG.log( - Level.WARNING, - "Found .env at {0} but it is not readable (permission denied?) — falling back to env" - + " vars/defaults.", - path); + warnings.accept( + new Warning( + Level.WARNING, + "Found .env at " + + path + + " but it is not readable (permission denied?) — falling back to env" + + " vars/defaults.", + null)); return Map.of(); } Map result = new LinkedHashMap<>(); @@ -50,10 +59,11 @@ static Map load(Path path) { result.put(key, value); } } catch (IOException e) { - LOG.log( - Level.WARNING, - "Failed to read .env at " + path + " — falling back to env vars/defaults.", - e); + warnings.accept( + new Warning( + Level.WARNING, + "Failed to read .env at " + path + " — falling back to env vars/defaults.", + e)); return Map.of(); } return Map.copyOf(result); diff --git a/src/main/java/com/marketdata/sdk/Format.java b/src/main/java/com/marketdata/sdk/Format.java index 2c042e6..4341114 100644 --- a/src/main/java/com/marketdata/sdk/Format.java +++ b/src/main/java/com/marketdata/sdk/Format.java @@ -8,21 +8,10 @@ * record from a JSON response; {@code stocks.candlesAsCsv(...)} returns the raw CSV). That keeps * the format choice surfaced as a method selection rather than a parameter the user has to import * {@code Format} for. - * - *

Why {@link #HTML} is here even though the server doesn't return it today: the SDK is supposed - * to be ready. Plumbing for {@code text/html} responses lives in the transport pipeline — Accept - * header, {@code ?format=html}, and round-trip-through-{@link HttpResponseEnvelope} — so the day an - * endpoint flips it on, the only change required is a resource façade exposing a matching {@code - * ...AsHtml(...)} method. No transport edits. - * - *

The server's renderer set today is JSON + CSV; {@code ?format=html} is currently a no-op - * server-side (the response falls back to the default renderer). Internal callers that pass {@link - * #HTML} should expect that until the server lights it up. */ enum Format { JSON("json", "application/json"), - CSV("csv", "text/csv"), - HTML("html", "text/html"); + CSV("csv", "text/csv"); private final String wireValue; private final String mediaType; diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index 81d384e..cbfe3c2 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -7,6 +7,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.concurrent.CancellationException; @@ -33,10 +34,16 @@ final class HttpDispatcher implements AutoCloseable { private final HttpClient httpClient; private final AsyncSemaphore permits; + private final Clock clock; HttpDispatcher(HttpClient httpClient, int concurrencyLimit) { + this(httpClient, concurrencyLimit, Clock.systemUTC()); + } + + HttpDispatcher(HttpClient httpClient, int concurrencyLimit, Clock clock) { this.httpClient = httpClient; this.permits = new AsyncSemaphore(concurrencyLimit); + this.clock = clock; } /** @@ -67,7 +74,7 @@ CompletableFuture> dispatch(HttpRequest request) { private CompletableFuture> send(HttpRequest request) { LOGGER.fine(() -> "GET " + safeUri(request.uri())); - Instant start = Instant.now(); + Instant start = clock.instant(); CompletableFuture> sendFuture; try { @@ -89,7 +96,7 @@ private CompletableFuture> send(HttpRequest request) { return CompletableFuture.failedFuture( new NetworkError( "Request to " + safeUri(request.uri()) + " failed before dispatch: " + t.getMessage(), - ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), + ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), t)); } @@ -97,7 +104,7 @@ private CompletableFuture> send(HttpRequest request) { .whenComplete((r, t) -> permits.release()) .handle( (response, error) -> { - long elapsedMs = Duration.between(start, Instant.now()).toMillis(); + long elapsedMs = Duration.between(start, clock.instant()).toMillis(); if (error != null) { Throwable root = unwrap(error); LOGGER.warning( @@ -111,7 +118,7 @@ private CompletableFuture> send(HttpRequest request) { throw new CompletionException( new NetworkError( "Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(), - ErrorContext.forNoResponse(request.uri().toString(), Instant.now()), + ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), root)); } LOGGER.fine( diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 4cad33f..25f2109 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -66,6 +66,8 @@ final class HttpTransport implements AutoCloseable { private final String apiVersion; private final String userAgent; private final @Nullable String token; + /** URI the StatusCache's own fetcher targets; matched verbatim by cacheAllowsRetry. */ + private final URI statusEndpointUri; /** * Canonical constructor — all dependencies explicit. Production code uses {@link @@ -97,6 +99,10 @@ final class HttpTransport implements AutoCloseable { this.retryExecutor = retryExecutor; this.statusCacheSupplier = statusCacheSupplier; this.clock = clock; + // Derive from baseUrl so a path-prefixed base (e.g. https://corp/proxy) still matches the + // /status/ self-referential bypass. Hardcoding "/status/" would silently stop working in + // that case. + this.statusEndpointUri = buildUri(RequestSpec.get("status").unversioned().build()); } /** @@ -110,15 +116,16 @@ static HttpTransport withDefaults( String userAgent, @Nullable String token, Supplier<@Nullable StatusCache> statusCacheSupplier) { + Clock clock = Clock.systemUTC(); return new HttpTransport( baseUrl, apiVersion, userAgent, token, - new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT), + new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT, clock), new RetryExecutor(RetryPolicy.defaults()), statusCacheSupplier, - Clock.systemUTC()); + clock); } private static HttpClient defaultHttpClient() { @@ -208,26 +215,18 @@ private CompletableFuture executeAsync( "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); } - /** - * Path of the {@code /status/} endpoint the {@link StatusCache} fetches. Hardcoded to the - * canonical no-prefix shape because today every {@link MarketDataClient} construction lands the - * endpoint at exactly this path; if a {@code baseUrl} with a path prefix ever ships (e.g. {@code - * https://corp/proxy}), the self-referential bypass below would silently stop applying and need - * to switch to a {@link URI} stored on the cache at construction time. - */ - private static final String STATUS_ENDPOINT_PATH = "/status/"; - private boolean cacheAllowsRetry(URI uri) { StatusCache cache = statusCacheSupplier.get(); if (cache == null) { return true; // pre-wire state or test setup without a cache } - // Self-referential bypass: the cache's own fetcher targets /status/. If we consulted the - // cache for retries of that fetch and the snapshot reported /status/ offline (or any - // wildcard match grazed it), the retry would be blocked — and because no successful fetch - // can land, the snapshot would stay frozen in that "offline" state forever. Skip the cache - // for /status/ so the §9.5 gate cannot trap its own refresh. - if (STATUS_ENDPOINT_PATH.equals(uri.getPath())) { + // Self-referential bypass: the cache's own fetcher targets statusEndpointUri. If we + // consulted the cache for retries of that fetch and the snapshot reported /status/ offline + // (or any wildcard match grazed it), the retry would be blocked — and because no + // successful fetch can land, the snapshot would stay frozen in that "offline" state + // forever. Skip the cache for the /status/ URI so the §9.5 gate cannot trap its own + // refresh. + if (statusEndpointUri.equals(uri)) { return true; } return cache.check(uri) == StatusCache.Decision.ALLOW; @@ -241,6 +240,17 @@ HttpResponseEnvelope executeSync(RequestSpec spec) { return joinSync(executeAsync(spec)); } + /** Instance bridge for resources: uses this transport's {@link Clock} for fallback errors. */ + T joinSync(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + throw asRuntime(e.getCause(), clock); + } catch (CancellationException e) { + throw asRuntime(e, clock); + } + } + @Override public void close() { // Drains the dispatcher's semaphore so pending waiters surface CancellationException instead @@ -274,7 +284,7 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI if ((status >= 200 && status < 300) || status == 404) { return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); } - Instant now = Instant.now(); + Instant now = clock.instant(); ErrorContext context = ErrorContext.forResponse(uri.toString(), status, requestId, now); Duration retryAfter = response @@ -313,7 +323,7 @@ private URI buildUri(RequestSpec spec) { sb.append(apiVersion).append('/'); } sb.append(path); - if (!path.endsWith("/")) { + if (!path.isEmpty() && !path.endsWith("/")) { sb.append('/'); } Map params = spec.queryParams(); @@ -359,29 +369,11 @@ private HttpRequest buildHttpRequest(URI uri, Format format) { return b.build(); } - /** - * Sync bridge for resource façades: waits on {@code future}, unwrapping {@link - * CompletionException} so the caller sees the underlying {@link MarketDataException} directly - * (ADR-006), and routing cancellations through {@link #asRuntime} so the surface is uniform. - * - *

One place to fix the sync semantics; every {@code public T xxx()} wrapper in a resource is - * just {@code return joinSync(xxxAsync())}. - */ - static T joinSync(CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException e) { - throw asRuntime(e.getCause()); - } catch (CancellationException e) { - throw asRuntime(e); - } - } - // Visible for tests: under the current SDK design, executeAsync always wraps failures as // MarketDataException so the MDE branch is the only one reached from the public surface. // The other two branches are defensive guardrails — extracted so they can be exercised // directly by tests rather than relying on a synthetic public-API path. - static RuntimeException asRuntime(@Nullable Throwable cause) { + static RuntimeException asRuntime(@Nullable Throwable cause, Clock clock) { if (cause instanceof MarketDataException mde) { return mde; } @@ -390,7 +382,7 @@ static RuntimeException asRuntime(@Nullable Throwable cause) { } return new NetworkError( "Unexpected failure invoking SDK", - ErrorContext.forNoResponse("(unknown)", Instant.now()), + ErrorContext.forNoResponse("(unknown)", clock.instant()), cause); } } diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index fa038da..0602622 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -8,7 +8,7 @@ import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; import java.io.IOException; -import java.time.Instant; +import java.time.Clock; /** * Decodes {@link HttpResponseEnvelope} bodies into typed records. @@ -24,8 +24,13 @@ final class JsonResponseParser { private final ObjectMapper mapper; + private final Clock clock; JsonResponseParser() { + this(Clock.systemUTC()); + } + + JsonResponseParser(Clock clock) { ObjectMapper m = new ObjectMapper(); SimpleModule wireModule = new SimpleModule("marketdata-wire"); wireModule.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); @@ -33,6 +38,7 @@ final class JsonResponseParser { wireModule.addDeserializer(ApiStatus.class, new ApiStatusDeserializer()); m.registerModule(wireModule); this.mapper = m; + this.clock = clock; } /** @@ -46,7 +52,7 @@ T parse(HttpResponseEnvelope env, Class type) { } catch (IOException e) { ErrorContext context = ErrorContext.forResponse( - env.url().toString(), env.statusCode(), env.requestId(), Instant.now()); + env.url().toString(), env.statusCode(), env.requestId(), clock.instant()); throw new ParseError( "Failed to decode response from " + env.url() + ": " + e.getMessage(), context, e); } diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 0216084..899e882 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -2,6 +2,8 @@ import java.nio.file.Path; import java.time.Clock; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.logging.Logger; @@ -45,8 +47,18 @@ public MarketDataClient( boolean validateOnStartup, Function env, Path dotEnvPath) { - this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath); + // Collect warnings from the configuration cascade (e.g. an unreadable .env) instead of + // letting DotEnvLoader log them directly. The loader runs BEFORE MarketDataLogging.configure + // — emitting WARNINGs there would land on an unconfigured JUL logger (wrong format, + // possibly invisible), undermining the breadcrumb the WARNING exists to provide. + List pendingWarnings = new ArrayList<>(); + this.config = + Configuration.resolve( + apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); MarketDataLogging.configure(config.loggingLevel()); + for (DotEnvLoader.Warning w : pendingWarnings) { + LOGGER.log(w.level(), w.message(), w.cause()); + } LOGGER.info( () -> "MarketDataClient initialized: baseUrl=" @@ -106,10 +118,10 @@ void runStartupValidation() { LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); return; } - // Single-attempt: see RetryPolicy#noRetry for why startup doesn't use the default budget - // (the consumer would otherwise wait up to ~6.75 min if the API is unreachable). + // Intent-named auth probe in UtilitiesResource — single-attempt so a slow/down API surfaces + // here within seconds instead of burning the default retry budget (~6.75 min). try { - utilities.user(RetryPolicy.noRetry()); + utilities.validateAuth(); } catch (Throwable t) { try { close(); @@ -121,12 +133,12 @@ void runStartupValidation() { } /** - * Latest rate-limit snapshot recorded from any successful response. Returns {@link - * RateLimitSnapshot#EMPTY} until the first rate-limit-bearing response has arrived; never null. + * Latest rate-limit snapshot recorded from any successful response. Returns {@code null} until + * the first rate-limit-bearing response has arrived — a real {@code remaining=0} reported by the + * server stays observable as {@code snapshot.remaining() == 0}, distinct from "no snapshot yet". */ - public RateLimitSnapshot getRateLimits() { - RateLimitSnapshot latest = transport.getLatestRateLimits(); - return latest != null ? latest : RateLimitSnapshot.EMPTY; + public @Nullable RateLimitSnapshot getRateLimits() { + return transport.getLatestRateLimits(); } @Override diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java index a660e35..074bf59 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataLogging.java +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -53,7 +53,22 @@ private MarketDataLogging() {} * {@link #DEFAULT_LEVEL}. */ static void configure(@Nullable String levelSpec) { + Level requested = parseLevel(levelSpec); if (!configured.compareAndSet(false, true)) { + // Already configured. If the caller is asking for a different level than the first call + // installed, the result is "their level is silently ignored" — flag it at DEBUG so a + // test that sees stale logging knows where to look, without spamming production where + // re-creating the client is normal. + Level installed = Logger.getLogger(SDK_LOGGER_NAME).getLevel(); + if (installed != null && !installed.equals(requested)) { + LOG.fine( + () -> + "MarketDataLogging.configure called with level " + + requested.getName() + + " but logger is already configured at " + + installed.getName() + + "; ignoring (first-call-wins)."); + } return; } Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); @@ -71,20 +86,44 @@ static void configure(@Nullable String levelSpec) { handler.setLevel(Level.ALL); sdkLogger.addHandler(handler); sdkLogger.setUseParentHandlers(false); - sdkLogger.setLevel(parseLevel(levelSpec)); + sdkLogger.setLevel(requested); } + private static final java.util.logging.Logger LOG = + java.util.logging.Logger.getLogger(SDK_LOGGER_NAME); + + static final java.util.Set VALID_LEVEL_NAMES = + java.util.Set.of("DEBUG", "INFO", "WARNING", "ERROR"); + static Level parseLevel(@Nullable String levelSpec) { if (levelSpec == null) { return DEFAULT_LEVEL; } - return switch (levelSpec.trim().toUpperCase(Locale.ROOT)) { - case "DEBUG" -> Level.FINE; - case "INFO" -> Level.INFO; - case "WARNING" -> Level.WARNING; - case "ERROR" -> Level.SEVERE; - default -> DEFAULT_LEVEL; // unknown spec → fall back to default rather than throw - }; + String normalized = levelSpec.trim().toUpperCase(Locale.ROOT); + Level resolved = + switch (normalized) { + case "DEBUG" -> Level.FINE; + case "INFO" -> Level.INFO; + case "WARNING" -> Level.WARNING; + case "ERROR" -> Level.SEVERE; + default -> null; + }; + if (resolved != null) { + return resolved; + } + // Unknown spec — fall back to the default level, but loudly. A silent fallback was the + // worst of both worlds: the consumer typed something wrong and saw INFO output instead of + // the DEBUG they expected, with no breadcrumb pointing at the typo. + LOG.warning( + () -> + "Unrecognized MARKETDATA_LOGGING_LEVEL value '" + + levelSpec + + "'; expected one of " + + VALID_LEVEL_NAMES + + ". Falling back to " + + DEFAULT_LEVEL.getName() + + "."); + return DEFAULT_LEVEL; } /** diff --git a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java index b27a829..544dc59 100644 --- a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java +++ b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java @@ -14,8 +14,7 @@ * partial responses preserves the caller's last-known-good snapshot in {@link * HttpTransport#latestRateLimits} instead of clobbering it with phantom zeros — those would * otherwise trip {@link HttpTransport#checkRateLimitPreflight} into blocking subsequent requests - * with a fake {@code remaining=0}, and would surface in {@code client.getRateLimits()} as a - * snapshot the consumer can't tell apart from a real one. + * with a fake {@code remaining=0}. */ final class RateLimitHeaders { diff --git a/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java index 88bcad7..e281c92 100644 --- a/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java +++ b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java @@ -2,7 +2,4 @@ import java.time.Instant; -public record RateLimitSnapshot(int limit, int remaining, Instant reset, int consumed) { - - public static final RateLimitSnapshot EMPTY = new RateLimitSnapshot(0, 0, Instant.EPOCH, 0); -} +public record RateLimitSnapshot(int limit, int remaining, Instant reset, int consumed) {} diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java index 3431591..ab3ace7 100644 --- a/src/main/java/com/marketdata/sdk/Response.java +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -6,14 +6,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; -import java.util.Optional; import org.jspecify.annotations.Nullable; /** * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, - * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), - * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} - * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. + * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}), no-data detection + * ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} envelope convention), + * and {@link #saveToFile(Path)} for writing the raw body verbatim. * *

The {@link Format} enum is intentionally not exposed publicly (it has private values like * {@code HTML} that consumers shouldn't depend on). Consumers query format via the boolean @@ -79,11 +78,13 @@ public URI requestUrl() { } /** - * Server-provided request id (Cloudflare {@code cf-ray}). Empty when the response did not carry - * one — useful when correlating with the support team. + * Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when the response did + * not carry one — useful when correlating with the support team. Matches the nullability shape + * of {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can + * branch the same way regardless of which surface carries the id. */ - public Optional requestId() { - return Optional.ofNullable(requestId); + public @Nullable String requestId() { + return requestId; } public boolean isJson() { @@ -94,10 +95,6 @@ public boolean isCsv() { return format == Format.CSV; } - public boolean isHtml() { - return format == Format.HTML; - } - /** * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 * for that envelope (it is a successful "we have nothing for that query", not an error), so we diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java index 1193098..eadb212 100644 --- a/src/main/java/com/marketdata/sdk/RetryExecutor.java +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -79,16 +79,17 @@ private void attempt( AtomicReference<@Nullable CompletableFuture> currentAttempt) { if (result.isDone()) { // Caller cancelled (or completed exceptionally from a previous attempt's whenComplete). - // Don't invoke the supplier again. + // Don't invoke the supplier again. Checking isDone() (not just isCancelled()) avoids + // running a fresh attempt after the previous one's whenComplete completed `result`. return; } CompletableFuture dispatched = supplier.get(); currentAttempt.set(dispatched); - // If the caller cancelled `result` between attempts (during a backoff window), the handler - // installed in execute() has fired but `currentAttempt` was either null or pointing to - // the previous (already-done) attempt — so the new attempt was never cancelled. Check - // here and propagate immediately. + // Race: `result.cancel(...)` may have fired between the isDone() check above and the + // currentAttempt.set() call. The cancellation handler in execute() observes + // currentAttempt under that race: if it sees the previous (already-done) attempt, it + // doesn't cancel the new one. Re-check after publishing the new attempt. if (result.isCancelled() && !dispatched.isDone()) { dispatched.cancel(false); return; diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java index 2fae80b..14ce83c 100644 --- a/src/main/java/com/marketdata/sdk/StatusCache.java +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -12,6 +12,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; import org.jspecify.annotations.Nullable; /** @@ -40,6 +42,8 @@ */ final class StatusCache { + private static final Logger LOGGER = Logger.getLogger(StatusCache.class.getName()); + static final Duration REFRESH_THRESHOLD = Duration.ofSeconds(270); static final Duration EXPIRY = Duration.ofSeconds(300); @@ -83,6 +87,10 @@ void triggerRefresh() { try { future = fetcher.get(); } catch (Throwable t) { + // Sync-throw from the fetcher (rare — most failures arrive as a failed future). Log so a + // permanently-broken fetcher doesn't degrade silently into "stale snapshot forever". + LOGGER.log( + Level.WARNING, "StatusCache fetcher threw synchronously; snapshot persists.", t); refreshInFlight.set(false); return; } @@ -91,8 +99,12 @@ void triggerRefresh() { try { if (err == null && apiStatus != null) { snapshot.set(Snapshot.from(apiStatus, clock.instant())); + } else if (err != null) { + // On error: cache persists — §9.5 "Cache persists across failed refresh attempts" — + // but the failure is logged so operators can detect a /status/ outage instead of + // wondering why the SDK keeps blocking retries against a stale snapshot. + LOGGER.log(Level.WARNING, "StatusCache refresh failed; snapshot persists.", err); } - // On error: cache persists — §9.5 "Cache persists across failed refresh attempts". } finally { refreshInFlight.set(false); } diff --git a/src/main/java/com/marketdata/sdk/UserDeserializer.java b/src/main/java/com/marketdata/sdk/UserDeserializer.java index 3f08547..de2ce79 100644 --- a/src/main/java/com/marketdata/sdk/UserDeserializer.java +++ b/src/main/java/com/marketdata/sdk/UserDeserializer.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.marketdata.sdk.utilities.User; import java.io.IOException; @@ -13,9 +14,11 @@ * camelCase fields here rather than via {@code @JsonProperty} on the record, keeping all wire * coupling out of the public response type (ADR-007). * - *

Missing fields default leniently — {@code 0} for ints, empty string for {@code - * optionsDataPermissions}. The server always sends all three keys today, so a missing one is either - * a backend regression or a partial response we'd rather not blow up on. + *

Strict by default — same reasoning as {@link ParallelArrays}: a silent default for a missing + * numeric field would hide server bugs at the worst time (e.g. construction-time + * validateOnStartup), surfacing later as "quota apparently exhausted" with no breadcrumb. The + * empty string is the server's legitimate signal for "real-time options access" so {@code + * optionsDataPermissions} only requires that the field be a JSON string, not that it be non-empty. */ final class UserDeserializer extends JsonDeserializer { @@ -26,9 +29,26 @@ final class UserDeserializer extends JsonDeserializer { @Override public User deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode root = p.readValueAsTree(); - int remaining = root.path(REMAINING_KEY).asInt(0); - int limit = root.path(LIMIT_KEY).asInt(0); - String optionsPerms = root.path(OPTIONS_PERMS_KEY).asText(""); + int remaining = readInt(p, root, REMAINING_KEY); + int limit = readInt(p, root, LIMIT_KEY); + String optionsPerms = readString(p, root, OPTIONS_PERMS_KEY); return new User(remaining, limit, optionsPerms); } + + private static int readInt(JsonParser p, JsonNode root, String key) throws JsonMappingException { + JsonNode node = root.get(key); + if (node == null || !node.isIntegralNumber()) { + throw new JsonMappingException(p, "missing or non-integer field: " + key); + } + return node.asInt(); + } + + private static String readString(JsonParser p, JsonNode root, String key) + throws JsonMappingException { + JsonNode node = root.get(key); + if (node == null || !node.isTextual()) { + throw new JsonMappingException(p, "missing or non-string field: " + key); + } + return node.asText(); + } } diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 10b78b4..12662bf 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -38,7 +38,7 @@ public CompletableFuture> headersAsync() { /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ public Response headers() { - return HttpTransport.joinSync(headersAsync()); + return transport.joinSync(headersAsync()); } /** @@ -50,24 +50,25 @@ public CompletableFuture> userAsync() { return executeAndWrap(RequestSpec.get("user").build(), User.class); } - /** - * Like {@link #userAsync()}, but driven by a caller-supplied {@link RetryPolicy}. Package-private - * because the only consumer today is {@link MarketDataClient#runStartupValidation()}, which wants - * a single-attempt call so a slow/down API doesn't burn the full retry budget before the - * constructor returns. - */ - CompletableFuture> userAsync(RetryPolicy policy) { - return executeAndWrap(RequestSpec.get("user").build(), policy, User.class); - } - /** Sync wrapper for {@link #userAsync()}. */ public Response user() { - return HttpTransport.joinSync(userAsync()); + return transport.joinSync(userAsync()); } - /** Sync wrapper for {@link #userAsync(RetryPolicy)}; package-private. */ - Response user(RetryPolicy policy) { - return HttpTransport.joinSync(userAsync(policy)); + /** + * Auth probe used by {@link MarketDataClient}'s startup validation. Hits {@code GET /v1/user/} + * with a single-attempt policy so a slow/down API surfaces to the constructor immediately + * instead of burning the default retry budget (~6.75 min worst-case). Result is discarded — + * only the throw shape matters: 401 → {@link com.marketdata.sdk.exception.AuthenticationError}, + * other failures propagate as their typed {@link + * com.marketdata.sdk.exception.MarketDataException} subtype. + * + *

Package-private and intent-named: not part of the public API and not an "endpoint" in the + * §1.2 sense, so ADR-006's sync+async parity does not apply. + */ + void validateAuth() { + transport.joinSync( + executeAndWrap(RequestSpec.get("user").build(), RetryPolicy.noRetry(), User.class)); } /** @@ -82,7 +83,7 @@ public CompletableFuture> statusAsync() { /** Sync wrapper for {@link #statusAsync()}. */ public Response status() { - return HttpTransport.joinSync(statusAsync()); + return transport.joinSync(statusAsync()); } // ---------- internal helpers ---------- diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java index 2915c2e..bc07a2f 100644 --- a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java +++ b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java @@ -1,5 +1,7 @@ package com.marketdata.sdk.exception; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -31,8 +33,31 @@ public ErrorContext getContext() { return context.requestId(); } + /** + * The request URL with any query string redacted (replaced by {@code ?…}). Mirrors the SDK's + * ambient-log policy — query strings can carry PII (account IDs), competitive signal (queried + * symbols), or hypothetical future credentials, none of which should land in consumer logs + * just because someone called {@code logger.error("Request failed: " + ex.getRequestUrl())}. + * The full URI (with query) is preserved internally; use {@link #getContext()} when raw access + * is genuinely needed for diagnostics that won't be persisted. + */ public String getRequestUrl() { - return context.requestUrl(); + return redactQuery(context.requestUrl()); + } + + private static String redactQuery(String rawUrl) { + try { + URI uri = new URI(rawUrl); + if (uri.getRawQuery() == null) { + return rawUrl; + } + int qIndex = rawUrl.indexOf('?'); + return qIndex < 0 ? rawUrl : rawUrl.substring(0, qIndex) + "?…"; + } catch (URISyntaxException e) { + // Defensive: never throw from a getter. If the stored URL is malformed, return verbatim — + // it's the consumer's problem to diagnose, but not one to compound by hiding everything. + return rawUrl; + } } public int getStatusCode() { diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java index 68cf3bb..8c07943 100644 --- a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -6,13 +6,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; import java.util.Map; -import java.util.logging.Handler; import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -20,38 +17,30 @@ class DotEnvLoaderTest { - private CapturingHandler handler; - - @BeforeEach - void attachLogHandler() { - handler = new CapturingHandler(); - handler.setLevel(Level.ALL); - Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME).addHandler(handler); - } - - @AfterEach - void detachLogHandler() { - Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME).removeHandler(handler); + /** Convenience wrapper for tests that don't care about warnings. */ + private static Map load(Path path) { + return DotEnvLoader.load(path, w -> {}); } @Test void load_returns_empty_when_file_missing(@TempDir Path tmp) { Path missing = tmp.resolve("does-not-exist.env"); - Map result = DotEnvLoader.load(missing); + Map result = load(missing); assertThat(result).isEmpty(); } @Test - void load_missing_file_does_not_log(@TempDir Path tmp) { + void load_missing_file_does_not_warn(@TempDir Path tmp) { // The cascade explicitly tolerates a missing .env — that's the common case, not an error. // Emitting a WARNING here would spam every consumer that runs without a .env file. Path missing = tmp.resolve("does-not-exist.env"); + List warnings = new ArrayList<>(); - DotEnvLoader.load(missing); + DotEnvLoader.load(missing, warnings::add); - assertThat(handler.records).isEmpty(); + assertThat(warnings).isEmpty(); } @Test @@ -59,19 +48,21 @@ void load_missing_file_does_not_log(@TempDir Path tmp) { void load_unreadable_file_emits_warning_and_returns_empty(@TempDir Path tmp) throws IOException { // Existing-but-unreadable is suspicious: the user dropped a .env expecting it to apply, but // the SDK can't open it. Silent fallback would surface much later as a confusing - // AuthenticationError. Log a WARNING with the path so the breadcrumb is obvious. + // AuthenticationError. Emit a Warning with the path so the breadcrumb is obvious. Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("---------")); try { - Map result = DotEnvLoader.load(file); + List warnings = new ArrayList<>(); + Map result = DotEnvLoader.load(file, warnings::add); assertThat(result).isEmpty(); - assertThat(handler.records) + assertThat(warnings) .singleElement() .satisfies( - r -> { - assertThat(r.getLevel()).isEqualTo(Level.WARNING); - assertThat(handler.formatLast()).contains("not readable").contains(file.toString()); + w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("not readable").contains(file.toString()); + assertThat(w.cause()).isNull(); }); } finally { // Restore so @TempDir cleanup can delete the file. @@ -85,19 +76,18 @@ void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) t // encoding mismatch, etc.). A directory passed as the path is a portable way to make // Files.readAllLines blow up after the readability check succeeds. Path asDir = Files.createDirectory(tmp.resolve("env-as-dir")); + List warnings = new ArrayList<>(); - Map result = DotEnvLoader.load(asDir); + Map result = DotEnvLoader.load(asDir, warnings::add); assertThat(result).isEmpty(); - assertThat(handler.records) + assertThat(warnings) .singleElement() .satisfies( - r -> { - assertThat(r.getLevel()).isEqualTo(Level.WARNING); - assertThat(r.getThrown()).isNotNull(); - assertThat(handler.formatLast()) - .contains("Failed to read .env") - .contains(asDir.toString()); + w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("Failed to read .env").contains(asDir.toString()); + assertThat(w.cause()).isNotNull(); }); } @@ -105,7 +95,7 @@ void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) t void load_returns_empty_for_empty_file(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), ""); - assertThat(DotEnvLoader.load(file)).isEmpty(); + assertThat(load(file)).isEmpty(); } @Test @@ -118,7 +108,7 @@ void load_parses_simple_key_value_pairs(@TempDir Path tmp) throws IOException { MARKETDATA_BASE_URL=https://example.com """); - Map result = DotEnvLoader.load(file); + Map result = load(file); assertThat(result) .containsEntry("MARKETDATA_TOKEN", "abc123") @@ -129,21 +119,21 @@ void load_parses_simple_key_value_pairs(@TempDir Path tmp) throws IOException { void load_strips_double_quotes(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc 123\"\n"); - assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc 123"); + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); } @Test void load_strips_single_quotes(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN='abc 123'\n"); - assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc 123"); + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); } @Test void load_does_not_strip_mismatched_quotes(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc'\n"); - assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "\"abc'"); + assertThat(load(file)).containsEntry("TOKEN", "\"abc'"); } @Test @@ -157,7 +147,7 @@ void load_ignores_comment_lines(@TempDir Path tmp) throws IOException { #TOKEN=should-be-ignored """); - Map result = DotEnvLoader.load(file); + Map result = load(file); assertThat(result).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } @@ -175,16 +165,14 @@ void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { """); - assertThat(DotEnvLoader.load(file)) - .containsEntry("TOKEN", "abc") - .containsEntry("BASE_URL", "https://x"); + assertThat(load(file)).containsEntry("TOKEN", "abc").containsEntry("BASE_URL", "https://x"); } @Test void load_keeps_equals_signs_in_value(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=a=b=c\n"); - assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "a=b=c"); + assertThat(load(file)).containsEntry("TOKEN", "a=b=c"); } @Test @@ -198,59 +186,39 @@ void load_skips_lines_without_equals(@TempDir Path tmp) throws IOException { also-not-a-pair """); - assertThat(DotEnvLoader.load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } @Test void load_skips_lines_starting_with_equals(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "=novalue\nTOKEN=abc\n"); - assertThat(DotEnvLoader.load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } @Test void load_trims_whitespace_around_key_and_value(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), " TOKEN = abc \n"); - assertThat(DotEnvLoader.load(file)).containsEntry("TOKEN", "abc"); + assertThat(load(file)).containsEntry("TOKEN", "abc"); } @Test void load_returns_immutable_map(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); - Map result = DotEnvLoader.load(file); + Map result = load(file); assertThat(result).isUnmodifiable(); } @Test - void load_successful_read_does_not_log(@TempDir Path tmp) throws IOException { + void load_successful_read_does_not_warn(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + List warnings = new ArrayList<>(); - DotEnvLoader.load(file); - - assertThat(handler.records).isEmpty(); - } - - /** Captures {@link LogRecord}s emitted on the SDK logger so tests can assert on them. */ - private static final class CapturingHandler extends Handler { - private final java.util.List records = new java.util.ArrayList<>(); - private final java.util.logging.Formatter fmt = new java.util.logging.SimpleFormatter(); + DotEnvLoader.load(file, warnings::add); - @Override - public void publish(LogRecord r) { - records.add(r); - } - - @Override - public void flush() {} - - @Override - public void close() {} - - String formatLast() { - return fmt.format(records.get(records.size() - 1)); - } + assertThat(warnings).isEmpty(); } } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index fe42cef..6eabc41 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -115,6 +115,17 @@ void unversionedSpecOmitsTheVersionSegment() { assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/status/"); } + @Test + void emptyPathDoesNotProduceDoubleSlash() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("").build()).join(); + + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/v1/"); + } + @Test void leadingSlashInPathIsStripped() { CapturingClient client = diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 7dbafea..9473497 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -92,16 +92,52 @@ void parsesUserWithEmptyOptionsPermissionsAsRealTimeMarker() { } @Test - void missingUserFieldsDefaultLeniently() { - // The server sends all three fields today, but if a future regression drops one, the SDK - // should produce a populated record with defaults rather than blow up parsing. + void missingUserNumericFieldRaisesParseError() { + // Strict: a silent zero would mask backend regressions and surface later as a confusing + // "quota apparently exhausted". Same policy as ParallelArrays. JsonResponseParser parser = new JsonResponseParser(); - User u = parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class); + assertThatThrownBy( + () -> parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-integer") + .hasMessageContaining("x-ratelimit-requests-remaining"); + } - assertThat(u.requestsRemaining()).isZero(); - assertThat(u.requestsLimit()).isEqualTo(500); - assertThat(u.optionsDataPermissions()).isEmpty(); + @Test + void userNumericFieldOfWrongTypeRaisesParseError() { + // String "500" instead of integer 500 — strict rejection rather than Jackson's lax coercion. + JsonResponseParser parser = new JsonResponseParser(); + + assertThatThrownBy( + () -> + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":\"5\"," + + "\"x-ratelimit-requests-limit\":10," + + "\"x-options-data-permissions\":\"\"}"), + User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-integer") + .hasMessageContaining("x-ratelimit-requests-remaining"); + } + + @Test + void userMissingOptionsPermsRaisesParseError() { + // The empty string is the legitimate "real-time access" marker — but the field must be + // present as a JSON string. Absence is treated as a backend regression, not a default. + JsonResponseParser parser = new JsonResponseParser(); + + assertThatThrownBy( + () -> + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":1," + + "\"x-ratelimit-requests-limit\":2}"), + User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-string") + .hasMessageContaining("x-options-data-permissions"); } // ---------- ApiStatus: parallel-arrays wire format zipped into List ---------- diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 4faf3c6..e2914e9 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -36,10 +36,12 @@ private static String reserveClosedLocalUrl() throws Exception { } @Test - void no_arg_constructor_resolves_defaults_and_returns_empty_rate_limits(@TempDir Path tmp) { + void no_arg_constructor_resolves_defaults_and_returns_null_rate_limits(@TempDir Path tmp) { try (MarketDataClient client = new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { - assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + // Before any rate-limit-bearing response arrives, the snapshot is null — distinct from a + // server-reported (0, 0, EPOCH, 0) snapshot that a real "remaining=0" would produce. + assertThat(client.getRateLimits()).isNull(); } } @@ -171,7 +173,7 @@ void quick_start_usage_resolves_real_environment_and_never_leaks_token() { // that path here — this test asserts config resolution and token redaction, not the live // call. Use the 4-arg variant with validateOnStartup=false to keep this a pure unit test. try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { - assertThat(client.getRateLimits()).isEqualTo(RateLimitSnapshot.EMPTY); + assertThat(client.getRateLimits()).isNull(); assertThat(client.toString()).startsWith("MarketDataClient[").endsWith("]"); String envToken = System.getenv(EnvVars.TOKEN); diff --git a/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java index 9c1658c..9525271 100644 --- a/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java +++ b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java @@ -19,14 +19,6 @@ void exposes_all_fields() { assertThat(snapshot.consumed()).isEqualTo(250); } - @Test - void empty_snapshot_has_zero_values_and_epoch_reset() { - assertThat(RateLimitSnapshot.EMPTY.limit()).isZero(); - assertThat(RateLimitSnapshot.EMPTY.remaining()).isZero(); - assertThat(RateLimitSnapshot.EMPTY.consumed()).isZero(); - assertThat(RateLimitSnapshot.EMPTY.reset()).isEqualTo(Instant.EPOCH); - } - @Test void records_with_same_values_are_equal() { Instant reset = Instant.parse("2026-05-15T12:00:00Z"); diff --git a/src/test/java/com/marketdata/sdk/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java index 2397706..ec20f9b 100644 --- a/src/test/java/com/marketdata/sdk/RequestSpecTest.java +++ b/src/test/java/com/marketdata/sdk/RequestSpecTest.java @@ -69,17 +69,6 @@ void formatSetterWritesQueryParamAndUpdatesField() { assertThat(spec.queryParams()).containsEntry("format", "csv"); } - @Test - void htmlFormatWiresThroughEvenThoughItIsNotUserVisible() { - // Format.HTML is package-private — no SDK consumer can reference it — but the transport - // pipeline must accept it end-to-end so the day the server lights up HTML responses, the - // only change is exposing a `...AsHtml()` method on the relevant resource. - RequestSpec spec = RequestSpec.get("stocks/candles").format(Format.HTML).build(); - assertThat(spec.format()).isEqualTo(Format.HTML); - assertThat(spec.queryParams()).containsEntry("format", "html"); - assertThat(Format.HTML.mediaType()).isEqualTo("text/html"); - } - @Test void dateformatWritesQueryParam() { RequestSpec spec = RequestSpec.get("stocks/candles").dateformat(DateFormat.SPREADSHEET).build(); diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java index 8eb428f..3f49c27 100644 --- a/src/test/java/com/marketdata/sdk/ResponseTest.java +++ b/src/test/java/com/marketdata/sdk/ResponseTest.java @@ -30,11 +30,11 @@ void exposesDataStatusAndUrl() { assertThat(r.data()).isEqualTo("payload"); assertThat(r.statusCode()).isEqualTo(200); assertThat(r.requestUrl()).isEqualTo(URI.create("http://x/y")); - assertThat(r.requestId()).contains("req-id-123"); + assertThat(r.requestId()).isEqualTo("req-id-123"); } @Test - void requestIdEmptyWhenServerOmitsIt() { + void requestIdNullWhenServerOmitsIt() { HttpResponseEnvelope e = new HttpResponseEnvelope( "x".getBytes(), @@ -44,28 +44,22 @@ void requestIdEmptyWhenServerOmitsIt() { URI.create("http://x")); Response r = Response.wrap("data", e, Format.JSON); - assertThat(r.requestId()).isEmpty(); + assertThat(r.requestId()).isNull(); } // ---------- format detection ---------- @Test void formatDetectionExposesBooleansOnly() { - // The Format enum itself is package-private (HTML is hidden from consumers per ADR). - // Consumers only see the booleans. + // The Format enum itself is package-private; consumers only see the booleans. Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); - Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); assertThat(json.isJson()).isTrue(); assertThat(json.isCsv()).isFalse(); - assertThat(json.isHtml()).isFalse(); assertThat(csv.isCsv()).isTrue(); assertThat(csv.isJson()).isFalse(); - - assertThat(html.isHtml()).isTrue(); - assertThat(html.isJson()).isFalse(); } // ---------- no-data ---------- From ee3250d3affa1ee63810ed59fdaba75be7e92b0c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 17:11:41 -0300 Subject: [PATCH 43/57] preflight bypass --- .../com/marketdata/sdk/HttpStatusMapper.java | 15 ++- .../com/marketdata/sdk/HttpTransport.java | 28 +++- .../com/marketdata/sdk/MarketDataClient.java | 3 +- .../com/marketdata/sdk/ParallelArrays.java | 22 ++- .../java/com/marketdata/sdk/Response.java | 10 +- .../com/marketdata/sdk/RetryExecutor.java | 36 ++++- .../java/com/marketdata/sdk/StatusCache.java | 3 +- .../com/marketdata/sdk/UserDeserializer.java | 4 +- .../com/marketdata/sdk/UtilitiesResource.java | 10 +- .../sdk/exception/MarketDataException.java | 8 +- .../sdk/exception/RateLimitError.java | 29 +++- .../marketdata/sdk/HttpStatusMapperTest.java | 29 +++- .../com/marketdata/sdk/HttpTransportTest.java | 125 ++++++++++++++++++ .../sdk/JsonResponseParserTest.java | 30 ++++- .../marketdata/sdk/ParallelArraysTest.java | 39 ++++++ .../com/marketdata/sdk/RetryExecutorTest.java | 29 ++++ .../marketdata/sdk/UtilitiesResourceTest.java | 23 ++++ .../sdk/exception/SealedHierarchyTest.java | 3 + 18 files changed, 405 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java index a4bf8da..d21cead 100644 --- a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -18,16 +18,19 @@ final class HttpStatusMapper { /** * Maps an HTTP status to its typed exception. When {@code retryAfter} is non-null, it is attached - * to the resulting {@link ServerError} so the retry policy can honor §9.4. The other subtypes - * ignore it — only server errors retry, and only retries care about Retry-After. + * to the resulting {@link ServerError} (so the retry policy can honor §9.4) and to the resulting + * {@link RateLimitError} (so consumers receiving a 429 can read the server's directive even + * though the SDK does not retry 429 itself, per RFC 6585). * - *

Unmapped status codes are split by range rather than lumped into a single bucket: + *

The sealed hierarchy is fixed at the 7 permits documented in ADR-002. Status codes outside + * the canonical buckets (403, 422, 3xx, 1xx, out-of-range) fall back to {@link BadRequestError} + * with a message that identifies the actual failure mode so consumers can branch on the message + * if needed: * *

    *
  • 5xx → {@link ServerError} (retryable). *
  • 4xx (other than 401/404/429) → {@link BadRequestError} with the status - * code in the message — the request itself was malformed for some endpoint-specific reason - * (403 forbidden, 422 unprocessable entity, etc.). + * code in the message — covers 403 (permission), 422 (validation), 405, 418, 451, etc. *
  • 3xx → {@link BadRequestError} with a "redirect" message. The transport's * {@code HttpClient} follows redirects per {@code NORMAL} policy, so a 3xx escaping that * means the redirect could not be followed (e.g., cross-protocol, max redirects). Surfaces @@ -46,7 +49,7 @@ final class HttpStatusMapper { case 400 -> new BadRequestError("Bad request", context); case 401 -> new AuthenticationError("Authentication failed", context); case 404 -> new NotFoundError("Not found", context); - case 429 -> new RateLimitError("Rate limit exceeded", context); + case 429 -> new RateLimitError("Rate limit exceeded", context, null, retryAfter); default -> mapByRange(statusCode, context, retryAfter); }; } diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 25f2109..77aa724 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -66,6 +66,7 @@ final class HttpTransport implements AutoCloseable { private final String apiVersion; private final String userAgent; private final @Nullable String token; + /** URI the StatusCache's own fetcher targets; matched verbatim by cacheAllowsRetry. */ private final URI statusEndpointUri; @@ -169,13 +170,22 @@ private CompletableFuture executeAsync( HttpRequest request = buildHttpRequest(uri, spec.format()); RetryPolicy policy = executor.policy(); return executor.execute( - () -> { + (attemptIdx, previousCause) -> { // §10.3: pre-flight gate — if our latest snapshot says credits are exhausted, fail // fast without hitting the wire. RateLimitError is non-retriable per §11.2, so the // retry executor will surface it directly. - RateLimitError preflight = checkRateLimitPreflight(uri); - if (preflight != null) { - return CompletableFuture.failedFuture(preflight); + // + // Exception: when the previous attempt failed with a ServerError carrying an explicit + // Retry-After (§9.4), the server has just told us "come back at ". + // That directive is more authoritative than our snapshot for this specific retry; + // honoring it is exactly what §9.4 demands. Without this bypass, a 503 + Retry-After + // after a snapshot that reports remaining=0 with a far-future reset would sabotage the + // server-orchestrated backoff — the retry would never reach the wire. + if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) { + return CompletableFuture.failedFuture(preflight); + } } return dispatcher .dispatch(request) @@ -187,6 +197,16 @@ private CompletableFuture executeAsync( (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); } + /** + * Was the previous attempt's failure a server-side directive to come back at a specific time? + * Only 5xx responses carrying a parsed {@code Retry-After} qualify — that's the case where the + * server has explicitly scheduled our retry, and our local rate-limit snapshot (whose {@code + * reset} may be unrelated and far in the future) must not veto it. + */ + private static boolean isServerHintedRetry(@Nullable Throwable previousCause) { + return previousCause instanceof ServerError server && server.getRetryAfter().isPresent(); + } + /** * Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits * and the snapshot's {@code reset} timestamp is still in the future. Returns {@code diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 899e882..9b1271b 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -53,8 +53,7 @@ public MarketDataClient( // possibly invisible), undermining the breadcrumb the WARNING exists to provide. List pendingWarnings = new ArrayList<>(); this.config = - Configuration.resolve( - apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); + Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); MarketDataLogging.configure(config.loggingLevel()); for (DotEnvLoader.Warning w : pendingWarnings) { LOGGER.log(w.level(), w.message(), w.cause()); diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index 357974a..c249ec5 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -37,12 +37,29 @@ final class ParallelArrays { private static final String ENVELOPE_STATUS = "s"; private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String ENVELOPE_ERROR = "error"; private ParallelArrays() {} /** * Zip the parallel arrays under {@code root} into a list of rows via {@code rowBuilder}. * + *

    Envelope handling: + * + *

      + *
    • {@code "s":"error"} → {@link JsonMappingException} carrying the server-side {@code + * errmsg}. The parent parser turns it into a {@link + * com.marketdata.sdk.exception.ParseError}. + *
    • {@code "s":"no_data"} → empty list. The backend uses this envelope (paired with HTTP 404, + * see {@code HttpTransport.routeAndEnvelope}) for "the query has no results"; the data + * arrays are deliberately omitted in that case. Returning an empty list lets the resource + * wrap it in its container type ({@code new ApiStatus(emptyList)}, etc.) so the consumer + * reaches {@link Response#isNoData()} and {@link Response#data()} normally instead of + * hitting a spurious {@code "missing field"} error from the field-validation loop. + *
    • Any other status (typically {@code "ok"}) → normal field validation. + *
    + * * @throws JsonMappingException if the envelope reports {@code "s":"error"}, a required field is * absent or not an array, or arrays have mismatched lengths. */ @@ -50,10 +67,13 @@ static List zip(JsonParser p, JsonNode root, List fields, RowBuil throws IOException { String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); - if ("error".equals(envelopeStatus)) { + if (ENVELOPE_ERROR.equals(envelopeStatus)) { String errmsg = root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"); throw new JsonMappingException(p, "API responded with error: " + errmsg); } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return List.of(); + } Map arrays = new LinkedHashMap<>(); int expected = -1; diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java index ab3ace7..a7d03a7 100644 --- a/src/main/java/com/marketdata/sdk/Response.java +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -11,8 +11,8 @@ /** * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}), no-data detection - * ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} envelope convention), - * and {@link #saveToFile(Path)} for writing the raw body verbatim. + * ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} envelope convention), and + * {@link #saveToFile(Path)} for writing the raw body verbatim. * *

    The {@link Format} enum is intentionally not exposed publicly (it has private values like * {@code HTML} that consumers shouldn't depend on). Consumers query format via the boolean @@ -79,9 +79,9 @@ public URI requestUrl() { /** * Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when the response did - * not carry one — useful when correlating with the support team. Matches the nullability shape - * of {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can - * branch the same way regardless of which surface carries the id. + * not carry one — useful when correlating with the support team. Matches the nullability shape of + * {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can branch + * the same way regardless of which surface carries the id. */ public @Nullable String requestId() { return requestId; diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java index eadb212..13f17c4 100644 --- a/src/main/java/com/marketdata/sdk/RetryExecutor.java +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -22,6 +22,11 @@ *

    Cancellation of the outer result propagates to the in-flight attempt: if the caller cancels * mid-flight or mid-backoff, the next attempt is not scheduled and the current one (if any) is * cancelled. + * + *

    Two supplier shapes are supported: {@link Supplier} for callers that don't need per-attempt + * context, and {@link AttemptSupplier} for callers that need to inspect the previous attempt's + * cause — used by {@link HttpTransport} to bypass the §10.3 preflight when retrying on an explicit + * server-side {@code Retry-After} directive (§9.4). */ final class RetryExecutor { @@ -36,6 +41,16 @@ RetryPolicy policy() { return policy; } + /** + * Builds the future for one attempt. The {@code attemptIdx} starts at 0 for the first attempt; + * {@code previousCause} is the (unwrapped) cause that triggered this retry, or {@code null} on + * the first attempt. + */ + @FunctionalInterface + interface AttemptSupplier { + CompletableFuture get(int attemptIdx, @Nullable Throwable previousCause); + } + /** * Drive {@code supplier} with retry. Each invocation of the supplier represents one attempt; if * the resulting future fails with a retriable cause, {@code supplier} is invoked again after the @@ -52,6 +67,16 @@ CompletableFuture execute(Supplier> supplier) { */ CompletableFuture execute( Supplier> supplier, BiPredicate shouldRetry) { + return execute((attemptIdx, previousCause) -> supplier.get(), shouldRetry); + } + + /** + * Like {@link #execute(Supplier, BiPredicate)} but the supplier receives the attempt index and + * the previous attempt's cause so it can adjust behavior across retries — e.g. skip preflight + * checks when the previous failure carried an explicit server-side {@code Retry-After}. + */ + CompletableFuture execute( + AttemptSupplier supplier, BiPredicate shouldRetry) { CompletableFuture result = new CompletableFuture<>(); // One cancellation handler installed once: whichever attempt is currently in flight is // tracked in `currentAttempt`; cancelling `result` cancels that. Previous attempts are @@ -67,14 +92,15 @@ CompletableFuture execute( } } }); - attempt(supplier, shouldRetry, 0, result, currentAttempt); + attempt(supplier, shouldRetry, 0, null, result, currentAttempt); return result; } private void attempt( - Supplier> supplier, + AttemptSupplier supplier, BiPredicate shouldRetry, int attemptIdx, + @Nullable Throwable previousCause, CompletableFuture result, AtomicReference<@Nullable CompletableFuture> currentAttempt) { if (result.isDone()) { @@ -83,7 +109,7 @@ private void attempt( // running a fresh attempt after the previous one's whenComplete completed `result`. return; } - CompletableFuture dispatched = supplier.get(); + CompletableFuture dispatched = supplier.get(attemptIdx, previousCause); currentAttempt.set(dispatched); // Race: `result.cancel(...)` may have fired between the isDone() check above and the @@ -109,7 +135,9 @@ private void attempt( long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) .execute( - () -> attempt(supplier, shouldRetry, attemptIdx + 1, result, currentAttempt)); + () -> + attempt( + supplier, shouldRetry, attemptIdx + 1, cause, result, currentAttempt)); } else { result.completeExceptionally(cause); } diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java index 14ce83c..46ba75e 100644 --- a/src/main/java/com/marketdata/sdk/StatusCache.java +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -89,8 +89,7 @@ void triggerRefresh() { } catch (Throwable t) { // Sync-throw from the fetcher (rare — most failures arrive as a failed future). Log so a // permanently-broken fetcher doesn't degrade silently into "stale snapshot forever". - LOGGER.log( - Level.WARNING, "StatusCache fetcher threw synchronously; snapshot persists.", t); + LOGGER.log(Level.WARNING, "StatusCache fetcher threw synchronously; snapshot persists.", t); refreshInFlight.set(false); return; } diff --git a/src/main/java/com/marketdata/sdk/UserDeserializer.java b/src/main/java/com/marketdata/sdk/UserDeserializer.java index de2ce79..08dd531 100644 --- a/src/main/java/com/marketdata/sdk/UserDeserializer.java +++ b/src/main/java/com/marketdata/sdk/UserDeserializer.java @@ -16,8 +16,8 @@ * *

    Strict by default — same reasoning as {@link ParallelArrays}: a silent default for a missing * numeric field would hide server bugs at the worst time (e.g. construction-time - * validateOnStartup), surfacing later as "quota apparently exhausted" with no breadcrumb. The - * empty string is the server's legitimate signal for "real-time options access" so {@code + * validateOnStartup), surfacing later as "quota apparently exhausted" with no breadcrumb. The empty + * string is the server's legitimate signal for "real-time options access" so {@code * optionsDataPermissions} only requires that the field be a JSON string, not that it be non-empty. */ final class UserDeserializer extends JsonDeserializer { diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 12662bf..a95f10d 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -57,11 +57,11 @@ public Response user() { /** * Auth probe used by {@link MarketDataClient}'s startup validation. Hits {@code GET /v1/user/} - * with a single-attempt policy so a slow/down API surfaces to the constructor immediately - * instead of burning the default retry budget (~6.75 min worst-case). Result is discarded — - * only the throw shape matters: 401 → {@link com.marketdata.sdk.exception.AuthenticationError}, - * other failures propagate as their typed {@link - * com.marketdata.sdk.exception.MarketDataException} subtype. + * with a single-attempt policy so a slow/down API surfaces to the constructor immediately instead + * of burning the default retry budget (~6.75 min worst-case). Result is discarded — only the + * throw shape matters: 401 → {@link com.marketdata.sdk.exception.AuthenticationError}, other + * failures propagate as their typed {@link com.marketdata.sdk.exception.MarketDataException} + * subtype. * *

    Package-private and intent-named: not part of the public API and not an "endpoint" in the * §1.2 sense, so ADR-006's sync+async parity does not apply. diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java index bc07a2f..3d27632 100644 --- a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java +++ b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java @@ -36,10 +36,10 @@ public ErrorContext getContext() { /** * The request URL with any query string redacted (replaced by {@code ?…}). Mirrors the SDK's * ambient-log policy — query strings can carry PII (account IDs), competitive signal (queried - * symbols), or hypothetical future credentials, none of which should land in consumer logs - * just because someone called {@code logger.error("Request failed: " + ex.getRequestUrl())}. - * The full URI (with query) is preserved internally; use {@link #getContext()} when raw access - * is genuinely needed for diagnostics that won't be persisted. + * symbols), or hypothetical future credentials, none of which should land in consumer logs just + * because someone called {@code logger.error("Request failed: " + ex.getRequestUrl())}. The full + * URI (with query) is preserved internally; use {@link #getContext()} when raw access is + * genuinely needed for diagnostics that won't be persisted. */ public String getRequestUrl() { return redactQuery(context.requestUrl()); diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java index 80056ee..8bbcdc3 100644 --- a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java +++ b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java @@ -1,16 +1,43 @@ package com.marketdata.sdk.exception; +import java.time.Duration; +import java.util.Optional; import org.jspecify.annotations.Nullable; public final class RateLimitError extends MarketDataException { private static final long serialVersionUID = 1L; + private final @Nullable Duration retryAfter; + public RateLimitError(String message, ErrorContext context) { - this(message, context, null); + this(message, context, null, null); } public RateLimitError(String message, ErrorContext context, @Nullable Throwable cause) { + this(message, context, cause, null); + } + + /** + * Construct a rate-limit error that carries the server-specified {@code Retry-After} hint (SDK + * requirements §9.4). RFC 6585 defines {@code Retry-After} for 429 responses; consumers can + * inspect this value to schedule their own backoff before the next call. + */ + public RateLimitError( + String message, + ErrorContext context, + @Nullable Throwable cause, + @Nullable Duration retryAfter) { super(message, context, cause); + this.retryAfter = retryAfter; + } + + /** + * The value parsed from the server's {@code Retry-After} response header, when present. Empty + * when the header was absent or the error was raised by the SDK's local preflight gate (no server + * response). + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); } } diff --git a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java index 871e217..c83039d 100644 --- a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java +++ b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java @@ -10,6 +10,7 @@ import com.marketdata.sdk.exception.NotFoundError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; import java.time.Instant; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -67,9 +68,9 @@ void maps_unhandled_four_xx_to_bad_request_error(int statusCode) { @ParameterizedTest @ValueSource(ints = {402, 403, 405, 418}) void unhandled_four_xx_message_includes_the_status_code(int statusCode) { - // Previously the default branch produced a generic "Unexpected status code: 403" which read - // like an SDK bug rather than a server response. The message now identifies the bucket - // ("Client error") and the actual status, making it obvious what came back. + // The mapper differentiates the failure mode within the message (e.g. "Client error: HTTP + // 403") so consumers can branch on getMessage() / getStatusCode() even though the type is the + // shared BadRequestError bucket dictated by ADR-002's canonical 7-permit hierarchy. @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); assertThat(exception).isNotNull(); @@ -137,6 +138,28 @@ void server_error_message_includes_the_actual_status(int statusCode) { assertThat(exception.getMessage()).contains(String.valueOf(statusCode)); } + // ---------- §9.4 Retry-After on 429 (RFC 6585) ---------- + + @Test + void rate_limit_error_carries_retry_after_when_present() { + Duration retryAfter = Duration.ofSeconds(45); + + @Nullable MarketDataException exception = HttpStatusMapper.map(429, context(429), retryAfter); + + assertThat(exception).isExactlyInstanceOf(RateLimitError.class); + RateLimitError rle = (RateLimitError) exception; + assertThat(rle.getRetryAfter()).contains(retryAfter); + } + + @Test + void rate_limit_error_retry_after_is_empty_when_absent() { + @Nullable MarketDataException exception = HttpStatusMapper.map(429, context(429), null); + + assertThat(exception).isExactlyInstanceOf(RateLimitError.class); + RateLimitError rle = (RateLimitError) exception; + assertThat(rle.getRetryAfter()).isEmpty(); + } + @Test void error_carries_the_full_context() { ErrorContext ctx = context(401); diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 6eabc41..ce35dce 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -530,6 +530,92 @@ void preflightAllowsAtTheExactResetInstant() { assertThat(client.captured).hasSize(2); } + /** + * §9.4 vs §10.3 conflict: a 5xx response can carry both rate-limit headers (which exhaust the + * snapshot) AND an explicit {@code Retry-After} (which schedules the retry). The retry must honor + * the server's directive — bypassing the local preflight gate — otherwise the SDK would sabotage + * the server-orchestrated backoff. Two attempts must reach the wire. + */ + @Test + void retryWithServerHintedRetryAfterBypassesPreflight() { + HttpHeaders exhaustedWithHint = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + "0", + "x-api-ratelimit-reset", + String.valueOf(Instant.now().plus(Duration.ofHours(1)).getEpochSecond()), + "x-api-ratelimit-consumed", + "1000", + "Retry-After", + "0")); + CapturingClient client = new CapturingClient(503, new byte[0], exhaustedWithHint); + RetryPolicy twoAttempts = new RetryPolicy(2, Duration.ofMillis(1), Duration.ofMillis(1)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(twoAttempts), + () -> null, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + // Both attempts reached the wire: the snapshot would have BLOCKed the retry, but the + // server's Retry-After said "come back" and the SDK honored it. Without the bypass this + // would be 1 (and the final cause would be RateLimitError). + assertThat(client.captured).hasSize(2); + } + + /** + * Regression guard for the bypass scope: when the retry is NOT server-hinted (no {@code + * Retry-After}), the snapshot's exhaustion verdict still vetoes the retry. Otherwise we'd be + * leaking the bypass to every retry and defeating §10.3 entirely. + */ + @Test + void retryWithoutServerHintStillTriggersPreflightBlock() { + HttpHeaders exhaustedNoHint = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + "0", + "x-api-ratelimit-reset", + String.valueOf(Instant.now().plus(Duration.ofHours(1)).getEpochSecond()), + "x-api-ratelimit-consumed", + "1000")); + CapturingClient client = new CapturingClient(503, new byte[0], exhaustedNoHint); + RetryPolicy twoAttempts = new RetryPolicy(2, Duration.ofMillis(1), Duration.ofMillis(1)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(twoAttempts), + () -> null, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); + + // Only the first attempt reached the wire; the retry was vetoed by the preflight because + // the previous cause had no server-side Retry-After to authorize the bypass. + assertThat(client.captured).hasSize(1); + } + @Test void preflightStillBlocksWhenResetIsInTheFuture() { // reset is in the future → the snapshot's "exhausted" verdict is still current; the @@ -599,6 +685,45 @@ void serverErrorRetryAfterIsEmptyWhenHeaderAbsent() { }); } + /** + * RFC 6585 defines {@code Retry-After} for 429. The SDK does not retry 429 itself, but the + * consumer can read the directive to schedule its own backoff. The parsed duration must travel + * with the {@link RateLimitError}. + */ + @Test + void rateLimitErrorCarriesParsedRetryAfterDuration() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("Retry-After", "30")); + CapturingClient client = new CapturingClient(429, new byte[0], headers); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class) + .satisfies( + t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).contains(Duration.ofSeconds(30)); + }); + } + + @Test + void rateLimitErrorRetryAfterIsEmptyWhenHeaderAbsent() { + CapturingClient client = + new CapturingClient(429, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class) + .satisfies( + t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).isEmpty(); + }); + } + // ---------- §9.5 status-cache gate ---------- /** diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 9473497..f1f2391 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -97,8 +97,7 @@ void missingUserNumericFieldRaisesParseError() { // "quota apparently exhausted". Same policy as ParallelArrays. JsonResponseParser parser = new JsonResponseParser(); - assertThatThrownBy( - () -> parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class)) + assertThatThrownBy(() -> parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("missing or non-integer") .hasMessageContaining("x-ratelimit-requests-remaining"); @@ -195,6 +194,33 @@ void apiStatusServicesListIsImmutable() { .isInstanceOf(UnsupportedOperationException.class); } + @Test + void apiStatusNoDataEnvelopeProducesEmptyApiStatus() { + // The backend returns {"s":"no_data"} (with HTTP 404) when a query has no matches. The + // parser must not explode on the absent arrays — the response wrapper relies on the typed + // model being constructable so consumers can branch on isNoData() while still calling + // .data().services(). + String body = "{\"s\":\"no_data\"}"; + + ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + + @Test + void apiStatusNoDataEnvelopeWithMetadataFieldsStillEmpty() { + // Some backend handlers attach hints to the no_data envelope (nextTime, prevTime, errmsg); + // those siblings must not perturb the empty result. + String body = + "{\"s\":\"no_data\"," + + "\"nextTime\":null,\"prevTime\":null," + + "\"errmsg\":\"Market closed on this date.\"}"; + + ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + @Test void apiStatusServerSideErrorBecomesParseError() { // `s: "error"` is the server's soft-error path — the body is valid JSON but doesn't carry diff --git a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java index a92d8be..b74f603 100644 --- a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java +++ b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java @@ -87,6 +87,45 @@ null, parse("{\"s\":\"error\"}"), List.of("symbol"), row -> null)) .hasMessageContaining("no errmsg field"); } + // ---------- no_data envelope (paired with HTTP 404) ---------- + + @Test + void noDataEnvelopeShortCircuitsToEmptyListWithoutFieldValidation() throws IOException { + // The backend returns {"s":"no_data"} (HTTP 404) when a query has no results — the data + // arrays are deliberately absent. The helper must return an empty list so the deserializer + // wraps it in its container type instead of complaining about "missing field". + List rows = + ParallelArrays.zip( + null, + parse("{\"s\":\"no_data\"}"), + List.of("symbol", "price"), + row -> { + throw new AssertionError("builder must not be invoked for no_data envelope"); + }); + + assertThat(rows).isEmpty(); + } + + @Test + void noDataEnvelopeIgnoresAdjacentMetadataFields() throws IOException { + // Some backend handlers attach metadata to the no_data envelope (e.g. nextTime, prevTime, + // errmsg). Those fields are not the parallel-array columns and must not affect the result. + List rows = + ParallelArrays.zip( + null, + parse( + "{\"s\":\"no_data\"," + + "\"nextTime\":null," + + "\"prevTime\":null," + + "\"errmsg\":\"Market closed on this date.\"}"), + List.of("symbol", "price"), + row -> { + throw new AssertionError("builder must not be invoked for no_data envelope"); + }); + + assertThat(rows).isEmpty(); + } + // ---------- presence and length validation ---------- @Test diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java index 4f08d8b..2e0bff0 100644 --- a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -243,6 +243,35 @@ void customPredicateReceivesUnwrappedCauseAndAttemptIndex() { assertThat(seenAttempts).containsExactly(0, 1, 2); } + // ---------- context-aware supplier threading previousCause ---------- + + @Test + void attemptSupplierReceivesAttemptIndexAndPreviousCause() { + // The AttemptSupplier variant exposes the previous attempt's (unwrapped) cause so callers + // can branch — e.g. HttpTransport bypasses preflight when previousCause carries an explicit + // server-side Retry-After. This test pins that the threading is correct across attempts. + java.util.List seenAttempts = new java.util.ArrayList<>(); + java.util.List seenCauses = new java.util.ArrayList<>(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + NetworkError netError = retriableNet(); + + exec.execute( + (attemptIdx, previousCause) -> { + seenAttempts.add(attemptIdx); + seenCauses.add(previousCause); + return CompletableFuture.failedFuture(netError); + }, + (cause, attempt) -> attempt < 2) + .exceptionally(e -> null) + .join(); + + assertThat(seenAttempts).containsExactly(0, 1, 2); + // First attempt has no previous cause; subsequent attempts see the unwrapped NetworkError. + assertThat(seenCauses.get(0)).isNull(); + assertThat(seenCauses.get(1)).isSameAs(netError); + assertThat(seenCauses.get(2)).isSameAs(netError); + } + @Test void resultFutureCarriesCancellationException() { RetryExecutor exec = new RetryExecutor(NO_RETRY); diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index bcf69d0..ccdf297 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -234,6 +234,29 @@ void resourceWrapsTypedDataWithRawBodyAndMetadata() { assertThat(r.requestUrl().toString()).isEqualTo("http://localhost/headers/"); } + // ---------- §13.5 no_data convention (HTTP 404 + {"s":"no_data"}) ---------- + + /** + * When the backend returns the no_data envelope with HTTP 404, the consumer must reach {@link + * Response#isNoData()} and {@link Response#data()} normally — no {@link + * com.marketdata.sdk.exception.ParseError} from the parallel-array field validation. The data + * payload is the typed model with an empty collection. + */ + @Test + void statusEndpointSurfaces404NoDataAsEmptyResponseInsteadOfParseError() { + String body = "{\"s\":\"no_data\"}"; + CapturingClient client = + new CapturingClient(404, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + Response r = utilities.status(); + + assertThat(r.statusCode()).isEqualTo(404); + assertThat(r.isNoData()).isTrue(); + assertThat(r.data().services()).isEmpty(); + assertThat(new String(r.rawBody())).isEqualTo(body); + } + // ---------- error surfacing through sync ---------- /** diff --git a/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java index eb6cace..8301607 100644 --- a/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java +++ b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java @@ -8,6 +8,9 @@ class SealedHierarchyTest { @Test void permits_exactly_the_seven_canonical_subtypes() { + // ADR-002 fixes the canonical list at exactly these 7 permits. Expanding requires an ADR + // amendment — adding a permit silently would break consumers compiling against the documented + // shape on JDK 21+ (pattern matching for switch). This snapshot is the regression guard. Class[] permitted = MarketDataException.class.getPermittedSubclasses(); assertThat(permitted) From e80c83de35cc51ee8c9a42b07112284355f56afc Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 17:42:14 -0300 Subject: [PATCH 44/57] ALLOWED_KEYS added to DotEnvLoader --- .../com/marketdata/sdk/Configuration.java | 2 +- .../java/com/marketdata/sdk/DotEnvLoader.java | 17 ++++- .../com/marketdata/sdk/UtilitiesResource.java | 13 ++-- .../com/marketdata/sdk/ConfigurationTest.java | 23 +++++++ .../com/marketdata/sdk/DotEnvLoaderTest.java | 64 +++++++++++++++++-- 5 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index 83100e9..6b4b88d 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -44,7 +44,7 @@ static Configuration resolve( Function env, Path dotEnvPath, Consumer warnings) { - Map dotEnv = DotEnvLoader.load(dotEnvPath, warnings); + Map dotEnv = DotEnvLoader.load(dotEnvPath, warnings, EnvVars.ALLOWED_KEYS); String apiKey = pickFirst(explicitApiKey, env.apply(EnvVars.TOKEN), dotEnv.get(EnvVars.TOKEN)); String baseUrl = pickFirstOrDefault( diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java index 2162b84..35f70dc 100644 --- a/src/main/java/com/marketdata/sdk/DotEnvLoader.java +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.logging.Level; import org.jspecify.annotations.Nullable; @@ -28,7 +29,18 @@ final class DotEnvLoader { /** Diagnostic emitted by the loader, replayed by {@link MarketDataClient} after logging setup. */ record Warning(Level level, String message, @Nullable Throwable cause) {} - static Map load(Path path, Consumer warnings) { + /** + * Parse {@code path} into an immutable map of {@code key → value} pairs. + * + *

    {@code allowedKeys} is the allowlist: when non-null, keys outside the set are dropped during + * parsing and never materialize in the returned map. This mirrors the defensive principle of + * {@link EnvVars#systemLookup} — the SDK does not need to retain the consumer's unrelated secrets + * ({@code AWS_SECRET_ACCESS_KEY}, {@code GITHUB_TOKEN}, etc.) in memory just because they + * happened to share a {@code .env} file with our config. Passing {@code null} disables the + * filter; that surface exists for tests that exercise the parser independently of the cascade. + */ + static Map load( + Path path, Consumer warnings, @Nullable Set allowedKeys) { if (!Files.exists(path)) { return Map.of(); } @@ -55,6 +67,9 @@ static Map load(Path path, Consumer warnings) { continue; } String key = trimmed.substring(0, eq).trim(); + if (allowedKeys != null && !allowedKeys.contains(key)) { + continue; + } String value = stripQuotes(trimmed.substring(eq + 1).trim()); result.put(key, value); } diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index a95f10d..333687b 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -57,11 +57,14 @@ public Response user() { /** * Auth probe used by {@link MarketDataClient}'s startup validation. Hits {@code GET /v1/user/} - * with a single-attempt policy so a slow/down API surfaces to the constructor immediately instead - * of burning the default retry budget (~6.75 min worst-case). Result is discarded — only the - * throw shape matters: 401 → {@link com.marketdata.sdk.exception.AuthenticationError}, other - * failures propagate as their typed {@link com.marketdata.sdk.exception.MarketDataException} - * subtype. + * with a single-attempt policy so the constructor caps at one {@code REQUEST_TIMEOUT} (99 s) + * instead of burning the default retry budget (~6.75 min worst-case on a down API). A truly + * unreachable API surfaces within {@code CONNECT_TIMEOUT} (~2 s); a slow-but-TCP-open API can + * still take up to {@code REQUEST_TIMEOUT} — consumers that need a tighter ceiling should set + * {@code validateOnStartup = false} and probe themselves with their own deadline. Result is + * discarded — only the throw shape matters: 401 → {@link + * com.marketdata.sdk.exception.AuthenticationError}, other failures propagate as their typed + * {@link com.marketdata.sdk.exception.MarketDataException} subtype. * *

    Package-private and intent-named: not part of the public API and not an "endpoint" in the * §1.2 sense, so ADR-006's sync+async parity does not apply. diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 87ef309..160c52f 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -81,6 +81,29 @@ void resolve_falls_back_to_dotenv_when_explicit_and_env_missing(@TempDir Path tm assertThat(config.apiVersion()).isEqualTo("v3"); } + @Test + void resolve_ignores_non_marketdata_keys_in_dotenv(@TempDir Path tmp) throws IOException { + // The cascade hands DotEnvLoader the EnvVars allowlist; secrets unrelated to the SDK (AWS + // creds, GitHub tokens, etc.) must not leak into the SDK's memory just because they share a + // .env file with the MARKETDATA_* keys. Spec §16's allowlist principle (defined for + // System.getenv via EnvVars.systemLookup) extends to .env reads. + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=marketdata-key + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + """); + + Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(config.apiKey()).isEqualTo("marketdata-key"); + // No accessor exposes non-MARKETDATA values — but verify the cascade did not pluck them + // through some accidental future path by snapshotting the record's toString. + assertThat(config.toString()).doesNotContain("supersecret").doesNotContain("leaked"); + } + @Test void resolve_uses_defaults_for_base_url_and_api_version_when_nothing_provided(@TempDir Path tmp) { Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java index 8c07943..64b3d2a 100644 --- a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.logging.Level; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; @@ -17,9 +18,12 @@ class DotEnvLoaderTest { - /** Convenience wrapper for tests that don't care about warnings. */ + /** + * Convenience wrapper for parser-level tests: no warning sink, no allowlist (the parser is + * exercised independently of the cascade's allowlist). + */ private static Map load(Path path) { - return DotEnvLoader.load(path, w -> {}); + return DotEnvLoader.load(path, w -> {}, null); } @Test @@ -38,7 +42,7 @@ void load_missing_file_does_not_warn(@TempDir Path tmp) { Path missing = tmp.resolve("does-not-exist.env"); List warnings = new ArrayList<>(); - DotEnvLoader.load(missing, warnings::add); + DotEnvLoader.load(missing, warnings::add, null); assertThat(warnings).isEmpty(); } @@ -53,7 +57,7 @@ void load_unreadable_file_emits_warning_and_returns_empty(@TempDir Path tmp) thr Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("---------")); try { List warnings = new ArrayList<>(); - Map result = DotEnvLoader.load(file, warnings::add); + Map result = DotEnvLoader.load(file, warnings::add, null); assertThat(result).isEmpty(); assertThat(warnings) @@ -78,7 +82,7 @@ void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) t Path asDir = Files.createDirectory(tmp.resolve("env-as-dir")); List warnings = new ArrayList<>(); - Map result = DotEnvLoader.load(asDir, warnings::add); + Map result = DotEnvLoader.load(asDir, warnings::add, null); assertThat(result).isEmpty(); assertThat(warnings) @@ -212,12 +216,60 @@ void load_returns_immutable_map(@TempDir Path tmp) throws IOException { assertThat(result).isUnmodifiable(); } + // ---------- allowlist filter (defense for unrelated secrets) ---------- + + @Test + void load_with_allowlist_drops_keys_outside_the_set(@TempDir Path tmp) throws IOException { + // A consumer's .env can legitimately contain secrets unrelated to the SDK (AWS creds, OAuth + // tokens for other services, etc.). The loader must not retain those in memory just because + // they happened to share a file — the SDK only needs the MARKETDATA_* keys it declares. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=abc123 + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + MARKETDATA_BASE_URL=https://example.com + """); + + Map result = + DotEnvLoader.load(file, w -> {}, Set.of("MARKETDATA_TOKEN", "MARKETDATA_BASE_URL")); + + assertThat(result) + .containsOnlyKeys("MARKETDATA_TOKEN", "MARKETDATA_BASE_URL") + .containsEntry("MARKETDATA_TOKEN", "abc123") + .containsEntry("MARKETDATA_BASE_URL", "https://example.com"); + // The disallowed values are not retained anywhere reachable from the returned map. + assertThat(result.values()).noneMatch(v -> v.contains("supersecret") || v.contains("leaked")); + } + + @Test + void load_with_empty_allowlist_returns_empty(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "MARKETDATA_TOKEN=abc\n"); + + Map result = DotEnvLoader.load(file, w -> {}, Set.of()); + + assertThat(result).isEmpty(); + } + + @Test + void load_with_null_allowlist_admits_everything(@TempDir Path tmp) throws IOException { + // Null allowlist = parser-only mode (test surface). Filtering is the caller's job — for + // production the cascade always passes EnvVars.ALLOWED_KEYS. + Path file = Files.writeString(tmp.resolve(".env"), "FOO=bar\nMARKETDATA_TOKEN=abc\n"); + + Map result = DotEnvLoader.load(file, w -> {}, null); + + assertThat(result).containsEntry("FOO", "bar").containsEntry("MARKETDATA_TOKEN", "abc"); + } + @Test void load_successful_read_does_not_warn(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); List warnings = new ArrayList<>(); - DotEnvLoader.load(file, warnings::add); + DotEnvLoader.load(file, warnings::add, null); assertThat(warnings).isEmpty(); } From 3f6b2dc6853425f612d6fb2c28f7563160e0f313 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 17:49:43 -0300 Subject: [PATCH 45/57] Loggers per-class consolidation --- .../com/marketdata/sdk/HttpDispatcher.java | 2 +- .../com/marketdata/sdk/HttpTransport.java | 2 +- .../com/marketdata/sdk/MarketDataClient.java | 6 ++++- .../java/com/marketdata/sdk/StatusCache.java | 2 +- .../sdk/CanonicalLogFormatterTest.java | 7 ++++-- .../marketdata/sdk/MarketDataLoggingTest.java | 25 +++++++++++++++++++ 6 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index cbfe3c2..957b2d5 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -30,7 +30,7 @@ */ final class HttpDispatcher implements AutoCloseable { - private static final Logger LOGGER = Logger.getLogger(HttpDispatcher.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); private final HttpClient httpClient; private final AsyncSemaphore permits; diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 77aa724..b653d37 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -42,7 +42,7 @@ */ final class HttpTransport implements AutoCloseable { - private static final Logger LOGGER = Logger.getLogger(HttpTransport.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); /** SDK requirements §10: fixed 99-second per-request timeout. */ static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 9b1271b..7b6db83 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -11,7 +11,11 @@ public final class MarketDataClient implements AutoCloseable { - private static final Logger LOGGER = Logger.getLogger(MarketDataClient.class.getName()); + // §7: one logger for the whole SDK (com.marketdata.sdk). Consumers configure or attach handlers + // to that single name; consolidating here keeps MarketDataLogging's consumer-pre-config + // detection and useParentHandlers=false guard aware of every emission path. Parity with the + // Python SDK (single marketdata.logger). + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); private final Configuration config; private final HttpTransport transport; diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java index 46ba75e..2d03954 100644 --- a/src/main/java/com/marketdata/sdk/StatusCache.java +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -42,7 +42,7 @@ */ final class StatusCache { - private static final Logger LOGGER = Logger.getLogger(StatusCache.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); static final Duration REFRESH_THRESHOLD = Duration.ofSeconds(270); static final Duration EXPIRY = Duration.ofSeconds(300); diff --git a/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java index 72be7fe..b8f97b0 100644 --- a/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java +++ b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java @@ -21,10 +21,13 @@ private static LogRecord recordAt(Level level, String logger, String message, In @Test void formatProducesCanonicalShape() { CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + // Production records all carry "com.marketdata.sdk" as the logger name (§7 consolidation); + // the formatter renders whatever name the record carries — this fixture uses the production + // value so reading it doesn't imply per-class sub-loggers exist. LogRecord r = recordAt( Level.INFO, - "com.marketdata.sdk.HttpTransport", + "com.marketdata.sdk", "Sending GET to https://api/v1/markets/status/", Instant.parse("2026-05-19T18:00:00Z")); @@ -33,7 +36,7 @@ void formatProducesCanonicalShape() { // {timestamp} - {logger_name} - {level} - {message}\n String[] parts = out.split(" - ", 4); assertThat(parts).hasSize(4); - assertThat(parts[1]).isEqualTo("com.marketdata.sdk.HttpTransport"); + assertThat(parts[1]).isEqualTo("com.marketdata.sdk"); assertThat(parts[2]).isEqualTo("INFO"); assertThat(parts[3]).startsWith("Sending GET to https://api/v1/markets/status/"); assertThat(out).endsWith(System.lineSeparator()); diff --git a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java index 41f33fe..348fff3 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java @@ -137,6 +137,31 @@ void configureRunsAgainAfterResetClearsConsumerState() { assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); } + // ---------- §7 consolidation: every SDK class emits via the single root logger ---------- + + /** + * Regression guard for the consolidation: if anyone re-introduces a per-class logger via {@code + * Logger.getLogger(SomeClass.class.getName())}, the configure() consumer-pre-config detection and + * {@code useParentHandlers=false} guard would no longer cover the new sub-logger — records could + * double-emit through the root JUL handler or escape the SDK's level control. + */ + @Test + void all_sdk_classes_emit_via_the_consolidated_root_logger() throws Exception { + for (Class clazz : + java.util.List.of( + MarketDataClient.class, HttpTransport.class, HttpDispatcher.class, StatusCache.class)) { + java.lang.reflect.Field loggerField = clazz.getDeclaredField("LOGGER"); + loggerField.setAccessible(true); + Logger logger = (Logger) loggerField.get(null); + assertThat(logger.getName()) + .as( + "Class %s must use Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME) so its records" + + " stay under the single configured root (§7).", + clazz.getSimpleName()) + .isEqualTo(MarketDataLogging.SDK_LOGGER_NAME); + } + } + /** Minimal Handler stub used to simulate a consumer-attached handler. */ private static final class TestHandler extends Handler { @Override From 6df7c8c12ed5940c4998e277f6cb2359dcfcef0f Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 17:53:09 -0300 Subject: [PATCH 46/57] improve logger config --- .../com/marketdata/sdk/MarketDataLogging.java | 43 +++++++++++++------ .../marketdata/sdk/MarketDataLoggingTest.java | 23 ++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java index 074bf59..b2b416c 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataLogging.java +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -38,15 +38,23 @@ final class MarketDataLogging { static final String SDK_LOGGER_NAME = "com.marketdata.sdk"; static final Level DEFAULT_LEVEL = Level.INFO; + /** + * Latched to {@code true} only when {@link #configure(String)} successfully installs the SDK + * handler. Calls that return because the consumer had pre-configured the logger do not + * latch — if the consumer later releases control (clears their handler / level), a subsequent + * {@code configure(...)} can still install the SDK defaults. "First call wins" applies to the + * install, not to the skip. + */ private static final AtomicBoolean configured = new AtomicBoolean(false); private MarketDataLogging() {} /** - * Install the SDK's handler + formatter on the SDK root logger. Idempotent — first call wins; - * subsequent calls are no-ops. Also backs off entirely when the SDK logger already carries a - * handler or an explicit level (see class docs): the consumer has taken control, the SDK respects - * it. + * Install the SDK's handler + formatter on the SDK root logger. Idempotent — first successful + * install wins; subsequent calls are no-ops. Also backs off entirely when the SDK logger already + * carries a handler or an explicit level (see class docs): the consumer has taken control, the + * SDK respects it; that path does not latch the idempotency flag, so the SDK can still + * install later if the consumer's control is released. * * @param levelSpec a level string from {@code MARKETDATA_LOGGING_LEVEL} ({@code DEBUG}, {@code * INFO}, {@code WARNING}, {@code ERROR}, case-insensitive), or {@code null} for the default @@ -54,11 +62,10 @@ private MarketDataLogging() {} */ static void configure(@Nullable String levelSpec) { Level requested = parseLevel(levelSpec); - if (!configured.compareAndSet(false, true)) { - // Already configured. If the caller is asking for a different level than the first call - // installed, the result is "their level is silently ignored" — flag it at DEBUG so a - // test that sees stale logging knows where to look, without spamming production where - // re-creating the client is normal. + if (configured.get()) { + // SDK already installed by an earlier call. Subsequent calls don't replace the handler + // (first-install-wins) but may diagnose level mismatches at DEBUG so a stale-logging + // surprise has a breadcrumb. Level installed = Logger.getLogger(SDK_LOGGER_NAME).getLevel(); if (installed != null && !installed.equals(requested)) { LOG.fine( @@ -67,16 +74,24 @@ static void configure(@Nullable String levelSpec) { + requested.getName() + " but logger is already configured at " + installed.getName() - + "; ignoring (first-call-wins)."); + + "; ignoring (first-install-wins)."); } return; } Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); if (sdkLogger.getHandlers().length > 0 || sdkLogger.getLevel() != null) { - // Consumer (or another library) already configured the SDK logger. Respect that - // entirely: don't add our ConsoleHandler (would double-emit), don't flip - // useParentHandlers (would break their parent-handler routing), don't overwrite the - // level they explicitly chose. + // Consumer (or another library) already configured the SDK logger. Respect that entirely: + // don't add our ConsoleHandler (would double-emit), don't flip useParentHandlers (would + // break their parent-handler routing), don't overwrite the level they explicitly chose. + // Crucially, do NOT latch `configured` here — the consumer can release control later + // (remove their handler, clear their level), and a subsequent configure() call should be + // allowed to install the SDK defaults at that point. Latching would freeze the SDK out + // for the lifetime of the process even after the consumer-pre-config disappeared. + return; + } + // Claim the install slot. Losing the race with another concurrent configure() means another + // thread is already installing — treat as idempotent skip. + if (!configured.compareAndSet(false, true)) { return; } Handler handler = new ConsoleHandler(); diff --git a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java index 348fff3..e8fcef5 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java @@ -122,6 +122,29 @@ void configureSkipsWhenConsumerAlreadySetALevel() { assertThat(sdkLogger().getHandlers()).isEmpty(); } + @Test + void configureRetriesInstallAfterConsumerReleasesControl() { + // Bug from review #8: previously `configured` latched on the consumer-pre-config skip path, + // so the SDK was frozen out for the lifetime of the process even after the consumer + // cleared their handler/level. The fix latches `configured` only on actual install. This + // test pins the new behavior — no resetForTests() called between attempts. + sdkLogger().setLevel(Level.FINE); // simulate consumer state + MarketDataLogging.configure("INFO"); + // First call backed off: no handler, consumer's level intact. + assertThat(sdkLogger().getHandlers()).isEmpty(); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + + // Consumer releases control (e.g. their bootstrap completed and they cleared the override). + sdkLogger().setLevel(null); + + MarketDataLogging.configure("WARNING"); + + // SDK now installs because consumer-pre-config disappeared — the prior skip did not latch + // the flag. + assertThat(sdkLogger().getHandlers()).hasSize(1); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.WARNING); + } + @Test void configureRunsAgainAfterResetClearsConsumerState() { // Defensive: resetForTests() wipes both the idempotency flag and the logger state, so a From 4cb5a080eb4248ffb5a20b174db43e584f696f6d Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 18:00:39 -0300 Subject: [PATCH 47/57] avoid harcoded jsonformatter --- .../marketdata/sdk/JsonResponseParser.java | 32 +++++---- .../com/marketdata/sdk/UtilitiesResource.java | 20 ++++++ .../sdk/JsonResponseParserTest.java | 70 +++++++++++++------ 3 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 0602622..5b714b5 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -1,12 +1,9 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.utilities.ApiStatus; -import com.marketdata.sdk.utilities.RequestHeaders; -import com.marketdata.sdk.utilities.User; import java.io.IOException; import java.time.Clock; @@ -15,8 +12,13 @@ * *

    Owns one {@link ObjectMapper} per {@link MarketDataClient} (Jackson mappers are thread-safe * and expensive to construct, so we build and reuse). Per ADR-007, wire-format deserializers are - * registered programmatically on a package-private {@link SimpleModule} here — response records - * never carry {@code @JsonDeserialize} annotations. + * registered programmatically — response records never carry {@code @JsonDeserialize} annotations. + * The parser itself is resource-agnostic: it does not know about {@code User}, + * {@code ApiStatus}, or any other domain type. Each {@code *Resource} self-registers its + * deserializers in its constructor via {@link #registerModule(Module)}, so adding a new resource + * does not require editing this file. Registration must happen before the first {@link #parse} + * call, which is satisfied today because resources are constructed at {@code MarketDataClient} + * construction time, before any HTTP traffic. * *

    Resources that need raw bytes (CSV, HTML) skip this class entirely and read {@link * HttpResponseEnvelope#body()} directly. @@ -31,16 +33,20 @@ final class JsonResponseParser { } JsonResponseParser(Clock clock) { - ObjectMapper m = new ObjectMapper(); - SimpleModule wireModule = new SimpleModule("marketdata-wire"); - wireModule.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); - wireModule.addDeserializer(User.class, new UserDeserializer()); - wireModule.addDeserializer(ApiStatus.class, new ApiStatusDeserializer()); - m.registerModule(wireModule); - this.mapper = m; + this.mapper = new ObjectMapper(); this.clock = clock; } + /** + * Attach a Jackson {@link Module} (typically a {@code SimpleModule} populated with one resource's + * deserializers). Resources call this from their constructor to wire their wire-format mappings + * without coupling the parser to their domain types. Idempotent for modules sharing the same + * type-id (Jackson skips duplicates). + */ + void registerModule(Module module) { + mapper.registerModule(module); + } + /** * Decode an envelope's body into the requested type. Throws {@link ParseError} when Jackson * cannot read the body — the error context carries the envelope's url, status, and request id for diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 333687b..c0fd0af 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -1,5 +1,6 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.User; @@ -24,6 +25,25 @@ public final class UtilitiesResource { UtilitiesResource(HttpTransport transport, JsonResponseParser parser) { this.transport = transport; this.parser = parser; + // §9 / ADR-007: resources own their wire-format deserializer registration. Registering here + // (in the resource that ships the response models) keeps the parser resource-agnostic and + // lets future resources (stocks, options, funds, markets) add their wire formats without + // editing a central file. + parser.registerModule(wireFormatModule()); + } + + /** + * Build the Jackson module that maps this resource's response records ({@link RequestHeaders}, + * {@link User}, {@link ApiStatus}) to their custom deserializers. Each call returns a fresh + * {@link SimpleModule}; tests that need the same wiring without constructing a full resource can + * register this directly on a bare parser. + */ + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-utilities"); + m.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); + m.addDeserializer(User.class, new UserDeserializer()); + m.addDeserializer(ApiStatus.class, new ApiStatusDeserializer()); + return m; } /** diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index f1f2391..749bb95 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -15,6 +15,18 @@ class JsonResponseParserTest { + /** + * Build a parser pre-loaded with the utilities resource's wire-format module. The parser itself + * is resource-agnostic (issue #9 fix); these tests exercise the deserializers shipped by {@link + * UtilitiesResource}, so the registration that the production constructor performs is replicated + * here. + */ + private static JsonResponseParser parserWithUtilitiesModule() { + JsonResponseParser p = new JsonResponseParser(); + p.registerModule(UtilitiesResource.wireFormatModule()); + return p; + } + private static HttpResponseEnvelope env(String body) { return new HttpResponseEnvelope( body.getBytes(), @@ -26,7 +38,7 @@ private static HttpResponseEnvelope env(String body) { @Test void parsesRequestHeadersFromFlatJsonObject() { - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); RequestHeaders rh = parser.parse( @@ -41,14 +53,14 @@ void parsesRequestHeadersFromFlatJsonObject() { @Test void emptyJsonObjectProducesEmptyHeaders() { - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); RequestHeaders rh = parser.parse(env("{}"), RequestHeaders.class); assertThat(rh.headers()).isEmpty(); } @Test void requestHeadersMapIsImmutable() { - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); RequestHeaders rh = parser.parse(env("{\"a\":\"1\"}"), RequestHeaders.class); assertThatThrownBy(() -> rh.headers().put("hacked", "value")) @@ -59,7 +71,7 @@ void requestHeadersMapIsImmutable() { @Test void parsesUserMappingHyphenatedKeysToCamelCase() { - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); User u = parser.parse( @@ -78,7 +90,7 @@ void parsesUserMappingHyphenatedKeysToCamelCase() { void parsesUserWithEmptyOptionsPermissionsAsRealTimeMarker() { // Empty string is the server's convention for "real-time access"; the SDK preserves it // verbatim so consumers can detect realTime via `permissions.isEmpty()`. - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); User u = parser.parse( @@ -95,7 +107,7 @@ void parsesUserWithEmptyOptionsPermissionsAsRealTimeMarker() { void missingUserNumericFieldRaisesParseError() { // Strict: a silent zero would mask backend regressions and surface later as a confusing // "quota apparently exhausted". Same policy as ParallelArrays. - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); assertThatThrownBy(() -> parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class)) .isInstanceOf(ParseError.class) @@ -106,7 +118,7 @@ void missingUserNumericFieldRaisesParseError() { @Test void userNumericFieldOfWrongTypeRaisesParseError() { // String "500" instead of integer 500 — strict rejection rather than Jackson's lax coercion. - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); assertThatThrownBy( () -> @@ -125,7 +137,7 @@ void userNumericFieldOfWrongTypeRaisesParseError() { void userMissingOptionsPermsRaisesParseError() { // The empty string is the legitimate "real-time access" marker — but the field must be // present as a JSON string. Absence is treated as a backend regression, not a default. - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); assertThatThrownBy( () -> @@ -155,7 +167,7 @@ void parsesApiStatusByZippingParallelArrays() { + "\"updated\":[1734036832,1734036833]" + "}"; - ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); assertThat(status.services()).hasSize(2); ServiceStatus first = status.services().get(0); @@ -178,7 +190,7 @@ void parsesApiStatusWithEmptyArrays() { "{\"s\":\"ok\",\"service\":[],\"status\":[],\"online\":[]," + "\"uptimePct30d\":[],\"uptimePct90d\":[],\"updated\":[]}"; - ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); assertThat(status.services()).isEmpty(); } @@ -188,7 +200,7 @@ void apiStatusServicesListIsImmutable() { String body = "{\"s\":\"ok\",\"service\":[\"a\"],\"status\":[\"online\"],\"online\":[true]," + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[0]}"; - ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); assertThatThrownBy(() -> status.services().add(null)) .isInstanceOf(UnsupportedOperationException.class); @@ -202,7 +214,7 @@ void apiStatusNoDataEnvelopeProducesEmptyApiStatus() { // .data().services(). String body = "{\"s\":\"no_data\"}"; - ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); assertThat(status.services()).isEmpty(); } @@ -216,7 +228,7 @@ void apiStatusNoDataEnvelopeWithMetadataFieldsStillEmpty() { + "\"nextTime\":null,\"prevTime\":null," + "\"errmsg\":\"Market closed on this date.\"}"; - ApiStatus status = new JsonResponseParser().parse(env(body), ApiStatus.class); + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); assertThat(status.services()).isEmpty(); } @@ -227,7 +239,7 @@ void apiStatusServerSideErrorBecomesParseError() { // the usable arrays. Surface as ParseError so it doesn't masquerade as an empty success. String body = "{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("database connection refused"); } @@ -243,7 +255,7 @@ void apiStatusMismatchedArrayLengthsBecomeParseError() { + "\"uptimePct90d\":[1.0,1.0]," + "\"updated\":[0,0]}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("mismatched lengths"); } @@ -261,7 +273,7 @@ void apiStatusNonArrayFieldBecomesParseError() { + "\"uptimePct90d\":[1.0]," + "\"updated\":[0]}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("missing or non-array") .hasMessageContaining("service"); @@ -279,7 +291,7 @@ void apiStatusMissingArrayBecomesParseError() { + "\"uptimePct90d\":[1.0]," + "\"updated\":[0]}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("missing or non-array") .hasMessageContaining("online"); @@ -300,7 +312,7 @@ void apiStatusNullCellInOnlineArrayBecomesParseError() { + "\"uptimePct90d\":[1.0,1.0]," + "\"updated\":[0,0]}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("null cell") .hasMessageContaining("online"); @@ -319,7 +331,7 @@ void apiStatusWrongTypeInUptimeArrayBecomesParseError() { + "\"uptimePct90d\":[1.0]," + "\"updated\":[0]}"; - assertThatThrownBy(() -> new JsonResponseParser().parse(env(body), ApiStatus.class)) + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("expected number") .hasMessageContaining("uptimePct30d"); @@ -327,7 +339,7 @@ void apiStatusWrongTypeInUptimeArrayBecomesParseError() { @Test void malformedJsonRaisesParseErrorCarryingResponseContext() { - JsonResponseParser parser = new JsonResponseParser(); + JsonResponseParser parser = parserWithUtilitiesModule(); assertThatThrownBy(() -> parser.parse(env("{not-json"), RequestHeaders.class)) .isInstanceOf(ParseError.class) @@ -340,4 +352,22 @@ void malformedJsonRaisesParseErrorCarryingResponseContext() { assertThat(err.getCause()).isNotNull(); }); } + + // ---------- §9 / ADR-007: parser is resource-agnostic ---------- + + /** + * Regression guard: a bare {@link JsonResponseParser} (no modules registered) must NOT know how + * to deserialize {@link RequestHeaders} or any other resource type. If a future change + * reintroduces hardcoded deserializers in the parser's constructor, this test catches it. + */ + @Test + void bareParserDoesNotKnowResourceDeserializers() { + JsonResponseParser bare = new JsonResponseParser(); + + // RequestHeaders requires the custom deserializer; without it Jackson's default record + // mapping fails for the wire shape ({"accept":"*/*",...}) because the record has no + // matching property names. Surfaces as ParseError per the parser's contract. + assertThatThrownBy(() -> bare.parse(env("{\"accept\":\"*/*\"}"), RequestHeaders.class)) + .isInstanceOf(ParseError.class); + } } From 3e68a6ca82f9d36d4336455c588177d619be9174 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 18:13:22 -0300 Subject: [PATCH 48/57] factory for ParallelArrays.listDeserializer --- .../marketdata/sdk/ApiStatusDeserializer.java | 46 -------------- .../com/marketdata/sdk/ParallelArrays.java | 34 ++++++++++ .../com/marketdata/sdk/UtilitiesResource.java | 20 +++++- .../marketdata/sdk/ParallelArraysTest.java | 62 +++++++++++++++++++ 4 files changed, 115 insertions(+), 47 deletions(-) delete mode 100644 src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java diff --git a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java b/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java deleted file mode 100644 index d22299d..0000000 --- a/src/main/java/com/marketdata/sdk/ApiStatusDeserializer.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.marketdata.sdk; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.marketdata.sdk.utilities.ApiStatus; -import com.marketdata.sdk.utilities.ServiceStatus; -import java.io.IOException; -import java.util.List; - -/** - * Wire-format deserializer for {@link ApiStatus}. The server uses the API's standard - * parallel-arrays shape: six equal-length arrays of column values plus the {@code "s"} envelope. - * {@link ParallelArrays#zip} handles the structural validation; this class only declares which - * columns are expected and how to materialize a {@link ServiceStatus} from one row. - * - *

    Error envelopes ({@code s == "error"}), missing arrays, and mismatched lengths bubble up as - * {@link JsonMappingException}, which the parent {@link JsonResponseParser} turns into a {@link - * com.marketdata.sdk.exception.ParseError} with the response context attached. - */ -final class ApiStatusDeserializer extends JsonDeserializer { - - private static final List FIELDS = - List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"); - - @Override - public ApiStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - JsonNode root = p.readValueAsTree(); - List services = - ParallelArrays.zip( - p, - root, - FIELDS, - row -> - new ServiceStatus( - row.text("service"), - row.text("status"), - row.bool("online"), - row.dbl("uptimePct30d"), - row.dbl("uptimePct90d"), - MarketDataDates.marketTimeFromEpochSecond(row.lng("updated")))); - return new ApiStatus(services); - } -} diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index c249ec5..7ffabcc 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -1,6 +1,8 @@ package com.marketdata.sdk; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; @@ -8,6 +10,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; /** * Helper for deserializing the API's parallel-arrays wire format. Almost every endpoint that @@ -118,6 +121,37 @@ interface RowBuilder { T build(Row row) throws IOException; } + /** + * Build a {@link JsonDeserializer} for a parallel-arrays response: parses the tree, zips the + * columns into rows, then wraps the resulting list in the container record. Lets each + * response-shape declaration be a single call instead of a hand-written deserializer class — the + * ~30-line boilerplate (extend {@code JsonDeserializer}, read tree, call {@code zip}, build + * record) collapses to the three pieces that actually differ per endpoint: column names, per-row + * constructor, container wrapper. + * + *

    {@code wrapper} is typically the record constructor reference (e.g. {@code ApiStatus::new}). + * Receives an immutable list — the record's compact constructor can {@code List.copyOf} for + * defensive copy without surprises about mutability. + * + * @param fields names of the parallel arrays expected under the response root, in the order the + * {@link RowBuilder} will reference them + * @param rowBuilder how to materialize one element of the resulting list from a {@link Row} + * @param wrapper how to wrap the resulting list of rows in the container response record + * @param per-row element type produced by {@code rowBuilder} + * @param container response type + */ + static JsonDeserializer listDeserializer( + List fields, RowBuilder rowBuilder, Function, T> wrapper) { + return new JsonDeserializer() { + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + List rows = zip(p, root, fields, rowBuilder); + return wrapper.apply(rows); + } + }; + } + /** * Strict typed accessors over one row of the parallel arrays. Each accessor verifies that the * cell is present and is of the expected JSON type; otherwise a {@link JsonMappingException} is diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index c0fd0af..1f86ffb 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; +import java.util.List; import java.util.concurrent.CompletableFuture; /** @@ -42,7 +44,23 @@ static SimpleModule wireFormatModule() { SimpleModule m = new SimpleModule("marketdata-utilities"); m.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); m.addDeserializer(User.class, new UserDeserializer()); - m.addDeserializer(ApiStatus.class, new ApiStatusDeserializer()); + // §11 parallel-arrays decoding via the declarative factory (issue #10): no hand-written + // JsonDeserializer subclass — just the column list, row builder, and container wrapper. The + // pattern scales to every future parallel-arrays endpoint (stocks/candles, options/chain, …) + // without copy-pasting the ~30-line deserializer skeleton. + m.addDeserializer( + ApiStatus.class, + ParallelArrays.listDeserializer( + List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"), + row -> + new ServiceStatus( + row.text("service"), + row.text("status"), + row.bool("online"), + row.dbl("uptimePct30d"), + row.dbl("uptimePct90d"), + MarketDataDates.marketTimeFromEpochSecond(row.lng("updated"))), + ApiStatus::new)); return m; } diff --git a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java index b74f603..75b23af 100644 --- a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java +++ b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java @@ -317,7 +317,69 @@ void nodeAccessorReturnsNullJsonNodeVerbatimForCustomHandling() throws IOExcepti assertThat(rows).containsExactly(true); } + // ---------- listDeserializer factory (issue #10) ---------- + + @Test + void listDeserializerProducesJacksonDeserializerWiringTheZipPipeline() throws IOException { + // The factory replaces hand-written JsonDeserializer subclasses for parallel-arrays endpoints + // (issue #10). Each new endpoint declares only its fields, row builder, and wrapper — the + // zip + tree-read + wrap plumbing is shared. + com.fasterxml.jackson.databind.JsonDeserializer deser = + ParallelArrays.listDeserializer( + List.of("symbol", "price"), + row -> new Record(row.text("symbol"), row.dbl("price"), false, 0), + Container::new); + + // Register on a fresh ObjectMapper and round-trip a wire-shaped payload. + com.fasterxml.jackson.databind.ObjectMapper m = + new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.module.SimpleModule module = + new com.fasterxml.jackson.databind.module.SimpleModule("test"); + module.addDeserializer(Container.class, deser); + m.registerModule(module); + + Container c = + m.readValue( + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"],\"price\":[150.0,400.0]}", + Container.class); + + assertThat(c.rows()).hasSize(2); + assertThat(c.rows().get(0).symbol()).isEqualTo("AAPL"); + assertThat(c.rows().get(1).price()).isEqualTo(400.0); + } + + @Test + void listDeserializerHonorsEnvelopeShortCircuits() throws IOException { + // The factory delegates structural validation to zip(): envelope errors and no_data + // short-circuit consistently regardless of which factory call instantiated the deserializer. + com.fasterxml.jackson.databind.JsonDeserializer deser = + ParallelArrays.listDeserializer( + List.of("symbol"), row -> new Record(row.text("symbol"), 0, false, 0), Container::new); + + com.fasterxml.jackson.databind.ObjectMapper m = + new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.module.SimpleModule module = + new com.fasterxml.jackson.databind.module.SimpleModule("test"); + module.addDeserializer(Container.class, deser); + m.registerModule(module); + + // no_data → empty list, wrapped in Container. + Container empty = m.readValue("{\"s\":\"no_data\"}", Container.class); + assertThat(empty.rows()).isEmpty(); + + // error envelope → JsonMappingException bubbles up through Jackson. + assertThatThrownBy(() -> m.readValue("{\"s\":\"error\",\"errmsg\":\"boom\"}", Container.class)) + .isInstanceOf(com.fasterxml.jackson.databind.JsonMappingException.class) + .hasMessageContaining("boom"); + } + // ---------- helper record ---------- private record Record(String symbol, double price, boolean active, long updated) {} + + /** + * Container wrapper for the {@link + * #listDeserializerProducesJacksonDeserializerWiringTheZipPipeline} test. + */ + private record Container(List rows) {} } From 2604844894cb86cb7e2a86b3949125751292c3c7 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 18:23:56 -0300 Subject: [PATCH 49/57] add HttpTransport test --- .../com/marketdata/sdk/HttpTransportTest.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index ce35dce..259a6c4 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -914,6 +914,70 @@ void selfReferentialBypassDoesNotLeakToOtherEndpoints() throws Exception { assertThat(client.captured).hasSize(1); } + // ---------- §12 concurrency limit (50 in-flight) ---------- + + /** + * §12 mandates a 50-request concurrency cap on in-flight HTTP work. This test pins the value AND + * verifies the gating happens at the {@code transport.executeAsync} entry point — not just inside + * {@link HttpDispatcher} in isolation — so a future refactor that bypasses the dispatcher (or + * accidentally instantiates a parallel pool elsewhere) breaks the assertion. + * + *

    Determinism: {@link TestHttpClients.Controllable} hands out fresh pending futures from + * {@code sendAsync}, so 50 dispatches fill the {@code pending} list and the 51st is forced onto + * the semaphore's slow path. Completing the in-flight set in two passes (the second covers the + * 51st request, which re-enters {@code sendAsync} as permits transfer through release()) drains + * the system back to a fully-available pool. + */ + @Test + void respectsGlobalConcurrencyLimitOfFifty() { + // Pin the constant — a silent edit that drops it (or hikes it past 50) would otherwise pass + // every existing test while quietly violating §12. + assertThat(HttpTransport.CONCURRENCY_LIMIT).isEqualTo(50); + + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + dispatcher, + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + + List> calls = new ArrayList<>(); + for (int i = 0; i < 51; i++) { + calls.add(transport.executeAsync(RequestSpec.get("markets/status").build())); + } + + // 50 reached the wire; the 51st is parked in the semaphore queue. + assertThat(client.pendingCount()).isEqualTo(50); + assertThat(dispatcher.queueLength()).isOne(); + assertThat(dispatcher.availablePermits()).isZero(); + + HttpResponse ok = + TestHttpClients.response( + 200, + new byte[0], + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/v1/markets/status/")); + + // Pass 1: complete the original 50. Each release transfers to the queued 51st, which + // re-enters sendAsync and lands a fresh pending future. The 49 remaining releases bump the + // available counter. + client.completeAll(ok); + // Pass 2: complete the 51st's now-pending sendFuture so its dispatch chain settles too. + client.completeAll(ok); + + for (CompletableFuture c : calls) { + assertThat(c).isCompleted(); + } + assertThat(dispatcher.availablePermits()).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); + assertThat(dispatcher.queueLength()).isZero(); + } + // ---------- stub HttpClient ---------- /** From a23b056cb89ed5b0ee32f23193cdd3d44623b0b6 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 19:02:42 -0300 Subject: [PATCH 50/57] fixes --- .../com/marketdata/sdk/HttpTransport.java | 5 ++- .../marketdata/sdk/JsonResponseParser.java | 10 ++++- .../java/com/marketdata/sdk/Response.java | 11 +++-- src/main/java/com/marketdata/sdk/Tokens.java | 9 +++- .../sdk/JsonResponseParserTest.java | 32 +++++++++++++++ .../java/com/marketdata/sdk/ResponseTest.java | 41 +++++++++++++++++-- .../java/com/marketdata/sdk/TokensTest.java | 20 ++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index b653d37..bb7c440 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -326,7 +326,10 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI } // Mapper only returns null for 2xx, which the branch above already handled. Belt & // suspenders for the impossible case so a future mapper edit can't silently swallow. - throw new ServerError("Unmapped status " + status + " from " + uri, context); + // §16: route the URI through safeUri so getMessage() — accessible to any consumer that + // logs the exception — never carries query strings (token, account_id, symbols, …). + throw new ServerError( + "Unmapped status " + status + " from " + HttpDispatcher.safeUri(uri), context); } private URI buildUri(RequestSpec spec) { diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 5b714b5..4439343 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -59,8 +59,16 @@ T parse(HttpResponseEnvelope env, Class type) { ErrorContext context = ErrorContext.forResponse( env.url().toString(), env.statusCode(), env.requestId(), clock.instant()); + // §16: getMessage() is consumer-accessible and routinely logged. Strip query strings so + // tokens/account_ids/symbols never persist through this surface. The full URI remains + // available on the ErrorContext for callers with the right discretion. throw new ParseError( - "Failed to decode response from " + env.url() + ": " + e.getMessage(), context, e); + "Failed to decode response from " + + HttpDispatcher.safeUri(env.url()) + + ": " + + e.getMessage(), + context, + e); } } } diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java index a7d03a7..6e13e82 100644 --- a/src/main/java/com/marketdata/sdk/Response.java +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -118,6 +118,13 @@ public void saveToFile(Path path) { } } + /** + * Log-safe representation: status, format, byte count, and the request URL with the query string + * redacted (§16 — token, account_id, symbol queries must not persist through {@code toString}). + * {@code data} is intentionally omitted: consumers that need the payload have {@link #data()}; + * embedding it here would let a routine {@code log.info(response)} leak a {@code RequestHeaders} + * map (Authorization, client IP) or whatever else the future resource models carry. + */ @Override public String toString() { return "Response[status=" @@ -127,9 +134,7 @@ public String toString() { + ", bytes=" + rawBody.length + ", url=" - + requestUrl - + ", data=" - + data + + HttpDispatcher.safeUri(requestUrl) + "]"; } } diff --git a/src/main/java/com/marketdata/sdk/Tokens.java b/src/main/java/com/marketdata/sdk/Tokens.java index e6f4cd8..4e101cf 100644 --- a/src/main/java/com/marketdata/sdk/Tokens.java +++ b/src/main/java/com/marketdata/sdk/Tokens.java @@ -6,8 +6,15 @@ final class Tokens { private static final String REDACTED = "***…***"; + /** + * Redact a token for log/diagnostic output. Returns {@code ***…***} alone when the token is + * absent or short enough that exposing the trailing 4 characters would reveal most of the value + * (length ≤ 8 — at 4 chars the suffix is the whole token; at 5–7 it's 57–80%). Only tokens with + * >8 characters get the {@code ***…***ABCD} form, which is enough material to disambiguate + * which token is in use without leaking it. + */ static String redact(@Nullable String token) { - if (token == null || token.length() < 4) { + if (token == null || token.length() <= 8) { return REDACTED; } return REDACTED + token.substring(token.length() - 4); diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 749bb95..2d5280f 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -353,6 +353,38 @@ void malformedJsonRaisesParseErrorCarryingResponseContext() { }); } + /** + * Issue #16: {@code getMessage()} is consumer-accessible — anything embedded in it ends up in + * logs the moment a consumer does the obvious {@code log.error(ex.getMessage())}. Query strings + * carry tokens, account ids, and queried symbols; §16 requires that they not leak through this + * surface. The full URI remains on {@link ParseError#getRequestUrl()} for callers that need it. + */ + @Test + void parseErrorMessageRedactsQueryString() { + JsonResponseParser parser = parserWithUtilitiesModule(); + HttpResponseEnvelope envWithQuery = + new HttpResponseEnvelope( + "{not-json".getBytes(), + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/stocks/quotes/?token=secret-xyz&symbol=AAPL")); + + assertThatThrownBy(() -> parser.parse(envWithQuery, RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .satisfies( + t -> { + ParseError err = (ParseError) t; + // Message redacts the query. + assertThat(err.getMessage()).doesNotContain("secret-xyz").doesNotContain("AAPL"); + assertThat(err.getMessage()).contains("?…"); + // getRequestUrl() also redacts (pre-existing policy on MarketDataException). + assertThat(err.getRequestUrl()).doesNotContain("secret-xyz"); + // The raw context retains the full URI for diagnostic use that won't be persisted. + assertThat(err.getContext().requestUrl()).contains("token=secret-xyz"); + }); + } + // ---------- §9 / ADR-007: parser is resource-agnostic ---------- /** diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java index 3f49c27..866789b 100644 --- a/src/test/java/com/marketdata/sdk/ResponseTest.java +++ b/src/test/java/com/marketdata/sdk/ResponseTest.java @@ -129,17 +129,52 @@ void saveToFileWrapsIoFailuresInUncheckedIoException(@TempDir Path tmp) { // ---------- toString ---------- @Test - void toStringIncludesStatusFormatAndUrl() { + void toStringIncludesStatusFormatBytesAndUrl() { Response r = Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); String repr = r.toString(); + // safeUri emits the path only (its contract): host/scheme are uninteresting in a log line and + // omitting them mirrors what HttpDispatcher already does for ambient request logs. assertThat(repr) .contains("status=200") .contains("format=json") .contains("bytes=4") - .contains("http://x/y") - .contains("payload"); + .contains("url=/y"); + } + + /** + * Issue #38: {@code toString} is a routine logging surface. The typed payload may carry sensitive + * content (e.g. a {@code RequestHeaders} map with {@code authorization} or client IPs), so it + * must not be embedded. Consumers that want the payload have {@link Response#data()}. + */ + @Test + void toStringDoesNotIncludeDataPayload() { + Response r = + Response.wrap( + "sensitive-payload-do-not-leak", + env("body".getBytes(), 200, "http://x/y"), + Format.JSON); + + assertThat(r.toString()).doesNotContain("sensitive-payload-do-not-leak"); + } + + /** + * Issue #38 + §16: query strings (tokens, account_ids, symbols) must not survive through {@code + * toString}. The full URI is still available via {@link Response#requestUrl()}. + */ + @Test + void toStringRedactsQueryStringInUrl() { + Response r = + Response.wrap( + "data", + env("body".getBytes(), 200, "http://x/quotes/?token=secret&symbol=AAPL"), + Format.JSON); + + String repr = r.toString(); + + assertThat(repr).doesNotContain("secret").doesNotContain("AAPL"); + assertThat(repr).contains("?…"); } } diff --git a/src/test/java/com/marketdata/sdk/TokensTest.java b/src/test/java/com/marketdata/sdk/TokensTest.java index fcaf9d5..2ba628a 100644 --- a/src/test/java/com/marketdata/sdk/TokensTest.java +++ b/src/test/java/com/marketdata/sdk/TokensTest.java @@ -25,9 +25,25 @@ void redact_returns_marker_for_tokens_shorter_than_four_chars() { assertThat(Tokens.redact("abc")).isEqualTo(REDACTED); } + /** + * Issue #24: tokens of length ≤ 8 are fully redacted — emitting the last 4 chars would expose + * 50%–100% of the value. Sandbox/demo keys are exactly this short, and the SDK promises (§16) + * never to log a token verbatim. Above 8 chars the trailing 4 give consumers enough material to + * disambiguate which key is loaded without revealing it. + */ @Test - void redact_appends_full_token_when_exactly_four_chars() { - assertThat(Tokens.redact("abcd")).isEqualTo(REDACTED + "abcd"); + void redact_returns_marker_only_for_tokens_eight_or_shorter() { + assertThat(Tokens.redact("abcd")).isEqualTo(REDACTED); // len=4: would have been 100% leak + assertThat(Tokens.redact("abcde")).isEqualTo(REDACTED); // 80% leak + assertThat(Tokens.redact("abcdef")).isEqualTo(REDACTED); // 67% + assertThat(Tokens.redact("abcdefg")).isEqualTo(REDACTED); // 57% + assertThat(Tokens.redact("abcdefgh")).isEqualTo(REDACTED); // 50% + } + + @Test + void redact_appends_last_four_only_above_length_eight() { + // Boundary: length 9 is the first that gets the trailing-4 form. + assertThat(Tokens.redact("abcdefghi")).isEqualTo(REDACTED + "fghi"); } @Test From 886d11fdc836c7348cb19bde155c4d31d2256c60 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 19:15:40 -0300 Subject: [PATCH 51/57] fixes --- .../java/com/marketdata/sdk/RetryPolicy.java | 63 ++++++++++++++- .../java/com/marketdata/sdk/StatusCache.java | 25 +++++- .../com/marketdata/sdk/RetryPolicyTest.java | 79 +++++++++++++++++++ .../com/marketdata/sdk/StatusCacheTest.java | 72 ++++++++++++++++- 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java index b1137a6..3ad2de6 100644 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -5,6 +5,7 @@ import com.marketdata.sdk.exception.ServerError; import java.io.IOException; import java.time.Duration; +import java.util.logging.Logger; /** * Decides which failures get retried and how long to wait between attempts. Per SDK requirements @@ -29,6 +30,19 @@ */ final class RetryPolicy { + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + + /** + * Issue #21: hard cap on the server-supplied {@code Retry-After} the SDK will honor for its + * automatic retry. A compromised or buggy backend that emits {@code Retry-After: 9999999999} + * would otherwise freeze the next attempt for ~292 billion years inside the {@code + * delayedExecutor}. Above this threshold the SDK logs a warning and falls back to its calculated + * exponential backoff — the server's hint is still visible to consumers via {@code + * ServerError.getRetryAfter()} so they can decide on their own (e.g. surface to humans, schedule + * a real cool-off) without the SDK silently holding a thread for hours. + */ + static final Duration MAX_RETRY_AFTER = Duration.ofMinutes(10); + private final int maxAttempts; private final Duration initialBackoff; private final Duration maxBackoff; @@ -78,7 +92,21 @@ Duration backoffDelay(Throwable cause, int attempt) { if (cause instanceof ServerError server) { Duration override = server.getRetryAfter().orElse(null); if (override != null) { - return override; + if (override.compareTo(MAX_RETRY_AFTER) > 0) { + // Issue #21: server-supplied delay exceeds the SDK's hard cap. Log and fall through + // to the calculated exponential backoff. The unbounded value is still preserved on the + // ServerError instance for consumer inspection — only the SDK's own automatic wait is + // capped. + LOGGER.warning( + () -> + "Server-supplied Retry-After of " + + override.toSeconds() + + "s exceeds cap of " + + MAX_RETRY_AFTER.toSeconds() + + "s; ignoring and using calculated exponential backoff"); + } else { + return override; + } } } return backoffDelay(attempt); @@ -116,7 +144,13 @@ private static boolean isRetriable(Throwable cause) { // ConnectException, HttpTimeoutException, ...) and sync-throws from httpClient.sendAsync // (NPE, IllegalArgumentException — bugs, not network). Retry only the former; the latter // is deterministic and just burns the backoff for the same crash. - return net.getCause() instanceof IOException; + // + // Issue #15: walk the full cause chain rather than only checking the direct cause. JDK + // HttpClient under HTTP/2 multiplexing (and some JDK versions) can present an IOException + // nested under an ExecutionException/CompletionException wrapper that HttpDispatcher's + // single-level unwrap doesn't peel. Without the walk those legitimate transport failures + // fall out of retry silently — the SDK loses §9 resilience only under load. + return hasIoExceptionInCauseChain(net.getCause()); } if (cause instanceof ServerError server) { int status = server.getStatusCode(); @@ -129,4 +163,29 @@ private static boolean isRetriable(Throwable cause) { // never retry 4xx, and ParseError is deterministic. return false; } + + /** + * Walks {@code t}'s {@link Throwable#getCause()} chain looking for an {@link IOException}. + * Returns {@code false} for a {@code null} root. The walk caps at a defensive depth and tracks + * visited frames so a malformed cycle (theoretically impossible per {@link Throwable}'s contract, + * but cheap to guard) cannot spin the retry decision. + */ + private static boolean hasIoExceptionInCauseChain( + @org.jspecify.annotations.Nullable Throwable t) { + Throwable current = t; + int depth = 0; + while (current != null && depth < 16) { + if (current instanceof IOException) { + return true; + } + Throwable next = current.getCause(); + if (next == current) { + // Self-cycle — getCause() of an exception that wraps itself. Bail out. + return false; + } + current = next; + depth++; + } + return false; + } } diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java index 2d03954..6395901 100644 --- a/src/main/java/com/marketdata/sdk/StatusCache.java +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -66,6 +66,14 @@ Decision check(URI uri) { snap == null || Duration.between(snap.fetchedAt, now).compareTo(REFRESH_THRESHOLD) >= 0; if (refreshNeeded) { triggerRefresh(); + // Issue #19: re-read after triggerRefresh in case the fetcher completed synchronously (a + // test stub returning CompletableFuture.completedFuture, or any synchronous-by-design + // implementation). Without this re-read, a cold start always answers ALLOW — the local + // `snap` reference is still the null we captured above, even though `snapshot.get()` may + // now hold a fresh value populated by the synchronous whenComplete that ran inside + // triggerRefresh. The bug surfaces in production as "first request after startup against a + // known-offline service always burns the retry budget". + snap = snapshot.get(); } boolean usable = snap != null && Duration.between(snap.fetchedAt, now).compareTo(EXPIRY) < 0; @@ -113,12 +121,21 @@ void triggerRefresh() { /** * Find the cached status for the service whose path is the longest prefix of {@code uri}'s path. * Returns {@code null} when no service matches. + * + *

    Issue #18: keys are normalized to end with {@code /} at snapshot-construction time and we + * also append {@code /} to the input path before comparing. Without this, a key {@code /v1/stock} + * would falsely match {@code /v1/stocks/quotes/AAPL/} (path component boundary not respected) and + * one malformed/truncated server-side entry could block retries for an unrelated service. */ private static @Nullable String lookupService(Snapshot snap, URI uri) { String path = uri.getPath(); + if (path == null) { + return null; + } + String normalizedPath = path.endsWith("/") ? path : path + "/"; String bestKey = null; for (String key : snap.serviceToStatus.keySet()) { - if (path.startsWith(key) && (bestKey == null || key.length() > bestKey.length())) { + if (normalizedPath.startsWith(key) && (bestKey == null || key.length() > bestKey.length())) { bestKey = key; } } @@ -138,7 +155,11 @@ private record Snapshot(Instant fetchedAt, Map serviceToStatus) static Snapshot from(ApiStatus apiStatus, Instant fetchedAt) { Map map = new HashMap<>(apiStatus.services().size()); for (ServiceStatus s : apiStatus.services()) { - map.put(s.service(), s.status()); + // Issue #18: store with a trailing slash so path-boundary matching is correct. The + // server's `service` field is not contractually trailing-slashed; canonicalizing here + // (rather than in lookupService) keeps the hot read path simple. + String key = s.service().endsWith("/") ? s.service() : s.service() + "/"; + map.put(key, s.status()); } return new Snapshot(fetchedAt, Map.copyOf(map)); } diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java index c15c37d..fc7af18 100644 --- a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java +++ b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java @@ -59,6 +59,49 @@ void networkErrorsWrappingNonIoCauseAreNotRetriable() { assertThat(DEFAULTS.shouldRetry(syncThrow, 0)).isFalse(); } + /** + * Issue #15: under HTTP/2 multiplexing (and certain JDK builds) a real IOException can be + * delivered nested two levels deep — typically {@code ExecutionException → IOException} — because + * HttpDispatcher's unwrap peels only one CompletionException layer. The retry decision must walk + * the chain or this resilience gap surfaces only in production under load. + */ + @Test + void networkErrorsWithIoExceptionNestedInCauseChainAreRetriable() { + NetworkError nested = + new NetworkError( + "wrapped failure", + ctxNoResponse(), + new java.util.concurrent.ExecutionException( + new java.io.IOException("connect reset by peer"))); + assertThat(DEFAULTS.shouldRetry(nested, 0)).isTrue(); + } + + @Test + void networkErrorsWithDeeplyNestedIoExceptionAreRetriable() { + // Three wrapper layers — the walk must still find the IOException at the bottom. + NetworkError deep = + new NetworkError( + "thrice wrapped", + ctxNoResponse(), + new java.util.concurrent.CompletionException( + new java.util.concurrent.ExecutionException( + new RuntimeException(new java.io.IOException("socket closed"))))); + assertThat(DEFAULTS.shouldRetry(deep, 0)).isTrue(); + } + + @Test + void networkErrorsWithDeeplyNestedNonIoCauseAreNotRetriable() { + // Regression guard: the walk must not classify any nested cause as IO — it has to find an + // IOException specifically. A chain of non-IO causes still surfaces as non-retriable. + NetworkError nonIo = + new NetworkError( + "thrice wrapped non-IO", + ctxNoResponse(), + new java.util.concurrent.ExecutionException( + new RuntimeException(new IllegalArgumentException("bug")))); + assertThat(DEFAULTS.shouldRetry(nonIo, 0)).isFalse(); + } + @Test void status500IsNotRetriable() { ServerError err = new ServerError("500", ctxWithStatus(500)); @@ -206,6 +249,42 @@ void backoffWithCauseIgnoresRetryAfterOnNonServerErrorCauses() { assertThat(DEFAULTS.backoffDelay(net, 1)).isEqualTo(Duration.ofSeconds(2)); } + /** + * Issue #21: a server emitting an unbounded {@code Retry-After} (compromised, buggy, or a + * malicious upstream proxy) must not freeze the SDK's automatic retry for an unreasonable + * duration. Above {@link RetryPolicy#MAX_RETRY_AFTER} the SDK falls back to its calculated + * exponential backoff. The server's hint itself is still preserved on {@code ServerError} so + * consumers can decide what to do with it. + */ + @Test + void backoffIgnoresRetryAfterAboveCapAndFallsBackToExponential() { + ServerError pathological = + new ServerError( + "503", + ctxWithStatus(503), + null, + // Cap is 10 min; this would block the SDK for 1 day if honored verbatim. + Duration.ofDays(1)); + + // attempt 0 would be 1s exponential; that's what we expect since the 1d Retry-After is + // above the cap and ignored. + assertThat(DEFAULTS.backoffDelay(pathological, 0)).isEqualTo(Duration.ofSeconds(1)); + // attempt 3 would be 8s exponential. + assertThat(DEFAULTS.backoffDelay(pathological, 3)).isEqualTo(Duration.ofSeconds(8)); + // The original ServerError still carries the raw value — consumers see what the server said. + assertThat(pathological.getRetryAfter()).contains(Duration.ofDays(1)); + } + + @Test + void backoffHonorsRetryAfterRightAtTheCap() { + // Boundary: exactly MAX_RETRY_AFTER (10 min) is honored verbatim — only values strictly above + // the cap fall back to exponential. + ServerError atCap = + new ServerError("503", ctxWithStatus(503), null, RetryPolicy.MAX_RETRY_AFTER); + + assertThat(DEFAULTS.backoffDelay(atCap, 0)).isEqualTo(RetryPolicy.MAX_RETRY_AFTER); + } + @Test void rejectsNonPositiveMaxAttempts() { org.assertj.core.api.Assertions.assertThatThrownBy( diff --git a/src/test/java/com/marketdata/sdk/StatusCacheTest.java b/src/test/java/com/marketdata/sdk/StatusCacheTest.java index 71f77aa..4047764 100644 --- a/src/test/java/com/marketdata/sdk/StatusCacheTest.java +++ b/src/test/java/com/marketdata/sdk/StatusCacheTest.java @@ -134,11 +134,19 @@ void agingCacheServesAndKicksAsyncRefresh() { void expiredCacheReturnsAllowAndRefreshes() { FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); AtomicInteger refreshCalls = new AtomicInteger(); + // First call returns synchronously to populate the cache; subsequent calls return a future + // that never completes — so the refresh is genuinely in-flight when we measure the decision, + // matching real-world async behavior. With the #19 fix, a synchronous-completing refresh + // would otherwise immediately overwrite the stale snapshot and the "stale → unknown → ALLOW" + // invariant would be untestable. StatusCache cache = new StatusCache( () -> { - refreshCalls.incrementAndGet(); - return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + int n = refreshCalls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + return new CompletableFuture<>(); // never completes }, clock); cache.triggerRefresh(); @@ -278,4 +286,64 @@ void uriWithNoMatchingServiceTreatsAsUnknown() { assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); } + + /** + * Issue #18: a truncated or malformed service entry like {@code /v1/stock} must NOT match the + * unrelated {@code /v1/stocks/...} endpoint. Without path-boundary normalization, one bad + * snapshot entry could block retries across an entire family of services. + */ + @Test + void shorterServiceKeyDoesNotFalselyMatchLongerPath() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + // Note: `/v1/stock` (no trailing slash, no `s`) — a truncated entry. With the trailing-slash + // normalization the cache key becomes `/v1/stock/`, which is NOT a path prefix of + // `/v1/stocks/quotes/AAPL/`. + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/stock", "offline")), clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } + + /** + * Issue #18 positive case: a key that IS a real path-prefix still matches even when the server + * emits it without the trailing slash. The normalization is two-way (key + lookup path). + */ + @Test + void serverKeyWithoutTrailingSlashStillMatchesGenuinePrefix() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/stocks/quotes", "offline")), + clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + /** + * Issue #19: with a synchronous fetcher, the snapshot is populated before {@code check} returns, + * so a cold start against a known-offline service must already see the BLOCK decision on the + * first call. Pre-fix the local {@code snap} reference was captured before {@code triggerRefresh} + * and never re-read, so the first call always answered ALLOW — burning the retry budget against a + * service the API had just reported offline. + */ + @Test + void coldStartWithSyncFetcherUsesFreshSnapshotOnFirstCall() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")), clock); + + // No pre-warm — this is the very first check. The fetcher completes synchronously inside + // triggerRefresh and must populate the snapshot in time for the same call to use it. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } } From dcc42be64d8abead78eebf8ae711c46af0196641 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 19:37:06 -0300 Subject: [PATCH 52/57] fixes --- .../com/marketdata/sdk/Configuration.java | 32 +++++++ .../marketdata/sdk/JsonResponseParser.java | 15 ++++ .../com/marketdata/sdk/MarketDataClient.java | 29 ++++++- .../com/marketdata/sdk/ConfigurationTest.java | 83 +++++++++++++++++++ .../sdk/JsonResponseParserTest.java | 33 ++++++++ .../marketdata/sdk/MarketDataClientTest.java | 47 +++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index 6b4b88d..aaa2bf3 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -65,6 +65,7 @@ static Configuration resolve( String normalizedApiVersion = normalizeApiVersion(apiVersion); validateBaseUrl(normalizedBaseUrl); validateApiVersion(normalizedApiVersion); + validateApiKey(apiKey); return new Configuration( apiKey, normalizedBaseUrl, normalizedApiVersion, loggingLevel, dateFormat); } @@ -172,6 +173,37 @@ static void validateBaseUrl(String baseUrl) { * to send and the server will reject it; the regex's job is just to keep us from emitting * malformed URLs). */ + /** + * Issue #23: reject API keys with characters that would later be rejected by {@link + * java.net.http.HttpRequest.Builder#header} ({@code IllegalArgumentException} on CR/LF) or carry + * the smell of a copy-paste mishap (embedded NUL, control chars, high-bit bytes). Failing here + * gives the caller a clear message at construct time; without the check, a token loaded from a + * {@code .env} with stray {@code \r\n} surfaces as a generic {@code IllegalArgumentException} + * from {@code HttpClient} on the very first request — far from the actual configuration source. + * + *

    Rule: every character must be printable ASCII ({@code [0x20, 0x7E]}). That covers every + * legitimate token shape ({@code letters/digits/.-_+=/}) while ruling out CR/LF/NUL, DEL, and any + * accidentally pasted high-bit byte from a non-UTF-8 file. Demo mode (apiKey == null) is + * preserved untouched. + */ + static void validateApiKey(@Nullable String apiKey) { + if (apiKey == null) { + return; // demo mode — no token to validate + } + for (int i = 0; i < apiKey.length(); i++) { + char c = apiKey.charAt(i); + if (c < 0x20 || c > 0x7E) { + throw new IllegalArgumentException( + "apiKey contains an invalid character at offset " + + i + + " (code point 0x" + + Integer.toHexString(c) + + "). Tokens must be printable ASCII; check for stray CR/LF or non-UTF-8 bytes in" + + " the source (env var, .env file, or constructor argument)."); + } + } + } + static void validateApiVersion(String apiVersion) { if (apiVersion.isEmpty()) { throw new IllegalArgumentException( diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 4439343..76bc8ed 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -53,6 +53,21 @@ void registerModule(Module module) { * the consumer's diagnostics. */ T parse(HttpResponseEnvelope env, Class type) { + // Issue #29: a zero-length body surfaces from Jackson as a generic "No content to map" + // MismatchedInputException — diagnostically thin, often confusing in the presence of a + // body-stripping proxy. Pre-check so the failure carries a precise, actionable message that + // names the actual symptom ("empty response body") instead of looking like a corruption. + if (env.body().length == 0) { + ErrorContext context = + ErrorContext.forResponse( + env.url().toString(), env.statusCode(), env.requestId(), clock.instant()); + throw new ParseError( + "Empty response body from " + + HttpDispatcher.safeUri(env.url()) + + " — server returned 0 bytes (a proxy may have stripped the payload, or the" + + " endpoint replied without one)", + context); + } try { return mapper.readValue(env.body(), type); } catch (IOException e) { diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 7b6db83..d90e1f9 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -56,8 +56,18 @@ public MarketDataClient( // — emitting WARNINGs there would land on an unconfigured JUL logger (wrong format, // possibly invisible), undermining the breadcrumb the WARNING exists to provide. List pendingWarnings = new ArrayList<>(); - this.config = - Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); + try { + this.config = + Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); + } catch (RuntimeException e) { + // Issue #25: if resolve fails (typically IAE — invalid baseUrl/apiVersion/apiKey from the + // cascade), the consumer would otherwise lose any .env warnings collected so far. That + // hides the real story: e.g. "your .env was unreadable, so the missing baseUrl fell + // through to a default that conflicts with your explicit apiVersion". Attach each warning + // as a suppressed exception so the diagnostic trail surfaces in the same stack trace. + attachWarningsAsSuppressed(e, pendingWarnings); + throw e; + } MarketDataLogging.configure(config.loggingLevel()); for (DotEnvLoader.Warning w : pendingWarnings) { LOGGER.log(w.level(), w.message(), w.cause()); @@ -96,6 +106,21 @@ public MarketDataClient( } } + /** + * Attach each pending {@code .env} warning to {@code primary} as a suppressed exception so the + * diagnostic trail survives a configuration-resolve failure. {@link Throwable#getCause()} would + * conflict with the actual cause of the IAE; suppressed is the right surface for "additional + * context the consumer should see alongside the primary failure". + */ + private static void attachWarningsAsSuppressed( + RuntimeException primary, List warnings) { + for (DotEnvLoader.Warning w : warnings) { + Throwable wrapper = + new RuntimeException("[.env " + w.level() + "] " + w.message(), w.cause()); + primary.addSuppressed(wrapper); + } + } + /** System endpoints documented at the API root: {@code /headers/} (and more to come). */ public UtilitiesResource utilities() { return utilities; diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 160c52f..0ae3160 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -423,4 +423,87 @@ void resolve_validates_values_from_dotenv_too(@TempDir Path tmp) throws IOExcept .isThrownBy(() -> Configuration.resolve(null, null, null, NO_ENV, dotEnv)) .withMessageContaining("scheme http or https"); } + + // ---------- §16 / issue #23: apiKey character validation ---------- + + /** + * A token loaded from a .env file with a stray CRLF must be rejected at construction. Without + * this gate, the failure surfaces only at the first request as a cryptic IAE from {@code + * HttpRequest.Builder#header}, miles away from the actual source of the bad input. + */ + @Test + void resolve_rejects_apiKey_with_carriage_return(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve("good-prefix\rbad", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character") + .withMessageContaining("offset 11"); + } + + @Test + void resolve_rejects_apiKey_with_newline(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("token\nmore", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } + + @Test + void resolve_rejects_apiKey_with_tab(@TempDir Path tmp) { + // Tab (0x09) is below 0x20 — also rejected. Real tokens never contain tabs; if one appears + // it's a copy-paste artifact from a spreadsheet cell or formatted document. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("token\tmore", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } + + @Test + void resolve_rejects_apiKey_with_high_bit_byte(@TempDir Path tmp) { + // Non-ASCII (e.g. UTF-8 multi-byte) — almost always means the .env was decoded with the wrong + // charset and the original token is unusable anyway. Failing fast with a clear message beats + // a stream of authentication failures from the server. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("tokén-ABCD", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } + + @Test + void resolve_rejects_apiKey_with_nul_byte(@TempDir Path tmp) { + // A literal NUL (0x00) - far below the 0x20 floor; canonical "this token is corrupt". Built + // at runtime so the test source file does not carry an embedded NUL byte itself. + String tokenWithNul = "token" + (char) 0x00 + "more"; + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(tokenWithNul, null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } + + @Test + void resolve_accepts_apiKey_with_printable_ascii(@TempDir Path tmp) { + // Regression guard: tokens that legitimately use the full printable ASCII range + // (letters, digits, `.-_+/=` and friends) must not be rejected. + Configuration cfg = + Configuration.resolve("ABCdef-123_token.with+slashes/=", null, null, NO_ENV, noDotEnv(tmp)); + assertThat(cfg.apiKey()).isEqualTo("ABCdef-123_token.with+slashes/="); + } + + @Test + void resolve_does_not_validate_null_apiKey(@TempDir Path tmp) { + // Demo mode: no token at all is a supported cascade outcome; validation must not flag it. + Configuration cfg = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + assertThat(cfg.apiKey()).isNull(); + } + + /** + * The error message must NOT echo the token. The token's offset and the offending code point are + * enough for diagnostics; the token itself never appears in {@code getMessage()} (§16). + */ + @Test + void apiKey_validation_error_does_not_leak_token(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + "supersecret-prefix\rsuffix-leak", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageNotContaining("supersecret-prefix") + .withMessageNotContaining("suffix-leak"); + } } diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 2d5280f..5e9aa20 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -353,6 +353,39 @@ void malformedJsonRaisesParseErrorCarryingResponseContext() { }); } + /** + * Issue #29: a zero-length body must surface as a precise, actionable {@link ParseError} — "empty + * response body" — rather than Jackson's generic "No content to map" wrap. Proxies that strip + * bodies (some misconfigured corporate setups) are the canonical cause; consumers shouldn't have + * to read Jackson's stack trace to figure that out. + */ + @Test + void emptyBodyRaisesParseErrorWithExplicitMessage() { + JsonResponseParser parser = parserWithUtilitiesModule(); + HttpResponseEnvelope empty = + new HttpResponseEnvelope( + new byte[0], + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/headers/")); + + assertThatThrownBy(() -> parser.parse(empty, RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Empty response body") + .hasMessageContaining("0 bytes") + .satisfies( + t -> { + ParseError err = (ParseError) t; + // Context is still populated — consumers can correlate via requestId, see the + // status code, etc. + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + // No Jackson cause when we short-circuit at the empty-body check. + assertThat(err.getCause()).isNull(); + }); + } + /** * Issue #16: {@code getMessage()} is consumer-accessible — anything embedded in it ends up in * logs the moment a consumer does the obvious {@code log.error(ex.getMessage())}. Query strings diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index e2914e9..e1bc0c0 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -2,10 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; import com.marketdata.sdk.exception.MarketDataException; +import java.io.IOException; import java.net.ServerSocket; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -187,4 +191,47 @@ void quick_start_usage_resolves_real_environment_and_never_leaks_token() { } } } + + // ---------- issue #25: .env warnings survive resolve failure ---------- + + /** + * If {@link Configuration#resolve} throws (e.g. invalid baseUrl), any {@code .env} warnings + * collected before the throw used to be dropped — the constructor's replay loop runs only on the + * happy path. The fix attaches each warning as a suppressed exception so the IAE stack trace + * carries the breadcrumb (an unreadable {@code .env} could be the very reason the cascade fell + * back to a misconfigured default). + */ + @Test + void resolve_failure_attaches_pending_dotenv_warnings_as_suppressed(@TempDir Path tmp) + throws IOException { + // Build an unreadable .env so DotEnvLoader emits a "not readable" warning. + Path dotEnv = tmp.resolve(".env"); + Files.writeString(dotEnv, "MARKETDATA_TOKEN=irrelevant\n"); + boolean permsSupported = false; + try { + Files.setPosixFilePermissions(dotEnv, PosixFilePermissions.fromString("---------")); + permsSupported = true; + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem (rare on CI runners but possible on Windows) — skip the test cleanly + // by checking permission below. + } + org.junit.jupiter.api.Assumptions.assumeTrue( + permsSupported && !Files.isReadable(dotEnv), + "Test requires a filesystem that supports making files unreadable to the current user."); + + // Explicit baseUrl is invalid — resolve will throw IAE — AFTER the .env loader has fired its + // warning. Without the #25 fix, the warning vanishes; with the fix it surfaces as a + // suppressed exception on the IAE. + Throwable thrown = + catchThrowable( + () -> new MarketDataClient("any-token", "not-a-url", null, false, NO_ENV, dotEnv)); + + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + assertThat(thrown.getSuppressed()) + .as("the .env unreadable warning must be attached as a suppressed exception") + .isNotEmpty(); + assertThat(thrown.getSuppressed()[0]) + .hasMessageContaining(".env") + .hasMessageContaining("not readable"); + } } From 5aa3efee12d77a190eca191ddef12e2a2640464c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 19:57:20 -0300 Subject: [PATCH 53/57] add ishtml() --- src/main/java/com/marketdata/sdk/Format.java | 7 +++++- .../java/com/marketdata/sdk/Response.java | 22 ++++++++++++++----- .../java/com/marketdata/sdk/ResponseTest.java | 8 +++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/Format.java b/src/main/java/com/marketdata/sdk/Format.java index 4341114..972f057 100644 --- a/src/main/java/com/marketdata/sdk/Format.java +++ b/src/main/java/com/marketdata/sdk/Format.java @@ -11,7 +11,12 @@ */ enum Format { JSON("json", "application/json"), - CSV("csv", "text/csv"); + CSV("csv", "text/csv"), + // §13.5: the API can return HTML for endpoints like the marketing/error pages a misrouted + // request lands on; the spec requires `Response.isHtml()` to identify those responses without + // exposing the format enum itself. No resource façade ships HTML as a first-class output today, + // but the wire-level wiring (Accept header + ?format=html) is in place for when one does. + HTML("html", "text/html"); private final String wireValue; private final String mediaType; diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java index 6e13e82..3d254dc 100644 --- a/src/main/java/com/marketdata/sdk/Response.java +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -10,13 +10,14 @@ /** * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, - * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}), no-data detection - * ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} envelope convention), and - * {@link #saveToFile(Path)} for writing the raw body verbatim. + * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), + * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} + * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. * - *

    The {@link Format} enum is intentionally not exposed publicly (it has private values like - * {@code HTML} that consumers shouldn't depend on). Consumers query format via the boolean - * accessors. + *

    The {@link Format} enum is package-private — consumers query format via the boolean accessors + * rather than importing the enum. That keeps {@code Format} free to grow new values without + * breaking compiled consumer code (a {@code switch (response.format())} would otherwise be a + * source-compatibility hazard). * *

    Immutable. {@link #rawBody()} returns a defensive copy on every call. * @@ -95,6 +96,15 @@ public boolean isCsv() { return format == Format.CSV; } + /** + * Whether the response body is HTML — typically a misrouted request that landed on the API's + * web-server tier (a marketing or error page) rather than the API tier. Consumers can use this to + * short-circuit JSON-shaped parsing and log the {@link #rawBody()} for diagnosis. + */ + public boolean isHtml() { + return format == Format.HTML; + } + /** * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 * for that envelope (it is a successful "we have nothing for that query", not an error), so we diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java index 866789b..8f46b87 100644 --- a/src/test/java/com/marketdata/sdk/ResponseTest.java +++ b/src/test/java/com/marketdata/sdk/ResponseTest.java @@ -54,12 +54,20 @@ void formatDetectionExposesBooleansOnly() { // The Format enum itself is package-private; consumers only see the booleans. Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); + Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); assertThat(json.isJson()).isTrue(); assertThat(json.isCsv()).isFalse(); + assertThat(json.isHtml()).isFalse(); assertThat(csv.isCsv()).isTrue(); assertThat(csv.isJson()).isFalse(); + assertThat(csv.isHtml()).isFalse(); + + // §13.5: HTML detection — typically a misrouted request that landed on the web-server tier. + assertThat(html.isHtml()).isTrue(); + assertThat(html.isJson()).isFalse(); + assertThat(html.isCsv()).isFalse(); } // ---------- no-data ---------- From 71bc598bdd4a1303657828077676830eb18fa510 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 20 May 2026 20:00:01 -0300 Subject: [PATCH 54/57] StatusCahce leak --- .../com/marketdata/sdk/MarketDataClient.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index d90e1f9..e73231c 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -95,11 +95,27 @@ public MarketDataClient( "marketdata-sdk-java/" + Version.sdkVersion(), config.apiKey(), cacheRef::get); - JsonResponseParser parser = new JsonResponseParser(); - this.utilities = new UtilitiesResource(transport, parser); - cacheRef.set( - new StatusCache( - () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); + // Partial-construction guard: from here on the transport is a live AutoCloseable that holds + // the shared HttpClient and the 50-permit AsyncSemaphore. If any subsequent constructor + // throws (today none do, but a future change in UtilitiesResource / StatusCache could), + // the caller never receives a reference, their try-with-resources never fires, and the + // transport leaks until GC. Close it explicitly and surface the close failure (if any) as + // a suppressed exception on the primary cause — same pattern runStartupValidation already + // uses for the validation path. + try { + JsonResponseParser parser = new JsonResponseParser(); + this.utilities = new UtilitiesResource(transport, parser); + cacheRef.set( + new StatusCache( + () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); + } catch (Throwable t) { + try { + transport.close(); + } catch (Throwable closeFailure) { + t.addSuppressed(closeFailure); + } + throw t; + } if (validateOnStartup) { runStartupValidation(); From 747099b95e30084d3e7de7291956e111d2e476a1 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 26 May 2026 15:24:47 -0300 Subject: [PATCH 55/57] example added --- .gitignore | 5 + CLAUDE.md | 2 +- Makefile | 92 + docs/REFACTOR_REVIEW_GUIDE.md | 1602 +++++++++++++++++ examples/consumer-test/.gitignore | 17 + examples/consumer-test/README.md | 112 ++ examples/consumer-test/build.gradle.kts | 53 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/consumer-test/gradlew | 251 +++ examples/consumer-test/gradlew.bat | 94 + examples/consumer-test/settings.gradle.kts | 14 + .../marketdata/consumer/ConcurrencyApp.java | 93 + .../marketdata/consumer/DemoAndConfigApp.java | 197 ++ .../marketdata/consumer/ExceptionsApp.java | 241 +++ .../com/marketdata/consumer/LiveSmokeApp.java | 130 ++ .../marketdata/consumer/QuickstartApp.java | 161 ++ .../consumer/ResponseFeaturesApp.java | 147 ++ .../marketdata/consumer/RetryBehaviorApp.java | 222 +++ .../marketdata/consumer/shared/Console.java | 81 + .../consumer/shared/MockServerControl.java | 235 +++ examples/mock-server/README.md | 55 + examples/mock-server/requirements.txt | 2 + examples/mock-server/run.sh | 29 + examples/mock-server/server.py | 226 +++ .../com/marketdata/sdk/MarketDataClient.java | 6 +- .../com/marketdata/sdk/UtilitiesResource.java | 27 +- .../com/marketdata/sdk/utilities/User.java | 2 +- .../marketdata/sdk/MarketDataClientTest.java | 4 +- .../marketdata/sdk/UtilitiesResourceTest.java | 17 +- 30 files changed, 4098 insertions(+), 26 deletions(-) create mode 100644 Makefile create mode 100644 docs/REFACTOR_REVIEW_GUIDE.md create mode 100644 examples/consumer-test/.gitignore create mode 100644 examples/consumer-test/README.md create mode 100644 examples/consumer-test/build.gradle.kts create mode 100644 examples/consumer-test/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/consumer-test/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/consumer-test/gradlew create mode 100644 examples/consumer-test/gradlew.bat create mode 100644 examples/consumer-test/settings.gradle.kts create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java create mode 100644 examples/mock-server/README.md create mode 100644 examples/mock-server/requirements.txt create mode 100755 examples/mock-server/run.sh create mode 100644 examples/mock-server/server.py diff --git a/.gitignore b/.gitignore index 299483b..e8e6587 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ Thumbs.db .env .env.local +# Python (examples/mock-server) +.venv/ +__pycache__/ +*.pyc + # Logs / coverage *.log hs_err_pid* diff --git a/CLAUDE.md b/CLAUDE.md index 6eca75a..4aff09b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( **Already wired in:** - §1.1 client object — `MarketDataClient` with two public constructors: a no-arg one for production (everything from the cascade) and a 4-arg `(apiKey, baseUrl, apiVersion, validateOnStartup)` for tests and short-lived runtimes. All fields `final` (immutable). Default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor. - §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). `baseUrl` and `apiVersion` are normalized (trailing/leading slashes stripped) and validated (scheme http/https, host present, no query/fragment/user-info on `baseUrl`; `[A-Za-z0-9._-]+` on `apiVersion`) at resolution time, so misconfigured cascade inputs fail at construction with a clear message instead of producing malformed URLs later. -- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). `runStartupValidation` fires a single `GET /v1/user/` via `utilities.user(RetryPolicy.noRetry())`, skipping in demo mode; the no-retry policy keeps construction snappy on a slow/down API. +- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). `runStartupValidation` fires a single `GET /user/` (unversioned, like `/status/` and `/headers/`) via `utilities.validateAuth()` with `RetryPolicy.noRetry()`, skipping in demo mode; the no-retry policy keeps construction snappy on a slow/down API. - §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`. - §7 logging — `MarketDataLogging.configure(...)` installs `CanonicalLogFormatter` (the spec's `{timestamp} - {logger_name} - {level} - {message}` shape) on `com.marketdata.sdk` and applies the level from `MARKETDATA_LOGGING_LEVEL`. Detects consumer-pre-configured loggers (handler attached or level set) and backs off entirely so embedding the SDK doesn't clobber existing logging setups. - §8 rate-limit tracking — `RateLimitHeaders.parse` reads the four `x-api-ratelimit-*` headers per response (all-or-nothing: a partial header set returns null rather than emitting a snapshot with phantom zeros); `HttpTransport.latestRateLimits` keeps the latest snapshot, exposed via `client.getRateLimits()`. §10.3 preflight (`HttpTransport.checkRateLimitPreflight`) fails fast on `remaining=0`, but only while `now < reset` — once the reset window elapses the request goes through so the next response refreshes the snapshot. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0a2151b --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +# Convenience wrapper around ./gradlew and the consumer-test / mock-server. +# Run `make` (no args) or `make help` for the target list. + +CONSUMER_DIR := examples/consumer-test +MOCK_DIR := examples/mock-server + +.DEFAULT_GOAL := help + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +.PHONY: help +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m\033[0m\n\nTargets:\n"} \ + /^# ===/ { in_section = 1; next } \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \ + /^## ---/ { printf "\n\033[33m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) + @echo "" + @echo "Typical workflow:" + @echo " make publish # publish SDK to mavenLocal" + @echo " make mock-server # in another terminal" + @echo " make demo-config # in a third terminal" + +# --------------------------------------------------------------------------- +## --- SDK build --- +# --------------------------------------------------------------------------- + +.PHONY: build +build: ## Full build: unit tests + Spotless + JaCoCo (JDK 17) + ./gradlew build + +.PHONY: test +test: ## Unit tests only + ./gradlew test + +.PHONY: spotless +spotless: ## Apply code formatting + ./gradlew spotlessApply + +.PHONY: clean +clean: ## Clean all Gradle outputs (SDK + consumer-test) + ./gradlew clean + cd $(CONSUMER_DIR) && ./gradlew clean + +.PHONY: publish +publish: ## Publish SDK to ~/.m2 (prereq for any demo) + ./gradlew publishToMavenLocal + +# --------------------------------------------------------------------------- +## --- Mock server --- +# --------------------------------------------------------------------------- + +.PHONY: mock-server +mock-server: ## Start the FastAPI mock server (blocks, Ctrl+C to stop) + cd $(MOCK_DIR) && ./run.sh + +# --------------------------------------------------------------------------- +## --- Consumer demos (need `make publish` first) --- +# --------------------------------------------------------------------------- + +.PHONY: demo-quickstart +demo-quickstart: ## Idiomatic per-resource usage tour (live API; grows as resources land) + cd $(CONSUMER_DIR) && ./gradlew runQuickstart + +.PHONY: demo-live +demo-live: ## Live API smoke (needs MARKETDATA_TOKEN in examples/consumer-test/.env) + cd $(CONSUMER_DIR) && ./gradlew runLive + +.PHONY: demo-config +demo-config: ## Demo mode, cascade, validation (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig + +.PHONY: demo-exceptions +demo-exceptions: ## Every MarketDataException subtype (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runExceptions + +.PHONY: demo-retry +demo-retry: ## Retry, Retry-After, preflight (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runRetry + +.PHONY: demo-response +demo-response: ## Response surface features (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runResponse + +.PHONY: demo-concurrency +demo-concurrency: ## 50-permit semaphore (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runConcurrency + +.PHONY: demos-all +demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency diff --git a/docs/REFACTOR_REVIEW_GUIDE.md b/docs/REFACTOR_REVIEW_GUIDE.md new file mode 100644 index 0000000..996e4b7 --- /dev/null +++ b/docs/REFACTOR_REVIEW_GUIDE.md @@ -0,0 +1,1602 @@ +# Refactor Review Guide — `clean-architecture-restart` + +This guide walks a reviewer through the SDK foundation introduced on the `clean-architecture-restart` branch — a 54-commit, 92-file refactor. It is organized by **flow**, not by file. Each section names the parts of the code that participate in one slice of behavior, explains how they fit together, and calls out the non-obvious decisions. + +If you've never read this codebase, start with §1 (topology) → §2 (sync request flow) → §10 (subtle corners). That covers the load-bearing shape in under an hour. + +All file:line citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. + +## Table of contents + +- [Running it locally](#running-it-locally) +1. [SDK topology](#1-sdk-topology) +2. [Sync request flow end-to-end](#2-sync-request-flow-end-to-end) +3. [Construction flow](#3-construction-flow) +4. [Retry, `Retry-After`, and `StatusCache`](#4-retry-retry-after-and-statuscache) +5. [Rate-limit tracking + preflight](#5-rate-limit-tracking--preflight) +6. [Concurrency (`AsyncSemaphore`)](#6-concurrency-asyncsemaphore) +7. [`Response` + JSON parsing](#7-responset--json-parsing) +8. [Sealed exception hierarchy](#8-sealed-exception-hierarchy) +9. [Configuration & logging](#9-configuration--logging) +10. [Subtle corners (issue-driven)](#10-subtle-corners-issue-driven) + +--- + +## Running it locally + +Most sections below end with a **Verify** note pointing at a runnable demo. The demos live under `examples/consumer-test/` and use the scriptable mock server under `examples/mock-server/`. The repo's `Makefile` wraps both — running anything from the SDK root looks like this: + +```bash +make help # list all targets +make publish # publish SDK to ~/.m2 — prereq for any demo + +# Each mock-server demo needs the mock running in a separate terminal: +make mock-server # terminal 2 (blocks; Ctrl+C to stop) +make demo-config # terminal 3 — runs DemoAndConfigApp +make demo-retry # ...etc + +make demo-live # hits api.marketdata.app (needs MARKETDATA_TOKEN, no mock needed) +make demos-all # runs the five mock-server demos back-to-back +``` + +| Demo target | Section it exercises | +|---|---| +| `make demo-quickstart` | Idiomatic per-resource usage. Today `utilities` only; grows as `stocks`/`options`/`funds`/`markets` land. The "what does consumer code look like" demo. | +| `make demo-live` | End-to-end plumbing against the real API (§2 sync flow, §9 cascade, §8 rate-limit snapshot) | +| `make demo-config` | §3 construction, §9 configuration & logging | +| `make demo-exceptions` | §8 sealed exception hierarchy | +| `make demo-retry` | §4 retry + `Retry-After` + §5 preflight | +| `make demo-response` | §7 `Response` surface | +| `make demo-concurrency` | §6 `AsyncSemaphore` cap | + +Underneath, `make demo-X` is `cd examples/consumer-test && ./gradlew runX`, and `make publish` is `./gradlew publishToMavenLocal` — the Makefile is just convenience. If a target misbehaves, `make -n ` prints the underlying command without running it. + +The mock server (`make mock-server`) is a FastAPI app on `127.0.0.1:8765` that the demo apps POST scripted responses to via `/_admin/script`, then make a real SDK call against the same host. That's how scenarios like "503 → 503 → 200 recovers in ~3 s" are made deterministic. + +For unit tests: + +```bash +make test # ./gradlew test +make build # full build: tests + Spotless + JaCoCo +./gradlew -PtestJdk=21 test # JDK 21 unit tests (also works for 25) +``` + +The `-PtestJdk=N` toolchain flag has no Make wrapper — pass it to gradlew directly. CI runs the `{17, 21, 25}` matrix on push to `main`. + +--- + +## 1. SDK topology + +### 1.1 Package layout + +All internal classes live in the root package `com.marketdata.sdk` per [ADR-007](adr/ADR-007-internal-api-encapsulation.md). The "internal" boundary is enforced by Java's package-private visibility rather than a subpackage rename — classes that consumers must not reach drop the `public` modifier. Two subpackages exist: + +- `com.marketdata.sdk.exception` — public sealed hierarchy plus its `ErrorContext` record. Subpackage chosen so an `import com.marketdata.sdk.exception.*` brings in the full taxonomy. +- `com.marketdata.sdk.utilities` — public response models (`ApiStatus`, `ServiceStatus`, `User`, `RequestHeaders`). Each future resource will get a parallel subpackage. + +The repo at HEAD has **53 files in `src/main`** (~3,700 LoC of production code) and **34 test classes**. + +### 1.2 Public API surface + +What a consumer can `import`: + +``` +com.marketdata.sdk.MarketDataClient +com.marketdata.sdk.UtilitiesResource (returned from client.utilities()) +com.marketdata.sdk.Response +com.marketdata.sdk.RateLimitSnapshot + +com.marketdata.sdk.exception.MarketDataException (sealed) + ├── AuthenticationError + ├── BadRequestError + ├── NotFoundError + ├── RateLimitError + ├── ServerError + ├── NetworkError + └── ParseError +com.marketdata.sdk.exception.ErrorContext + +com.marketdata.sdk.utilities.ApiStatus +com.marketdata.sdk.utilities.ServiceStatus +com.marketdata.sdk.utilities.User +com.marketdata.sdk.utilities.RequestHeaders +``` + +Everything else is package-private and therefore unreachable from consumer code. This includes — deliberately — `Configuration`, `EnvVars`, `Tokens`, `Version`, `RequestSpec`, `HttpTransport`, `HttpDispatcher`, `AsyncSemaphore`, `RetryPolicy`, `RetryExecutor`, `RetryAfterHeader`, `StatusCache`, `RateLimitHeaders`, `JsonResponseParser`, `ParallelArrays`, `Format`, `DateFormat`, `Mode`, `DemoMode`, `DotEnvLoader`, `MarketDataLogging`, `CanonicalLogFormatter`, `MarketDataDates`, `HttpStatusMapper`, `HttpResponseEnvelope`, `RequestHeadersDeserializer`, `UserDeserializer`. + +`UtilitiesResource` is `public final` so the *type* can be named in `client.utilities()` return positions, but its constructor is package-private — consumers can hold a reference but cannot instantiate one. + +### 1.3 Inventory by layer + +#### Lifecycle & configuration + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `MarketDataClient` | public | 205 | Entry point. Holds `Configuration`, `HttpTransport`, `UtilitiesResource`. Drives the §4 cascade, §5 startup probe, and the §7 logger configuration. | +| `Configuration` | record (pkg) | 233 | Resolved configuration: `apiKey?`, `baseUrl`, `apiVersion`, `loggingLevel?`, `dateFormat?`. Owns the cascade (`resolve`), normalization, and validation. | +| `EnvVars` | pkg | ~30 | The only place that knows the `MARKETDATA_*` env-var names. `systemLookup()` restricts reads to the allowed set. | +| `DotEnvLoader` | pkg | 99 | `.env` reader with explicit `allowedKeys` filter and buffered `Warning`s. | +| `DemoMode` | pkg | 11 | One-line predicate: `apiKey == null`. | +| `Version` | pkg | 19 | `sdkVersion()` — JAR-manifest lookup with a build-time-injected fallback. | +| `Tokens` | pkg | 24 | `redact(token)` — `***…***` (≤8) / `***…***ABCD` (>8). | +| `MarketDataLogging` | pkg | 158 | Installs `CanonicalLogFormatter`. First-install-wins; consumer-pre-config detection. | +| `CanonicalLogFormatter` | pkg | 58 | JUL `Formatter` enforcing the §7 line shape. | +| `MarketDataDates` | pkg | 37 | Epoch-seconds → `ZonedDateTime` in America/New_York. | +| `Format`, `DateFormat`, `Mode` | pkg enums | 27–38 | Wire-format / date-format / mode enums with `wireValue()` accessors. | + +#### Transport & dispatch + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `HttpTransport` | pkg | 411 | Orchestrates one request: build URL/request, preflight gate, dispatch, retry, route response. Owns `latestRateLimits`. | +| `HttpDispatcher` | pkg | 185 | Single-shot send under the `AsyncSemaphore`. Maps transport errors to `NetworkError`. | +| `HttpResponseEnvelope` | record (pkg) | 25 | Format-agnostic response carrier: `body[]`, `statusCode`, `requestId?`, `headers`, `url`. | +| `HttpStatusMapper` | pkg | 84 | Status code → `MarketDataException` subtype. | +| `RequestSpec` | record (pkg) | 134 | Declarative GET spec: `path`, `queryParams` (ordered), `format`, `versioned`. Builder. | + +#### Retry, rate limiting, concurrency + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `RetryPolicy` | pkg | 191 | `shouldRetry(cause, attempt)` and `backoffDelay(cause, attempt)`. Static factories `defaults()` and `noRetry()`. | +| `RetryExecutor` | pkg | 151 | Generic retry-on-failure orchestrator for `Supplier>`. | +| `RetryAfterHeader` | pkg | 64 | Parser for delta-seconds + RFC 1123 HTTP-date. | +| `StatusCache` | pkg | 167 | Stale-while-revalidate cache of `/status/`. Gates retries. | +| `RateLimitHeaders` | pkg | 53 | All-or-nothing parser for the four `x-api-ratelimit-*` headers. | +| `RateLimitSnapshot` | public record | ~10 | `limit`, `remaining`, `reset`, `consumed`. | +| `AsyncSemaphore` | pkg | 146 | Async-safe 50-permit limiter, FIFO waiter queue. | + +#### Parsing & response + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `JsonResponseParser` | pkg | 89 | One `ObjectMapper` per client; resources self-register their `SimpleModule`. Pre-checks empty body. | +| `ParallelArrays` | pkg | 271 | `zip(...)` + `listDeserializer(...)` factory + strict `Row` accessors. | +| `RequestHeadersDeserializer` | pkg | 27 | Hand-written for the `{headers: {...}}` shape. | +| `UserDeserializer` | pkg | 54 | Hand-written for the `/user/` shape. | +| `Response` | public | 150 | Typed `data()`, defensive `rawBody()`, format predicates, `isNoData()`, `saveToFile()`, redacted `toString()`. | + +#### Resources + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `UtilitiesResource` | public (pkg ctor) | 144 | Sync + async pair for `/status/`, `/user/`, `/headers/` (all unversioned). Registers wire-format module on the parser. | +| `ApiStatus`, `ServiceStatus`, `User`, `RequestHeaders` | public records | <30 ea. | Response models. | + +#### Exceptions + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `MarketDataException` | public sealed | 96 | Base. Holds `ErrorContext`. `getRequestUrl` returns redacted URL. `getSupportInfo` formats the multi-line dump. | +| `ErrorContext` | public record | 17 | `requestId?`, `requestUrl`, `statusCode`, `timestamp`. | +| 7 permits | public final | 15–45 ea. | Subtypes. `RateLimitError` and `ServerError` carry an extra `retryAfter` Duration. | + +### 1.4 Dependency arrows + +A simplified view (not every static dependency — just the load-bearing ones): + +``` + MarketDataClient + │ + ┌───────────┼───────────────────┐ + ▼ ▼ ▼ + Configuration HttpTransport UtilitiesResource ──── JsonResponseParser + │ │ │ │ + │ │ ├─→ HttpDispatcher ─→ AsyncSemaphore │ + │ │ ├─→ RetryExecutor ──→ RetryPolicy ─→ RetryAfterHeader + │ │ ├─→ StatusCache ──────┐ ▲ │ + │ │ ├─→ HttpStatusMapper │ │ │ + │ │ ├─→ RateLimitHeaders │ │ │ + │ │ └─→ HttpResponseEnvelope ParallelArrays + │ │ (record) │ + │ ▼ ▼ + │ latestRateLimits ◀──── RateLimitSnapshot Response + │ + DotEnvLoader, EnvVars, Tokens, MarketDataLogging, CanonicalLogFormatter +``` + +`MarketDataClient` is the only class that knows about all three of `Configuration`, `HttpTransport`, and `UtilitiesResource`. Everything else is one layer of abstraction below. + +The `StatusCache → MarketDataClient → ... → HttpTransport → StatusCache` cycle (the cache's fetcher calls a `/status/` request through the transport) is resolved by a deferred reference — see §3 below. + +--- + +## 2. Sync request flow end-to-end + +This section traces a single line of consumer code: + +```java +Response r = client.utilities().status(); +``` + +through every class it touches, ending at the typed `Response`. + +### 2.1 Sequence diagram + +```mermaid +sequenceDiagram + autonumber + participant C as Consumer + participant U as UtilitiesResource + participant T as HttpTransport + participant R as RetryExecutor + participant D as HttpDispatcher + participant S as AsyncSemaphore + participant H as java.net.http.HttpClient + participant SC as StatusCache + participant P as JsonResponseParser + participant PA as ParallelArrays + + C->>U: status() + U->>U: statusAsync()
    build RequestSpec + U->>T: executeAsync(spec) + T->>T: build URI + HttpRequest + T->>R: execute(supplier, shouldRetry) + + rect rgba(180,210,255,0.15) + Note over R,SC: attempt N + R->>T: supplier.get(attemptIdx, prevCause) + T->>T: checkRateLimitPreflight(uri) + alt remaining=0 & now>R: failedFuture(RateLimitError) + else allow + T->>D: dispatch(request) + D->>S: acquire() + S-->>D: permit (fast or slow) + D->>H: sendAsync() + H-->>D: HttpResponse + D->>S: release() + D-->>T: response + T->>T: routeAndEnvelope() + T->>T: parse rate-limit headers,
    update latestRateLimits + alt 2xx or 404 + T-->>R: HttpResponseEnvelope + else 4xx/5xx + T-->>R: throw MarketDataException + end + end + R->>R: shouldRetry? + opt retriable + cache allows + R->>SC: cache.check(uri) + R-->>R: schedule next attempt (delayedExecutor) + end + end + + R-->>T: CompletableFuture + T-->>U: same future + U->>P: parser.parse(env, ApiStatus.class) + P->>PA: ParallelArrays.zip(root, fields, rowBuilder) + PA-->>P: List + P-->>U: ApiStatus + U->>U: Response.wrap(data, env, format) + U-->>T: CompletableFuture> + T->>T: joinSync(future)
    unwrap CompletionException + T-->>U: Response + U-->>C: Response +``` + +### 2.2 Step-by-step walk + +#### Entry — `UtilitiesResource` + +`UtilitiesResource.status()` at `src/main/java/com/marketdata/sdk/UtilitiesResource.java:126` is a one-liner: `return transport.joinSync(statusAsync())`. It's a sync wrapper around the async surface, satisfying ADR-006's sync+async parity rule. + +`statusAsync()` at line 120 builds a `RequestSpec`: + +```java +RequestSpec spec = RequestSpec.get("status").unversioned().build(); +return executeAndWrap(spec, ApiStatus.class); +``` + +`unversioned()` flips the `versioned` flag in the builder; `/status/` lives at the API root, not under `/v1/`. `executeAndWrap` (line 132) hands the spec to the transport and composes a `thenApply` that turns the raw envelope into a typed `Response`: + +```java +return transport + .executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); +``` + +#### Transport orchestration — `HttpTransport.executeAsync` + +The orchestrator lives at `HttpTransport.java:153`. The interesting variant is the private 2-arg overload at line 167: + +```java +private CompletableFuture executeAsync( + RequestSpec spec, RetryExecutor executor) { + URI uri = buildUri(spec); + HttpRequest request = buildHttpRequest(uri, spec.format()); + RetryPolicy policy = executor.policy(); + return executor.execute( + (attemptIdx, previousCause) -> { + if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) { + return CompletableFuture.failedFuture(preflight); + } + } + return dispatcher + .dispatch(request) + .thenApply(response -> routeAndEnvelope(response, uri)); + }, + (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); +} +``` + +The lambda passed to `executor.execute` is what `RetryExecutor` invokes per attempt. The retry predicate is a composition of the policy's own decision with the `StatusCache`'s veto power (§9.5). + +#### URL building — `buildUri` + +`buildUri(spec)` at `HttpTransport.java:335` assembles `baseUrl + "/" + (versioned ? apiVersion + "/" : "") + path + "/" + (params ? "?…" : "")`. Two non-obvious pieces: + +- Leading-slash defensiveness on `path` (line 340). `RequestSpec`'s Javadoc says paths have no leading slash, but a caller mistake would produce `baseUrl//v1//path` — strip defensively. +- Trailing-slash insertion (line 349). Every endpoint URL the API exposes ends in `/`; consumers who write `"status"` instead of `"status/"` shouldn't be surprised. + +Query encoding uses a custom `encodeQueryComponent` (line 378) that replaces `+` with `%20` after `URLEncoder.encode`. `URLEncoder` emits `application/x-www-form-urlencoded` (`+` for spaces), which strict servers reject in query strings — the replacement is the canonical patch. + +#### Preflight — `checkRateLimitPreflight` + +`checkRateLimitPreflight(uri)` at `HttpTransport.java:221` is §10.3: + +```java +RateLimitSnapshot snap = latestRateLimits.get(); +if (snap == null || snap.remaining() > 0) return null; // allow +Instant now = clock.instant(); +if (!now.isBefore(snap.reset())) return null; // reset has elapsed → allow +return new RateLimitError(...); // block +``` + +Two cases produce `null` (= allow): no snapshot yet, or the snapshot's reset time has passed. The second case is critical — without it, a single response carrying `remaining=0` would freeze the client forever (every subsequent request short-circuits, no response ever updates the snapshot). See §5 for the full reasoning. + +The preflight is bypassed entirely when the previous attempt was a server-hinted retry (line 184). See §10.11. + +#### Dispatch + permit — `HttpDispatcher.dispatch` + +`dispatch(request)` at `HttpDispatcher.java:55`: + +```java +CompletableFuture permit = permits.acquire(); +CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); +dispatched.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + permit.cancel(false); + } +}); +return dispatched; +``` + +`permits.acquire()` is the `AsyncSemaphore` from §6. It returns an *already-completed* future on the fast path (a permit was available) or a *pending* future on the slow path (the request waits in FIFO until a peer calls `release()`). Either way no thread is parked. + +`send(request)` at line 75 calls `httpClient.sendAsync(...)` and registers `whenComplete((r, t) -> permits.release())` so the permit is released exactly once. The pre-send `try/catch` (line 80) handles `sendAsync` throwing synchronously (malformed request, OOM): release the permit explicitly because the `whenComplete` would never fire if the future never formed. + +Transport-layer failures (anything that's not an `HttpResponse`) are mapped to `NetworkError` via the `handle(...)` block at line 105. The `unwrap` helper at line 182 peels one layer of `CompletionException`; deeper nesting is handled later in `RetryPolicy.hasIoExceptionInCauseChain` (§4 / §10.10). + +#### Response routing — `routeAndEnvelope` + +`routeAndEnvelope(response, uri)` at `HttpTransport.java:289` decides what the status code means. + +First, rate-limit headers are parsed and the `latestRateLimits` `AtomicReference` is updated if (and only if) the four headers arrived together: + +```java +RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); +if (parsed != null) latestRateLimits.set(parsed); +``` + +The `if (parsed != null)` is load-bearing — see §5. + +Then status routing (line 304): + +```java +if ((status >= 200 && status < 300) || status == 404) { + return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); +} +``` + +**404 is a success.** The API uses HTTP 404 as the carrier for `{"s":"no_data"}` envelopes, which mean "we have nothing for that query" — successful, not error. The body is handed to the parser like any 2xx. The parser sees `s:"no_data"` and returns the empty container (§7). The `Response` ends up with `isNoData() == true` (`Response.java:113`). + +For 4xx/5xx (line 307+), a `Retry-After` is parsed up front (line 309) and attached to the resulting exception so that `RetryPolicy.backoffDelay` can honor it on the next attempt: + +```java +Duration retryAfter = response.headers().firstValue("Retry-After") + .flatMap(v -> RetryAfterHeader.parse(v, now)) + .orElse(null); +MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); +``` + +The exception is logged with `safeUri(uri)` — the query-stripped URL — and thrown. `RetryExecutor`'s `whenComplete` catches it. + +If `HttpStatusMapper.map(...)` returns `null` (only possible for 2xx, already handled), a defensive `ServerError("Unmapped status …")` is thrown. Belt-and-suspenders: a future mapper edit can't silently swallow an unknown status. + +#### Retry envelope — `RetryExecutor.execute` + +`RetryExecutor.execute(AttemptSupplier, BiPredicate)` at `RetryExecutor.java:78`: + +```java +CompletableFuture result = new CompletableFuture<>(); +AtomicReference> currentAttempt = new AtomicReference<>(); +result.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + CompletableFuture inFlight = currentAttempt.get(); + if (inFlight != null && !inFlight.isDone()) inFlight.cancel(false); + } +}); +attempt(supplier, shouldRetry, 0, null, result, currentAttempt); +return result; +``` + +One cancellation handler installed once on the *outer* `result`. The `currentAttempt` reference tracks whichever attempt is in flight; cancelling `result` cancels the live one. Previous attempts are already done by the time the next one overwrites the reference, so this avoids accumulating one handler per attempt. + +`attempt(...)` at line 99 is the recursive worker. The body is intentionally small: + +```java +if (result.isDone()) return; // caller cancelled or already failed +CompletableFuture dispatched = supplier.get(attemptIdx, previousCause); +currentAttempt.set(dispatched); +if (result.isCancelled() && !dispatched.isDone()) { + dispatched.cancel(false); + return; +} +dispatched.whenComplete((value, error) -> { + if (result.isDone()) return; + if (error == null) { result.complete(value); return; } + Throwable cause = unwrap(error); + if (shouldRetry.test(cause, attemptIdx)) { + long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); + CompletableFuture.delayedExecutor(delayMs, MILLISECONDS) + .execute(() -> attempt(supplier, shouldRetry, attemptIdx + 1, cause, result, currentAttempt)); + } else { + result.completeExceptionally(cause); + } +}); +``` + +`delayedExecutor` is the key. No scheduled thread pool to manage — `ForkJoinPool.commonPool` runs the next attempt after the delay elapses. + +The `currentAttempt.set(dispatched)` line is followed by an *immediate* re-check of cancellation. There's a TOCTOU window between the outer `isDone()` check and the `set()` call; the cancellation handler observes `currentAttempt`, so if cancellation fires in that window, it sees the previous (already-done) attempt and doesn't cancel the new one. The re-check closes the race. + +#### Parser — `JsonResponseParser.parse` + +`parse(env, type)` at `JsonResponseParser.java:55` is two paths: + +1. Empty body (line 60) — fast-path `ParseError` with a précis message before Jackson is invoked. See §10.9. +2. Otherwise — `mapper.readValue(env.body(), type)`. Jackson reads the body and invokes the registered deserializer for `type`. For `ApiStatus.class`, that's the deserializer produced by `ParallelArrays.listDeserializer(...)` in `UtilitiesResource.wireFormatModule()` (`UtilitiesResource.java:51`). + +If Jackson throws `IOException` (malformed JSON, type mismatch, etc.), the parser wraps it in `ParseError` (line 80). The message uses `safeUri(env.url())` — query-stripped — so a routine `logger.error(parseError.getMessage())` doesn't leak `?token=`. + +#### Parallel-arrays decode — `ParallelArrays.zip` + +`zip(p, root, fields, rowBuilder)` at `ParallelArrays.java:69`: + +```java +String envelopeStatus = root.path("s").asText(""); +if ("error".equals(envelopeStatus)) { + throw new JsonMappingException(p, "API responded with error: " + errmsg); +} +if ("no_data".equals(envelopeStatus)) { + return List.of(); +} +// validate columns: every requested field must be a present, equal-length array +// then build rows via rowBuilder(new IndexedRow(arrays, i)) +``` + +`s:"error"` short-circuits to a `JsonMappingException` carrying the server-supplied `errmsg`. The parent parser catches the `IOException` and re-wraps as `ParseError`. The consumer ends up with `ParseError.getMessage()` containing the server's `errmsg` — actionable. + +`s:"no_data"` returns the empty list and the wrapper record's compact constructor copies it: `ApiStatus(List.of())`. The consumer's `Response.data().services()` is empty; `Response.isNoData()` is `true` (status was 404). + +Column validation enforces presence and equal length (lines 83–102). Any deviation throws `JsonMappingException` → `ParseError`. The `Row` accessors are strict by default — see §10.4. + +#### Response wrap — `Response.wrap` + +`Response.wrap(data, envelope, format)` at `Response.java:54` builds the immutable `Response`: + +```java +return new Response<>( + data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); +``` + +The constructor at line 35 clones `rawBody` defensively. The accessor `rawBody()` clones again on every call (line 67) — see §10.2 for why that's not paranoid. + +#### Sync unwrap — `HttpTransport.joinSync` + +Back in `UtilitiesResource.status()`, `transport.joinSync(future)` at `HttpTransport.java:264`: + +```java +try { + return future.join(); +} catch (CompletionException e) { + throw asRuntime(e.getCause(), clock); +} catch (CancellationException e) { + throw asRuntime(e, clock); +} +``` + +`asRuntime(...)` at line 399 has three branches: +- `MarketDataException` → return verbatim. +- `RuntimeException` → return verbatim. +- Anything else → wrap as `NetworkError` with a `forNoResponse` context. + +The first branch is the one that fires in practice — the SDK always wraps failures as `MarketDataException` before they reach here. The other two are defensive guardrails so a future bug that lets some other type through doesn't surface as a confusing `CompletionException`. + +### 2.3 Branches a reviewer should think about + +| Branch | Path | Outcome | +|---|---|---| +| 200 OK with parseable body | `routeAndEnvelope → JsonResponseParser → ParallelArrays → Response.wrap` | Typed `Response` with `isNoData() == false`. | +| 200 OK with `s:"error"` body | `JsonResponseParser` → `ParallelArrays` throws | `ParseError` with server's `errmsg` in the message. | +| 200 OK with truncated parallel arrays | `ParallelArrays` length-check fails | `ParseError`. | +| 200 OK with empty body | `JsonResponseParser` empty-body pre-check | `ParseError` "Empty response body…". | +| 404 + `s:"no_data"` | Route to success envelope, parser zips to `List.of()` | `Response` with `isNoData() == true`. | +| 401 | `HttpStatusMapper.map(401, ...)` → `AuthenticationError` → `joinSync` unwraps | `AuthenticationError` thrown sync. | +| 503 (single shot) | `routeAndEnvelope` throws `ServerError(503, ...)`; retry retries | If recovers within budget: `Response`. Otherwise `ServerError`. | +| ConnectException buried in `ExecutionException` | `HttpDispatcher` wraps in `NetworkError`; `RetryPolicy.hasIoExceptionInCauseChain` walks chain | Retried (§10.10). | +| Caller cancels the returned `CompletableFuture` | `RetryExecutor.whenComplete` propagates cancel to `currentAttempt` | `CancellationException`. | + +### 2.4 Verify locally + +```bash +make publish +make demo-live # hits api.marketdata.app — needs MARKETDATA_TOKEN +``` + +`LiveSmokeApp` prints `client.toString` (token redacted), runs `/status/` sync + async to prove ADR-006 parity, fires three async calls in parallel and reports the elapsed wall-time (should be ≈ the slowest single call, not the sum), and dumps the final rate-limit snapshot. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java`. + +--- + +## 3. Construction flow + +This section walks through `new MarketDataClient(...)`. The constructor is dense — every line earns its place. + +### 3.1 Flowchart + +```mermaid +flowchart TD + A[ctor entry] --> B[DotEnvLoader.load with ALLOWED_KEYS] + B --> C{Configuration.resolve
    cascade + validate} + C -- throws IAE --> X[attach warnings as suppressed
    + rethrow] + C -- OK --> D[Configuration record] + D --> E[MarketDataLogging.configure] + E --> F[replay buffered warnings
    through logger] + F --> G[log INFO 'initialized'
    with token redacted] + G --> H[AtomicReference<StatusCache>
    cacheRef] + H --> I[HttpTransport.withDefaults
    uses cacheRef::get] + I --> J{partial-construction guard} + J -- try --> K[new JsonResponseParser] + K --> L[new UtilitiesResource
    registers wire-format module] + L --> M[new StatusCache
    cacheRef.set] + J -- throws --> Y[transport.close + addSuppressed
    + rethrow] + M --> N{validateOnStartup?} + N -- no --> Z[ctor returns] + N -- yes --> O{DemoMode.isDemo} + O -- yes --> P[log skip] --> Z + O -- no --> Q[utilities.validateAuth
    noRetry policy] + Q -- 200 --> Z + Q -- 401 --> R[AuthenticationError
    close + addSuppressed
    rethrow] + Q -- other --> R +``` + +### 3.2 Step-by-step walk + +#### Public 4-arg constructor + +`MarketDataClient(apiKey, baseUrl, apiVersion, validateOnStartup)` at `MarketDataClient.java:28` just delegates to the package-private 6-arg constructor with the production seams: + +```java +this(apiKey, baseUrl, apiVersion, validateOnStartup, + EnvVars.systemLookup(), Configuration.DEFAULT_DOTENV_PATH); +``` + +The two extra params (`env` lookup and `.env` path) are the seams that let tests drive the cascade hermetically — no `System.getenv` reads, no real filesystem. + +#### Buffer-then-replay warnings + +`MarketDataClient.java:54`: + +```java +List pendingWarnings = new ArrayList<>(); +try { + this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); +} catch (RuntimeException e) { + attachWarningsAsSuppressed(e, pendingWarnings); + throw e; +} +``` + +`DotEnvLoader` runs *inside* `Configuration.resolve` — i.e. before `MarketDataLogging.configure(...)` has had a chance to install the SDK's logger. If `DotEnvLoader` logged its warnings directly, they'd land on an unconfigured JUL logger with the wrong shape, possibly invisible. Buffering them and replaying after `configure` is the only way to get §7-shaped warnings on every rung of the cascade. + +Issue #25 wired the failure path: if `Configuration.resolve` itself throws (typically `IllegalArgumentException` on a bad `baseUrl`/`apiVersion`/`apiKey`), the warnings are attached as suppressed exceptions on the primary cause. Without this, the consumer would lose the `.env` warning that explained *why* the config went wrong. + +#### Configuration cascade + +`Configuration.resolve(...)` at `Configuration.java:40` does five things: + +1. `DotEnvLoader.load(dotEnvPath, warnings, EnvVars.ALLOWED_KEYS)` — read `.env` if present, filtering to allowed keys only. +2. `pickFirst(explicit, env, dotEnv)` for nullable values (`apiKey`, `loggingLevel`, `dateFormat`). `pickFirstOrDefault` for non-nullable ones (`baseUrl`, `apiVersion`). +3. `normalizeBaseUrl` — strip trailing slashes. +4. `normalizeApiVersion` — strip leading/trailing slashes. +5. `validateBaseUrl`, `validateApiVersion`, `validateApiKey`. + +`validateBaseUrl` (line 125) rejects empty strings, non-parseable URIs, non-http(s) schemes, missing hosts, and the presence of query/fragment/user-info. The `user-info` check is included because someone pasting `https://user:pass@api.marketdata.app` into `baseUrl` is almost always confused about where credentials go. + +`validateApiVersion` (line 207) checks against the `[A-Za-z0-9._-]+` regex — a single URL-safe path segment. This rejects `"v1/extra"`, `"%2Fv1"`, spaces, etc. + +`validateApiKey` (line 189) — issue #23 — checks every character is printable ASCII (`[0x20, 0x7E]`). CR/LF would be rejected later by `HttpRequest.Builder#header` with a generic IAE; rejecting here gives a clear constructor-time message that names the offset. NUL and high-bit bytes are also rejected because they're almost always a copy-paste mishap. Demo mode (`apiKey == null`) is exempted. + +#### Logger configuration + +`MarketDataClient.java:71`: + +```java +MarketDataLogging.configure(config.loggingLevel()); +for (DotEnvLoader.Warning w : pendingWarnings) { + LOGGER.log(w.level(), w.message(), w.cause()); +} +LOGGER.info(() -> "MarketDataClient initialized: baseUrl=" + ... + token=Tokens.redact(...)); +``` + +After `configure` returns, every subsequent log line (including the replay of buffered warnings) emits in the canonical §7 format. The `INFO` line at the end records the resolved configuration — including the token, which is run through `Tokens.redact` (§9.3). + +See §9.2 for `MarketDataLogging.configure`'s internals (consumer-pre-config detection, etc.). + +#### Deferred StatusCache reference + +`MarketDataClient.java:90`: + +```java +AtomicReference cacheRef = new AtomicReference<>(); +this.transport = HttpTransport.withDefaults( + config.baseUrl(), config.apiVersion(), + "marketdata-sdk-java/" + Version.sdkVersion(), + config.apiKey(), + cacheRef::get); +``` + +This is the **chicken-and-egg solution** that §10.12 covers in detail. Briefly: the transport needs the cache (to gate retries) and the cache needs the transport (to fetch `/status/`). The `AtomicReference` lets us build the transport first with a `Supplier` that returns `null` until the cache is constructed. The transport handles `null` gracefully — `cacheAllowsRetry` short-circuits to `true` when the supplier returns `null` (`HttpTransport.java:240`). + +#### Partial-construction guard + +`MarketDataClient.java:105`: + +```java +try { + JsonResponseParser parser = new JsonResponseParser(); + this.utilities = new UtilitiesResource(transport, parser); + cacheRef.set(new StatusCache( + () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); +} catch (Throwable t) { + try { transport.close(); } catch (Throwable closeFailure) { t.addSuppressed(closeFailure); } + throw t; +} +``` + +From the line where `this.transport = ...` succeeds, the transport is a live `AutoCloseable` holding the shared `HttpClient` and the 50-permit `AsyncSemaphore`. If any subsequent line in the constructor throws, the caller never receives a `MarketDataClient` reference, their try-with-resources never fires, and the transport leaks until GC. The explicit close-and-rethrow makes that impossible. + +Today, no line below `this.transport = ...` is expected to throw — `JsonResponseParser` is trivial, `UtilitiesResource`'s constructor just registers a module, `StatusCache`'s constructor just stores references. But the guard exists because a future refactor of any of those constructors could break the invariant silently. + +`UtilitiesResource`'s constructor at `UtilitiesResource.java:27` calls `parser.registerModule(wireFormatModule())`, which is where Jackson learns how to decode `ApiStatus`, `User`, `RequestHeaders`. This must happen before any `parse(...)` call — satisfied because resources are constructed before any HTTP request is made. + +#### Startup validation + +`MarketDataClient.java:120`: + +```java +if (validateOnStartup) { + runStartupValidation(); +} +``` + +`runStartupValidation()` at line 160: + +```java +if (DemoMode.isDemo(config)) { + LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); + return; +} +try { + utilities.validateAuth(); +} catch (Throwable t) { + try { close(); } catch (Throwable closeFailure) { t.addSuppressed(closeFailure); } + throw t; +} +``` + +`utilities.validateAuth()` at `UtilitiesResource.java:110`: + +```java +void validateAuth() { + transport.joinSync( + executeAndWrap(RequestSpec.get("user").build(), RetryPolicy.noRetry(), User.class)); +} +``` + +Three deliberate choices: + +1. **`RetryPolicy.noRetry()`** — a single attempt. A slow/down API surfaces here within the 99 s request timeout instead of burning the default budget (~6.75 min worst case). Consumers who need a tighter ceiling pass `validateOnStartup = false` and probe themselves. +2. **Result discarded** — only the throw shape matters. 401 → `AuthenticationError`, network failures → `NetworkError`, etc. +3. **Package-private and intent-named** — not a public `/user/` endpoint (that's `utilities.user()` with a custom retry path). Sharing the codepath but not the name keeps "auth probe" and "fetch user data" semantically distinct in the source. + +The constructor-level catch (`MarketDataClient.java:169`) closes the transport on any failure so a partially-constructed client doesn't leak. Same suppressed-exception pattern as the partial-construction guard above. + +### 3.3 Cases to call out + +| Scenario | Outcome | +|---|---| +| All cascade rungs empty, `apiKey = null` | Demo mode. `validateOnStartup` skipped. Constructor returns. | +| `validateOnStartup = true` + 200 | Constructor returns OK. `latestRateLimits` is populated from the `/user/` response headers as a side effect. | +| `validateOnStartup = true` + 401 | `AuthenticationError` thrown from constructor. Transport closed. | +| `validateOnStartup = true` + network failure | `NetworkError` thrown from constructor. Transport closed. (After the no-retry single attempt — no backoff burnt.) | +| `apiKey` contains CRLF | `IllegalArgumentException` at construct time, no transport allocated yet. `.env` warnings attached as suppressed. | +| `baseUrl = "not-a-url"` | `IllegalArgumentException` at construct time. | +| `.env` is unreadable | Warning collected, attached as suppressed if any later step throws; otherwise replayed through the logger as a WARNING. | + +### 3.4 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-config # terminal 3 +``` + +`DemoAndConfigApp` walks each construction-time scenario in order: demo mode (when the cascade resolves no token), token redaction at the 8-char hinge (short and long), explicit-wins cascade, IAE on malformed `baseUrl`, CRLF rejection on `apiKey`, `validateOnStartup` success against a 200, and `validateOnStartup` failing against a scripted 401. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java`. + +--- + +## 4. Retry, `Retry-After`, and `StatusCache` + +This section covers the §9 retry contract end-to-end. + +### 4.1 Decision flowchart + +```mermaid +flowchart TD + A[attempt N fails] --> B{cause is
    MarketDataException?} + B -- no --> NO[no retry] + B -- yes --> C{type?} + + C -- NetworkError --> D{IOException in
    cause chain
    depth ≤ 16?} + D -- no --> NO + D -- yes --> R1[retry candidate] + + C -- ServerError --> E{statusCode in
    501-599?} + E -- no --> NO + E -- yes --> R1 + + C -- RateLimitError
    BadRequestError
    AuthenticationError
    NotFoundError
    ParseError --> NO + + R1 --> F{attempt+1 < maxAttempts?} + F -- no --> NO + F -- yes --> G{StatusCache.check
    == ALLOW?} + G -- BLOCK --> NO + G -- ALLOW --> H[backoffDelay] + + H --> I{cause is ServerError
    with Retry-After?} + I -- no --> J[exponential:
    initial × 2^attempt
    capped at maxBackoff] + I -- yes --> K{retryAfter ≤
    MAX_RETRY_AFTER
    10 min?} + K -- yes --> L[use retryAfter] + K -- no --> M[log warning,
    use exponential] + J --> Z[schedule attempt N+1
    via delayedExecutor] + L --> Z + M --> Z +``` + +### 4.2 `RetryPolicy.shouldRetry` + +`RetryPolicy.java:79`: + +```java +boolean shouldRetry(Throwable cause, int attempt) { + if (attempt + 1 >= maxAttempts) return false; + return isRetriable(cause); +} +``` + +`attempt` is zero-indexed: `attempt == 0` means the original call just failed and we're considering the *first* retry. The default `maxAttempts = 4` means up to 3 retries (attempts at indices 0, 1, 2 schedule retries; attempt at index 3 surfaces). + +`isRetriable(cause)` at line 136 has three branches: + +1. **Not a `MarketDataException`** → false. Conservative: an unknown failure type doesn't get retried. +2. **`NetworkError`** → `hasIoExceptionInCauseChain(net.getCause())`. See §10.10 — the walk is critical under HTTP/2. +3. **`ServerError`** → `status in [501, 599]`. 500 is explicitly excluded (deterministic server bug — retrying just hits the same crash). The `0` sentinel from `ErrorContext.forNoResponse` falls outside the range, so a `ServerError` without an HTTP code (impossible today but defensible) is correctly excluded. + +`RateLimitError`, `AuthenticationError`, `BadRequestError`, `NotFoundError`, `ParseError` all return `false` — §9 says never retry 4xx, and `ParseError` is deterministic. + +### 4.3 `RetryPolicy.backoffDelay` + +`RetryPolicy.java:91`: + +```java +Duration backoffDelay(Throwable cause, int attempt) { + if (cause instanceof ServerError server) { + Duration override = server.getRetryAfter().orElse(null); + if (override != null) { + if (override.compareTo(MAX_RETRY_AFTER) > 0) { + LOGGER.warning(() -> "Server-supplied Retry-After of ... exceeds cap ... ignoring"); + } else { + return override; + } + } + } + return backoffDelay(attempt); +} +``` + +The override-or-exponential decision tree. The `MAX_RETRY_AFTER = 10 minutes` cap (issue #21, §10.6) is what prevents pathological values from freezing the next retry for hours. + +`backoffDelay(int attempt)` at line 119 is the pure exponential calculation with two saturation guards (line 128 and the rearranged inequality at line 132) so the math doesn't silently wrap on large attempt indices. Today `maxAttempts = 4` makes this defensive — but a consumer test that constructs a `RetryPolicy(100, ...)` shouldn't get `Long`-overflow surprises. + +### 4.4 `RetryAfterHeader.parse` + +`RetryAfterHeader.java:33`: + +```java +static Optional parse(String value, Instant now) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) return Optional.empty(); + Optional asSeconds = parseSeconds(trimmed); + if (asSeconds.isPresent()) return asSeconds; + return parseHttpDate(trimmed, now); +} +``` + +Order matters: `parseSeconds` first, then `parseHttpDate`. RFC 1123 dates contain non-digits so `Long.parseLong` fails fast and falls through. + +Both parsers `Math.max(0L, ...)` the result. Negative deltas and past dates clamp to `Duration.ZERO` — "retry now". Malformed values produce `Optional.empty()`, which the transport at `HttpTransport.java:313` flows through `.flatMap(...).orElse(null)` so the `ServerError` ends up with `retryAfter = null` and `backoffDelay` falls back to exponential. + +This file is intentionally cap-free. The 10-min cap lives at the policy layer (`RetryPolicy`); the parser stays a pure RFC 7231 implementation that any other code in the SDK could reuse without inheriting policy decisions. + +### 4.5 `RetryExecutor` + +Covered structurally in §2.2 ("Retry envelope"). Two invariants worth restating: + +1. **One cancellation handler.** The handler is installed once on the outer `result`. Each attempt is tracked in the `currentAttempt` `AtomicReference`; cancelling `result` cancels the live one. No handler accumulation across retries. +2. **Re-check after `currentAttempt.set`.** A race exists between the outer `isDone()` and the `set(...)`: if cancellation fires inside the window, the cancellation handler sees the *previous* (done) attempt and doesn't cancel the *new* one. The immediate re-check (`RetryExecutor.java:119`) closes the race. + +The supplier is invoked with `(attemptIdx, previousCause)`. `previousCause` is what makes the server-hinted-retry bypass possible — see §10.11. + +### 4.6 `StatusCache` + +`StatusCache.check(uri)` at `StatusCache.java:61`: + +```java +Snapshot snap = snapshot.get(); +Instant now = clock.instant(); +boolean refreshNeeded = snap == null || + Duration.between(snap.fetchedAt, now).compareTo(REFRESH_THRESHOLD) >= 0; +if (refreshNeeded) { + triggerRefresh(); + snap = snapshot.get(); // issue #19 — see §10.7 +} +boolean usable = snap != null && Duration.between(snap.fetchedAt, now).compareTo(EXPIRY) < 0; +if (!usable) return Decision.ALLOW; +String status = lookupService(snap, uri); +return "offline".equals(status) ? Decision.BLOCK : Decision.ALLOW; +``` + +The TTL is stale-while-revalidate: + +- `age < 270s` (REFRESH_THRESHOLD) — serve cached, no refresh. +- `270s ≤ age < 300s` (EXPIRY) — serve cached, fire async refresh. +- `age ≥ 300s` or no cache — treat as "unknown" → ALLOW (§9.5 says unknown allows retries) and fire async refresh. + +`triggerRefresh()` at line 90 is gated by an `AtomicBoolean` so concurrent retries on different services don't fire N refreshes. The fetcher is consulted via a `Supplier>` — see §10.12 for why this indirection exists. + +If the refresh future fails, the previous snapshot survives (§9.5 says cache persists across failed refreshes). The failure is logged at WARNING level so operators can detect a `/status/` outage instead of wondering why the SDK keeps blocking retries. + +`lookupService` at line 130 is the longest-prefix-match (§10.5). + +### 4.7 The full retry composition + +In `HttpTransport.executeAsync` (line 197) the retry predicate is: + +```java +(cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri) +``` + +`policy.shouldRetry` is the §9.3 decision. `cacheAllowsRetry(uri)` is the §9.5 veto (with the self-bypass for `/status/`, §10.7). Both must say "yes" for a retry to proceed. + +### 4.8 Cases to call out + +| Scenario | Wall-time | Outcome | +|---|---|---| +| 503 → 503 → 200 | ~3 s (1 s + 2 s backoff) | `Response`. | +| 503 → 503 → 503 → 503 | ~7 s (1 + 2 + 4) | `ServerError`. | +| 503 with `Retry-After: 5` → 200 | ~5 s | `Response`. Server hint honored. | +| 503 with `Retry-After: 86400` → 200 | ~1 s | `Response`. Cap engaged, fell back to exponential. The 86400 is still visible on the `ServerError.getRetryAfter()` for consumer-side inspection of the first-attempt failure. | +| 500 (single shot) | ~0 s | `ServerError`. Not retriable. | +| `ConnectException` buried in `ExecutionException → CompletionException → ConnectException` | ~7 s after 3 retries | `NetworkError`. Retried thanks to the cause-chain walk (§10.10). | +| `ParseError` from a 200 with malformed body | ~0 s | `ParseError`. Never retried. | +| `/status/` reports service offline → 503 on the affected URI | ~0 s | `ServerError`. Retry vetoed by `StatusCache`. | +| Caller cancels `CompletableFuture` mid-backoff | — | `CancellationException`. The scheduled next attempt is never run because `attempt()` checks `result.isDone()` first. | + +### 4.9 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-retry # terminal 3 +``` + +`RetryBehaviorApp` scripts: `503 → 503 → 200` (expect ~3 s recovery), `503 + Retry-After: 3 → 200` (~3 s, exponential bypassed), `503 + Retry-After: ` (HTTP-date honored), `503 + Retry-After: 86400` (cap engaged, falls back to ~1 s exponential), and the §10.3 preflight test (snapshot reports `remaining=0` → second call fails in 0 ms with 0 server-side requests). The wall-clock printed for each scenario is the proof. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java`. + +--- + +## 5. Rate-limit tracking + preflight + +### 5.1 The data path + +The server sets four headers on every successful response: + +``` +x-api-ratelimit-limit: # plan-wide cap +x-api-ratelimit-remaining: # remaining in window +x-api-ratelimit-reset: # when remaining resets +x-api-ratelimit-consumed: # consumed in window +``` + +`RateLimitHeaders.parse(headers)` at `RateLimitHeaders.java:28` reads all four. The reader is **all-or-nothing**: if any header is missing or unparseable, `parse` returns `null`. The reasoning lives in the file's class Javadoc: + +> Returns `null` when the four headers do not arrive together. … A partial delivery is a server-side rate-limit-tracking outage, not legitimate data. Returning `null` … preserves the caller's last-known-good snapshot instead of clobbering it with phantom zeros — those would otherwise trip `checkRateLimitPreflight` into blocking subsequent requests with a fake `remaining=0`. + +`HttpTransport.routeAndEnvelope` (line 293) updates the snapshot only when the parser returns non-null: + +```java +RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); +if (parsed != null) latestRateLimits.set(parsed); +``` + +The reverse path — `client.getRateLimits()` at `MarketDataClient.java:184` — just returns `transport.getLatestRateLimits()`, which is the `AtomicReference.get()`. + +### 5.2 The preflight gate + +`HttpTransport.checkRateLimitPreflight(uri)` at line 221: + +```java +RateLimitSnapshot snap = latestRateLimits.get(); +if (snap == null || snap.remaining() > 0) return null; // allow +Instant now = clock.instant(); +if (!now.isBefore(snap.reset())) return null; // reset elapsed → allow +ErrorContext context = ErrorContext.forNoResponse(uri.toString(), now); +return new RateLimitError( + "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); +``` + +Three branches: + +1. **No snapshot yet** (cold start, or every response so far lacked rate-limit headers) → allow. The next response will populate it. +2. **`remaining > 0`** → allow. +3. **`remaining == 0` and `now < reset`** → block with `RateLimitError`. The exception's `ErrorContext` uses `forNoResponse` because no HTTP round-trip happened; `statusCode == 0`, `requestId == null`. + +The "reset elapsed → allow" branch on line 227 is what prevents the **stuck-forever** failure mode: without it, a single response carrying `remaining=0` would short-circuit every subsequent request, no request would ever reach the wire, and the snapshot would never refresh. By letting the request through once `now >= reset`, the server's response refreshes the snapshot. If the server hasn't actually replenished credits yet it will reject with 429, which costs one round-trip — strictly better than locking the client out indefinitely. + +`RateLimitError` is non-retriable per `RetryPolicy.isRetriable`. The retry executor sees the failed-future returned by the supplier, the policy says no, and the consumer gets the error directly. + +### 5.3 The server-hinted-retry bypass + +`HttpTransport.executeAsync` (line 184): + +```java +if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) return CompletableFuture.failedFuture(preflight); +} +``` + +`isServerHintedRetry(previousCause)` at line 206: + +```java +return previousCause instanceof ServerError server && server.getRetryAfter().isPresent(); +``` + +When a retry was scheduled because the previous attempt returned `503 + Retry-After: 5`, the server has just told us "come back at `now + 5s`". That directive is more authoritative than our snapshot for this specific retry. Without the bypass, a snapshot reporting `remaining=0` with a far-future `reset` would veto the server-orchestrated backoff — the retry would never reach the wire. See §10.11. + +### 5.4 Cases to call out + +| State | Behavior | +|---|---| +| First-ever call from a new `MarketDataClient` | Snapshot is `null`. Preflight allows. Response (if successful and carrying headers) populates the snapshot. | +| Snapshot says `remaining = 10, reset = now+1h` | Allow. | +| Snapshot says `remaining = 0, reset = now+1h` | Block with `RateLimitError` instantly. Server sees zero additional requests until the second condition fails. | +| Snapshot says `remaining = 0, reset = now-1s` | Allow. The response refreshes the snapshot. | +| Retry of a 503 with `Retry-After: 5`, snapshot says `remaining = 0, reset = now+1h` | Allow (bypass). Server's directive prevails. | +| Response without rate-limit headers (e.g., 500 from internal server error) | Snapshot is not updated. Last-known-good survives. | + +### 5.5 Verify locally + +Same demo as §4 — the preflight check is the last scenario in `RetryBehaviorApp`: + +```bash +make publish +make mock-server # terminal 2 +make demo-retry # terminal 3 — scroll to "§10.3 preflight" output +``` + +The demo scripts one 200 carrying `x-api-ratelimit-remaining: 0` and a far-future `reset`, then issues a second call. Expectation: the second call fails with `RateLimitError` in ~0 ms and the server logs zero additional requests (visible via `/_admin/stats`). Source: the `preflightBlocksWhenSnapshotExhausted` method. + +--- + +## 6. Concurrency (`AsyncSemaphore`) + +### 6.1 Why not `j.u.c.Semaphore` + +`java.util.concurrent.Semaphore.acquire()` is blocking. The caller's thread parks until a permit is available. That's incompatible with ADR-006's async-first design: `executeAsync` must return a `CompletableFuture` immediately, even when the pool is exhausted, so a consumer's `client.utilities().statusAsync().thenApply(...)` chain doesn't accidentally pin a thread inside the SDK. + +`AsyncSemaphore.acquire()` returns a `CompletableFuture`. Fast path: a permit is available → already-completed future. Slow path: a permit isn't available → pending future enqueued FIFO; it completes when some peer calls `release()`. Either way, no thread parks. + +### 6.2 Invariants + +From the class Javadoc at `AsyncSemaphore.java:16`: + +1. **Every permit is accounted for exactly once.** A permit is either in `available` (free counter), held by an in-flight caller (will be `release()`d), or pending in the waiter queue (will be released by completing the waiter's future). Never two of those at once. +2. **`CompletableFuture.complete(...)` always runs outside the lock.** Completing a future runs the caller's attached callbacks synchronously on the releasing thread. Doing that with a lock held is a deadlock waiting to happen. + +### 6.3 Acquire + +`AsyncSemaphore.acquire()` at line 54: + +```java +synchronized (lock) { + if (closed) return CompletableFuture.failedFuture(closedException()); + if (available > 0) { + available--; + return CompletableFuture.completedFuture(null); + } + CompletableFuture waiter = new CompletableFuture<>(); + waiters.addLast(waiter); + return waiter; +} +``` + +Notice the lock is held only for the counter decrement / enqueue. The completed future and the new pending future are both constructed inside the lock, but they're returned to the caller, who attaches their callbacks after the lock is released. + +### 6.4 Release + +`AsyncSemaphore.release()` at line 73: + +```java +while (true) { + CompletableFuture next = null; + synchronized (lock) { + while (!waiters.isEmpty()) { + CompletableFuture w = waiters.pollFirst(); + if (!w.isDone()) { next = w; break; } + } + if (next == null) { available++; return; } + } + if (next.complete(null)) return; + // else: waiter was cancelled in the gap; loop and try the next one +} +``` + +Three things going on: + +1. **Drain stale waiters.** Inside the lock, skip any waiter that's already done (cancelled). `pollFirst` removes them so they don't sit in the queue forever. +2. **Transfer permit outside the lock.** `next.complete(null)` runs the caller's callbacks; we don't want our lock held during that. +3. **Outer loop on cancellation race.** Between `pollFirst` (inside lock) and `complete` (outside lock), the waiter could have been cancelled. `complete` returns `false` in that case; we loop and try the next waiter. + +### 6.5 Close + +`AsyncSemaphore.close()` at line 128 is idempotent. It drains the queue inside the lock and completes the drained waiters (with `CancellationException`) outside the lock. Permits already held by in-flight callers can still be `release()`d harmlessly — the counter accepts it. + +### 6.6 Integration with `HttpDispatcher` + +`HttpDispatcher.dispatch` at line 55 acquires a permit, composes with `send`, and registers a cancellation handler that propagates cancellation to the *permit*: + +```java +CompletableFuture permit = permits.acquire(); +CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); +dispatched.whenComplete((r, t) -> { + if (t instanceof CancellationException) permit.cancel(false); +}); +``` + +Without the permit cancellation, a slow-path waiter cancelled by the caller would stay live in the semaphore queue. `release()` would later "transfer" the permit by completing the waiter, but `thenCompose`'s function wouldn't run (its dependent is already cancelled), and `send` — which registers the `release()` `whenComplete` — would never fire. The permit would never come back. + +### 6.7 Cases to call out + +| Scenario | Behavior | +|---|---| +| 60 parallel async calls, mock server delays each 800 ms | Semaphore admits 50 immediately; the other 10 sit in FIFO. After ~800 ms the first batch releases and the 10 are admitted. Total wall-time ≈ 1.6 s. Server-side `peak_in_flight = 50`. | +| One slow caller, others fast | The slow call holds its permit; the fast ones go through the fast path on each release. | +| Caller cancels a slow-path waiter | Waiter is set done. `release()` skips it on the next pass. Net effect: zero permits leaked. | +| `MarketDataClient.close()` called mid-flight | `AsyncSemaphore.close()` rejects future `acquire()` calls with `CancellationException` and drains queued waiters. In-flight HTTP sends are not cancelled (until JDK 21 brings `HttpClient.close()` — see ADR-002). | + +### 6.8 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-concurrency # terminal 3 +``` + +`ConcurrencyApp` scripts 60 identical 800-ms-delayed responses, fires 60 `statusAsync()` calls in parallel, and after `allOf(...).join()` reads `/_admin/stats` from the mock server. Expectation: `peak_in_flight == 50` (exactly — not less, not more) and total wall-time ≈ 1.6 s (two batches of 50 + 10). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java`. + +--- + +## 7. `Response` + JSON parsing + +### 7.1 `Response` surface + +`Response` at `Response.java:26` is the carrier consumers see for every successful call. + +Constructor (line 35) is private; only the package-private static factory `wrap(...)` at line 54 builds instances. Resources call it from their `executeAndWrap` (`UtilitiesResource.java:132`): + +```java +return transport.executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); +``` + +Public accessors: + +| Method | Returns | Notes | +|---|---|---| +| `data()` | `T` | Never null. Typed result of `parser.parse(envelope, T.class)`. | +| `rawBody()` | `byte[]` | **Defensive copy on every call** (line 67 — `return rawBody.clone()`). The constructor also clones on the way in (line 43). See §10.2. | +| `statusCode()` | `int` | 200, 203, or 404 today. | +| `requestId()` | `String?` | Cloudflare `cf-ray`, or null. | +| `requestUrl()` | `URI` | Absolute URL. | +| `isJson()` / `isCsv()` / `isHtml()` | `boolean` | Mutually exclusive (one is true). | +| `isNoData()` | `boolean` | `statusCode == 404`. | +| `saveToFile(Path)` | `void` | Writes `rawBody` verbatim. `UncheckedIOException` on write failure. | +| `toString()` | `String` | Status + format + bytes + redacted URL. **Never includes `data`.** See §10.2 / §10.3. | + +The `Format` enum is package-private. Consumers query format via the boolean predicates, not the enum. That keeps `Format` free to grow new values without breaking compiled consumers (a `switch (response.format())` would otherwise be a source-compatibility hazard). + +### 7.2 `JsonResponseParser` + +`JsonResponseParser` at `JsonResponseParser.java:26` owns one `ObjectMapper` per `MarketDataClient`. Jackson mappers are thread-safe and expensive to construct, so we build one and reuse. + +The parser is **resource-agnostic**: it doesn't know about `User`, `ApiStatus`, etc. Each `*Resource` self-registers its wire-format deserializers in its constructor via `parser.registerModule(...)`. The registration must complete before the first `parse(...)` call — satisfied today because resources are constructed before any HTTP traffic. + +`parse(env, type)` at line 55: + +```java +if (env.body().length == 0) { + ErrorContext context = ErrorContext.forResponse(...); + throw new ParseError("Empty response body from " + safeUri(...) + " — server returned 0 bytes ...", context); +} +try { + return mapper.readValue(env.body(), type); +} catch (IOException e) { + ErrorContext context = ErrorContext.forResponse(...); + throw new ParseError("Failed to decode response from " + safeUri(...) + ": " + e.getMessage(), context, e); +} +``` + +The empty-body pre-check (issue #29) is §10.9. The `safeUri` in the error messages is §16's query-string redaction. + +### 7.3 `ParallelArrays` + +Most API endpoints return parallel arrays — N equal-length arrays of column values plus a leading `s` envelope status: + +```json +{ "s": "ok", + "symbol": ["AAPL", "MSFT"], + "price": [150.0, 400.0] } +``` + +`ParallelArrays.listDeserializer(fields, rowBuilder, wrapper)` at line 143 returns a `JsonDeserializer` that: +1. Reads the body as a `JsonNode`. +2. Calls `zip(p, root, fields, rowBuilder)` to produce `List`. +3. Calls `wrapper.apply(rows)` to produce the container record (e.g. `ApiStatus::new`). + +The factory collapses the ~30-line boilerplate (extend `JsonDeserializer`, read tree, call zip, build record) to three pieces: column names, row builder, container wrapper. `UtilitiesResource.wireFormatModule()` at line 51 is the canonical use site: + +```java +m.addDeserializer( + ApiStatus.class, + ParallelArrays.listDeserializer( + List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"), + row -> new ServiceStatus( + row.text("service"), + row.text("status"), + row.bool("online"), + row.dbl("uptimePct30d"), + row.dbl("uptimePct90d"), + MarketDataDates.marketTimeFromEpochSecond(row.lng("updated"))), + ApiStatus::new)); +``` + +Future endpoints with parallel-arrays bodies follow this exact shape: three lines for the column list, one row builder lambda, one container constructor reference. + +### 7.4 `ParallelArrays.zip` + +`zip(p, root, fields, rowBuilder)` at line 69: + +```java +String envelopeStatus = root.path("s").asText(""); +if ("error".equals(envelopeStatus)) { + throw new JsonMappingException(p, "API responded with error: " + errmsg); +} +if ("no_data".equals(envelopeStatus)) return List.of(); +// validate columns: each field must be a present, equal-length array +// then build rows: rows.add(rowBuilder.build(new IndexedRow(arrays, i))); +return rows; +``` + +Three envelope cases: + +- `s:"error"` — `JsonMappingException` carrying the server's `errmsg`. Caught and re-wrapped as `ParseError` by the parser. Consumer sees the server's diagnostic in the exception message. +- `s:"no_data"` — empty list. The container record's compact constructor copies it (`ApiStatus(List.of()) → services = List.copyOf(List.of())`). Combined with the 404 status, the consumer sees `response.isNoData() == true` and `response.data().services().isEmpty()`. +- Anything else (typically `"ok"`) — normal field validation runs. + +Field validation enforces presence and equal-length. Any deviation throws `JsonMappingException` → `ParseError`. The error messages name the failing column, so a server-side regression that drops `online` or returns mismatched lengths produces an actionable diagnostic. + +### 7.5 Strict `Row` accessors + +`Row.text(field)`, `.bool(field)`, `.dbl(field)`, `.lng(field)` all throw `JsonMappingException` if the cell is null, missing, or the wrong JSON type (`ParallelArrays.java:197+`). The reasoning lives in the class Javadoc at line 30: + +> The previous lenient behavior — substituting `""`, `false`, `0.0`, `0` for missing cells — masked real server bugs: e.g. a regression that dropped the `online` column would have silently flipped every service to `online=false`, propagating to `StatusCache` decisions and blocking retries across the board. + +If a future endpoint legitimately has nullable columns, `textOr(field, default)` overloads can be added then — explicitly, per-column. Pre-emptive lenience is rejected. + +### 7.6 Cases to call out + +| Body | Status | Outcome | +|---|---|---| +| Parallel-arrays `s:"ok"` | 200/203 | `Response` with typed `data()`. | +| `{"s":"no_data"}` | 404 | `Response` with empty container, `isNoData() == true`. No exception. | +| `{"s":"error", "errmsg":"…"}` | any | `ParseError` containing the server's `errmsg`. | +| Truncated arrays (one column shorter than the others) | 200 | `ParseError` with the offending column name. | +| Empty body (proxy stripped) | 200 | `ParseError` with the "Empty response body" message. | +| Malformed JSON | 200 | `ParseError` wrapping Jackson's `IOException`. | +| Body with the wrong type for a column (e.g. `"true"` string where boolean expected) | 200 | `ParseError` from the strict `Row` accessor. | + +### 7.7 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-response # terminal 3 +``` + +`ResponseFeaturesApp` exercises: `isJson/isCsv/isHtml` mutual exclusion, the 404 + `{"s":"no_data"}` success envelope (`isNoData() == true`), `rawBody()` defensive-copy (mutating the returned array doesn't affect a second call), `saveToFile(...)` round-trip, and `toString()` log-safety (no `data`, query string redacted). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java`. + +--- + +## 8. Sealed exception hierarchy + +### 8.1 The seven permits + +`MarketDataException.java:10`: + +```java +public abstract sealed class MarketDataException extends RuntimeException + permits AuthenticationError, + BadRequestError, + NotFoundError, + RateLimitError, + ServerError, + NetworkError, + ParseError { + // ... +} +``` + +The list is fixed at **seven**. Per [ADR-002](adr/ADR-002-minimum-jdk-version.md), the JDK-17 floor was chosen specifically so this hierarchy could be sealed — adding an 8th permit in a future major version must break consumer `switch` exhaustiveness at compile time. That's the contract a sealed type promises; adding a permit without amending ADR-002 would forfeit it. A reviewer who sees a PR with an 8th permit (or a removed one) should check for an accompanying ADR amendment. + +`RuntimeException` (not `Exception`) is the base — checked exceptions in resource façades would force consumers into ceremonial `try/catch` for every call. The sealed hierarchy gives them the same compile-time safety without the boilerplate. + +### 8.2 `ErrorContext` + +`ErrorContext.java:6`: + +```java +public record ErrorContext( + @Nullable String requestId, String requestUrl, int statusCode, Instant timestamp) { + + public static ErrorContext forResponse(String requestUrl, int statusCode, @Nullable String requestId, Instant timestamp); + public static ErrorContext forNoResponse(String requestUrl, Instant timestamp); +} +``` + +`forResponse` is for HTTP-level errors (4xx/5xx). `forNoResponse` is for failures that didn't produce a response (`NetworkError` from `ConnectException`, `RateLimitError` from preflight). The latter sets `statusCode = 0` and `requestId = null` — the `0` sentinel is what `RetryPolicy.isRetriable` checks against to exclude `ServerError(statusCode=0)` from its 501–599 retriable range. + +### 8.3 `MarketDataException` API + +| Method | Returns | Notes | +|---|---|---| +| `getContext()` | `ErrorContext` | Full context with the **raw** `requestUrl`. Use this when the consumer has discretion to log/process the full URI. | +| `getRequestId()` | `@Nullable String` | `cf-ray` header value, or null. | +| `getRequestUrl()` | `String` | **Query-redacted** URL (line 44). The query string is replaced by `?…`. Mirrors `safeUri` in dispatchers/parsers — every getter that might land in ambient logs respects §16. | +| `getStatusCode()` | `int` | 0 for `forNoResponse`. | +| `getTimestamp()` | `Instant` | When the SDK observed the failure. | +| `getExceptionType()` | `String` | Simple class name (`"ServerError"`, etc.) for logging / dashboarding. | +| `getSupportInfo()` | `String` | Multi-line dump (line 75); ready to paste into a support ticket. | + +`getSupportInfo()` formats with a fixed-width label column and a timestamp in `America/New_York`: + +``` +--- MARKET DATA SUPPORT INFO --- +request_id: 76a40b21d5e1c0a4-IAD +request_url: /user/?… +status_code: 401 +timestamp: 2026-05-21 09:12:34 +message: Authentication failed +exception_type: AuthenticationError +-------------------------------- +``` + +The Eastern timezone is hard-coded (`MarketDataException.java:90`). See §10.14. + +### 8.4 `HttpStatusMapper` + +`HttpStatusMapper.map(statusCode, context, retryAfter)` at `HttpStatusMapper.java:43`: + +```java +return switch (statusCode) { + case 400 -> new BadRequestError("Bad request", context); + case 401 -> new AuthenticationError("Authentication failed", context); + case 404 -> new NotFoundError("Not found", context); + case 429 -> new RateLimitError("Rate limit exceeded", context, null, retryAfter); + default -> mapByRange(statusCode, context, retryAfter); +}; +``` + +`mapByRange` at line 57 handles the broad buckets: + +- 500–599 → `ServerError` (retriable per `RetryPolicy.isRetriable`). +- 4xx (other than 401/404/429) → `BadRequestError` with the actual status in the message. +- 3xx → `BadRequestError` "Unhandled redirect" (the `HttpClient` is configured with `NORMAL` redirect following; a 3xx surviving means the redirect couldn't be followed). +- 1xx → `BadRequestError` "Unexpected informational response" (defensive; `HttpClient` handles `100 Continue` itself). +- Negative / >599 → `BadRequestError` "Unexpected HTTP status". + +**The `case 404 -> NotFoundError` is currently dead code.** `HttpTransport.routeAndEnvelope` short-circuits all 404 responses to the success envelope (line 304) before `map(...)` is consulted. Resolving this — either by requiring an `s:"no_data"` body before routing 404 as success, or by removing `NotFoundError` from the sealed permits via an ADR amendment — is a follow-up. See PR.md "Out of scope / known caveats". + +### 8.5 Consumer-side routing + +A consumer routes by the sealed hierarchy. With JDK 21+ pattern switches: + +```java +try { + client.utilities().user(); +} catch (MarketDataException e) { + String label = switch (e) { + case AuthenticationError a -> "AUTH"; + case BadRequestError b -> "BAD_REQUEST"; + case NotFoundError n -> "NOT_FOUND"; + case RateLimitError r -> "RATE_LIMITED (retryAfter=" + r.getRetryAfter() + ")"; + case ServerError s -> "SERVER (status=" + s.getStatusCode() + ")"; + case NetworkError n -> "NETWORK"; + case ParseError p -> "PARSE"; + }; + // routed +} +``` + +The switch is exhaustive: no `default` clause is needed because the sealed hierarchy is closed. On the SDK's minimum JDK 17, the same routing uses an `instanceof` chain. + +### 8.6 Cases to call out + +| Wire condition | Mapped exception | +|---|---| +| HTTP 401 | `AuthenticationError`. | +| HTTP 400 | `BadRequestError`. | +| HTTP 403, 422, 405, etc. | `BadRequestError` with the actual status in the message. | +| HTTP 429 + `Retry-After: 60` | `RateLimitError` with the parsed Duration on `getRetryAfter()`. **Not retried** by the policy. | +| HTTP 500 | `ServerError`. **Not retriable** (statusCode == 500 is not in [501, 599]). | +| HTTP 503 + `Retry-After: 5` | `ServerError` with `getRetryAfter().isPresent()` and `getStatusCode() == 503`. **Retriable**. | +| Preflight blocked (snapshot remaining=0) | `RateLimitError` with `statusCode == 0` and `requestId == null`. | +| Connection refused | `NetworkError` with the `ConnectException` as cause. | +| 200 + body Jackson can't parse | `ParseError` with the underlying `IOException` as cause. | + +### 8.7 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-exceptions # terminal 3 +``` + +`ExceptionsApp` round-trips each sealed permit through a scripted scenario: 401 → `AuthenticationError`, 400 → `BadRequestError`, 429 + `Retry-After` → `RateLimitError`, 500 → `ServerError` (1 server-side request, no retries), 503×4 → `ServerError` after ~7 s (3 retries with exponential backoff), malformed JSON → `ParseError`, empty body → `ParseError` with the precise message, connection refused → `NetworkError` after retries. The final scenario uses an `instanceof` chain over the sealed type to prove the routing surface (the JDK 21+ pattern-switch equivalent is in source comments as reference). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java`. + +`NotFoundError` is currently unreachable end-to-end (see §8.4 — `HttpTransport.routeAndEnvelope` short-circuits all 404 to the success branch). The demo notes this explicitly and skips that scenario rather than fabricating it. + +--- + +## 9. Configuration & logging + +### 9.1 Cascade rungs + +Already covered structurally in §3.2 ("Configuration cascade"). The order is: **explicit → env var (`MARKETDATA_*`) → `.env` → default**. + +`Configuration.pickFirst(...)` (line 220) and `pickFirstOrDefault(...)` (line 229) walk the rungs in that order. The first non-null, non-blank candidate wins. Blank strings (`" "`) are treated as absent — a `.env` file with `MARKETDATA_TOKEN=` should not produce a blank token. + +`EnvVars.ALLOWED_KEYS` is the explicit whitelist passed to `DotEnvLoader.load(...)`. Any key in the `.env` that isn't on the list is silently skipped — that's the §16 minimization rule (the SDK doesn't read env vars it doesn't know about). + +`EnvVars.systemLookup()` returns a `Function` that only reads from the allowed set, with a `null` for unknown keys. SDK code that needs to read env vars goes through this function, never `System.getenv()` directly. + +### 9.2 `MarketDataLogging.configure` + +`MarketDataLogging.java:63`: + +```java +static void configure(@Nullable String levelSpec) { + Level requested = parseLevel(levelSpec); + if (configured.get()) { + // idempotency: first install wins + return; + } + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + if (sdkLogger.getHandlers().length > 0 || sdkLogger.getLevel() != null) { + // consumer pre-config detected — back off entirely + // DO NOT latch `configured` here + return; + } + if (!configured.compareAndSet(false, true)) return; // lost the race + Handler handler = new ConsoleHandler(); + handler.setFormatter(new CanonicalLogFormatter()); + handler.setLevel(Level.ALL); + sdkLogger.addHandler(handler); + sdkLogger.setUseParentHandlers(false); + sdkLogger.setLevel(requested); +} +``` + +Two paths matter: + +1. **First call, no consumer pre-config.** Install the SDK's `ConsoleHandler` + `CanonicalLogFormatter`. Set `useParentHandlers = false` so the JDK's default root handler doesn't re-emit each record with `SimpleFormatter`'s shape. Latch `configured` so subsequent calls are no-ops. +2. **Consumer pre-config detected.** If the consumer (or another library) already attached a handler or set a level on `com.marketdata.sdk` before `MarketDataClient` was constructed, the SDK installs nothing. Crucially, `configured` is **not** latched on this path. The consumer might later remove their handler / clear their level — a subsequent `configure(...)` call should be allowed to install the SDK defaults then. Latching here would freeze the SDK out for the lifetime of the process. + +`parseLevel(...)` at line 113 maps `DEBUG/INFO/WARNING/ERROR` (case-insensitive) to JUL levels (`FINE/INFO/WARNING/SEVERE`). Unknown values fall back to `INFO` with a logged warning — silent fallback was the worst of both worlds (consumer types something wrong, sees `INFO` output instead of the `DEBUG` they expected, with no breadcrumb). + +### 9.3 `Tokens.redact` + +`Tokens.java:16`: + +```java +static String redact(@Nullable String token) { + if (token == null || token.length() <= 8) return "***…***"; + return "***…***" + token.substring(token.length() - 4); +} +``` + +The 8-char hinge is the design decision. At ≤ 4 chars, the last-4 *is* the full token. At 5–7 chars, the last-4 is 57–80% of the value — still too leaky. At ≥ 9 chars, the last-4 is at most 44% of the token, enough material for a human to disambiguate which token is in use without enabling someone with log access to use it. + +The function is called from: +- `MarketDataClient.toString()` (line 199) and the INFO log (line 75). +- Tests that verify redaction. + +It is **not** called from exception messages — that path goes through `safeUri` instead, which strips entire query strings. Tokens never make it onto query strings via SDK code (they're always in `Authorization` headers), so there's no SDK-emitted message that needs `Tokens.redact` for a query-string token. The two surfaces handle disjoint risks. + +### 9.4 Query-string redaction + +`HttpDispatcher.safeUri(URI)` at `HttpDispatcher.java:146`: + +```java +String path = uri.getPath(); +if (path == null) return uri.toString(); // opaque URI fallback +return uri.getRawQuery() != null ? path + "?…" : path; +``` + +Used everywhere a URL might land in ambient logs: +- `HttpDispatcher` request/response log lines. +- `HttpTransport.routeAndEnvelope` exception log. +- `MarketDataException.getRequestUrl()` (via `redactQuery` mirroring the same convention — see §10.3). +- `Response.toString()` (line 147). +- `JsonResponseParser.parse` error message (line 67, 82). + +The full URI is preserved on `ErrorContext.requestUrl` for consumer-side diagnostic access via `e.getContext().requestUrl()`. Ambient logs ≠ exception context. + +### 9.5 `CanonicalLogFormatter` + +The §7 shape: `{timestamp} - {logger_name} - {level} - {message}`. Implementation at `CanonicalLogFormatter.java`. Tested by `CanonicalLogFormatterTest`. + +### 9.6 Cases to call out + +| Scenario | Behavior | +|---|---| +| No env vars, no `.env`, no explicit args | Demo mode (apiKey null). `baseUrl` and `apiVersion` fall through to defaults. INFO logger. | +| `MARKETDATA_BASE_URL` set, explicit `baseUrl = null` | env var wins. | +| `MARKETDATA_BASE_URL` set, explicit `baseUrl = "https://prod"` | explicit wins. | +| `.env` carries `MARKETDATA_TOKEN`, env var doesn't, no explicit | `.env` wins. | +| `.env` is unreadable | Warning collected. Logged after `configure(...)` runs; attached as suppressed if `resolve` throws downstream. | +| Consumer ran `Logger.getLogger("com.marketdata.sdk").addHandler(myHandler)` before `new MarketDataClient(...)` | SDK detects, installs nothing, `configured` stays `false`. | +| Consumer later removes their handler, then constructs a second client | Now SDK installs its handler — first-install wins from that point. | +| `MARKETDATA_LOGGING_LEVEL=lolwut` | `INFO` with a logged warning. | + +### 9.7 Verify locally + +Configuration cascade and the token-redaction hinge are exercised by `make demo-config` (same demo as §3.4 — `DemoAndConfigApp`). + +End-to-end logging coverage is not in a dedicated demo today: there is no `LoggingApp` that toggles `MARKETDATA_LOGGING_LEVEL` between runs and verifies the canonical-format output or the consumer-pre-config detection. A reviewer who wants to eyeball the logger shape can: + +```bash +MARKETDATA_LOGGING_LEVEL=DEBUG make demo-live +``` + +and inspect stderr for the `{timestamp} - com.marketdata.sdk - {level} - {message}` shape and the FINE-level retry attempts. The consumer-pre-config detection is covered by `MarketDataLoggingTest` in unit tests rather than a runnable demo. + +--- + +## 10. Subtle corners (issue-driven) + +These are the spots a reviewer would otherwise rabbit-hole on. Each one names the file, what looks weird, and why. + +### 10.1 Token redaction hinge at 8 characters + +`Tokens.java:17`. Tokens ≤ 8 chars are fully masked (`***…***`); >8 chars get the last-4 suffix (`***…***ABCD`). The hinge is set because for short tokens the suffix would be most of the value: at 4 chars it's the whole thing; at 5–7 it's 57–80%. >8 caps the suffix's share of the token at 44%. Reviewer: don't be tempted to "always show the last 4" — that defeats the whole point for short tokens. + +### 10.2 Defensive copy on both ends of `rawBody` + +`Response.java:43` (constructor clones in) and `Response.java:67` (getter clones out). Belt-and-suspenders. The wrap factory passes `envelope.body()` directly; if a later refactor accidentally lets the envelope's body be a mutable buffer, the constructor's clone is the firewall. The getter's clone is what prevents a consumer's `byte[] b = response.rawBody(); b[0] = 'X';` from poisoning the next `rawBody()` call. The `toString` redaction (§10.3) is a separate firewall on a separate surface. + +### 10.3 Query-string redaction in two layers + +There are two redaction surfaces: + +- **Ambient logs** — `HttpDispatcher.safeUri(URI)` (line 146). Used wherever the SDK *itself* logs a URL. Replaces the query string with `?…`. +- **Exception getter** — `MarketDataException.getRequestUrl()` (line 44, via `redactQuery(String)`). Used when a consumer's `logger.error("Failed: " + e.getRequestUrl())` would otherwise persist the raw URL. + +Both surfaces converge on the same shape (`path?…`) but live in different places because the URL types differ (`URI` for fresh dispatches, `String` for stored `ErrorContext.requestUrl`). `getContext().requestUrl()` still exposes the raw URL — that's the discretionary path for diagnostic code that knows what it's doing. + +### 10.4 Strict deserialization in `ParallelArrays.Row` + +`ParallelArrays.java:160+`. Every accessor throws `JsonMappingException` on null, missing, or wrong-type cells. The class Javadoc explains why: a previous lenient version silently substituted sentinel values, masking server-side regressions (a dropped `online` column flipping every service to `online=false`). A reviewer who's tempted to add `textOr(field, default)` overloads should do so per-column-per-endpoint, only when the field is *contractually* nullable on that endpoint — not as a global escape hatch. + +### 10.5 Status path canonicalization to trailing slash (issue #18) + +`StatusCache.java:130`. `lookupService` does longest-path-prefix matching. Without trailing-slash normalization, a key `/v1/stock` would falsely match `/v1/stocks/quotes/AAPL/` (path-component boundary not respected). Canonicalization happens at *snapshot construction* (line 161) so keys are stored with a trailing slash; the lookup also appends a slash to the input path before comparing. One malformed/truncated server-side entry can no longer block retries for an unrelated service. + +### 10.6 `Retry-After` 10-minute cap (issue #21) + +`RetryPolicy.java:44`. A compromised or buggy backend that emits `Retry-After: 9999999999` would otherwise freeze the next attempt for ~292 billion years inside `CompletableFuture.delayedExecutor`. The cap is intentionally generous (10 minutes) so legitimate "come back in an hour" hints would still… wait, no, anything above 10 min falls back to exponential. The raw value remains visible on `ServerError.getRetryAfter()` so consumers can decide for themselves (surface to a human, schedule a real cool-off via a job runner). The cap only controls the SDK's *automatic* wait. + +### 10.7 Re-check after `triggerRefresh` in `StatusCache` (issue #19) + +`StatusCache.java:69`. When a refresh is needed, `triggerRefresh()` fires the fetcher and returns immediately — production fetchers go through `HttpTransport`, which is async. But in tests, a stub fetcher returning `CompletableFuture.completedFuture(...)` completes *synchronously* — the `whenComplete` populating `snapshot` runs inside the same call. Without the post-trigger re-read, the local `snap` variable is still the null we captured before `triggerRefresh`, and `check` always answers ALLOW on a cold start even when the cache *now* says BLOCK. The fix is one line: re-read `snapshot.get()` after `triggerRefresh()`. Production path is unaffected (the future is genuinely async; the re-read just observes the still-null snapshot). + +### 10.8 Printable-ASCII validation of `apiKey` rejecting CRLF (issue #23) + +`Configuration.java:189`. Tokens with CR/LF embedded — usually from a `.env` file edited on Windows or copy-pasted from an email — would later be rejected by `HttpRequest.Builder#header` with a generic `IllegalArgumentException` from the bowels of `HttpClient`, far from the actual configuration source. Validating at constructor time produces a clear, sourced message: "apiKey contains an invalid character at offset N (code point 0xXX)". The rule (`[0x20, 0x7E]`) is permissive enough for every legitimate token shape while ruling out NUL, DEL, high-bit bytes, and CR/LF. + +### 10.9 Empty-body pre-check before Jackson (issue #29) + +`JsonResponseParser.java:60`. A zero-length body produces a generic `"No content to map"` from Jackson — diagnostically thin, often confusing in the presence of a body-stripping proxy. The pre-check produces a précis message: `"Empty response body from /user/?… — server returned 0 bytes (a proxy may have stripped the payload, or the endpoint replied without one)"`. The message names the actual symptom and the most common cause. + +### 10.10 Full IOException cause-chain walk for retry classification (issue #15) + +`RetryPolicy.java:173`. `HttpClient` under HTTP/2 multiplexing — particularly on certain JDK versions — can present an `IOException` nested under an `ExecutionException` or `CompletionException` wrapper that `HttpDispatcher`'s single-level `unwrap` doesn't peel. Without the walk, legitimate transport failures fall out of retry silently — the SDK loses §9 resilience under exactly the load conditions that need it. + +The walk is depth-capped at 16 and detects self-cycles. Both are defensive: `Throwable.getCause()` cycles are theoretically impossible but cheap to guard. + +### 10.11 Preflight bypass on server-hinted retry + +`HttpTransport.java:184` + `:206`. A retry following a `503 + Retry-After: 5` is server-orchestrated — the server has told us "come back at `now + 5s`". Our local rate-limit snapshot (whose `reset` may be unrelated and hours in the future) must not veto that directive. The bypass detects this exact case: `previousCause instanceof ServerError server && server.getRetryAfter().isPresent()`. Only ServerError-with-parsed-Retry-After qualifies — a generic 503 retry still goes through preflight. + +### 10.12 Deferred `StatusCache` construction via `AtomicReference` supplier + +`MarketDataClient.java:90` + `HttpTransport.java:60`. The cache's fetcher uses `utilities.statusAsync()`, which goes through `HttpTransport`. So: + +- Transport needs cache (to gate retries). +- Cache needs `utilities`. +- `utilities` needs transport. + +Chicken-and-egg. The resolution is `cacheRef::get` — a `Supplier` passed to the transport that returns `null` until the cache is constructed below. `HttpTransport.cacheAllowsRetry` (line 240) handles `null` gracefully by short-circuiting to `true`. This means: + +- During construction (before `cacheRef.set(...)`), the transport behaves as if there's no cache. +- After construction (`cacheRef.set(...)`), every subsequent call sees the cache. + +The startup validation (`runStartupValidation` → `utilities.validateAuth`) runs *after* `cacheRef.set(...)` but uses `RetryPolicy.noRetry()` anyway, so the cache wouldn't be consulted on its retry path even if it weren't there. + +### 10.13 `joinSync` unwraps `CompletionException` + +`HttpTransport.java:264`. `CompletableFuture.join()` wraps any failure as `CompletionException`. Per ADR-006 the SDK's sync contract is to surface `MarketDataException` directly. `joinSync` catches `CompletionException`, calls `asRuntime(e.getCause(), clock)`, and re-throws. + +`asRuntime(cause)` (line 399) has three branches: + +- `cause` is `MarketDataException` → return it (the common path). +- `cause` is some other `RuntimeException` → return it (defensive — shouldn't happen in production). +- Anything else → wrap as `NetworkError` with `forNoResponse` context. + +The two non-`MarketDataException` branches are unreachable from the public API today; they exist so a future bug doesn't surface as a confusing `CompletionException` to the consumer. + +### 10.14 `getSupportInfo` timestamps hard-coded to America/New_York + +`MarketDataException.java:90`: + +```java +private static final DateTimeFormatter EASTERN_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("America/New_York")); +``` + +The market-data API operates in Eastern time. Support staff reading a pasted `getSupportInfo()` dump should be able to correlate against their tooling without timezone math. The `Instant` is preserved on `ErrorContext.timestamp()` for consumers who want UTC. This is a deliberate divergence from "always use UTC in interfaces" — the support-info dump is for humans, not machines, and the humans live in ET. diff --git a/examples/consumer-test/.gitignore b/examples/consumer-test/.gitignore new file mode 100644 index 0000000..ec5c286 --- /dev/null +++ b/examples/consumer-test/.gitignore @@ -0,0 +1,17 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +out/ +.vscode/ + +# OS +.DS_Store + +# Local config (may contain MARKETDATA_TOKEN) +.env +.env.local diff --git a/examples/consumer-test/README.md b/examples/consumer-test/README.md new file mode 100644 index 0000000..ccf9915 --- /dev/null +++ b/examples/consumer-test/README.md @@ -0,0 +1,112 @@ +# consumer-test + +A collection of small runnable apps that exercise every consumer-facing +behavior of `marketdata-sdk-java`. Each app stands alone — pick the one that +matches the scenario you want to see, run it, read the console output. + +Lives under `examples/` rather than as a `src/test` source set on purpose: it +consumes the SDK as an *external* artifact (via `mavenLocal`), so the demos +exercise exactly the shape a published JAR exposes — no accidental package- +private leaks, no access to internal seams. + +## One-time setup + +```bash +# 1. Publish the SDK to your local Maven cache. Run from the SDK root +# (two directories up). The Makefile wraps it: +cd ../.. +make publish + +# 2. (For runLive only) put your token in this directory's .env: +echo "MARKETDATA_TOKEN=your-token-here" > examples/consumer-test/.env +``` + +## Running + +From the SDK root, the easy path is `make` (see `make help` for the full list): + +```bash +make demo-quickstart # idiomatic per-resource tour — live API, no mock +make demo-live # full plumbing smoke — live API, no mock +make demo-config # config, validation, demo mode — needs mock server +make demo-exceptions # every MarketDataException subtype — needs mock server +make demo-retry # retry, Retry-After, preflight — needs mock server +make demo-response # Response features — needs mock server +make demo-concurrency # 50-permit semaphore — needs mock server +make demos-all # the five mock-server demos back-to-back +``` + +Or directly from this directory, bypassing the Makefile: + +```bash +./gradlew tasks --group "consumer demos" # list all apps +./gradlew runLive # same as `make demo-live` +./gradlew runDemoConfig # etc. +``` + +Apps that say "needs mock server" require the mock running in another +terminal. Easiest: + +```bash +make mock-server # from the SDK root +# or, equivalently: +cd ../mock-server && ./run.sh +``` + +Without it the demo fails fast with a clear "server not reachable" message. + +## What each app shows + +| App | Scenario | What you should see | +|---|---|---| +| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers). | +| **LiveSmokeApp** | The happy path against the real API | client.toString redacted; sync + async parity on status/user/headers; parallel calls completing in ≈ slowest-single-call wall-time; final rate-limit snapshot populated | +| **DemoAndConfigApp** | Construction-time behavior | demo-mode skip; §16 token redaction (short ≤8 → full; >8 → ***…***ABCD); cascade (explicit wins); IAE on invalid baseUrl / CRLF API key; validateOnStartup 200 vs 401 paths | +| **ExceptionsApp** | Every sealed exception subtype | 401 → AuthenticationError; 400 → BadRequestError; 429 → RateLimitError (+ Retry-After); 500 → ServerError (no retry); 503×4 → ServerError after ≈7s; malformed JSON → ParseError; empty body → ParseError (§29 fix message); connection refused → NetworkError after retries; ADR-002 sealed routing via instanceof | +| **RetryBehaviorApp** | The §9 retry contract | 503→503→200 recovers in ≈3s; Retry-After delta overrides exponential; Retry-After HTTP-date honored; pathological Retry-After (1 day) capped at 10 min — falls back to exponential; §10.3 preflight blocks the 2nd request when snapshot reports remaining=0 (0ms wall-time, 0 server-side requests) | +| **ResponseFeaturesApp** | §13.5 Response surface | isJson / isCsv / isHtml mutually exclusive; 404 + `{"s":"no_data"}` returns successfully with isNoData=true; rawBody() is a defensive copy (consumer mutations don't leak); saveToFile() writes verbatim; toString() omits data + redacts query (§16) | +| **ConcurrencyApp** | §12 / ADR-007 50-permit semaphore | 60 parallel calls; server observes peak in-flight = exactly 50; total wall-time ≈ 2× per-call delay (two batches of 50+10) | + +## Adding a section to QuickstartApp + +`QuickstartApp` is the only app in this directory designed to be **extended** as +the SDK grows. The other six prove a fixed contract; this one is the running +catalog of "what each resource looks like in consumer code". + +When a new resource lands on the SDK: + +1. Open `src/main/java/com/marketdata/consumer/QuickstartApp.java`. +2. Uncomment the matching placeholder line in `main(...)` (e.g. + `// stocksExamples(client);`). +3. Implement `xxxExamples(MarketDataClient client)` following the + `utilitiesExamples` shape: one `Console.step` + one short SDK call + one + `Console.ok` per typical use case. Catch `AuthenticationError` separately + when the endpoint needs a token, so the demo stays runnable in demo mode. +4. Keep each example to **3–5 lines of SDK code** — the goal is "what you'd + copy-paste into your own app", not exhaustive coverage. Edge cases belong + in the other demos. + +## How the mock server fits + +The mock server (FastAPI, `../mock-server/`) is what makes the +non-live demos deterministic. Each demo POSTs a list of scripted responses +to `/_admin/script`, then makes its real SDK call against the same host — +the server's catch-all pops exactly the scripted response. Apps that need to +see specific status codes, headers (Retry-After, x-api-ratelimit-*), or +timing behavior depend on this scripting. + +The control plane (`/_admin/*`) is hit with a plain `java.net.http.HttpClient`, +not the SDK — keeping the SDK's surface uncluttered. + +> Note: `MockServerControl` forces HTTP/1.1 because uvicorn's HTTP/2 upgrade +> handling drops POST bodies during the negotiation. The SDK itself stays on +> its ADR-004 HTTP/2 default — only the admin client downgrades. + +## Caveats + +- The local `.env` has a token that's been used during development. If the + token is no longer valid against api.marketdata.app, `runLive` will show + `AuthenticationError` on calls that need it (status/ stays public). +- "Demo mode" (no token at all) is hard to see if you have a token in your + env or `.env` — the cascade picks it up. The demo detects this and prints + a skip note rather than constructing a misleading client. diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts new file mode 100644 index 0000000..8de5ec1 --- /dev/null +++ b/examples/consumer-test/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + application +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { + implementation("com.marketdata:marketdata-sdk-java:0.1.0-SNAPSHOT") +} + +// Default `./gradlew run` lands on the live-API smoke. The other apps are +// reachable via the named tasks below — each one is its own self-contained +// scenario walk-through. +application { + mainClass = "com.marketdata.consumer.LiveSmokeApp" +} + +// Each demo gets its own JavaExec task in the same Gradle group so +// `./gradlew tasks --group "consumer demos"` lists them all. +val demoApps = mapOf( + "runQuickstart" to ("com.marketdata.consumer.QuickstartApp" to + "Idiomatic per-resource usage. Grows as new resources land."), + "runLive" to ("com.marketdata.consumer.LiveSmokeApp" to + "Live API smoke (needs MARKETDATA_TOKEN)."), + "runDemoConfig" to ("com.marketdata.consumer.DemoAndConfigApp" to + "Demo mode, configuration cascade, validation. Needs mock server."), + "runExceptions" to ("com.marketdata.consumer.ExceptionsApp" to + "Round-trip each MarketDataException subtype. Needs mock server."), + "runRetry" to ("com.marketdata.consumer.RetryBehaviorApp" to + "Retry policy, Retry-After header, preflight gate. Needs mock server."), + "runResponse" to ("com.marketdata.consumer.ResponseFeaturesApp" to + "Response surface: predicates, isNoData, rawBody, saveToFile, toString. Needs mock server."), + "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to + "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server.") +) + +demoApps.forEach { (taskName, app) -> + val (mainClassName, taskDescription) = app + tasks.register(taskName) { + group = "consumer demos" + description = taskDescription + mainClass = mainClassName + classpath = sourceSets["main"].runtimeClasspath + // Inherit stdio so the demo's println output is visible in the console + // and matches what a real consumer would see when they run their own app. + standardInput = System.`in` + } +} + diff --git a/examples/consumer-test/gradle/wrapper/gradle-wrapper.jar b/examples/consumer-test/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties b/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cea7a79 --- /dev/null +++ b/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/consumer-test/gradlew b/examples/consumer-test/gradlew new file mode 100755 index 0000000..f3b75f3 --- /dev/null +++ b/examples/consumer-test/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/consumer-test/gradlew.bat b/examples/consumer-test/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/examples/consumer-test/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/consumer-test/settings.gradle.kts b/examples/consumer-test/settings.gradle.kts new file mode 100644 index 0000000..88ac292 --- /dev/null +++ b/examples/consumer-test/settings.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "consumer-test" + +dependencyResolutionManagement { + repositories { + // mavenLocal first so the SNAPSHOT we just published is found before + // hitting Maven Central (where it doesn't exist). + mavenLocal() + mavenCentral() + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java new file mode 100644 index 0000000..77a4abf --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java @@ -0,0 +1,93 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Response; +import com.marketdata.sdk.utilities.ApiStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * §12 / ADR-007 concurrency: the SDK's AsyncSemaphore holds at most 50 + * in-flight HTTP requests. This demo fires 60 calls in parallel against the + * mock server, asks each one to hang for 800 ms, and then reads + * /_admin/stats — the {@code peak_in_flight} the server observed should be + * exactly 50. + * + *

    The other 10 requests sit in the semaphore's wait queue and drain after + * the first batch completes, so the total wall-clock is ≈ 2 × 800 ms even + * though all 60 were dispatched at t=0. + * + *

    Run: {@code ./gradlew runConcurrency} + */ +public final class ConcurrencyApp { + private ConcurrencyApp() {} + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + mock.reset(); + + // Script 60 identical slow responses so the SDK's semaphore is the only thing throttling. + int fanout = 60; + int delayMs = 800; + String okBody = validStatusBody(); + List steps = new ArrayList<>(fanout); + for (int i = 0; i < fanout; i++) { + steps.add(Step.of(200, okBody).delayMs(delayMs)); + } + mock.script(steps); + + Console.header( + "Firing " + fanout + " parallel async calls; each response delayed " + delayMs + " ms"); + Console.info( + "Expectation: peak_in_flight = 50 (the ADR-007 semaphore cap), total wall-clock ≈ " + + (delayMs * 2) + + " ms (2 batches: 50 then 10)."); + + try (var client = + new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + long t0 = System.nanoTime(); + List>> futures = new ArrayList<>(fanout); + for (int i = 0; i < fanout; i++) { + futures.add(client.utilities().statusAsync()); + } + CompletableFuture all = + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + all.join(); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + + Console.ok("all " + fanout + " calls completed in " + elapsedMs + " ms"); + MockServerControl.Stats stats = mock.stats(); + Console.info("server saw " + stats.requests() + " requests (expected " + fanout + ")"); + Console.info("peak_in_flight observed by server: " + stats.peakInFlight()); + + if (stats.peakInFlight() == 50) { + Console.ok("§12 honored exactly: 50 concurrent, no more, no less."); + } else if (stats.peakInFlight() < 50) { + Console.fail( + "peak below cap (" + + stats.peakInFlight() + + ") — system was slow to dispatch, retry on a quiet machine"); + } else { + Console.fail("peak ABOVE cap (" + stats.peakInFlight() + ") — §12 violated"); + } + } + } + + private static String validStatusBody() { + long now = System.currentTimeMillis() / 1000L; + return "{\"s\":\"ok\"," + + "\"service\":[\"/v1/markets/status/\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[" + + now + + "]}"; + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java new file mode 100644 index 0000000..63d968a --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java @@ -0,0 +1,197 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; + +/** + * Configuration cascade, demo mode, validation, and the §16 token-redaction + * promises — all things that fire at construction time, before any request is + * made. + * + *

    Some scenarios use the mock server (start it first: {@code cd + * ../mock-server && ./run.sh}) so the live API can't accidentally + * influence the outcome. + * + *

    Run: {@code ./gradlew runDemoConfig} + */ +public final class DemoAndConfigApp { + private DemoAndConfigApp() {} + + public static void main(String[] args) { + new MockServerControl().requireUp(); + + demoModeNoToken(); + tokenRedactionShort(); + tokenRedactionLong(); + explicitOverridesEnv(); + invalidBaseUrlFailsAtConstruct(); + invalidApiKeyCrlfFailsAtConstruct(); + validateOnStartupSucceedsAgainstMockServer(); + validateOnStartupFailsOn401(); + } + + // ---------- demo mode ---------- + + private static void demoModeNoToken() { + Console.header("Demo mode: no token → demoMode=true, validateOnStartup is a no-op"); + // The §4 cascade resolves the token from explicit → MARKETDATA_TOKEN env → .env → null. + // If any earlier rung populated a token (the local .env in this repo always does), the + // 4-arg constructor with apiKey=null still picks it up — that's correct cascade behavior, + // but it means "demo mode" can only be observed when ALL upstream sources are empty. + if (anyTokenSourcePopulated()) { + Console.info( + "Skipping live demo-mode construction: a token is available somewhere in the cascade"); + Console.info( + "(env var MARKETDATA_TOKEN and/or .env file), so the 4-arg ctor with apiKey=null still"); + Console.info( + "resolves a real token. To see demo mode live: unset the env var AND remove .env, then"); + Console.info("re-run this app."); + Console.info(""); + Console.info("Static verification of demo mode's existence:"); + Console.info( + " - MarketDataClient.toString() prints `demoMode=true` when Configuration.apiKey() is null"); + Console.info( + " - runStartupValidation() short-circuits in demo mode (see DemoMode.isDemo)"); + return; + } + try (var client = new MarketDataClient(null, MockServerControl.BASE_URL, null, true)) { + Console.info(client.toString()); + Console.ok("constructor succeeded — demo mode skipped the /user/ probe"); + } + } + + /** True if either the env var or a readable {@code .env} in CWD contains MARKETDATA_TOKEN. */ + private static boolean anyTokenSourcePopulated() { + String envValue = System.getenv("MARKETDATA_TOKEN"); + if (envValue != null && !envValue.isBlank()) { + return true; + } + java.nio.file.Path dotEnv = java.nio.file.Path.of(".env"); + if (java.nio.file.Files.isReadable(dotEnv)) { + try { + for (String line : java.nio.file.Files.readAllLines(dotEnv)) { + if (line.trim().startsWith("MARKETDATA_TOKEN=")) { + String value = line.substring(line.indexOf('=') + 1).trim(); + if (!value.isEmpty() && !value.equals("\"\"")) { + return true; + } + } + } + } catch (java.io.IOException ignored) { + // Treat unreadable .env as "no token there". + } + } + return false; + } + + // ---------- §16 token redaction ---------- + + private static void tokenRedactionShort() { + Console.header("§16: short tokens (≤8 chars) redact entirely — no last-4 leak"); + try (var client = new MarketDataClient("abcd", MockServerControl.BASE_URL, null, false)) { + String repr = client.toString(); + Console.info(repr); + if (repr.contains("abcd")) { + Console.fail("token leaked into toString — expected ***…*** alone"); + } else { + Console.ok("token fully redacted (length 4 ≤ 8)"); + } + } + } + + private static void tokenRedactionLong() { + Console.header("§16: tokens > 8 chars show the trailing 4"); + try (var client = + new MarketDataClient( + "supersecret-token-YKT0", MockServerControl.BASE_URL, null, false)) { + String repr = client.toString(); + Console.info(repr); + if (repr.contains("supersecret") || repr.contains("token-")) { + Console.fail("token prefix leaked"); + } else if (!repr.contains("YKT0")) { + Console.fail("trailing 4 missing — expected ***…***YKT0"); + } else { + Console.ok("redacted as ***…***YKT0 — enough to disambiguate, not enough to use"); + } + } + } + + // ---------- §4 configuration cascade ---------- + + private static void explicitOverridesEnv() { + Console.header("§4 cascade: explicit constructor args win over env / .env"); + String explicitUrl = MockServerControl.BASE_URL; + try (var client = new MarketDataClient("any-token", explicitUrl, "v1", false)) { + String repr = client.toString(); + if (!repr.contains("baseUrl=" + explicitUrl)) { + Console.fail("explicit baseUrl not honored: " + repr); + } else { + Console.ok("explicit baseUrl applied: " + explicitUrl); + } + } + } + + // ---------- early-fail validation ---------- + + private static void invalidBaseUrlFailsAtConstruct() { + Console.header("Validation: malformed baseUrl fails at construct, not at first request"); + try { + new MarketDataClient("token", "not-a-url", null, false).close(); + Console.fail("constructor returned for a baseUrl that isn't even a URL"); + } catch (IllegalArgumentException e) { + Console.ok("IAE at construct: " + e.getMessage()); + } + } + + private static void invalidApiKeyCrlfFailsAtConstruct() { + Console.header("Validation: API key with CRLF rejected at construct (§23 fix)"); + try { + new MarketDataClient("good-prefix\rinjected", MockServerControl.BASE_URL, null, false).close(); + Console.fail("constructor accepted an API key containing CR"); + } catch (IllegalArgumentException e) { + Console.ok("IAE at construct: " + e.getMessage()); + if (e.getMessage().contains("good-prefix") || e.getMessage().contains("injected")) { + Console.fail("token leaked into IAE message — §16 violation"); + } else { + Console.ok("token NOT echoed in the message — §16 honored"); + } + } + } + + // ---------- §5 validateOnStartup ---------- + + private static void validateOnStartupSucceedsAgainstMockServer() { + Console.header("§5: validateOnStartup=true → /user/ probe on construct (mock returns 200)"); + new MockServerControl().reset(); + try (var client = + new MarketDataClient("any-token", MockServerControl.BASE_URL, null, true)) { + Console.ok("constructor returned — probe succeeded"); + Console.info("rateLimits captured from /user/ response: " + client.getRateLimits()); + } + } + + private static void validateOnStartupFailsOn401() { + Console.header("§5: validateOnStartup=true + 401 on /user/ → AuthenticationError at construct"); + MockServerControl mock = new MockServerControl(); + mock.reset(); + mock.script( + MockServerControl.Step.of( + 401, "{\"s\":\"error\",\"errmsg\":\"Unauthorized\"}") + .forPath("/user/")); + Console.info("server queue before construct: " + mock.stats().requests() + " requests, scripted step queued"); + try { + new MarketDataClient("bad-token", MockServerControl.BASE_URL, null, true).close(); + Console.fail("constructor returned despite 401 on /user/"); + Console.info("server stats after: " + mock.stats().requests() + " requests"); + } catch (AuthenticationError e) { + Console.ok("AuthenticationError at construct: " + e.getMessage()); + Console.info("statusCode: " + e.getStatusCode() + ", requestId: " + e.getRequestId()); + } catch (Throwable t) { + Console.fail( + "unexpected throwable type: " + t.getClass().getName() + " — " + t.getMessage()); + Console.info("server stats after: " + mock.stats().requests() + " requests"); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java new file mode 100644 index 0000000..03a4b11 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java @@ -0,0 +1,241 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.net.ServerSocket; + +/** + * Round-trips every one of the §6 / ADR-002 sealed exception subtypes through + * the SDK. Each scenario: + * + *

      + *
    • scripts the mock server (or chooses an unreachable address for the + * network-error case) to produce the trigger condition, + *
    • fires a call through the SDK and asserts the exception type, + *
    • prints the §6 support-info dump so the wire-level diagnostic surface is + * visible to a human. + *
    + * + *

    The exhaustive switch at the bottom is the consumer-facing proof of the + * sealed hierarchy — adding an 8th subtype to the SDK would break this switch + * at compile time, exactly the contract ADR-002 promised. + * + *

    Run: {@code ./gradlew runExceptions} + */ +public final class ExceptionsApp { + private ExceptionsApp() {} + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = + new MarketDataClient("any-token", MockServerControl.BASE_URL, null, false)) { + + authenticationError401(mock, client); + badRequest400(mock, client); + notFound404WithRealError(mock, client); + rateLimit429WithRetryAfter(mock, client); + serverError500NotRetriable(mock, client); + serverError503Retriable(mock, client); + parseErrorMalformedBody(mock, client); + parseErrorEmptyBody(mock, client); + sealedSwitchDemo(mock, client); + } + + networkErrorConnectionRefused(); + } + + // ---------- 401 ---------- + + private static void authenticationError401(MockServerControl mock, MarketDataClient client) { + Console.header("AuthenticationError on HTTP 401"); + mock.reset(); + mock.script(Step.of(401, "{\"s\":\"error\",\"errmsg\":\"Unauthorized\"}")); + Console.expectException("AuthenticationError", () -> client.utilities().user()); + } + + // ---------- 400 ---------- + + private static void badRequest400(MockServerControl mock, MarketDataClient client) { + Console.header("BadRequestError on HTTP 400"); + mock.reset(); + mock.script(Step.of(400, "{\"s\":\"error\",\"errmsg\":\"invalid params\"}")); + Console.expectException("BadRequestError", () -> client.utilities().status()); + } + + // ---------- 404 (real error, not no_data) ---------- + + private static void notFound404WithRealError(MockServerControl mock, MarketDataClient client) { + Console.header("NotFoundError on HTTP 404 — wait, actually..."); + Console.info( + "Spec §11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response. The SDK returns a"); + Console.info( + "Response with isNoData() = true. To see NotFoundError, we'd need a 404 that"); + Console.info( + "ISN'T the no-data envelope — but the current routing maps all 404s to a successful"); + Console.info( + "envelope (see HttpTransport.routeAndEnvelope). So in practice consumers see"); + Console.info( + "NotFoundError only if a future endpoint maps it differently."); + Console.info("Skipping this scenario — see ResponseFeaturesApp for the 404+no_data path."); + // Suppress 'unused parameter' warnings by referencing the locals once. + if (false) { + mock.reset(); + Console.expectException("NotFoundError", () -> client.utilities().status()); + } + } + + // ---------- 429 ---------- + + private static void rateLimit429WithRetryAfter(MockServerControl mock, MarketDataClient client) { + Console.header("RateLimitError on HTTP 429 — Retry-After surfaces on the exception"); + mock.reset(); + mock.script( + Step.of(429, "{\"s\":\"error\",\"errmsg\":\"rate limited\"}") + .withHeader("Retry-After", "5")); + try { + client.utilities().status(); + Console.fail("expected RateLimitError, call returned"); + } catch (RateLimitError e) { + Console.ok("RateLimitError caught"); + Console.info("Retry-After parsed: " + e.getRetryAfter()); + Console.info("statusCode: " + e.getStatusCode()); + } + } + + // ---------- 500 (not retriable per §9) ---------- + + private static void serverError500NotRetriable(MockServerControl mock, MarketDataClient client) { + Console.header("ServerError on HTTP 500 — §9 says 500 is NOT retriable"); + mock.reset(); + mock.script(Step.of(500, "{\"s\":\"error\",\"errmsg\":\"internal\"}")); + Console.expectException("ServerError (no retry)", () -> client.utilities().status()); + Console.info("server saw exactly " + mock.stats().requests() + " request(s) — should be 1"); + } + + // ---------- 503 (retriable, exhausted) ---------- + + private static void serverError503Retriable(MockServerControl mock, MarketDataClient client) { + Console.header("ServerError on HTTP 503 — retried 3x by default policy, then surfaces"); + mock.reset(); + // 4 attempts (1 initial + 3 retries) of 503 — all fail. + mock.script( + java.util.List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"))); + long t0 = System.nanoTime(); + try { + client.utilities().status(); + Console.fail("expected ServerError"); + } catch (ServerError e) { + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok("ServerError after " + elapsed + " ms (exponential 1s + 2s + 4s ≈ 7s)"); + Console.info("server saw " + mock.stats().requests() + " requests (expected 4)"); + } + } + + // ---------- ParseError: malformed body ---------- + + private static void parseErrorMalformedBody(MockServerControl mock, MarketDataClient client) { + Console.header("ParseError on malformed JSON"); + mock.reset(); + mock.script(Step.of(200, "{this-is-not-json")); + Console.expectException("ParseError", () -> client.utilities().user()); + } + + // ---------- ParseError: empty body (#29 fix) ---------- + + private static void parseErrorEmptyBody(MockServerControl mock, MarketDataClient client) { + Console.header("ParseError on empty body (#29 fix) — explicit 'Empty response body' message"); + mock.reset(); + mock.script(Step.of(200, "")); + try { + client.utilities().user(); + Console.fail("expected ParseError"); + } catch (ParseError e) { + if (e.getMessage().contains("Empty response body")) { + Console.ok("explicit empty-body message: " + e.getMessage()); + } else { + Console.fail("generic message, #29 fix not engaged: " + e.getMessage()); + } + } + } + + // ---------- NetworkError ---------- + + private static void networkErrorConnectionRefused() { + Console.header("NetworkError on connection refused (then retried 3x)"); + int closedPort; + try (ServerSocket probe = new ServerSocket(0)) { + closedPort = probe.getLocalPort(); + } catch (java.io.IOException e) { + Console.fail("couldn't reserve a closed port: " + e.getMessage()); + return; + } + String unreachable = "http://127.0.0.1:" + closedPort; + try (var client = new MarketDataClient("token", unreachable, null, false)) { + long t0 = System.nanoTime(); + try { + client.utilities().status(); + Console.fail("expected NetworkError"); + } catch (NetworkError e) { + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok("NetworkError after " + elapsed + " ms (IOException retried per §9)"); + Console.info("cause: " + (e.getCause() == null ? "(none)" : e.getCause().getClass().getName())); + } + } + } + + // ---------- exhaustive switch over the sealed hierarchy ---------- + + private static void sealedSwitchDemo(MockServerControl mock, MarketDataClient client) { + Console.header("ADR-002 sealed hierarchy — consumer-side routing"); + mock.reset(); + mock.script(Step.of(401, "{\"s\":\"error\",\"errmsg\":\"nope\"}")); + try { + client.utilities().user(); + } catch (com.marketdata.sdk.exception.MarketDataException e) { + // JDK 17 (the SDK's minimum): use instanceof patterns. The hierarchy is sealed, so a + // future SDK release that adds an 8th subtype cannot do so silently — it'd require an + // amendment to ADR-002 and would break consumer compilations that DO use the pattern + // switch (JDK 21+). + // + // JDK 21+ version (kept here as a reference for consumers on a newer JDK; pattern + // switches over a sealed type are exhaustiveness-checked at compile time): + // + // String routed = switch (e) { + // case AuthenticationError a -> "→ AUTH"; + // case BadRequestError b -> "→ BAD_REQUEST"; + // case NotFoundError n -> "→ NOT_FOUND"; + // case RateLimitError r -> "→ RATE_LIMITED"; + // case ServerError s -> "→ SERVER"; + // case NetworkError n -> "→ NETWORK"; + // case ParseError p -> "→ PARSE"; + // }; + String routed; + if (e instanceof AuthenticationError) routed = "→ AUTH"; + else if (e instanceof BadRequestError) routed = "→ BAD_REQUEST"; + else if (e instanceof NotFoundError) routed = "→ NOT_FOUND"; + else if (e instanceof RateLimitError r) + routed = "→ RATE_LIMITED (retryAfter=" + r.getRetryAfter() + ")"; + else if (e instanceof ServerError s) routed = "→ SERVER (status=" + s.getStatusCode() + ")"; + else if (e instanceof NetworkError) routed = "→ NETWORK"; + else if (e instanceof ParseError) routed = "→ PARSE"; + else routed = "→ UNKNOWN (sealed permits drift!)"; + Console.ok("instanceof chain routed: " + routed); + Console.info("(JDK 21+ pattern-switch reference in the source comments)"); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java new file mode 100644 index 0000000..5dad3b4 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java @@ -0,0 +1,130 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Response; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; +import com.marketdata.sdk.utilities.User; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Live-API smoke test against the real {@code api.marketdata.app}. Requires + * a valid {@code MARKETDATA_TOKEN} in the environment or in {@code .env}. + * + *

    Exercises every public endpoint on {@code client.utilities()} once sync + * and once async, plus the §13.5 response surface ({@code data()}, + * {@code rawBody()}, {@code requestId()}, {@code isJson()}, {@code isNoData()}, + * {@code requestUrl()}, {@code statusCode()}). Concludes with the §8 rate-limit + * snapshot the most recent call left on the client. + * + *

    Run: {@code ./gradlew runLive} + */ +public final class LiveSmokeApp { + private LiveSmokeApp() {} + + public static void main(String[] args) { + // validateOnStartup = false on purpose for this smoke. The §5 probe is exercised by + // DemoAndConfigApp against the mock server. Keeping it off here so a transient backend hiccup + // on /user/ (5xx, slow response) surfaces as a per-row failure instead of a constructor crash + // that takes down the rest of the smoke. + try (var client = new MarketDataClient(null, null, null, false)) { + Console.header("Client snapshot"); + Console.info("toString: " + client); + Console.info("rateLimits before any call: " + client.getRateLimits()); + + Console.header("/status/ (sync) — unversioned, no token required"); + Console.run( + () -> client.utilities().status(), + r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + + Console.header("/status/ (async) — same call via the async surface"); + Console.run( + () -> joinResponse(client.utilities().statusAsync()), + r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + + Console.header("/user/ (sync) — needs a token"); + Console.run( + () -> client.utilities().user(), + r -> { + User u = r.data(); + return "requestsRemaining=" + + u.requestsRemaining() + + ", requestsLimit=" + + u.requestsLimit() + + ", optionsDataPermissions=" + + (u.optionsDataPermissions().isEmpty() ? "(real-time)" : u.optionsDataPermissions()) + + "; " + + describe(r); + }); + + Console.header("/headers/ (sync) — what the server saw on this call"); + Console.run( + () -> client.utilities().headers(), + r -> { + RequestHeaders rh = r.data(); + String auth = rh.headers().getOrDefault("authorization", "(absent)"); + return "headers=" + + rh.headers().size() + + " entries (authorization echoed back: " + + auth + + "); " + + describe(r); + }); + + Console.header("Parallel async — fan out 3 calls, await all"); + long t0 = System.nanoTime(); + CompletableFuture> a = client.utilities().statusAsync(); + CompletableFuture> b = client.utilities().userAsync(); + CompletableFuture> c = client.utilities().headersAsync(); + // exceptionally() turns a failure into a null sentinel so allOf doesn't short-circuit on + // the first failing call — we still want to see whether the others succeeded. + CompletableFuture aSafe = a.thenApply(r -> (Object) r).exceptionally(t -> t); + CompletableFuture bSafe = b.thenApply(r -> (Object) r).exceptionally(t -> t); + CompletableFuture cSafe = c.thenApply(r -> (Object) r).exceptionally(t -> t); + CompletableFuture.allOf(aSafe, bSafe, cSafe).join(); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Console.ok( + "all 3 completed in " + + elapsedMs + + " ms (≈ slowest single call, not sum — proves true parallelism)"); + describeResult("status", aSafe.join(), r -> { + List services = ((Response) r).data().services(); + return services.size() + " services; first: " + services.get(0).service(); + }); + describeResult("user", bSafe.join(), r -> "remaining=" + ((Response) r).data().requestsRemaining()); + describeResult("headers", cSafe.join(), r -> ((Response) r).data().headers().size() + " entries"); + + Console.header("Final rate-limit snapshot"); + Console.info("rateLimits after the calls: " + client.getRateLimits()); + } + } + + @SuppressWarnings("unchecked") + private static void describeResult(String label, Object resultOrThrowable, java.util.function.Function describe) { + if (resultOrThrowable instanceof Throwable t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + Console.fail(label + " failed: " + cause.getClass().getSimpleName() + " — " + cause.getMessage()); + } else { + Console.ok(label + ": " + describe.apply(resultOrThrowable)); + } + } + + private static String describe(Response r) { + return "status=" + r.statusCode() + ", requestId=" + r.requestId() + ", url=" + r.requestUrl(); + } + + private static Response joinResponse(CompletableFuture> f) { + // CompletableFuture.join wraps the cause in CompletionException, but the SDK's joinSync + // contract is to surface MarketDataException directly. We mimic that here so the demo's + // exception output matches what a sync caller would see. + try { + return f.join(); + } catch (java.util.concurrent.CompletionException e) { + if (e.getCause() instanceof RuntimeException re) throw re; + throw e; + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java new file mode 100644 index 0000000..695005b --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java @@ -0,0 +1,161 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Response; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; +import com.marketdata.sdk.utilities.User; + +/** + * Idiomatic consumer-style examples — one short snippet per SDK resource showing + * the typical "first call you'd write" against it. + * + *

    This is the growth surface for resource coverage. Each new + * resource that lands on the SDK (stocks, options, funds, markets) gets a new + * private {@code xxxExamples(client)} method below and a call from {@link #main}. + * The other demos in this directory each prove one cross-cutting behavior + * (retry, concurrency, etc.); this one shows the per-resource shape. + * + *

    Hits the real API at {@code api.marketdata.app}. {@code MARKETDATA_TOKEN} + * in the env or {@code .env} is needed for endpoints that require auth; without + * it, the demo runs in demo mode — public endpoints succeed and the rest skip + * with a clear note instead of crashing. + * + *

    The {@code Console.*} helpers used below are demo formatting only. In a + * real consumer app, the data lines would be plain {@code System.out.println} + * or your logger of choice — the SDK call itself is what to copy. + * + *

    Run: {@code make demo-quickstart} (or {@code ./gradlew runQuickstart}). + */ +public final class QuickstartApp { + + private QuickstartApp() {} + + public static void main(String[] args) { + Console.header("Quickstart — idiomatic SDK usage, one section per resource"); + Console.info( + "As stocks / options / funds / markets land on the SDK, each gets a new section below."); + + // The no-arg constructor is the idiomatic path: it reads MARKETDATA_TOKEN + // from the env or .env, falls back to demo mode if neither is set, and + // validates the token by firing one /user/ probe at construct time + // (validateOnStartup=true by default). A failure here means the token is + // invalid — caught and reported below so the rest of the demo still runs. + try (MarketDataClient client = buildClient()) { + if (client == null) { + return; + } + utilitiesExamples(client); + // stocksExamples(client); // ← add when client.stocks() lands + // optionsExamples(client); // ← add when client.options() lands + // fundsExamples(client); // ← add when client.funds() lands + // marketsExamples(client); // ← add when client.markets() lands + } + } + + // ---------- utilities ---------- + + private static void utilitiesExamples(MarketDataClient client) { + Console.header("utilities — service health, quota, request diagnostics"); + + // 1) Public endpoint: no token required. Useful as a liveness check. + Console.step("client.utilities().status() — per-service health snapshot"); + try { + Response health = client.utilities().status(); + long online = health.data().services().stream().filter(ServiceStatus::online).count(); + Console.ok(online + " of " + health.data().services().size() + " services online"); + } catch (MarketDataException e) { + Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 2) Authenticated endpoint: returns your quota state. Catching + // AuthenticationError is the consumer pattern for "token missing or + // invalid" — surface a hint to the user rather than crashing. + Console.step("client.utilities().user() — your quota & permissions"); + try { + Response me = client.utilities().user(); + User u = me.data(); + Console.ok( + u.requestsRemaining() + " requests remaining of " + u.requestsLimit() + " (today)"); + } catch (AuthenticationError e) { + Console.info( + "401 — set MARKETDATA_TOKEN (env var or .env) to see your real quota." + + " Demo mode reaches this endpoint and gets rejected, as designed."); + } catch (MarketDataException e) { + Console.fail("user() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 3) Diagnostic endpoint: echoes back the headers the server saw. Handy + // when debugging "is my Authorization header actually getting through?". + Console.step("client.utilities().headers() — what the server saw on this call"); + try { + Response echo = client.utilities().headers(); + Console.ok( + "server received " + + echo.data().headers().size() + + " request headers (Authorization echoed back redacted)"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token (same reason as utilities().user())."); + } catch (MarketDataException e) { + Console.fail("headers() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + } + + // ---------- stocks (TODO: enable when client.stocks() lands) ---------- + // + // private static void stocksExamples(MarketDataClient client) { + // Console.header("stocks — quotes, candles, news"); + // + // Console.step("client.stocks().quote(\"AAPL\") — latest quote"); + // var q = client.stocks().quote("AAPL"); + // Console.ok("AAPL last=" + q.data().last() + " (asOf " + q.data().asOf() + ")"); + // + // Console.step("client.stocks().candles(\"AAPL\", Resolution.D, from, to) — historical OHLCV"); + // var c = client.stocks().candles("AAPL", Resolution.D, ...); + // Console.ok(c.data().rows().size() + " daily candles fetched"); + // } + + // ---------- helpers ---------- + + /** + * Build the client. Idiomatic path is the no-arg constructor (cascade + startup + * validation on). The fallback to the 4-arg constructor with {@code + * validateOnStartup=false} exists so any future startup-probe surprise + * (transient 5xx, slow API) doesn't kill the demo before the per-resource + * examples run. A real consumer app would normally just let the exception + * propagate to its top-level error handler. + */ + private static MarketDataClient buildClient() { + try { + return new MarketDataClient(); + } catch (AuthenticationError e) { + Console.fail("Constructor failed: " + e.getMessage()); + Console.info( + "MARKETDATA_TOKEN is set but the API rejected it. Fix the token, or unset it to use" + + " demo mode."); + return null; + } catch (MarketDataException e) { + // ParseError on /user/ (payload drift), NetworkError, etc. Retry with the + // startup probe disabled so the rest of the demo can run. + Console.info( + "Startup probe failed (" + + e.getExceptionType() + + "): " + + e.getMessage() + + ". Retrying with validateOnStartup=false so the demo can continue."); + try { + return new MarketDataClient(null, null, null, false); + } catch (Throwable t) { + Console.fail("Fallback construction failed: " + t.getClass().getSimpleName()); + return null; + } + } catch (Throwable t) { + Console.fail("Constructor failed: " + t.getClass().getSimpleName() + " — " + t.getMessage()); + return null; + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java new file mode 100644 index 0000000..3a8c5a8 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java @@ -0,0 +1,147 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Response; +import com.marketdata.sdk.utilities.ApiStatus; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +/** + * §13.5 {@code Response} surface: format predicates ({@code isJson}, + * {@code isCsv}, {@code isHtml}), the no-data envelope ({@code isNoData} + * from a 404 + {@code s:no_data}), defensive copies on {@code rawBody()}, the + * {@code saveToFile} helper, and the redacted {@code toString} shape. + * + *

    Run: {@code ./gradlew runResponse} + */ +public final class ResponseFeaturesApp { + private ResponseFeaturesApp() {} + + public static void main(String[] args) throws Exception { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = + new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + formatPredicates(mock, client); + noDataEnvelope(mock, client); + rawBodyIsDefensiveCopy(mock, client); + saveToFileWritesVerbatim(mock, client); + toStringIsLogSafe(mock, client); + } + } + + // ---------- format predicates ---------- + + private static void formatPredicates(MockServerControl mock, MarketDataClient client) { + Console.header("§13.5 format predicates"); + mock.reset(); + mock.script(Step.of(200, "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}")); + + Response resp = client.utilities().user(); + Console.info("isJson(): " + resp.isJson()); + Console.info("isCsv(): " + resp.isCsv()); + Console.info("isHtml(): " + resp.isHtml()); + if (resp.isJson() && !resp.isCsv() && !resp.isHtml()) { + Console.ok("JSON response detected — other predicates are false (mutually exclusive)"); + } else { + Console.fail("expected isJson=true and the others false"); + } + Console.info( + "(isCsv/isHtml are not reachable through the utilities resource today — utility"); + Console.info( + " endpoints are JSON-only. The wiring is there for future endpoints that negotiate format.)"); + } + + // ---------- 404 + s:no_data ---------- + + private static void noDataEnvelope(MockServerControl mock, MarketDataClient client) { + Console.header("§11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response"); + mock.reset(); + mock.script(Step.of(404, "{\"s\":\"no_data\"}")); + + try { + Response resp = client.utilities().status(); + Console.ok( + "no exception thrown; statusCode=" + + resp.statusCode() + + ", isNoData=" + + resp.isNoData()); + Console.info("data().services() = " + resp.data().services() + " (empty list as designed)"); + } catch (Exception e) { + Console.fail("404+no_data became an exception: " + e.getClass().getSimpleName()); + } + } + + // ---------- defensive rawBody copy ---------- + + private static void rawBodyIsDefensiveCopy(MockServerControl mock, MarketDataClient client) { + Console.header("rawBody() returns a defensive copy (mutations don't leak)"); + mock.reset(); + String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; + mock.script(Step.of(200, payload)); + + Response resp = client.utilities().user(); + byte[] first = resp.rawBody(); + Console.info("first rawBody() length: " + first.length); + first[0] = 'X'; // mutate the returned array — must not affect internal state + + byte[] second = resp.rawBody(); + if (Arrays.equals(second, payload.getBytes())) { + Console.ok("second rawBody() matches the original payload — defensive copy honored"); + } else { + Console.fail("internal body state was mutated by the consumer"); + } + } + + // ---------- saveToFile ---------- + + private static void saveToFileWritesVerbatim(MockServerControl mock, MarketDataClient client) + throws Exception { + Console.header("saveToFile writes the raw body verbatim"); + mock.reset(); + String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; + mock.script(Step.of(200, payload)); + + Response resp = client.utilities().user(); + Path tmp = Files.createTempFile("sdk-consumer-", ".json"); + try { + resp.saveToFile(tmp); + String on_disk = Files.readString(tmp); + if (on_disk.equals(payload)) { + Console.ok("on-disk content matches the original: " + tmp); + } else { + Console.fail("on-disk content differs from payload"); + } + } finally { + Files.deleteIfExists(tmp); + } + } + + // ---------- toString is log-safe (§16) ---------- + + private static void toStringIsLogSafe(MockServerControl mock, MarketDataClient client) { + Console.header("§16: toString omits data + redacts query strings"); + mock.reset(); + String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; + mock.script(Step.of(200, payload)); + + Response resp = client.utilities().user(); + String repr = resp.toString(); + Console.info(repr); + if (repr.contains("requestsRemaining")) { + Console.fail("response toString leaked typed data (field names visible)"); + } else { + Console.ok("typed payload NOT in toString"); + } + if (repr.contains("bytes=") && repr.contains("status=")) { + Console.ok("metadata visible (status, bytes, format, url)"); + } else { + Console.fail("metadata missing"); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java new file mode 100644 index 0000000..5dd465e --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java @@ -0,0 +1,222 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Walks through the §9 retry policy: which statuses retry, when {@code + * Retry-After} overrides the exponential backoff, the §21-fix cap on + * pathological values, and the §10.3 preflight that fails fast when the + * latest rate-limit snapshot reports remaining=0. + * + *

    Reading the wall-clock printed for each scenario is the point — the SDK's + * timing IS the spec behavior here. + * + *

    Run: {@code ./gradlew runRetry} + */ +public final class RetryBehaviorApp { + private RetryBehaviorApp() {} + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = + new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + retryRecovers503Then200(mock, client); + retryAfterDeltaOverridesExponential(mock, client); + retryAfterHttpDateHonored(mock, client); + retryAfterPathologicalIsCapped(mock, client); + preflightBlocksWhenSnapshotExhausted(mock, client); + } + } + + // ---------- 503 → 200: retry recovers ---------- + + private static void retryRecovers503Then200(MockServerControl mock, MarketDataClient client) { + Console.header("Retry recovers: 503 → 503 → 200 (≈ 3s wall-time from 1s + 2s backoff)"); + mock.reset(); + String okBody = validStatusBody(); + mock.script(MockServerControl.failNTimesThenSucceed(2, 503, okBody)); + + long t0 = System.nanoTime(); + try { + var resp = client.utilities().status(); + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok( + "succeeded after retries; data.services()=" + + resp.data().services().size() + + ", wall-time=" + + elapsed + + " ms"); + Console.info("server saw " + mock.stats().requests() + " requests (expected 3)"); + } catch (ServerError e) { + Console.fail("retries did not recover the call: " + e.getMessage()); + } + } + + // ---------- Retry-After: delta-seconds overrides exponential ---------- + + private static void retryAfterDeltaOverridesExponential( + MockServerControl mock, MarketDataClient client) { + Console.header( + "§9.4: Retry-After: 3 on a 503 overrides the calculated 1s backoff (≈ 3s wait, not 1s)"); + mock.reset(); + mock.script( + List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") + .withHeader("Retry-After", "3"), + Step.of(200, validStatusBody()))); + + long t0 = System.nanoTime(); + try { + client.utilities().status(); + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok("succeeded after " + elapsed + " ms (≈ 3000 — server's hint was honored)"); + } catch (ServerError e) { + Console.fail("call failed: " + e.getMessage()); + } + } + + // ---------- Retry-After: HTTP-date variant ---------- + + private static void retryAfterHttpDateHonored(MockServerControl mock, MarketDataClient client) { + Console.header("§9.4: Retry-After accepts HTTP-date (RFC 1123)"); + mock.reset(); + // Pick a future time large enough that the parse-on-the-server-side latency doesn't shrink + // the resulting delta below the exponential 1s floor (in which case we couldn't tell from + // wall-clock alone whether the date was honored or the SDK fell back to exponential). With + // +4s, the delta the SDK computes is always > 1s even after server round-trip latency. + String inFourSeconds = + ZonedDateTime.now(ZoneOffset.UTC) + .plusSeconds(4) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + mock.script( + List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") + .withHeader("Retry-After", inFourSeconds), + Step.of(200, validStatusBody()))); + + long t0 = System.nanoTime(); + try { + client.utilities().status(); + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok( + "succeeded after " + + elapsed + + " ms (HTTP-date parsed → delta from now; ~3-4s wall-time honors the date)"); + if (elapsed < 1500) { + Console.info( + " note: wall-time below ~1.5s suggests the parse failed silently and exponential 1s"); + Console.info( + " fired instead — open the SDK log to confirm."); + } + } catch (ServerError e) { + Console.fail("call failed: " + e.getMessage()); + } + } + + // ---------- Retry-After: pathological value capped (#21 fix) ---------- + + private static void retryAfterPathologicalIsCapped( + MockServerControl mock, MarketDataClient client) { + Console.header( + "#21 fix: Retry-After of 1 day on a 503 → capped, SDK falls back to exponential (≈ 1s)"); + mock.reset(); + mock.script( + List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") + // 86400s = 1 day — well above the 10-minute cap. + .withHeader("Retry-After", "86400"), + Step.of(200, validStatusBody()))); + + long t0 = System.nanoTime(); + try { + client.utilities().status(); + long elapsed = (System.nanoTime() - t0) / 1_000_000; + if (elapsed > 30_000) { + Console.fail("call took " + elapsed + " ms — cap did NOT engage"); + } else { + Console.ok( + "succeeded after " + + elapsed + + " ms (≈ 1000 — SDK ignored the 1-day directive and used exponential 1s backoff)"); + Console.info( + "the consumer can still see the raw value on the ServerError via getRetryAfter()"); + } + } catch (ServerError e) { + Console.fail("call failed: " + e.getMessage()); + } + } + + // ---------- §10.3 preflight ---------- + + private static void preflightBlocksWhenSnapshotExhausted( + MockServerControl mock, MarketDataClient client) { + Console.header( + "§10.3: snapshot says remaining=0 → preflight fails fast, server sees ZERO additional requests"); + mock.reset(); + // First call: 200 + rate-limit headers reporting remaining=0 with reset in the future. + long resetEpoch = (System.currentTimeMillis() / 1000L) + 3600L; + mock.script( + Step.of(200, validStatusBody()) + .withHeader("x-api-ratelimit-limit", "1000") + .withHeader("x-api-ratelimit-remaining", "0") + .withHeader("x-api-ratelimit-reset", String.valueOf(resetEpoch)) + .withHeader("x-api-ratelimit-consumed", "1000")); + + try { + client.utilities().status(); + Console.ok("first call succeeded; snapshot now says remaining=0"); + Console.info("rateLimits: " + client.getRateLimits()); + } catch (Exception e) { + Console.fail("first call failed: " + e.getMessage()); + } + + int before = mock.stats().requests(); + Console.step("second call: preflight should block it before it hits the wire"); + long t0 = System.nanoTime(); + try { + client.utilities().status(); + Console.fail("second call returned — preflight did not engage"); + } catch (RateLimitError e) { + long elapsed = (System.nanoTime() - t0) / 1_000_000; + Console.ok( + "RateLimitError raised after " + + elapsed + + " ms (instant — no network round-trip)"); + Console.info("message: " + e.getMessage()); + } + int after = mock.stats().requests(); + if (after == before) { + Console.ok("server saw 0 additional requests — preflight blocked at the SDK boundary"); + } else { + Console.fail("server saw " + (after - before) + " additional requests — preflight failed"); + } + } + + // ---------- helpers ---------- + + /** Minimal /status/ payload that ApiStatusDeserializer accepts. */ + private static String validStatusBody() { + long now = System.currentTimeMillis() / 1000L; + return "{\"s\":\"ok\"," + + "\"service\":[\"/v1/markets/status/\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[" + + now + + "]}"; + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java new file mode 100644 index 0000000..7650833 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java @@ -0,0 +1,81 @@ +package com.marketdata.consumer.shared; + +import com.marketdata.sdk.exception.MarketDataException; +import java.time.Duration; +import java.util.function.Supplier; + +/** + * Pretty-print helpers shared across every demo app. The output is plain + * text, no ANSI colors — these demos are meant to be readable in any + * terminal and copy-pastable into bug reports. + */ +public final class Console { + + private Console() {} + + /** Print a section header to make demo output scan-able. */ + public static void header(String title) { + System.out.println(); + System.out.println("==== " + title + " ===="); + } + + /** Print a sub-step under the current header. */ + public static void step(String description) { + System.out.println(); + System.out.println(" -- " + description); + } + + /** Indented success line. */ + public static void ok(String message) { + System.out.println(" ✓ " + message); + } + + /** Indented failure line — used when an exception is the expected outcome. */ + public static void fail(String message) { + System.out.println(" ✗ " + message); + } + + /** Indented info line. */ + public static void info(String message) { + System.out.println(" · " + message); + } + + /** + * Run {@code body} and either print {@code expected} on a thrown + * {@link MarketDataException}, or "no exception" if it succeeds. The full + * support-info dump is printed under the exception line so consumers can + * see the §6 shape end-to-end. + */ + public static void expectException(String expected, Runnable body) { + try { + body.run(); + fail("expected " + expected + " but call returned normally"); + } catch (MarketDataException e) { + ok("got " + e.getExceptionType() + " — message: " + e.getMessage()); + System.out.println(e.getSupportInfo().indent(6).stripTrailing()); + } catch (RuntimeException e) { + fail("expected " + expected + " but got " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + /** Run {@code body}, print {@code printer.toString(result)} on success, or the exception on failure. */ + public static void run(Supplier body, java.util.function.Function printer) { + try { + ok(printer.apply(body.get())); + } catch (MarketDataException e) { + fail(e.getExceptionType() + ": " + e.getMessage()); + } + } + + /** Same as {@link #run} but prints elapsed wall-clock — useful for retry/backoff demos. */ + public static void runTimed(Supplier body, java.util.function.Function printer) { + long startNanos = System.nanoTime(); + try { + ok(printer.apply(body.get())); + } catch (MarketDataException e) { + fail(e.getExceptionType() + ": " + e.getMessage()); + } + Duration elapsed = Duration.ofNanos(System.nanoTime() - startNanos); + info("wall-time: " + elapsed.toMillis() + " ms"); + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java new file mode 100644 index 0000000..0ff03f7 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java @@ -0,0 +1,235 @@ +package com.marketdata.consumer.shared; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Tiny client for the FastAPI mock server's /_admin/* endpoints. Each demo + * that scripts behavior uses this class to: + * + *

      + *
    • verify the mock server is up before the demo runs (fail-fast with a + * clear "did you forget to start the server?" message) + *
    • queue scripted responses via /_admin/script + *
    • read the request counter and peak in-flight via /_admin/stats + *
    • reset between demo steps + *
    + * + *

    Uses {@link java.net.http.HttpClient} directly — not the SDK — because + * the admin plane is intentionally outside the SDK's surface area. + */ +public final class MockServerControl { + + public static final String BASE_URL = "http://127.0.0.1:8765"; + + // Force HTTP/1.1 — uvicorn doesn't speak HTTP/2 out of the box. Java's default of HTTP/2 makes + // the first request attempt an upgrade that uvicorn rejects, and at least in some scenarios + // the body gets dropped during the fallback. Plain 1.1 sidesteps the whole dance for the admin + // control plane, which is the only thing this class talks to. + private final HttpClient http = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + /** + * Throw a helpful error if the mock server isn't running. Call this at the + * top of every demo that needs it. + */ + public void requireUp() { + try { + HttpResponse resp = + http.send( + HttpRequest.newBuilder(URI.create(BASE_URL + "/_admin/stats")) + .timeout(Duration.ofSeconds(2)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + throw new IllegalStateException( + "Mock server responded with HTTP " + resp.statusCode() + " — expected 200"); + } + } catch (Exception e) { + throw new IllegalStateException( + "Mock server is not reachable at " + + BASE_URL + + ". Start it in another terminal: cd ../mock-server && ./run.sh", + e); + } + } + + /** Drop the script queue and reset request counters. */ + public void reset() { + post("/_admin/reset", "{}"); + } + + /** Snapshot of the server's request counter + peak concurrency. */ + public Stats stats() { + String body = get("/_admin/stats"); + int requests = parseIntField(body, "\"requests\":"); + int peak = parseIntField(body, "\"peak_in_flight\":"); + return new Stats(requests, peak); + } + + /** Replace the script queue with {@code steps}. */ + public void script(List steps) { + StringBuilder json = new StringBuilder("{\"steps\":["); + for (int i = 0; i < steps.size(); i++) { + if (i > 0) json.append(','); + json.append(steps.get(i).toJson()); + } + json.append("]}"); + post("/_admin/script", json.toString()); + } + + /** Convenience overload for a single step. */ + public void script(Step step) { + script(List.of(step)); + } + + // ---------- internals ---------- + + private String get(String path) { + try { + HttpResponse resp = + http.send( + HttpRequest.newBuilder(URI.create(BASE_URL + path)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + return resp.body(); + } catch (Exception e) { + throw new RuntimeException("GET " + path + " failed", e); + } + } + + private void post(String path, String body) { + try { + HttpResponse resp = + http.send( + HttpRequest.newBuilder(URI.create(BASE_URL + path)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new RuntimeException( + "POST " + + path + + " returned HTTP " + + resp.statusCode() + + " — body sent: " + + body + + " — server response: " + + resp.body()); + } + } catch (Exception e) { + throw new RuntimeException("POST " + path + " failed", e); + } + } + + /** Cheap JSON extractor — pulls the integer that follows {@code marker} in {@code json}. */ + private static int parseIntField(String json, String marker) { + int idx = json.indexOf(marker); + if (idx < 0) return -1; + int start = idx + marker.length(); + while (start < json.length() && (json.charAt(start) == ' ' || json.charAt(start) == '\t')) { + start++; + } + int end = start; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { + end++; + } + return Integer.parseInt(json.substring(start, end)); + } + + /** Single scripted response. */ + public static final class Step { + private int status = 200; + private String body = "{}"; + private Map headers = Map.of(); + private int delayMs = 0; + private String path = null; + + public static Step of(int status, String body) { + Step s = new Step(); + s.status = status; + s.body = body; + return s; + } + + public Step withHeader(String name, String value) { + Map next = new java.util.LinkedHashMap<>(this.headers); + next.put(name, value); + this.headers = next; + return this; + } + + public Step delayMs(int ms) { + this.delayMs = ms; + return this; + } + + /** Restrict this step to requests for an exact path (e.g. "/user/"). */ + public Step forPath(String path) { + this.path = path; + return this; + } + + String toJson() { + StringBuilder sb = new StringBuilder("{"); + sb.append("\"status\":").append(status); + sb.append(",\"body\":").append(jsonString(body)); + sb.append(",\"delay_ms\":").append(delayMs); + if (path != null) { + sb.append(",\"path\":").append(jsonString(path)); + } + if (!headers.isEmpty()) { + sb.append(",\"headers\":{"); + boolean first = true; + for (var e : headers.entrySet()) { + if (!first) sb.append(','); + sb.append(jsonString(e.getKey())).append(':').append(jsonString(e.getValue())); + first = false; + } + sb.append('}'); + } + sb.append('}'); + return sb.toString(); + } + + private static String jsonString(String s) { + StringBuilder sb = new StringBuilder().append('"'); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + } + return sb.append('"').toString(); + } + } + + /** Convenience builder for "fail with status X N times, then succeed". */ + public static List failNTimesThenSucceed(int n, int failStatus, String successBody) { + List steps = new ArrayList<>(n + 1); + for (int i = 0; i < n; i++) { + steps.add(Step.of(failStatus, "{\"s\":\"error\",\"errmsg\":\"transient\"}")); + } + steps.add(Step.of(200, successBody)); + return steps; + } + + public record Stats(int requests, int peakInFlight) {} +} diff --git a/examples/mock-server/README.md b/examples/mock-server/README.md new file mode 100644 index 0000000..80a1ef7 --- /dev/null +++ b/examples/mock-server/README.md @@ -0,0 +1,55 @@ +# mock-server + +FastAPI server that backs the scripted-response scenarios in +`../consumer-test`. Endpoints under `/_admin/*` let a consumer app queue +exactly what the next N HTTP responses should look like (status, body, +headers, delay); everything else is the catch-all that pops one step per +incoming request. + +## Quick start + +```bash +./run.sh +``` + +Or, from the SDK root: + +```bash +make mock-server +``` + +Either way creates `.venv` (first run only), installs `fastapi` + `uvicorn`, +and starts the server on `http://127.0.0.1:8765`. Leave it running in one +terminal, then run a consumer demo in another (`make demo-config` etc. — see +`examples/consumer-test/README.md`). + +## Endpoints + +| Path | Method | Purpose | +|---|---|---| +| `/_admin/script` | POST | `{ "steps": [{ "status": 503, "body": "...", "headers": {...}, "delay_ms": 0, "path": "/user/" }] }` — replace the script queue | +| `/_admin/reset` | POST | Drop the script queue, the request log, and the in-flight counters | +| `/_admin/stats` | GET | Snapshot: total requests, peak concurrency, remaining script steps, last 50 log entries | +| `/user/` | GET | Default happy-path body when the script queue is empty (unversioned, mirrors the backend) | +| `/headers/` | GET | Default happy-path body when the script queue is empty (unversioned, mirrors the backend) | +| `/status/` | GET | Default happy-path body when the script queue is empty | +| (anything else) | * | `404 {"s":"error","errmsg":"..."}` when no script step matches | + +## Scripted-step semantics + +- A step matches the first incoming request whose path equals `step.path`. If + `path` is omitted, the step matches the next request to any non-admin path. +- `delay_ms` is applied **before** the response is sent. Use it to simulate + the SDK's 99-second per-request timeout or to make race conditions visible. +- `cf-ray` is added to every response if you don't set it yourself — that's + what the SDK reads for `requestId` on the response envelope and exception + context, so populating it makes the demos' logs traceable. +- Once popped, a step is gone. Re-script if you need the same shape twice. + +## Why this is separate from the SDK's tests + +The SDK's own JUnit suite covers the same scenarios at the wire level with a +`CapturingClient` stub. This server exists for the **consumer-facing** +scenarios: a human runs a demo, watches the wall-clock backoff between +retries, watches request count climb to 50 under concurrency, and sees the +SDK behave exactly the way the documentation promises a consumer will see it. diff --git a/examples/mock-server/requirements.txt b/examples/mock-server/requirements.txt new file mode 100644 index 0000000..7924492 --- /dev/null +++ b/examples/mock-server/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/examples/mock-server/run.sh b/examples/mock-server/run.sh new file mode 100755 index 0000000..b5654a9 --- /dev/null +++ b/examples/mock-server/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Bootstrap a venv if needed, install deps, and start uvicorn on :8765. +# Idempotent — re-running just starts the server with the existing venv. +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ ! -d .venv ]]; then + python3 -m venv .venv +fi + +# shellcheck source=/dev/null +source .venv/bin/activate + +pip install --quiet --disable-pip-version-check -r requirements.txt + +echo +echo "Mock server starting on http://127.0.0.1:8765" +echo " GET /user/ → default user payload" +echo " GET /headers/ → default headers payload" +echo " GET /status/ → default api-status payload" +echo " POST /_admin/script → enqueue scripted responses" +echo " POST /_admin/reset → clear queue + counters" +echo " GET /_admin/stats → request count + peak concurrency" +echo +echo "Press Ctrl+C to stop." +echo + +exec uvicorn server:app --host 127.0.0.1 --port 8765 --log-level warning diff --git a/examples/mock-server/server.py b/examples/mock-server/server.py new file mode 100644 index 0000000..a09d9f2 --- /dev/null +++ b/examples/mock-server/server.py @@ -0,0 +1,226 @@ +""" +Scriptable mock server for examples/consumer-test. + +Listens on http://127.0.0.1:8765 by default. The consumer apps POST to the +/_admin/* control plane to script the next N responses, then make their real +SDK call against the same host — the catch-all handler pops one response from +the script queue per request and returns exactly what was scripted (status, +body, headers, delay). + +When the queue is empty, well-known SDK endpoints (/headers/, /user/, +/status/) get sensible happy-path defaults so apps that don't care about +scripting can still talk to the server. + +Run: + ./run.sh # installs deps in .venv and starts uvicorn + uvicorn server:app --port 8765 # if you manage your own venv +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any, Optional + +from fastapi import FastAPI, Request, Response +from pydantic import BaseModel, Field + +app = FastAPI(title="market-data mock server") + +# ---------------------------------------------------------------------------- +# Scripted-response queue +# ---------------------------------------------------------------------------- + +class ScriptedStep(BaseModel): + """One response the server will emit on the next request matching `path`. + + When `path` is omitted, the step matches the next request to ANY path + (other than /_admin/*). Useful for "the next 3 requests all get 503" + regardless of which endpoint the SDK hits. + """ + + status: int = 200 + body: str = "{}" + # Arbitrary response headers. Content-Type defaults to application/json. + headers: dict[str, str] = Field(default_factory=dict) + # Sleep before sending the response — used to simulate slow servers, the + # 99-second per-request timeout, or just visible delay for human eyes. + delay_ms: int = 0 + # Optional path filter. If set, the step is only popped when the incoming + # request path equals this value (e.g. "/user/"). + path: Optional[str] = None + + +# We use a list as a FIFO queue. asyncio.Lock to make pop atomic under +# concurrent requests — necessary for the ConcurrencyApp scenario. +_script_lock = asyncio.Lock() +_script: list[ScriptedStep] = [] + +# Per-request bookkeeping that consumer apps can read back. +_request_log: list[dict[str, Any]] = [] + +# Concurrency tracker: counts active requests so we can observe the SDK's +# 50-permit semaphore in action. +_in_flight = 0 +_peak_in_flight = 0 +_in_flight_lock = asyncio.Lock() + +# Default bodies for the well-known SDK endpoints. Match the shapes the +# corresponding deserializers expect. +_DEFAULT_USER = json.dumps( + { + "x-ratelimit-requests-remaining": 9999, + "x-ratelimit-requests-limit": 100000, + "x-options-data-permissions": "", + } +) +_DEFAULT_HEADERS = json.dumps( + { + "accept": "application/json", + "user-agent": "marketdata-sdk-java/mock", + "authorization": "Bearer ***REDACTED***", + "cf-ray": "mock-ray-id", + } +) + + +def _default_status_body() -> str: + now_epoch = int(time.time()) + return json.dumps( + { + "s": "ok", + "service": [ + "/v1/markets/status/", + "/v1/stocks/quotes/", + "/v1/options/chain/", + ], + "status": ["online", "online", "online"], + "online": [True, True, True], + "uptimePct30d": [0.999, 0.998, 0.995], + "uptimePct90d": [0.999, 0.997, 0.994], + "updated": [now_epoch, now_epoch, now_epoch], + } + ) + + +# ---------------------------------------------------------------------------- +# Admin endpoints — used by consumer apps to script behavior +# ---------------------------------------------------------------------------- + +class ScriptRequest(BaseModel): + steps: list[ScriptedStep] + + +@app.post("/_admin/script") +async def set_script(req: ScriptRequest) -> dict[str, Any]: + """Replace the script queue with `steps`.""" + global _script + async with _script_lock: + _script = list(req.steps) + return {"ok": True, "queued": len(req.steps)} + + +@app.post("/_admin/reset") +async def reset() -> dict[str, Any]: + """Drop the script queue, the request log, and the concurrency counters.""" + global _script, _request_log, _in_flight, _peak_in_flight + async with _script_lock: + _script = [] + _request_log = [] + async with _in_flight_lock: + _in_flight = 0 + _peak_in_flight = 0 + return {"ok": True} + + +@app.get("/_admin/stats") +async def stats() -> dict[str, Any]: + """Snapshot of what the server has seen since the last /reset.""" + return { + "requests": len(_request_log), + "peak_in_flight": _peak_in_flight, + "remaining_script_steps": len(_script), + "log": _request_log[-50:], # last 50 for inspection + } + + +# ---------------------------------------------------------------------------- +# Catch-all for the SDK's endpoints +# ---------------------------------------------------------------------------- + +async def _pop_matching_step(path: str) -> Optional[ScriptedStep]: + """Pop the first script step whose path matches `path` (or is unbound).""" + async with _script_lock: + for i, step in enumerate(_script): + if step.path is None or step.path == path: + return _script.pop(i) + return None + + +@app.api_route("/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE"]) +async def catch_all(full_path: str, request: Request) -> Response: + """Default request handler — pops a scripted step if available, else returns + the well-known happy-path default for the path, else 404.""" + global _in_flight, _peak_in_flight + + # Strip the leading slash so we can compare against the SDK's URL shape + # (the SDK builds /v1/headers/ as a real absolute path). + path = "/" + full_path + method = request.method + + async with _in_flight_lock: + _in_flight += 1 + if _in_flight > _peak_in_flight: + _peak_in_flight = _in_flight + + try: + step = await _pop_matching_step(path) + + if step is not None: + if step.delay_ms > 0: + await asyncio.sleep(step.delay_ms / 1000.0) + response_headers = dict(step.headers) + # cf-ray is what the SDK uses for requestId — give every response one + # unless the script explicitly overrode it. + response_headers.setdefault("cf-ray", f"mock-{int(time.time() * 1000)}") + response_headers.setdefault("Content-Type", "application/json") + _request_log.append( + {"path": path, "method": method, "status": step.status, "scripted": True} + ) + return Response( + content=step.body, + status_code=step.status, + headers=response_headers, + ) + + # No script — return a happy default for the well-known endpoints, or + # 404 for anything else. + default_body, default_status = _default_response_for(path) + _request_log.append( + {"path": path, "method": method, "status": default_status, "scripted": False} + ) + return Response( + content=default_body, + status_code=default_status, + headers={ + "Content-Type": "application/json", + "cf-ray": f"mock-{int(time.time() * 1000)}", + }, + ) + + finally: + async with _in_flight_lock: + _in_flight -= 1 + + +def _default_response_for(path: str) -> tuple[str, int]: + # /user/ and /headers/ are unversioned in the real backend (no /v1/ prefix), + # same as /status/. See sdk-java's UtilitiesResource. + if path in ("/user/", "/user"): + return _DEFAULT_USER, 200 + if path in ("/headers/", "/headers"): + return _DEFAULT_HEADERS, 200 + if path in ("/status/", "/status"): + return _default_status_body(), 200 + return json.dumps({"s": "error", "errmsg": f"unknown endpoint {path}"}), 404 diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index e73231c..986c7c4 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -143,14 +143,14 @@ public UtilitiesResource utilities() { } /** - * Fire a single call to {@code GET /v1/user/} to confirm the token is accepted and a billing plan - * is attached (SDK requirements §5). A 401 surfaces as {@link + * Fire a single call to {@code GET /user/} to confirm the token is accepted and a billing plan is + * attached (SDK requirements §5). A 401 surfaces as {@link * com.marketdata.sdk.exception.AuthenticationError} directly via the sync wrapper. On any failure * we close the transport before re-throwing so a partially-constructed client doesn't leak its * HttpClient — the caller's try-with-resources is never triggered if the constructor itself * fails. * - *

    Skipped in demo mode: there is no token to validate, and {@code /v1/user/} would + *

    Skipped in demo mode: there is no token to validate, and {@code /user/} would * deterministically return 401, breaking construction for any consumer who instantiates the SDK * without a token configured (the "I want to kick the tires" path). * diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 1f86ffb..2716871 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -83,9 +83,13 @@ public Response headers() { * Async: fetch the caller's current quota state and data-tier permissions. Returns a 401 (as * {@link com.marketdata.sdk.exception.AuthenticationError}) when no billing plan is associated * with the token — the typical use case for {@code validateOnStartup}. + * + *

    Unversioned: the backend mounts the {@code user} router at the API root (no {@code /v1/} + * prefix), same as {@code /status/} and {@code /headers/}. Hitting {@code /v1/user/} falls + * through to the global 404 handler. */ public CompletableFuture> userAsync() { - return executeAndWrap(RequestSpec.get("user").build(), User.class); + return executeAndWrap(RequestSpec.get("user").unversioned().build(), User.class); } /** Sync wrapper for {@link #userAsync()}. */ @@ -94,22 +98,23 @@ public Response user() { } /** - * Auth probe used by {@link MarketDataClient}'s startup validation. Hits {@code GET /v1/user/} - * with a single-attempt policy so the constructor caps at one {@code REQUEST_TIMEOUT} (99 s) - * instead of burning the default retry budget (~6.75 min worst-case on a down API). A truly - * unreachable API surfaces within {@code CONNECT_TIMEOUT} (~2 s); a slow-but-TCP-open API can - * still take up to {@code REQUEST_TIMEOUT} — consumers that need a tighter ceiling should set - * {@code validateOnStartup = false} and probe themselves with their own deadline. Result is - * discarded — only the throw shape matters: 401 → {@link - * com.marketdata.sdk.exception.AuthenticationError}, other failures propagate as their typed - * {@link com.marketdata.sdk.exception.MarketDataException} subtype. + * Auth probe used by {@link MarketDataClient}'s startup validation. Hits {@code GET /user/} with + * a single-attempt policy so the constructor caps at one {@code REQUEST_TIMEOUT} (99 s) instead + * of burning the default retry budget (~6.75 min worst-case on a down API). A truly unreachable + * API surfaces within {@code CONNECT_TIMEOUT} (~2 s); a slow-but-TCP-open API can still take up + * to {@code REQUEST_TIMEOUT} — consumers that need a tighter ceiling should set {@code + * validateOnStartup = false} and probe themselves with their own deadline. Result is discarded — + * only the throw shape matters: 401 → {@link com.marketdata.sdk.exception.AuthenticationError}, + * other failures propagate as their typed {@link + * com.marketdata.sdk.exception.MarketDataException} subtype. * *

    Package-private and intent-named: not part of the public API and not an "endpoint" in the * §1.2 sense, so ADR-006's sync+async parity does not apply. */ void validateAuth() { transport.joinSync( - executeAndWrap(RequestSpec.get("user").build(), RetryPolicy.noRetry(), User.class)); + executeAndWrap( + RequestSpec.get("user").unversioned().build(), RetryPolicy.noRetry(), User.class)); } /** diff --git a/src/main/java/com/marketdata/sdk/utilities/User.java b/src/main/java/com/marketdata/sdk/utilities/User.java index a2f396a..bcfb9ae 100644 --- a/src/main/java/com/marketdata/sdk/utilities/User.java +++ b/src/main/java/com/marketdata/sdk/utilities/User.java @@ -1,7 +1,7 @@ package com.marketdata.sdk.utilities; /** - * Response shape for {@code GET /v1/user/} — the caller's current quota and data-tier permissions. + * Response shape for {@code GET /user/} — the caller's current quota and data-tier permissions. * *

    The numeric fields duplicate information that arrives on every response via the {@code * x-api-ratelimit-*} headers (see {@link com.marketdata.sdk.RateLimitSnapshot}); the dedicated diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index e1bc0c0..32893a9 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -160,7 +160,7 @@ void run_startup_validation_fails_fast_when_api_unreachable(@TempDir Path tmp) t @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { - // §5: when apiKey is unresolvable (demo mode), runStartupValidation must not hit /v1/user/ — + // §5: when apiKey is unresolvable (demo mode), runStartupValidation must not hit /user/ — // the server would return 401, breaking construction for any consumer who tries to "kick // the tires" without a token. The @Timeout guards against regression: if the skip ever // breaks, the test fails in 5s instead of hanging on the full retry budget (~6.75 min). @@ -173,7 +173,7 @@ void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { @Test void quick_start_usage_resolves_real_environment_and_never_leaks_token() { - // The no-arg public ctor now hits /v1/user/ for startup validation (§5). Don't exercise + // The no-arg public ctor now hits /user/ for startup validation (§5). Don't exercise // that path here — this test asserts config resolution and token redaction, not the live // call. Use the 4-arg variant with validateOnStartup=false to keep this a pure unit test. try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index ccdf297..9f96c07 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -87,11 +87,13 @@ void headersSyncMirrorsHeadersAsync() { assertThat(rh.headers()).containsEntry("x", "1"); } - // ---------- /v1/user/ endpoint ---------- + // ---------- /user/ endpoint ---------- @Test - void userHitsVersionedEndpoint() { - // Contrast with /headers/ — /v1/user/ is under the versioned prefix. + void userHitsUnversionedEndpoint() { + // The backend mounts the user router at the API root (no /v1/ prefix), same as /status/ + // and /headers/. Hitting /v1/user/ falls through to the global 404 handler — the SDK + // must request /user/ directly. CapturingClient client = new CapturingClient( 200, @@ -103,7 +105,7 @@ void userHitsVersionedEndpoint() { utilities.userAsync().join(); - assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/v1/user/"); + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/user/"); } @Test @@ -141,10 +143,9 @@ void userSyncMirrorsAsync() { } /** - * The {@code /v1/user/} endpoint's typical failure mode is "no billing plan" — surfaces as 401. - * The sync method must unwrap it to {@link AuthenticationError} directly so {@code - * validateOnStartup} (when wired) can catch it without digging through {@code - * CompletionException}. + * The {@code /user/} endpoint's typical failure mode is "no billing plan" — surfaces as 401. The + * sync method must unwrap it to {@link AuthenticationError} directly so {@code validateOnStartup} + * (when wired) can catch it without digging through {@code CompletionException}. */ @Test void user401SurfacesAuthenticationErrorDirectly() { From c4b350b907860d82d4f8783c2484ab28d31d8790 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 28 May 2026 08:24:15 -0300 Subject: [PATCH 56/57] .env inline comments --- CHANGELOG.md | 6 + .../java/com/marketdata/sdk/DotEnvLoader.java | 49 ++++- .../com/marketdata/sdk/DotEnvLoaderTest.java | 179 ++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b35e92..ddfe467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Default retry attempts corrected from 3 to 4 (one initial + three retries) to match SDK requirements §9.3 ("max 3 retries, yielding 4 total attempts"). +- `.env` parser now strips trailing inline `# comment` markers (quote-aware: a + `#` inside single/double quotes or adjacent to value chars stays part of the + value). Previously a line like `MARKETDATA_TOKEN=abc # prod` produced the + literal value `abc # prod`, which passes `validateApiKey` (printable ASCII) + and surfaces later as a confusing `AuthenticationError` far from the .env + source that caused it. ### Added - Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain, diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java index 35f70dc..9d1e344 100644 --- a/src/main/java/com/marketdata/sdk/DotEnvLoader.java +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -23,6 +23,13 @@ * {@link MarketDataLogging#configure}, so logging from here would land on an unconfigured JUL * logger — wrong format, possibly invisible. {@link MarketDataClient} drains the sink after * configuring logging, so the breadcrumb reaches its intended destination. + * + *

    Supported syntax: {@code KEY=value} pairs, full-line {@code #} comments, blank lines, single- + * or double-quote-wrapped values (quotes are stripped, inner whitespace preserved), and trailing + * inline {@code # comment} markers — recognized only when the {@code #} is outside any quoted span + * and preceded by whitespace (or sits at the start of the value). A {@code #} adjacent to + * value chars stays part of the value, so URLs with fragments and tokens that contain {@code #} + * survive intact. */ final class DotEnvLoader { @@ -70,7 +77,8 @@ static Map load( if (allowedKeys != null && !allowedKeys.contains(key)) { continue; } - String value = stripQuotes(trimmed.substring(eq + 1).trim()); + String afterEq = trimmed.substring(eq + 1).trim(); + String value = stripQuotes(stripInlineComment(afterEq).trim()); result.put(key, value); } } catch (IOException e) { @@ -84,6 +92,45 @@ static Map load( return Map.copyOf(result); } + /** + * Strip a trailing inline comment from {@code value} if present. An inline comment is a {@code #} + * that is (a) outside any single- or double-quoted span, and (b) preceded by whitespace or sits + * at the very start of {@code value}. A {@code #} adjacent to value chars (e.g. {@code pa#ss}, + * {@code "https://x.example/#frag"} unquoted as {@code https://x.example/#frag}) is part of the + * value, not a comment marker — matching python-dotenv and dotenv-java conventions, which keep + * URLs and hash-containing tokens intact unless the author put a space before the {@code #}. + * + *

    Quotes are tracked but not consumed: the wrapping quotes are still present in the returned + * string and are stripped afterwards by {@link #stripQuotes}. The walk does not interpret escape + * sequences, matching the existing quote handling (no {@code \"} support either). + * + *

    Trailing whitespace left behind between the value and the stripped {@code #} is removed by + * the caller's {@code trim()}. + */ + private static String stripInlineComment(String value) { + boolean inSingle = false; + boolean inDouble = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (inSingle) { + if (c == '\'') { + inSingle = false; + } + } else if (inDouble) { + if (c == '"') { + inDouble = false; + } + } else if (c == '\'') { + inSingle = true; + } else if (c == '"') { + inDouble = true; + } else if (c == '#' && (i == 0 || Character.isWhitespace(value.charAt(i - 1)))) { + return value.substring(0, i); + } + } + return value; + } + private static String stripQuotes(String value) { if (value.length() >= 2) { char first = value.charAt(0); diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java index 64b3d2a..6d8031a 100644 --- a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -156,6 +156,22 @@ void load_ignores_comment_lines(@TempDir Path tmp) throws IOException { assertThat(result).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } + @Test + void load_ignores_comment_lines_with_leading_whitespace(@TempDir Path tmp) throws IOException { + // The parser trims each line before checking the `#` prefix, so indented full-line comments + // (common when commenting out a block inside an aligned section) are skipped too. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + # leading-spaces comment + \t# leading-tab comment + TOKEN=abc + """); + + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + @Test void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { Path file = @@ -172,6 +188,169 @@ void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { assertThat(load(file)).containsEntry("TOKEN", "abc").containsEntry("BASE_URL", "https://x"); } + // ---------- inline comments ---------- + + @Test + void load_strips_inline_comment_after_whitespace(@TempDir Path tmp) throws IOException { + // The motivating bug: `TOKEN=abc # my note` previously yielded the literal value + // "abc # my note", which validateApiKey lets through (printable ASCII) and surfaces later + // as a confusing AuthenticationError far from the .env file that caused it. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc123 # production token\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc123"); + } + + @Test + void load_strips_inline_comment_after_tab(@TempDir Path tmp) throws IOException { + // Any Unicode whitespace before `#` qualifies — tabs are common in hand-aligned .env files. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc123\t# tab-separated comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc123"); + } + + @Test + void load_keeps_hash_when_not_preceded_by_whitespace(@TempDir Path tmp) throws IOException { + // `#` adjacent to value chars is part of the value (python-dotenv / dotenv-java convention). + // Critical for URLs with fragments and tokens that legitimately contain `#`. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + TOKEN=abc#123 + BASE_URL=https://example.com/path#frag + """); + + assertThat(load(file)) + .containsEntry("TOKEN", "abc#123") + .containsEntry("BASE_URL", "https://example.com/path#frag"); + } + + @Test + void load_keeps_hash_inside_double_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc # not a comment\"\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_keeps_hash_inside_single_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN='abc # not a comment'\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_strips_inline_comment_after_closing_quote(@TempDir Path tmp) throws IOException { + // Quoted value followed by a real comment outside the quotes: the comment is stripped and the + // quotes are removed normally. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc 123\" # the real comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_records_empty_value_when_value_is_blank(@TempDir Path tmp) throws IOException { + // `KEY=` and `KEY= ` both produce an empty-string entry. The cascade's pickFirst() treats + // blank values as unset, so this is functionally equivalent to omitting the key — but the + // parser still records it. Two reasons: (1) it documents the user's intent (they wrote the + // key, so it's part of the file's shape), and (2) it keeps the parser symmetric with the + // `KEY=#comment` case, which also yields "". + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + EMPTY_BARE= + EMPTY_SPACES=\s\s\s + KEPT=value + """); + + Map result = load(file); + assertThat(result) + .containsEntry("EMPTY_BARE", "") + .containsEntry("EMPTY_SPACES", "") + .containsEntry("KEPT", "value"); + } + + @Test + void load_strips_inline_comment_after_closing_single_quote(@TempDir Path tmp) throws IOException { + // Symmetry with `load_strips_inline_comment_after_closing_quote` (the double-quote variant): + // the walk treats single and double quotes the same way, so a `#` inside `'…'` is preserved + // and a `#` after the closing `'` with whitespace before it is a comment. + Path file = + Files.writeString(tmp.resolve(".env"), "TOKEN='abc # not a comment' # the real comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_strips_value_when_hash_is_first_non_whitespace_char(@TempDir Path tmp) + throws IOException { + // `KEY=#comment` and `KEY= # comment` both leave an empty value. The cascade's + // pickFirst() treats blank values as unset, so this is functionally equivalent to omitting + // the line — the empty entry is still recorded for symmetry with `KEY=`. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + EMPTY1=#comment-immediately + EMPTY2= # comment after spaces + KEPT=value + """); + + Map result = load(file); + assertThat(result) + .containsEntry("EMPTY1", "") + .containsEntry("EMPTY2", "") + .containsEntry("KEPT", "value"); + } + + @Test + void load_first_unquoted_hash_wins_over_later_ones(@TempDir Path tmp) throws IOException { + // `value # first-comment # second` → everything from the first qualifying `#` onward is + // comment. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=value # first-comment # second\n"); + + assertThat(load(file)).containsEntry("TOKEN", "value"); + } + + @Test + void load_keeps_hash_in_value_then_strips_later_comment(@TempDir Path tmp) throws IOException { + // The first `#` is adjacent to value chars (not a comment); the second `#` is preceded by + // whitespace (a comment) — only the trailing portion is stripped. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=value#part more # real-comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "value#part more"); + } + + @Test + void load_keeps_hash_outside_quotes_after_closing_quote_without_whitespace(@TempDir Path tmp) + throws IOException { + // `"x"#y` — `#` is preceded by the closing quote, not whitespace, so it's part of the value. + // stripQuotes does nothing here (last char isn't a matching quote), so the literal pair-of- + // quotes-plus-hash-tail is preserved as authored. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"x\"#y\n"); + + assertThat(load(file)).containsEntry("TOKEN", "\"x\"#y"); + } + + @Test + void load_last_assignment_wins_for_duplicate_keys(@TempDir Path tmp) throws IOException { + // Lines are processed top-to-bottom and stored in a LinkedHashMap, so a later assignment + // overwrites an earlier one for the same key. This documents the file as authoritative in + // line order — useful when a user commits a base `.env` and overrides a single line at the + // bottom for a local run. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + TOKEN=first + TOKEN=second + TOKEN=third + """); + + assertThat(load(file)).containsEntry("TOKEN", "third"); + } + @Test void load_keeps_equals_signs_in_value(@TempDir Path tmp) throws IOException { Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=a=b=c\n"); From df3becdfe4bca3486f3ab8c8e57ca798664b381c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 28 May 2026 08:48:50 -0300 Subject: [PATCH 57/57] add NPE validation to RequestHeaders --- CHANGELOG.md | 8 +++ .../sdk/RequestHeadersDeserializer.java | 28 +++++++++ .../sdk/utilities/RequestHeaders.java | 8 ++- .../marketdata/sdk/RequestHeadersTest.java | 60 +++++++++++++++++++ .../marketdata/sdk/UtilitiesResourceTest.java | 19 ++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/marketdata/sdk/RequestHeadersTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfe467..28f49db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 literal value `abc # prod`, which passes `validateApiKey` (printable ASCII) and surfaces later as a confusing `AuthenticationError` far from the .env source that caused it. +- `RequestHeaders` canonical constructor now rejects a `null` `headers` map + with a clear `NullPointerException` naming the field, replacing the bare + `Map.copyOf(null)` NPE that left consumers hunting for the offending + argument. The wire-format deserializer additionally intercepts a top-level + JSON `null` body via `JsonDeserializer#getNullValue` and surfaces it as a + `ParseError` carrying the endpoint URL, status, and request id — preventing + a malformed `/headers/` response from manifesting as an opaque NPE further + down the call stack. ### Added - Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain, diff --git a/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java index 011207d..a26cb77 100644 --- a/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java +++ b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.marketdata.sdk.utilities.RequestHeaders; import java.io.IOException; import java.util.Map; @@ -14,6 +15,14 @@ * record at the Jackson layer rather than via an annotation on the record (per ADR-007: response * records don't carry {@code @JsonDeserialize}; deserializers register programmatically on the * parser's {@code ObjectMapper}). + * + *

    A literal JSON {@code null} body — or any other path that would leave the parser holding a + * {@code null} map — is short-circuited to a {@link JsonMappingException} so {@link + * JsonResponseParser} surfaces it as a {@link com.marketdata.sdk.exception.ParseError} with the + * request's URL/status/id attached. Without the guard, Jackson would return {@code null} from the + * top-level {@code readValue} (via {@link #getNullValue}, its standard null-routing seam) and the + * NPE would surface much later — uncaught by the parser's {@code catch (IOException)} and far less + * useful to a consumer trying to diagnose a malformed response. */ final class RequestHeadersDeserializer extends JsonDeserializer { @@ -22,6 +31,25 @@ final class RequestHeadersDeserializer extends JsonDeserializer @Override public RequestHeaders deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { Map raw = p.readValueAs(MAP_OF_STRINGS); + if (raw == null) { + // Defense in depth: a top-level JSON null is intercepted by getNullValue(ctxt) below before + // deserialize() is ever called, so this branch is reachable only via a pathological future + // Jackson behavior. Better to fail with a clean JsonMappingException than to let the null + // reach the record's requireNonNull and bypass the parser's IOException catch. + throw JsonMappingException.from(p, "expected a JSON object for /headers/ body, got null map"); + } return new RequestHeaders(raw); } + + /** + * Jackson routes a top-level JSON {@code null} through this seam instead of calling {@link + * #deserialize}. Default behavior returns {@code null}; we instead throw so the wire-null case + * produces a {@link com.marketdata.sdk.exception.ParseError} with the endpoint URL in scope, + * matching the failure shape of any other malformed body. + */ + @Override + public RequestHeaders getNullValue(DeserializationContext ctxt) throws JsonMappingException { + throw JsonMappingException.from( + ctxt, "expected a JSON object for /headers/ body, got JSON null"); + } } diff --git a/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java index 4907df1..c8846d8 100644 --- a/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java +++ b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.utilities; import java.util.Map; +import java.util.Objects; /** * Response shape for {@code GET /headers/} — the request headers echoed back by the server (with @@ -10,11 +11,16 @@ * surfaces them. * * @param headers all headers the server received, lower-cased keys to values. The map is - * defensively copied and immutable. + * defensively copied and immutable. Never {@code null} — the package is {@code @NullMarked}, + * and the canonical constructor rejects a {@code null} argument with a {@link + * NullPointerException} naming the field. The wire-format deserializer pre-checks for a JSON + * {@code null} token and surfaces a {@link com.marketdata.sdk.exception.ParseError} instead, so + * consumers never see a bare NPE from the wire path. */ public record RequestHeaders(Map headers) { public RequestHeaders { + Objects.requireNonNull(headers, "headers"); headers = Map.copyOf(headers); } } diff --git a/src/test/java/com/marketdata/sdk/RequestHeadersTest.java b/src/test/java/com/marketdata/sdk/RequestHeadersTest.java new file mode 100644 index 0000000..89173d4 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RequestHeadersTest.java @@ -0,0 +1,60 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.utilities.RequestHeaders; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Focused tests for the {@link RequestHeaders} record's canonical constructor. The wire-level path + * (a server returning a JSON-{@code null} body) is exercised end-to-end in {@link + * UtilitiesResourceTest}; this class documents the public-API contract that consumers see when + * constructing the record directly. + */ +class RequestHeadersTest { + + @Test + void constructorRejectsNullMapWithNamedFieldMessage() { + // The package is @NullMarked, so `null` violates the public contract. Pre-checking with + // requireNonNull yields a clear "headers" message; without it, Map.copyOf(null) throws a bare + // NPE that leaves the consumer hunting for which constructor argument was null. + assertThatThrownBy(() -> new RequestHeaders(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("headers"); + } + + @Test + void constructorAcceptsEmptyMap() { + // An empty map is a legitimate (if unusual) value — Map.copyOf preserves emptiness and the + // result is still immutable. + RequestHeaders rh = new RequestHeaders(Map.of()); + + assertThat(rh.headers()).isEmpty(); + } + + @Test + void constructorDefensivelyCopiesTheInputMap() { + // Map.copyOf snapshots the input. A consumer mutating the original after construction must + // not be able to mutate the record's view — this is the defensive-copy guarantee the Javadoc + // promises. + Map mutable = new HashMap<>(); + mutable.put("accept", "*/*"); + RequestHeaders rh = new RequestHeaders(mutable); + + mutable.put("authorization", "Bearer leaked"); + + assertThat(rh.headers()).containsOnlyKeys("accept"); + } + + @Test + void headersAccessorReturnsImmutableView() { + // The Map.copyOf result is unmodifiable; consumers attempting to mutate get UOE rather than + // silently corrupting the record's invariant. + RequestHeaders rh = new RequestHeaders(Map.of("accept", "*/*")); + + assertThat(rh.headers()).isUnmodifiable(); + } +} diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 9f96c07..6a9601c 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -274,6 +274,25 @@ void headersSyncUnwrapsAuthenticationFailureFromCompletionException() { assertThatThrownBy(utilities::headers).isInstanceOf(AuthenticationError.class); } + /** + * A literal JSON {@code null} body for {@code /headers/} must surface as a {@link + * com.marketdata.sdk.exception.ParseError} carrying the request URL, status, and id — never as a + * raw {@code NullPointerException} from the {@link RequestHeaders} canonical constructor. The + * {@link RequestHeadersDeserializer} pre-check converts the null token into a {@code + * JsonMappingException}, which {@link JsonResponseParser} wraps with the support context. + */ + @Test + void headersJsonNullBodySurfacesParseErrorNotNpe() { + CapturingClient client = + new CapturingClient(200, "null".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::headers) + .isInstanceOf(com.marketdata.sdk.exception.ParseError.class) + .hasMessageContaining("/headers/") + .hasMessageContaining("JSON null"); + } + // ---------- stub HttpClient ---------- private static final class CapturingClient extends TestHttpClients.StubHttpClient {