From d7eeb10b0bfe1633bd881dec5ed6b979f49bc369 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 5 May 2026 14:58:38 -0300 Subject: [PATCH 1/8] add v1/market/status endpoint --- build.gradle.kts | 7 +- .../com/marketdata/sdk/PlaceholderIT.java | 16 -- .../sdk/markets/MarketsStatusIT.java | 48 ++++ .../com/marketdata/sdk/HttpStatusMapper.java | 37 +++ .../com/marketdata/sdk/HttpTransport.java | 222 +++++++++++++++++ .../com/marketdata/sdk/MarketDataClient.java | 54 ++-- .../sdk/MarketStatusDeserializer.java | 81 ++++++ .../com/marketdata/sdk/MarketsResource.java | 91 +++++++ .../com/marketdata/sdk/RateLimitHeaders.java | 52 ++++ .../java/com/marketdata/sdk/RequestSpec.java | 49 ++++ .../marketdata/sdk/markets/DailyStatus.java | 13 + .../marketdata/sdk/markets/MarketStatus.java | 23 ++ .../marketdata/sdk/markets/package-info.java | 8 + .../sdk/MarketStatusDeserializerTest.java | 88 +++++++ .../marketdata/sdk/MarketsResourceTest.java | 231 ++++++++++++++++++ 15 files changed, 978 insertions(+), 42 deletions(-) delete mode 100644 src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java create mode 100644 src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java create mode 100644 src/main/java/com/marketdata/sdk/HttpStatusMapper.java create mode 100644 src/main/java/com/marketdata/sdk/HttpTransport.java create mode 100644 src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java create mode 100644 src/main/java/com/marketdata/sdk/MarketsResource.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/markets/DailyStatus.java create mode 100644 src/main/java/com/marketdata/sdk/markets/MarketStatus.java create mode 100644 src/main/java/com/marketdata/sdk/markets/package-info.java create mode 100644 src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java create mode 100644 src/test/java/com/marketdata/sdk/MarketsResourceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 995403f..7b0ef0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,7 +41,12 @@ tasks.jar { } // ADR-003: integration tests live in a separate, env-var-gated source set. -val integrationTest by sourceSets.creating +val integrationTest by sourceSets.creating { + // Wire the main and unit-test outputs into the integration test classpath + // so ITs can use both production code and test helpers (junit, assertj). + compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + runtimeClasspath += output + compileClasspath +} val integrationTestImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) diff --git a/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java b/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java deleted file mode 100644 index b336e45..0000000 --- a/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.marketdata.sdk; - -import org.junit.jupiter.api.Test; - -/** - * Placeholder so the {@code integrationTest} source set compiles before any real integration tests - * exist. Replace with live-API tests in subsequent iterations. Gated by {@code - * MARKETDATA_RUN_INTEGRATION_TESTS=true}. - */ -class PlaceholderIT { - - @Test - void sourceSetCompiles() { - // Intentionally empty. - } -} diff --git a/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java new file mode 100644 index 0000000..49af82a --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java @@ -0,0 +1,48 @@ +package com.marketdata.sdk.markets; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.MarketDataClient; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +/** + * 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 it the + * client enters demo mode and the markets endpoint is not part of the demo allow-list, so the test + * would receive an authentication error. + */ +class MarketsStatusIT { + + @Test + void todayStatusReturnsAtLeastOneEntry() { + try (var client = new MarketDataClient(null, null, null, false)) { + MarketStatus status = client.markets().status(); + + // The endpoint always returns at least one entry for "today" — even on weekends/holidays + // the row is present with status="closed". + assertThat(status.days()).isNotEmpty(); + assertThat(status.days().get(0).date()).isNotNull(); + } + } + + @Test + void historicalRangeReturnsExpectedDays() { + LocalDate from = LocalDate.now().minusDays(7); + LocalDate to = LocalDate.now().minusDays(1); + + try (var client = new MarketDataClient(null, null, null, false)) { + MarketStatus status = client.markets().status(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/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java new file mode 100644 index 0000000..dff9f0f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..a1780ed --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -0,0 +1,222 @@ +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.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Semaphore; +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 Semaphore concurrencyPermits; + 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 = baseUrl; + this.apiVersion = apiVersion; + this.userAgent = userAgent; + this.token = token; + this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT); + this.jsonMapper = buildJsonMapper(); + this.httpClient = + 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 request has succeeded yet. */ + @Nullable RateLimits getLatestRateLimits() { + return latestRateLimits.get(); + } + + /** + * Async-first request execution. + * + *

Acquires a concurrency permit, fires the request, parses rate-limit headers, decodes the + * body when the status is 200/203/404 (the API returns 404 with {@code {"s":"no_data"}} as a + * sentinel — see SDK requirements §9.1), and translates other status codes to the appropriate + * {@link MarketDataException} subtype. + */ + CompletableFuture executeAsync(RequestSpec spec, Class responseType) { + URI uri = buildUri(spec); + HttpRequest request = buildRequest(uri); + + try { + concurrencyPermits.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture( + new NetworkError( + "Interrupted while waiting for a concurrency permit", + new ErrorContext(null, uri.toString(), null), + e)); + } + + return httpClient + .sendAsync(request, BodyHandlers.ofByteArray()) + .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)); + } + latestRateLimits.set(RateLimitHeaders.parse(response.headers())); + 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. + */ + T executeSync(RequestSpec spec, Class responseType) { + try { + return executeAsync(spec, responseType).join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof MarketDataException mde) { + throw mde; + } + if (cause instanceof RuntimeException re) { + throw re; + } + throw 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) { + StringBuilder sb = new StringBuilder(); + sb.append(baseUrl).append('/').append(apiVersion).append('/').append(spec.path()); + if (!spec.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; + } + + 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/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 4447806..5cab986 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -1,9 +1,6 @@ package com.marketdata.sdk; -import java.net.http.HttpClient; import java.time.Duration; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import org.jspecify.annotations.Nullable; @@ -11,9 +8,10 @@ /** * Entry point to the Market Data Java SDK. * - *

One {@code MarketDataClient} per application. Holds a single shared {@link HttpClient} - * (HTTP/2, 2 s connect timeout) for connection pooling and a 50-permit semaphore that gates the - * global concurrency pool required by SDK requirements §12. + *

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: * @@ -32,19 +30,17 @@ public final class MarketDataClient implements AutoCloseable { /** SDK requirements §10: fixed 99-second per-request timeout. */ - public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); + public static final Duration REQUEST_TIMEOUT = HttpTransport.REQUEST_TIMEOUT; /** SDK requirements §10: fixed 2-second connect timeout. */ - public static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); + 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 = 50; + public static final int CONCURRENCY_LIMIT = HttpTransport.CONCURRENCY_LIMIT; private static final Logger LOG = Logger.getLogger(MarketDataClient.class.getName()); - private final HttpClient httpClient; - private final Semaphore concurrencyPermits; - private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); + private final HttpTransport transport; private final @Nullable String token; private final String baseUrl; @@ -53,6 +49,9 @@ public final class MarketDataClient implements AutoCloseable { 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. @@ -95,13 +94,8 @@ public MarketDataClient( this.validateOnStartup = validateOnStartup; this.userAgent = "marketdata-sdk-java/" + Version.current(); - this.httpClient = - HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .version(HttpClient.Version.HTTP_2) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT); + this.transport = new HttpTransport(this.baseUrl, this.apiVersion, this.userAgent, this.token); + this.markets = new MarketsResource(this.transport); LOG.log( Level.INFO, @@ -116,9 +110,22 @@ public MarketDataClient( } // SDK requirements §5: validate on startup by default. The actual - // /user/ call lands with the request layer; this flag is the seam. + // /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; } @@ -141,15 +148,12 @@ public boolean isValidateOnStartup() { /** Latest client-level rate-limit snapshot, or {@code null} if none has been received yet. */ public @Nullable RateLimits getRateLimits() { - return latestRateLimits.get(); + return transport.getLatestRateLimits(); } @Override public void close() { - // java.net.http.HttpClient gained explicit close() in JDK 21. - // While the minimum target is JDK 17, this method is a no-op: - // the JVM releases the executor and connection pool on process - // exit. Revisit if/when the minimum bumps to 21+. + transport.close(); } private static String trimTrailingSlash(String url) { diff --git a/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java b/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java new file mode 100644 index 0000000..5dd4815 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..13af185 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketsResource.java @@ -0,0 +1,91 @@ +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 new file mode 100644 index 0000000..909cffa --- /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 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/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java new file mode 100644 index 0000000..206f634 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -0,0 +1,49 @@ +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 { + queryParams = Map.copyOf(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() { + return new RequestSpec(path, Collections.unmodifiableMap(queryParams)); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/DailyStatus.java b/src/main/java/com/marketdata/sdk/markets/DailyStatus.java new file mode 100644 index 0000000..2f20caa --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/DailyStatus.java @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..18ff857 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..f8f4dee --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/package-info.java @@ -0,0 +1,8 @@ +/** + * 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/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java new file mode 100644 index 0000000..c08f4a8 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java @@ -0,0 +1,88 @@ +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"); + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java new file mode 100644 index 0000000..0388935 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java @@ -0,0 +1,231 @@ +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.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; + +/** + * 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 ---------- + + @Test + void statusNoArgsHitsCanonicalUrlAndDecodesPayload() { + 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 = client.markets().status(); + + 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); + } + } + + @Test + void statusForDateBuildsDateQueryParam() { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + MarketStatus result = client.markets().status(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"); + } + } + + @Test + void statusForRangeBuildsFromAndToQueryParams() { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + client.markets().status(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 parity ---------- + + @Test + void statusAsyncReturnsSameResultAsSync() 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 ---------- + + @Test + void notFoundWithNoDataBodyDecodesAsEmpty() { + handler.setResponse(404, "{\"s\":\"no_data\"}", List.of()); + + try (var client = newClient()) { + MarketStatus result = client.markets().status(); + assertThat(result.isEmpty()).isTrue(); + } + } + + @Test + void http401ThrowsAuthenticationError() { + handler.setResponse(401, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .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); + } + } + + // ---------- helpers ---------- + + 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(); + } + } +} From ac0e1c3edf9ea60926351506dc1b8762382839c2 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 5 May 2026 16:19:31 -0300 Subject: [PATCH 2/8] adds integration test --- .github/workflows/main.yml | 65 +++++- .../workflows/pr-integration-on-demand.yml | 205 +++++++++++++++++ .github/workflows/pr-matrix-on-demand.yml | 8 +- .github/workflows/pull-request.yml | 7 + CLAUDE.md | 13 +- build.gradle.kts | 23 +- .../com/marketdata/sdk/MarketsStatusIT.java | 52 +++++ .../sdk/markets/MarketsStatusIT.java | 48 ---- .../java/com/marketdata/sdk/RequestSpec.java | 5 +- .../java/com/marketdata/sdk/CallMode.java | 68 ++++++ .../marketdata/sdk/MarketsResourceTest.java | 214 ++++++++++++++++-- 11 files changed, 613 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/pr-integration-on-demand.yml create mode 100644 src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java delete mode 100644 src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java create mode 100644 src/test/java/com/marketdata/sdk/CallMode.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ed211da..33eaadb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,16 +34,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Install both JDK 17 (for compilation) and the matrix JDK (for - # test execution). setup-java exports JAVA_HOME__; - # Gradle's toolchain auto-detection picks them up. - - name: Set up JDKs (compile=17, test=${{ matrix.java }}) + # Install both the matrix JDK (test execution) and JDK 17 (compile + + # Gradle daemon runtime). Order matters: setup-java sets JAVA_HOME + # to the LAST entry, and Gradle 8.12 only supports JDKs up to 23 as + # its own runtime — so JDK 17 must be last for matrix.java = 25. + # setup-java still exports JAVA_HOME__ for both, and + # setup-gradle registers them as toolchain candidates. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) uses: actions/setup-java@v4 with: distribution: temurin java-version: | - 17 ${{ matrix.java }} + 17 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 @@ -72,8 +75,50 @@ jobs: files: build/reports/jacoco/test/jacocoTestReport.xml fail_ci_if_error: true - # Integration-tests job is intentionally not wired up yet: - # SDK requirements §13 says they run on PRs and release pipelines, but - # they hit the live API and require a MARKETDATA_TOKEN secret. Add this - # job (gated on `if: ${{ secrets.MARKETDATA_TOKEN != '' }}`) once the - # token is configured in the repo's GitHub Actions secrets. + # SDK requirements §13: integration tests run mandatorily on every push + # to main (release-pipeline gate) on the full forward-compat matrix. + # On PRs they're triggered on demand instead — see + # pr-integration-on-demand.yml. + integration-tests: + name: Integration tests (live API, JDK ${{ matrix.java }}) + runs-on: ubuntu-latest + needs: verify + strategy: + fail-fast: false + matrix: + java: ['17', '21', '25'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + ${{ matrix.java }} + 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run integration tests against live API + env: + MARKETDATA_TOKEN: ${{ secrets.MARKETDATA_TOKEN }} + MARKETDATA_RUN_INTEGRATION_TESTS: 'true' + run: | + if [ -z "$MARKETDATA_TOKEN" ]; then + echo "::error::MARKETDATA_TOKEN secret is required on main; integration tests must run on every merge." + exit 1 + fi + ./gradlew integrationTest -PtestJdk=${{ matrix.java }} --stacktrace + + - name: Upload integration-test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: integration-test-reports-jdk${{ matrix.java }} + path: | + build/reports/tests/integrationTest/ + build/test-results/integrationTest/ + retention-days: 14 diff --git a/.github/workflows/pr-integration-on-demand.yml b/.github/workflows/pr-integration-on-demand.yml new file mode 100644 index 0000000..8d013e7 --- /dev/null +++ b/.github/workflows/pr-integration-on-demand.yml @@ -0,0 +1,205 @@ +name: Integration tests on demand + +# Manually triggered by commenting on an open PR: +# `integrationtest` → JDK 17 only +# `integrationtestfull` → full matrix {17, 21, 25} +# +# Integration tests hit the live Market Data API, so we don't run them +# automatically on every PR open/sync (saves API quota + CI minutes). +# They ARE required for merge — branch protection on `main` should list +# "Integration tests pass" as a required status check, which is the +# aggregator job below. PRs cannot merge until a reviewer comments one +# of the two trigger phrases AND the resulting run is green. +# +# Important security note: workflows triggered by `issue_comment` always +# run from the *default branch's* version of the workflow file, not from +# the PR. Adding/changing this file on a feature branch has no effect +# until it lands on main. +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write # to react and post the result comment + +# Multiple trigger comments on the same PR cancel earlier runs. +concurrency: + group: pr-integration-on-demand-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + guard: + name: Guard + runs-on: ubuntu-latest + # Only fire on PR comments (not generic issue comments) that contain + # one of the two accepted slash-style commands. `contains` is + # substring match — note that `integrationtest` is itself a substring + # of `integrationtestfull`, so this OR matches both, and the matrix + # decision below disambiguates. + if: | + github.event.issue.pull_request != null && ( + contains(github.event.comment.body, 'integrationtest') || + contains(github.event.comment.body, 'integrationtestfull') + ) + outputs: + head_sha: ${{ steps.pr.outputs.head_sha }} + jdks: ${{ steps.matrix.outputs.jdks }} + mode: ${{ steps.matrix.outputs.mode }} + steps: + - name: Verify commenter has write permission + uses: actions/github-script@v7 + with: + script: | + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['write', 'maintain', 'admin'].includes(perm.permission); + if (!allowed) { + core.setFailed( + `@${context.payload.comment.user.login} (${perm.permission}) ` + + `cannot trigger integration tests; write access required.` + ); + } + + - name: React 👀 to the trigger comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + + - name: Resolve PR head SHA + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number, + }); + if (pr.state !== 'open') { + core.setFailed(`PR #${pr.number} is ${pr.state}; refusing to run.`); + return; + } + core.setOutput('head_sha', pr.head.sha); + + - name: Decide JDK matrix from comment body + id: matrix + env: + BODY: ${{ github.event.comment.body }} + run: | + # Check for the long form first because it contains 'integrationtest' as a substring. + if [[ "$BODY" == *"integrationtestfull"* ]]; then + echo 'jdks=["17","21","25"]' >> "$GITHUB_OUTPUT" + echo 'mode=full' >> "$GITHUB_OUTPUT" + echo "Trigger: integrationtestfull → matrix {17, 21, 25}" + else + echo 'jdks=["17"]' >> "$GITHUB_OUTPUT" + echo 'mode=single' >> "$GITHUB_OUTPUT" + echo "Trigger: integrationtest → JDK 17" + fi + + integration-tests: + name: Integration tests (JDK ${{ matrix.java }}) + needs: guard + runs-on: ubuntu-latest + strategy: + # Don't cancel siblings: if JDK 21 fails, we still want 17 and 25 + # results to surface. + fail-fast: false + matrix: + java: ${{ fromJSON(needs.guard.outputs.jdks) }} + + steps: + # Check out exactly the PR's HEAD commit so we test the proposed + # change, not the merge ref. + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.guard.outputs.head_sha }} + + # Order matters: JDK 17 must be last so JAVA_HOME=17 (Gradle 8.12 + # only supports JDKs up to 23 as its daemon runtime). The matrix + # JDK is still installed and registered as a toolchain target. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + ${{ matrix.java }} + 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run integration tests against live API + env: + MARKETDATA_TOKEN: ${{ secrets.MARKETDATA_TOKEN }} + MARKETDATA_RUN_INTEGRATION_TESTS: 'true' + run: | + if [ -z "$MARKETDATA_TOKEN" ]; then + echo "::error::MARKETDATA_TOKEN secret missing — cannot run integration tests." + exit 1 + fi + ./gradlew integrationTest -PtestJdk=${{ matrix.java }} --stacktrace + + - name: Upload integration-test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: integration-test-reports-jdk${{ matrix.java }} + path: | + build/reports/tests/integrationTest/ + build/test-results/integrationTest/ + retention-days: 14 + + # Aggregator job. Branch protection on `main` should require this + # check name ("Integration tests pass") so a single required check + # covers both `integrationtest` (matrix=[17]) and `integrationtestfull` + # (matrix=[17,21,25]) modes uniformly. Without this, branch protection + # would have to list the per-matrix-entry check names which only exist + # in the `full` mode. + required-check: + name: Integration tests pass + needs: [guard, integration-tests] + if: always() && needs.guard.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Aggregate matrix outcome + env: + MATRIX_RESULT: ${{ needs.integration-tests.result }} + MODE: ${{ needs.guard.outputs.mode }} + run: | + echo "Mode: $MODE" + echo "Matrix outcome: $MATRIX_RESULT" + if [[ "$MATRIX_RESULT" != "success" ]]; then + echo "::error::One or more integration-test JDK entries failed." + exit 1 + fi + echo "All integration tests passed." + + - name: Comment outcome on the PR + if: always() + uses: actions/github-script@v7 + with: + script: | + const ok = '${{ needs.integration-tests.result }}' === 'success'; + const mode = '${{ needs.guard.outputs.mode }}'; + const emoji = ok ? '✅' : '❌'; + const status = ok ? 'passed' : 'failed'; + const matrix = mode === 'full' ? '`{17, 21, 25}`' : '`17`'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `${emoji} On-demand integration tests on JDK ${matrix} ${status}. [View run](${runUrl}).`, + }); diff --git a/.github/workflows/pr-matrix-on-demand.yml b/.github/workflows/pr-matrix-on-demand.yml index 6c309ad..5dda271 100644 --- a/.github/workflows/pr-matrix-on-demand.yml +++ b/.github/workflows/pr-matrix-on-demand.yml @@ -110,14 +110,16 @@ jobs: with: ref: ${{ needs.guard.outputs.head_sha }} - # Compile=17, test=matrix JDK; same shape as main.yml. - - name: Set up JDKs (compile=17, test=${{ matrix.java }}) + # Order matters: JDK 17 must be last so JAVA_HOME=17 (Gradle 8.12 + # doesn't support JDK 24+ as its daemon runtime). The matrix JDK + # is still installed and registered as a toolchain target. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) uses: actions/setup-java@v4 with: distribution: temurin java-version: | - 17 ${{ matrix.java }} + 17 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0ced2ec..4411957 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -63,3 +63,10 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: build/reports/jacoco/test/jacocoTestReport.xml fail_ci_if_error: true + + # NOTE: integration tests are NOT run automatically on PR open/sync. + # They are required for merge but only fire when a reviewer comments + # `integrationtest` (JDK 17) or `integrationtestfull` (matrix 17/21/25) + # — see .github/workflows/pr-integration-on-demand.yml. Branch-protection + # rules should require the "Integration tests pass" check name produced + # by that workflow before allowing merge to main. diff --git a/CLAUDE.md b/CLAUDE.md index 877be32..e98bc7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,10 +69,13 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - §12 concurrency: `Semaphore(50)` field on `MarketDataClient` (wiring of acquire/release lands with the request layer). - §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). -- ADR-002 CI: split into three 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` and uploads `build/reports/jacoco/test/jacocoTestReport.xml` to Codecov. - - `.github/workflows/main.yml` — runs only on `push` to `main`. Full forward-compat matrix `{17, 21, 25}` via `-PtestJdk=N` (wired into `tasks.test.javaLauncher` in `build.gradle.kts`). The JDK 17 matrix entry also uploads coverage to Codecov, establishing the base coverage that PRs compare against. - - `.github/workflows/pr-matrix-on-demand.yml` — manually triggered by commenting one of `/run-all-jdks`, `/jdk-matrix`, or `/test-all` on an open PR. Runs JDK 21 and 25 (17 already ran via `pull-request.yml`). Gated to commenters with write/maintain/admin permission. Reacts 👀 to the trigger comment and posts a result summary comment when the matrix finishes. Note: `issue_comment` workflows always execute from the default branch's copy of the file — feature-branch edits to this workflow have no effect until merged to main. +- 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). + - `.github/workflows/pr-matrix-on-demand.yml` — manually triggered on a PR by commenting `/run-all-jdks`, `/jdk-matrix`, or `/test-all`. Runs the **unit-test** matrix on JDK 21 and 25 (17 already ran via `pull-request.yml`). Gated to write/maintain/admin commenters. Reacts 👀 to the trigger comment and posts a result summary. + - `.github/workflows/pr-integration-on-demand.yml` — manually triggered on a PR by commenting `integrationtest` (JDK 17 only) or `integrationtestfull` (matrix `{17, 21, 25}`). Runs the **integration-test** suite against the live API. Same write+ permission gate as the matrix-on-demand workflow. Aggregates the matrix outcome into a single required check named **"Integration tests pass"** so branch protection can require it uniformly regardless of which command was used. Branch-protection rules on `main` should list this check as required for merge. + - All four `issue_comment`-driven workflows execute from the default branch's copy of their YAML, not the PR's. Feature-branch edits to these workflows take effect only after merge to main. + - `-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):** @@ -84,7 +87,7 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - §9 retry/backoff policy and `/status/` cache workflow. - §12 acquire/release of the concurrency semaphore around dispatched requests. - §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold. -- §13 integration-test CI job: stubbed at the bottom of `ci.yml` with a comment. Gating on a `MARKETDATA_TOKEN` GitHub Actions secret; will be wired up once the secret exists. +- §13 integration-test CI job: wired into both `pull-request.yml` and `main.yml` as a separate `integration-tests` job that depends on `verify`. Pulls `MARKETDATA_TOKEN` from repo secrets, exports `MARKETDATA_RUN_INTEGRATION_TESTS=true`, runs `./gradlew integrationTest`. A shell-level guard skips gracefully with `::warning::` + `exit 0` when the secret isn't available (e.g. fork PRs). The IT class `MarketsStatusIT` parameterizes scenarios over `CallMode.{SYNC,ASYNC}` — `CallMode` is extracted to its own file in `src/test/java/com/marketdata/sdk/CallMode.java` (root package, package-private per ADR-007) so the integrationTest source set can reuse it via `compileClasspath += sourceSets.test.get().output`. 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. diff --git a/build.gradle.kts b/build.gradle.kts index 7b0ef0a..1ab8216 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -86,18 +86,19 @@ dependencies { tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) +} - // ADR-002 CI matrix: optionally run tests on a specific JDK while - // compilation stays pinned to --release 17. The CI workflow passes - // -PtestJdk=17|21|25; locally you can do ./gradlew test -PtestJdk=21 - // (Gradle will provision the JDK via the foojay resolver if missing). - val testJdk = providers.gradleProperty("testJdk").orNull - if (testJdk != null) { - javaLauncher.set( - javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(testJdk.toInt()) - } - ) +// ADR-002 CI matrix: optionally run any Test task on a specific JDK +// while compilation stays pinned to --release 17. The flag is wired to +// every Test task (unit `test` + `integrationTest`) so on-demand and +// merge-to-main matrix runs cover the live API on JDK 17/21/25 too. +val testJdkProperty = providers.gradleProperty("testJdk").orNull +if (testJdkProperty != null) { + val launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(testJdkProperty.toInt()) + } + tasks.withType().configureEach { + javaLauncher.set(launcher) } } diff --git a/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java new file mode 100644 index 0000000..3fe05ee --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/MarketsStatusIT.java @@ -0,0 +1,52 @@ +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/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java deleted file mode 100644 index 49af82a..0000000 --- a/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.marketdata.sdk.markets; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.marketdata.sdk.MarketDataClient; -import java.time.LocalDate; -import org.junit.jupiter.api.Test; - -/** - * 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 it the - * client enters demo mode and the markets endpoint is not part of the demo allow-list, so the test - * would receive an authentication error. - */ -class MarketsStatusIT { - - @Test - void todayStatusReturnsAtLeastOneEntry() { - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = client.markets().status(); - - // The endpoint always returns at least one entry for "today" — even on weekends/holidays - // the row is present with status="closed". - assertThat(status.days()).isNotEmpty(); - assertThat(status.days().get(0).date()).isNotNull(); - } - } - - @Test - void historicalRangeReturnsExpectedDays() { - LocalDate from = LocalDate.now().minusDays(7); - LocalDate to = LocalDate.now().minusDays(1); - - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = client.markets().status(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/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java index 206f634..eae9538 100644 --- a/src/main/java/com/marketdata/sdk/RequestSpec.java +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -19,7 +19,10 @@ record RequestSpec(String path, Map queryParams) { RequestSpec { - queryParams = Map.copyOf(queryParams); + // 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) { diff --git a/src/test/java/com/marketdata/sdk/CallMode.java b/src/test/java/com/marketdata/sdk/CallMode.java new file mode 100644 index 0000000..d9144bc --- /dev/null +++ b/src/test/java/com/marketdata/sdk/CallMode.java @@ -0,0 +1,68 @@ +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/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java index 0388935..fc7c297 100644 --- a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java @@ -4,6 +4,8 @@ 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; @@ -21,6 +23,8 @@ 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 @@ -53,8 +57,15 @@ private MarketDataClient newClient() { // ---------- success paths ---------- - @Test - void statusNoArgsHitsCanonicalUrlAndDecodesPayload() { + /** + * 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, """ @@ -67,7 +78,7 @@ void statusNoArgsHitsCanonicalUrlAndDecodesPayload() { rateLimitHeader("consumed", "1"))); try (var client = newClient()) { - MarketStatus result = client.markets().status(); + MarketStatus result = mode.statusNoArgs(client.markets()); assertThat(result.days()).hasSize(2); assertThat(result.days().get(0).open()).isTrue(); @@ -91,13 +102,14 @@ void statusNoArgsHitsCanonicalUrlAndDecodesPayload() { } } - @Test - void statusForDateBuildsDateQueryParam() { + @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 = client.markets().status(LocalDate.of(2024, 2, 1)); + MarketStatus result = mode.statusForDate(client.markets(), LocalDate.of(2024, 2, 1)); assertThat(result.days()).hasSize(1); assertThat(lastRequest.get().path).isEqualTo("/v1/markets/status/"); @@ -105,13 +117,14 @@ void statusForDateBuildsDateQueryParam() { } } - @Test - void statusForRangeBuildsFromAndToQueryParams() { + @ParameterizedTest + @EnumSource(CallMode.class) + void statusForRangeBuildsFromAndToQueryParams(CallMode mode) { handler.setResponse( 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); try (var client = newClient()) { - client.markets().status(LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 5)); + 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"); } @@ -127,10 +140,16 @@ void rangeWithSwappedBoundsThrowsIllegalArgument() { } } - // ---------- async parity ---------- + // ---------- 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 statusAsyncReturnsSameResultAsSync() throws Exception { + void statusAsyncReturnsRealCompletableFuture() throws Exception { handler.setResponse( 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); @@ -143,22 +162,24 @@ void statusAsyncReturnsSameResultAsSync() throws Exception { // ---------- no-data and error paths ---------- - @Test - void notFoundWithNoDataBodyDecodesAsEmpty() { + @ParameterizedTest + @EnumSource(CallMode.class) + void notFoundWithNoDataBodyDecodesAsEmpty(CallMode mode) { handler.setResponse(404, "{\"s\":\"no_data\"}", List.of()); try (var client = newClient()) { - MarketStatus result = client.markets().status(); + MarketStatus result = mode.statusNoArgs(client.markets()); assertThat(result.isEmpty()).isTrue(); } } - @Test - void http401ThrowsAuthenticationError() { + @ParameterizedTest + @EnumSource(CallMode.class) + void http401ThrowsAuthenticationError(CallMode mode) { handler.setResponse(401, "{}", List.of()); try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) + assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) .isInstanceOf(AuthenticationError.class) .satisfies( t -> { @@ -187,8 +208,167 @@ void http500ThrowsServerError() { } } + // ---------- 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(); + } + } + + @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) { + // port 1 is privileged and rejects fast. + try (var client = new MarketDataClient("test-key", "http://127.0.0.1:1", 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:1"); + }); + } + } + // ---------- 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}; } From eb2567e10f9d46b2712c4de35a0a5d9c09e4cd87 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 5 May 2026 16:54:17 -0300 Subject: [PATCH 3/8] bump gradle to 8.14 --- CLAUDE.md | 3 +-- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 6 +++--- gradlew.bat | 4 ++-- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e98bc7b..3c7c61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ These decisions are not up for debate without amending the corresponding ADR: - **Java only.** Single artifact, no Kotlin sources. Published JAR must not bring `kotlin-stdlib` as a transitive dep. JSpecify is compile-time only and doesn't count. (ADR-001) - **Kotlin consumers are first-class via interop, not via a Kotlin artifact.** A separate `marketdata-sdk-java-kotlin` extensions JAR (Option E) is deferred. (ADR-001) - **JDK 17 minimum.** Build with `javac --release 17`; no multi-release JAR. CI test matrix is `{17, 21, 25}` for forward-compat. (ADR-002) -- **Gradle, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) +- **Gradle 8.14, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Wrapper pinned to **Gradle 8.14** (the minimum that supports JDK 25 as a toolchain target — 8.12 had this version cap and 9.0+ would also work but adds breaking changes we don't need yet). The daemon itself runs on JDK 17 via `JAVA_HOME`; toolchain forks compile/test JDKs as needed. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) - **`java.net.http.HttpClient` exclusively.** No third-party HTTP client (OkHttp, Apache) as a runtime dep — ever. HTTP/2 on (default). One shared `HttpClient` per `MarketDataClient`. Timeouts: 99s request, 2s connect. (ADR-004) - **Jackson (`jackson-databind`) for JSON.** Records-based response models (Jackson record support, 2.12+). The API's parallel-arrays wire format (e.g. `{"s":"ok","symbol":["AAPL","MSFT"],"price":[150.0,400.0]}`) is decoded via custom `JsonDeserializer` classes, *not* default reflection. Jackson is **not shaded** in v1; shading is held in reserve. (ADR-005) - **Sync + async parity per endpoint.** Every public endpoint exposes both `quote(...)` and `quoteAsync(...)`; async returns `CompletableFuture`. **Internal logic is async-first.** Sync methods are thin wrappers that call `.join()` and unwrap `CompletionException` to surface the underlying cause directly. Both surfaces share validation, retry, rate-limit, and concurrency-pool logic — no parallel implementations. Tests must cover both variants for every endpoint. (ADR-006) @@ -87,7 +87,6 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - §9 retry/backoff policy and `/status/` cache workflow. - §12 acquire/release of the concurrency semaphore around dispatched requests. - §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold. -- §13 integration-test CI job: wired into both `pull-request.yml` and `main.yml` as a separate `integration-tests` job that depends on `verify`. Pulls `MARKETDATA_TOKEN` from repo secrets, exports `MARKETDATA_RUN_INTEGRATION_TESTS=true`, runs `./gradlew integrationTest`. A shell-level guard skips gracefully with `::warning::` + `exit 0` when the secret isn't available (e.g. fork PRs). The IT class `MarketsStatusIT` parameterizes scenarios over `CallMode.{SYNC,ASYNC}` — `CallMode` is extracted to its own file in `src/test/java/com/marketdata/sdk/CallMode.java` (root package, package-private per ADR-007) so the integrationTest source set can reuse it via `compileClasspath += sourceSets.test.get().output`. 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. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 zSDF_?f~c=V=QJRSDTG0SibzjML$_?2eqZ;J*7Sv$*0SQ|ck$fX&LMyXFj}UH(!X;; zB_rKmM-taavzEk&gLSiCiBQajx$z%gBZY2MWvC{Hu6xguR`}SPCYt=dRq%rvBj{Fm zC((mn$ribN^qcyB1%X3(k|%E_DUER~AaFfd`ka)HnDr+6$D@YQOxx6KM*(1%3K(cN)g#u>Nj zSe+9sTUSkMGjfMgDtJR@vD1d)`pbSW-0<1e-=u}RsMD+k{l0hwcY_*KZ6iTiEY zvhB)Rb+_>O`_G{!9hoB`cHmH^`y16;w=svR7eT_-3lxcF;^GA1TX?&*pZ^>PO=rAR zf>Bg{MSwttyH_=OVpF`QmjK>AoqcfNU(>W7vLGI)=JN~Wip|HV<;xk6!nw-e%NfZ| zzTG*4uw&~&^A}>E>0cIw_Jv-|Eb%GzDo(dt3%-#DqGwPwTVxB|6EnQ;jGl@ua``AFlDZP;dPLtPI}=%iz-tv8 z0Wsw+|0e=GQ7YrS|6^cT|7SaRiKzV3V^_ao_ zLY3Jnp<0O6yE&KIx6-5V@Xf^n02@G2n5}2Z;SiD4L{RAFnq$Q#yt1)MDoHmEC6mX1 zS^rhw8mZJk9tiETa5*ryrCn&Ev?`7mQWz*vQE!SAF{D@b7IGpKrj^_PC2Cpj!8E{W zvFzy&O4Z-Exr$Z*YH4e|imE`&n<$L-_Bju=Axiik+hBtA4XNDik(G_;6^mQ3bT)Y% z6x=a+LKFZbjyb;`MRk~Dbxyc&L; z8*}!9&j0wewMM#O`c#7HJ|+Gh5%3~W10b6sdmCg3G_v+@H>n*c5H`f+7%{TeSrzt89GYJqm>j-!*dReeu&KHubhzjSy_c~BJcbaFtZWAB}~KP3%*u{zHi zVSUi2H8EsuSb3l7_T1hP!$xTtb{3|ZZNAJ{&Ko;#>^^43b7`eE;`87q81Jp;dZfC< z$BD`h-*j=%uTpG8Me6dF zrH%)Bw-a0}S41ILo*k2zn6P@?USXtC>pX*tzce7A^JD7^^p7K5kh-HO&2haDTL%2^ zSWQb2B6}e*;x?eKq?CdG7F=wHVY)Lb(kQu1R#1Fx|3?>_%cjNM-xJlAg9kr`!>&;E zTYmHhqHh&qbfO`~w3V;BM(q(_Q-5^!esaBI&QbZ^%N-ZDYft#FTS;%{ zKzlSwZIS%zDi#%DMK>`_vmE^krJL5@PmpT2m26Q`O)VRAL>){MN45|7GTk=q^zLpF zjS(Os=`#On$XI#$A5ewac9Ma}mDxSu^5{#jHC+24a2GbfBJ&Zn8W= zm=l7VE0g^z$3ikyU#ysh8b-PH(&-yZL$JV-of-ZM@~N^#DbQ3Ltlq*5@>WzSNxrRK zYl2VS8r;TT`wLfD_O0dhX9vR#S8rMOuUCRkWZE#OjRi$l*#C7}mgGzZBD%Z=p3z|CaVM$$pyW5-pJJDCToY zO3R5)P(Gnd>6wh9Z$Sr@cMXmClU(h-@5kmiBTNTU-|5vq&Fs!ah|o47kW?SO8uWv> zW$=Ud@@|*9p@Rb=!wl;%>k)kH7fPtcD=gd}^IxN^=Cg>zq^jij!f=1PlT|9jh3K9g zF~Z)B;kb^a0hLmJvON8Ho)foq-oC)&E)b|a^|b}6n!8&AIaousO^VnYzYfuijuEo5 z7IcUMbYD=vec4eZX7;p31NB+T9BOMJp9ZI9$dH1kJsJpEtf@}tL4)_*PxgdOge9_EaR!?wWtBx%*f$IGoR>f3Qf2aT0%+fq=1xVEqRl;UaA2Ncs4B1M1#foI2bj4 znX}t7;-FCLK&;>ZGP}{GxK67$Kz&pO%%J>DBMP_zZsLOmdpDUDp&f8=L>(Kcj+S^jA5dco4-7XN z)h;m#54CEy9)Ch-E7gHP@a@TXl=_%&|iUlIrQzn=LqONBu9FCn`3f8aqvRu=RrJ_RH1^Uf=t z%Ir*({+wEeC??C+u!hCi<5m`RsRO6ti7YaEtY0|U)-QfNsdN{=83K_}m$0Z=ElWyt znvo5=%f<;|hNnL-r#v5ab&S2*yK>~a7m(My$cfd*tff?=?7-j3^|&9H7G*W`)m8M7 zzd0+b)c@`bQN1-^dC$_04tK0{mU5tx_zo;&TWou8F(H_J?O+Y)VLXzmU^> zvL!5+1H?opj`?lAktaOu%N#k4;X;UX5LuO`4UCVO$t+kZBYu`1&6IV@J>0}x1ecuH zlD9U=_lk1TIRMm6DeY2;BJJEE%b0z;UdvH_a3%o)Z^wM&<$zhQpv90@0c+t?W`9kolKUklpX5M&Qw06u=>GPCr5Imvh*% zfI`tI-eneDRQo?m*zD1i;!B>*z4Xioa_-S=cbv-k_#Wg=)b$0@{SK>Mr!_T?H`S-?j;3$4)ITn$`g;J$^TppD)^pRz#^l?XgZ2CW z3g5G^iF*GZYQ}{B|H-fqh=_>)E~=3y3Zg=i75G5E)*a>R9bn~cNW{h5&P(vQ6!WHv zw1-89smtY~JnCQS(=9zM)6>UAi%G-r^LA9_HF0Vp3%JF2P%+E&^afy61yxnAyU;Z{ z$~H5X6?sMoUuOT_tU7i5i%5HI{^@#Hx@zhtP55>r_<3LwusK*SC#%i+gn&iRg z_8UN=rLVp*gT(K~{0X0f_=?~bBbfB`=XrTFn3U!)9n*@Uj$-mr^9PNi<22UJKAK&D z|1@Ck3(Ub;>68;)gIn_Zu{uoVRMhAkIqgBS(v2b2{gf?0xd(1sJfY`56mVy>~^w!wmX_kjW8#?_Nk{}zB9ULo>4fO(vnWfC+pG4>%*KZ?JuCdXu%aZ}q7pC%E50@U9+KQZL5 z!*I`SOtNf$Y$CsRsNaf~yyw^>#X_mCiF&*gr=cBb zoPu7PwX(+Wvl~i(XH|)jj@Cu+rzpJMn4kVvCJ~ReCf08viF$q9;CYnv-96k{G?pf_ zQglN`JiS#vok)~^Z2>41#7LPFgd_xrqNO%DQI|!Qs|nWt`co#BwY$&Wm^6#~)`_1k zpwiR~&z#mtSDuYm(=NoLv$%Y}bTjog$RJ8$j1(s})=}su0b?o8i28-|xu58ipFBml z2`4qZ$BbY5>(i2%wmh!+C}$97?X3LgTQ_{(SaFZvq9YCn@BNz z&h#;4h?5#`&_0()uJ;_rR(Q^eY*=&vu)#EeMeaN1puPv5+iQFg1EC(`_99_5v<1r4D ztc(+-eVWf_np;q$M*H49#{R)eIWCI%R&6F34;h9eNG(XNO5ao2MI8;j}y% zZeA>zX{#$;muhtY{_|;bkk~!U~Ih z2QUO}hk~o?sn;#|Mt$0}4=+BRa703n6>fBm(cesk8Cmugg_wi|BWj}V-VuU9jNH+o zgNYGSKPm>qR&nI(2Gu*})AOBfXf0J~CC50C!3KXu6-qZAG!VMZbmnqL6HWG>o$^sjoSLbQxra@WyKV$+_Qe}t7d)c`bpJG++ zw|9D3>XUH^Wplo~MN%WK18n3HeXoe*jKwVRK!=RMtIr1v z;Py~7;eZl&=^UyumN&CecrGBEat}4?mtZ>@`wPjVK@Z)FZ;05^9kztq;qmbxQIJ4kXTk)) zaVfD^K2x7SB6E!Zz@0p|Fkge*0(0?ogmTX8d=?n{2x)}K2$`bjDmcLg3#wU)i)by? zW^G8rRQKBwjke5zHScinRlE|wo0XyhBc9R52IsKWf4-@=l!yO&+l=K`-7Ib9U~hPy z!cH>H)e6$;m&w^0d`axGqDwBgu`B+L4a`xr#5g%b=0?c41`|lx0O9fiIVaFAsO$Ol zayhm4C9X%hzUf&ctylV$%ntuA$(yo*X`gaVX0$|x{#!YK^cvLmNWPZaTd3&xP7ny% zkn}2AdJkpAgmsh}Q$tY3(2RtO;%R*~8r#ZbSbMR4LaL9Sb6O&Ce(GlO${jtl&`n|D z9;zUQPXCHqTm&t^lk9RlZiiquSY_og^?kgVruz%myd95Fr!V z-$OIXSt?(pxN-M{NjA)j1KKIp(&c2RVjd_}7+CbQfw zTRjg}A0~}Ht_?-@wD0bI-;LQwT?mKywmDZ7*j4>4pR6@UVU3mb?-cbQt~aIG&RBjl zs-4UNtOH3+dAF%U=={qB@qijh4J6K?Et zPLlfPlv<+i>ty5rh;Q>iGFoaq4LyBIZl3L{KGUmqPL~ZCosOl;7w2SxcE}pvK;5|6 zly3JjUsvk|d7L3bFs&;q@_|p?vdU_UzhrS$Fw-_NoEdoIT#-0hKC37!>-i6FaO(es zY97)m4YO<|eqGMrYejC&-IFmc{=P7>qFWX;)}q!&e9-F59o>V+`X>J}%Te0$|A>0W z;7*>m4>udzwr$(C?TzhZqi<~6wv&x*+qP}v?C<}aI_Jeq*K|$4>AGurZe5=U>-0IX z>&2?v81(_Tn1tITYDSF@^Enhl9>e1$iAnX!+&YJVi>1uYEWsZ?o*Vyg+K~%XCxQP(WrdtEpc3sgbpTM_ zI7i6|pDr z{=xGh4O=PrB}pkX@o@A(%GfdU!c<$p#T*mLo^*7@bd4rIJ5eS&&A9VB$EhabJ1^TG z+dke8lOG5I(xMYZ`Xw8+olY0y6M)M0rcr%9tZHa=G0zICN@DQ>0rVASCK4=3OeMSv zD!v+POT0`UZEnP~1ro1?HPLqJ)xx0#Pg^yBJz@S6gmFN~cGvl(#fz4oTs7_Pi^+i_ zZP7<#ukx>i%V;uJJ~WwUW7pgq=>yuT+A5w(J5$1no67e(;mIO5>@`(U0{}+kg)B_8 zs=bfBbmZ{U`xjMpkAcEcEeF7^#ka}2zDU-sBt6yQqw&2p<+6Hb(Hi56S!+bU9AJJv*{ep2vD zG;PVwX@NC)+=6@I6J=nW6_99&4R00FKpUPepXoBVN*|V*C{e7X+Q({6O_^@SlI(9Y z8kRO3WDG5u=vmTjZ4DW89H&vNa;i%H@`{%(|J%tVs;1gDadzF0Jy%}C68|k?Zr!B9 z*lBN4{#6p#SQS-q#Ck&x#xhAOu4mK=Jxf+5E$h8l3-F4mQY^qaS5;Z* z-ddglOueLtXJhJ!%yJGk^-iZ_+qLJ zpTZn+6kq81D@^m(v$VFFI1Q!dtczYBt1xSn9~Q=@h%tsf*hCm%fwfx2u(u=-4|qf=I8WR*%`lsQ ziP!-b?(d_`TdA=^<$@(2c77&FowB0vhswM)fS>lYvjK7B_$<0SiQNzL6T?D721Y*( z9nG=@aWvmJMd%j$Jxp3-L4x99-X-9aGkW}yiPAo*9{^6b1>tDg4zIPFiTqVK$xq1rv1*kaE|~T5-jH#8{g31#^7M_uSsmQvNjyk; zbo|yP0w|uD1)wGrSavi=<;=H>IejRQlac$HMkU2rbq1{8UntI;oJ}*o(bXy{JC*l&^W{Y^}<%Nj1Tk z$(9f2a`BoyZZqxWF=hhmc3ldg+8&Ep%fVCSjopduonggw7@?XulP^JPo+_le`o@z)ofi9U%I z=~YZ3?Jok#3NeQ)U&qUqvoyuEMA?b&Ki=s%;_MTDX+8^>z@TOxb3qw~biG4!)XuQp z=>cVLGcp<{Piu-TqWLFz^P0>R1go1M41xFSn~y%8LZ{~t{iz!z$|ne5qkw!VwuI<6 z*6Bsnap!L>JA;B$u$J09!L&_iGdX<&v1jeDcEWM4&2q97^g9gK1%+zl7nY)PUU9<~ z!B??-0oFH5TEpfNW#V1m;(6-=mlUxm699O$g=ZrFZpn(6h%3n#!U7eFnC1BJzLFB) z-)SER^cpQ~AF(`0^?pNYWsz6(suJg4)Ke+|iTo4!8P8ND$ML1a%4|QMYe@SDDH#d& z)P6SOk~%xdQ?i^t{N0)(baSgQ(Fp*daGXR>=Vt-*#@)>A1Sfz0!iqKtjlY4}1i0v0 zyz)Z|vB+_QIX99Q+NFppI1+3`=qUen8NVELr!SOS8Vq1;{<}WKOhe7HMurM4mg~j5 z%|wM0)r4^=uC{9_OTf*An{G}>6hw}C=H|&8MY~l@u zmW-R8h;dJxjKNqEdGf85(5BrR>lY2A= z-_%9;IglQfHBuO%U)bt|g%1h-OMbL9H{TdFgM^rdBTt~gJ%{*c<;b$D13(ac>}*nJ zo@&y3%13-hUh^Oa$9U1ImdNfGO4bPX$I!c!6e;sRC>z{knTf~G5{#4J7y(vbrq-qWk%J5#0Iv((P!QKa6f#3?;#q$+(teR!nw%kOp&_W`3L^Xw}Dw&e2#l zc{fk56;UyHDpT@XdB?u!*)EdIMT8X1&e>VO;M_QH&MXI5|3xTbET#NTfyi14#+0+t zDS(NC?jbc{yIDjm-=9g^4*f1c;0!ytb~iQ;DSTKoa4ow@d-x3HI`EYcAe(li zjajb0cM*@u*kiU{)jd9yTNeRZLL+Y1&q`L>gx^Jj_B%sh2+%Z1d6xNVmTw5Fw!kd@ z+uT`4r(0=PXUZCNn9$VPo=aj+p${a|eqjB{Mf+k&$GEGV(lWHl#1xy1%5E)1KD$bK z0Z1Tsk4LpTn+b-iy}25uN>wvTfN+B~4r!aC19d7}&hDFchbqZ0;e7I0BK}RNujj9n zY8As>D%ez?Fkng~c1L3e^}<%h%!NhB5ZFmv4qmi`am*+A28lE6Pu4ekBJ8DW?YR4c zPeG`sZYLihHq~K3`oYvnQL$26Ojwnj1AOypgX_ca^06&6f`T8bedVhWj1y>F>d-sg zr9@SeL^T`CHIwyKW*F#~AZd==$aA_zOLRP>>S_&HK0s{HcEDpNQm9u|IZ{W%#*w4} zmN;)dX5OA?I{M$KLje0TCiQd&|g9E!YKD5 z)_8>@<$&L)EoO;WhhvUYgEDDJ8PPVpR_u`RN${}`PnjHc-4^~CwIh;mLF+#KK>Wc> zE|Wkj(OZ@zIa8-8rUq=a=x-F%J+$ozWaVUV@yS!{UWJ)}=^jM1_f&XffEjCb6H?Es zrqQ!sdrLtEHq=DIu@B|%&N$@{wC|>I`>>2EXn@+22x7PaM4p3V5XhXp8gSH8{)yq+VsXB@4DmPLA`4Qc`r2Z>3E&lVsUbpRejKO8Xc|ayAI6YT)d!q zrfQj!sa@T&5KPMxDUd4bZwub#5<;yenI>0~Zx=@R*M{S6d|Z3TAEsEW-w#undSQP7 z0ryg{By3CNOC^`$t=P&xCf<~vRz1}|>Oh+v>rBMi?&+;xKSGs;7Ie~^T>J4C9Ke&G zL&{aTYZk-|Pa*unK});DaF?Y=y73~NA0(lMPUz1G>G;8n^cmm2S>twrpU6ynN~J1! zHD!AXWk^D?nq)%#A^&d%DwIkh3Ku$<4{$Bnqe{R^e!E zD6qaK4g^V5kCJH~Ot$Im{2T}8sS28Gk(>QFg9I7A-=nDns|{X8NjAD%l(zhXxPR+i zsaKZiVQjKRN#@N{`Cm?#slb!NghtaUv~`T@mvslIbq5TcS-15muB2Hb$Zs``b(Pmm z>-keg*068f|SD zm-1~aS@!4?{PuWQ(%MlB?$oG~Y0UBQX_Nz{MC3%JvnoK+x5+GR`cIfTOE7r3_Xi|f z(1x{Bqg$A^m57WLbkEAc&hWkBABmV|cqNS(`o`}NaSI8Lm6{l$b%3paaK-^r1yrc* zQM|lY+je@P=AS7fX6VXPV>UYV77X|5G z5Zow(9=j+q0*H%#H}fpu-HF%`(GEbvHmWK({pqfv^b!p^KiWxjYXL)gZO^yLvY!1#{eH$?|l`7XcETF-V>)m#$Y-KUauf z^b+<*r?&Mks6o?n2JrEvgk?j+9|~S~2U~dq^}6M%or)_T?%jaFi!#+q3>YaIG?m3X z;{>&cQSHf29MCWgsDR$xyTZCe^~uYQ{iM+(@1tKCpyDxFoeVGQeW)9uT349)IDK!3 zsmbQfykCr7P5@r7$@N8b6KjN-vAfM%rz7|bveQ2v`Y|)B{2rfRwNw!r&1%%b*lWIy z+l$A~f%;yYgfY6h_(-1nXB!C4(VAsEqS^YKh9a{{_uW8t$M^?gPsm-J}^#E z_uO7hC+?sb1Iw^TeS$QC`8qwrX85eSYLIFX93I>dS^)6QIMdwX$;6F>2_T&M6o;jL zp&W3|Bd8rLlV}iSVY9G7Lo?V2_E`JVM(`rw^}DX9)wk0Q5GJ%esB@}u@C>dZ-byh| zBFz*MoXGGiF}DG?h!UZ#FN`;~1bd*pAWflMa5AtD-+Ut8Ymf#=b`potx5YLf&A%ZwGv$|Si7 z(0)Re$(F;{=Dhtq1%wCl0ijfk+T4jd3}^2Z$Q?L=1_lkM&nIax-Yo%VqZk6#Et%n& z0S9_V?yja0r@wi$m!-JJM2G=aQ@nYectR_Ln*dN6gmAR8L^dIf-bxR>0A)c$?#Ug@ zVlrY8#6Wp4wiP3OZ1@T=EBaaz(jrxuLG%?*J+=c#K7CorpL5*eKWVYiw<>#a7zv(N zO^RpkPM=xn!2?&s^7NCTu~a+aiGwc^_4Rnyqj!-l3-f+;6mkOx5@ynO(YF&u{yH5a z0{{W^{1E}V-LFeZcLzkH=SpZ_y1l&>1S=X`+@!Ai#KmNT?5ox%_;tp9`=F^;&%fxn zpX4I|M!d6`y%-8hequbo4%INVKruc+o|NwhsZB0<&TBCe}v2@CyI^$jlCsTrwmBFnzIMofx8PeKa1Av-Nj zlLtw2SI?rq_1(xc%<3sF%)ZrYIf>Xe7@jPt9BWoU%bg~g+6=1f;eW00nOrbo#*(mjYHCr_?8!#my~|i(0+2j{Uo+J%%rvg+%X5* z4!HCVyg~`t!LBG+X&89L&@QkGXe};GQ^moDsqI%U>#?IVQc53nUukdN%ij?m+%#Fv z*$`n_GFdWHC(!1z-ZhRjEV&n1wt#7VUXkgkW9Q5V;)k`XOO{*>9)xi@4}6zxlm4Ck zPC4Eq^0qB+yLg@{^VCgieuns3B!x#NzSr6q_VlhP>I4gzH4BI}DTx^r5(>Dyhc;-w znWU^i-9$N49%O1eIWyBV{K>wROpYjgCc5b?os*f=l~V;o)CB3G-E7LA7Rg3;!)~m@8(whM7Es zwF%4mEd^gMI<<|N60&DB)!+6-+8@EFbvGs4UP0$q5NEO<7?$NeaVcvz#eXkrXV;$H zPjNrI8gWTpphtwY&md>1N7T|$T^i@CM$EWZ;`6{q__Yr(^B!<>OPXT5%ICC%;4jl=T77^3T z0A$3`@j>`8*wH>vT`en;tj&YA60zbZw2F#^jE;rfTJ}-rcajHddN|Q>g}o$TX~osy`RPP=q0j_f1g@QgXPlY@q1Jh?-r4bB@~25Cj@AmJph{QR^Ya<4r(z*{F~ z=-nsVQY2K`sKEl*CR=AMEDIZD88T(wtjZ_((xf$>SIA*D#|jjfGw84wta;Nk03w~g zI(#i!OQDMse#AO065D@_gm?pQx@{rBjMat|bA$6MfVPq;S5zT5IKK&|LFZXuA zqj(kJK8jP}^ZYm?74hlPtf)m?w!rUP42d;f3Xx1K3raV-*P;*>hmzjAkyfcbEfZVM zJuLMoUQ0*&6p_BS@>f9!k`6HtNO_~}(0Jkg|_f8#- z!m%Jn^dX^G#qp$LnY0H)6WbFMeDL2eCjALoKs@6Ai81!~l3d5bNgZQ?f zTgufN#)|A&im|)K13cIGc?~(RCQ+E^pAR%xa6I`LxD$=mcOf z@v4=zb!i^TVJ(CsX?zlhk2fs((qe>+8Y#o60peO430M?7HT|g( zcVfD7@Ob>SyV%mu6}7g*=p&J}hJTo9hFn2o9Jy}QCXfAbC}WgpkeMXs7QNle)Z`PI zaU4~Uz`idIpQPmpq$?{N(5Wj_y%UX!5{=9|{BFV$P&Z}ciIVj<`zLyWb*T2wf|8o* zOk|-Qs_aJayia$?0k_jr6b#)1ONJ!Z;{~4NDyZJ6id*&SjT|kFCPH^!Q8MlaAE-*_ zNR!vqG}YZ6i}M3h>ENPmCHxC(#1( z7}2c0*RmVw1@+)M+n8t~gQT#+Yg3>|OA<9`Ynl5)ftY4g0EGA!t?E*;j*jRcB>mr~ z4f=etCrR1X;V_euWY<6p_AK%IoHB+bS8vl&LZ-5Q*QvzmfHq zZ>>MgWVvSa-wRV7cJ8O%vi&R+@2I&X=r`1P1;x8lhOpY4Z58^@Wm+--yBQ{&>GOL- zIJm(euOw?WYjBR|f~ue4(%k0i{lp`gI1~mF;g{;-0_gdf@ z*Q?M9wQ1ZdZwvrK|IY39={n^R^(zI|p=Px@ff|e_NEBug4N0vK!L9-J_DIiI7e5Pr z^Sce&Prjs*$mOY7Rf3V+?poBWP^ki{PIa+)OK%4)E`rV zxx7V^Qy14sZ;Dc2jD|ccyt5(5Zp~;Rg7N_IwB&EZ1jv&GoxT!1H7k>pY>Aa{$&oHg z`ykhr&GpvCL?|Xb;O}(ErzQAl=DZgICR);;Y=xkO<~chKzvaND<3}Wy~d>W0L>Q| z2-}wM73&w!hC@XZojB#$EnGzb4HAp3FWovUq|4f%x4KLKUg6YfVpokO|+JO^JSzIZEji>8`uBI~^1wYq9L`S;8*pu)y zTN!cO5)p_vO7vsEgglr#ee5WTiRh}7f0zLYNA)eB;_ z63%8_pGF-Dnkx@eu`dPn7Z1~vMk@*nIMW6HtpQX86HiyI1H>8W+4Y50C=@;!{F)Za-A9+#^G9aiAu<-#DuLR>+Vm6|21n$W?isfhl9KnurA)AcxJ* zIl$Iy_sl)Ewu1nV)Wiqc6M8RZ-OvG~x&%#S9h{L)QE&q|7$gk|*5h2|^bAvwHm@~P zRY4`*Kw4vB$#(Yqt2+Rd{vNGl*GA$FksiM6%fjfp!BEgA!3EEIq!j+(-cS%{(44@I z+KuDSMAy-fyJ3j}-3vV|_^?zVAkrrzw!3@QF<9e~z*m55Kjm<#D3z(4wCoyq=E3Z+5+o%*c82=9Dn;-mR<5ukCVG}$pfS0a zGXdRdAa-u4>?Cv7*|^+XrkWQGzzvT;h$l5u$vMI>9ouxPD^S{5-qvWAprQ>*&?#SpxdJ-SE&Kk2hn zy8lWI>IKrj;hSj%<-bXl8V%B!q_?jcj{k-hy&J%P3vb%^Qfyv08YOw$Qv~F2IOcFi z%I^ScI`VdU!El-&Werf%8X2asF7Tsk7{xt!qlOL$mCejuXC38O9pJ8y|M>$P50HUy zhcG}uKWP7NB@OTY;fq3kG@GPwLy>1x#YEu`vmQ=(0K)g*ckkeaAkM(C2nZ)rJS}8_IMTxIBXH|>190=4 zD%!`?a-E!T;jSVXMP%ETk{4ij&~`Q)&DZieRx)rLfXGfwvm9#PvZgMyX7+TpsoXa= z4Qq583C|0#1W{@tX6kUwtN40v^oyycsiqPP<(V!5f5bA~B0ZGZ{CU#4q>RznC|I_) z7I8BytRK$$wnfi79s*Phn%|0s_u9`zwWi2#=GE5F_sk({H`bq&(QCDy^X97O7~dVV zjm7hN0FhFY>Zr6d?l;%A(Z~&Ew$4)I4_&92>1%LB&Iz>(85AY z;VB`o-(qZZj2^wUL9TY=pDZ9{|L{Rg0eiHZxKR(>6I;B}xV?kpOG_~18o5kM9>bF; zvl22sk@FP)d1Mu!iPBd8n%hqPUH?B{lf+vBfKDaUjH};FB`hI|=TD}i4-Df(W|+FB zCt09JV@dNOy}=s3AS(U4&Ca^LI#IkDbY6-0Iby5ba=y`Wp2hYzhwTE5+|7W}HwTbp z9OzNwQYpe;mIt%rDX*W89h~mxYK3jmf-7Q*)B9kUP?Evo3sn(X81NyML>*eVx+RUlBPA+sDViBwk z7*Dl;#i5JP1+7=3^WriySJy*Ub#&|n!0jaOtW}%-grYW2t+eT{wz)iu1P?+?*78D4 z?m5`fN!6Uv7J4JU)^8tW`D-N9QO%RdtYTA8+bXhEgPf34?k{g{4Tq?|%C$Kz+U{9j z8RcUt*R}dKX*G74+BGaNebZUV{DCm;@U(5XnJYWyX(1gNvxR#br(Qa6)^hmsfX#aR zk+}yFE?Rp5@=+8!0rVoYMrk4eHt6+-pV!|CZFOXL81z;&nOQ!ct!B%hYyCe z$8CC^HadwLAC?`$JgYtvu%$b7`9Y=%pqA!R6Z96z- zLhL(4qE89OG&)oMjo05P>;5?Mp60` zPWdJ5-2@SE9T{-ytDRE{6sX)|Y1X;+C@K>yY^}14Y!088xh~SPfbJG?M1tBi?E>u?zdU>G{5+S>|$%tGJB zQ*X_vOy)g;@fbPm0a(Zh7zTzw2Ct$FB6Gz7!tmK*tZ2h588F#jY1p`jSJMli*7u-; z3tSU(fscAw1h}5i`&i`+?4UAF;AeV|b}3)i5zA^E*L0X|u;#%xYNx~?#g6jEh~;8t zQ8$5Sx)(-Y-j-9ugVW%b2(t*(k6(`>S>s9^t-podjkrgd0G}k7#${=(J0T7``%9)` zbz@# z89pMA4}>(ymEcPbh@I>#D9Az~sbv{(OXEh+fnx{b z6H8ULM@UCCdJbtvxLPl+w?prh49<(wWQ*(&g-1S%fFdrWy;&bp2wdG!zXt0n@O|(h^&64U7Am>%tK&1tn{(CN?9?pRJVbV0abQse6W* zjaunJ1r9_dkDSXE8y~{blX@E9+XdZr?+Cj9fSv4Dr%sM0X8+%}yVNrc%}Pks zfLfd-a~NL@9Ae&`->H9ihbrSTQK7`l0(9ei<9)-C-ZjdIKdOKOVrZbL^1x5+({hmz z^ka^IzOo7Z5kDX{UB^aJa=ZJ664{}im=U8r5}V}6e33gr#%&kPksN&;R!|y`-hx0+!ub!fTfgoWJ@3*jQ48CTp{?Y z$+bKR>!aBjD7x?Y0>>e`M#1*rfv0;edmByS@dJq0U>!j z12B#0J8%)E#AT3Tv<7hwsa2De$TgZ!6ya*gBbt8{dMpCoYg`{48qN!f$4KFI>9kSj zXqP7qQXV6DfRu{Jr(Mj>;=zUW>U{0sd8$z^(2$UE1b=z(K3T=YUsL(r3UwB%vS_@i zUw15;g`ql@wnozVkC>v|rqdrPO1t2>x^$SM@_>ucDEgntIq=60A2|p%szF-JmH5_! z>2S4sVX}c!H;5b!MnOy^fZYTP60VDhA{ikCTh{$>P4GK|N)1u_VGJ22k_IyXwj7Sj zcn5~M5{rQqE`|I<$3Bj`K#{b$K^z(UVwE$D46wB&kBgN&?rjSskPyQ3X&G^Acx^iv zW6lXF-}{o%ux^olbi{%ZmZM_C=6u(%CKQ={xs{jYqD zM26k$`Qj{UlW5Jt`l&1QP|d=7B{Dx;qd$8JdU$AE5&l(!MUkXC0mFRCM3JnDw?zVe z7`mm7)u~!VZs$|ahb9Y>#(9sjOV zcH~0w!lwVVM3oxLQd(|~MDZCpxbXh7qmbj2l;)N4J+?HVc6Jx7LG<@F&tGUvek#38UUOBInuVP22k}b4Ep?bEu^--cB#Ag|hqHNP79!T*v5&|g?2bQG86x5lB{ff(Rjr7|;rT&I0Ef(#dGARy zq-)N|z^0X-fAevH$bL+ip~x^dH#=T?vKN@HF~)7*3?~kd(`GwzGp*%S?H7db>`8F> zgx!tP`bl5-7lQ@AQ4i^?mNUb^ki+(Qvxg{R!^Ut%ya1_K$Ci-wGtO^W+(5We9^Z|i*}v@%bg{vBl7i??boO`xvQUh$k~C|d$i?y7U=W| z!<=;Y;tf9FpB=nOaU(_U#7Npj4id5?8H4? zsL^r@1_p9?VMR4cVe#mEOOH=f?>dB_m{#vzpM&E&KVbxd<&r?NMbz+F*duzV(?Y8LUgUpO4?&3)QPk z5&HoWONJr}EUHfHzJW4vCdqg&<>PN7f)paE#1!i^P<-8JfbLD7%T`A%By{h7P)CAW zJ1E&XBE96%#4a;dwNYQjcdiR0Nxh?uH~|2q&7C9LQ+QSv8X^PP0>Usz*HSS9C0>to ze1pO&s7BCS{x!VW_Pg@E-%TErJGYbnQ2hXL%RBzBNmFecgMmO#_uULhV~c2I)KHP{ zv{Eui!aMjaX?Mf>WoHp0KtGR^e4E^69*4@*{%8^>HwxUFNcSt7W0h7X$VzQ5JTGQg zLpd?yN%(bgiP_o-cst z@QA_VD0&n&*dj?j63J-vndy~X;lwmo=Q_8PV#w^VZOiYw;}mS|B;|u)e#GS8JRqxP zoWEuBMb#F=PknRG3P* z4GJA~MMpEbM%i4(YahXGEOSo2nB;oM z*5&1O`U}@hdRDps0PqD~2c@$6cz7sxmZ+b)O!Nllqto*I#I^<9nQ}0`3gtZjgFSc` zr<;IuXQCn=vP25FV3h8Z+}TdG6Sel7VCP+9#!U`9SHR~u*QtV&Ir;S6Z^sSGm|s;y z-f{CTn7y-&!B@eo#~6{h(77Nh6dHLyQG)b$p_3Gj)aRs!q6N>lUC*~^HSvWstrW}u z*CU=O3^xF*0&%aIQS)f~p!Vfgr70q9_)Pqs1=T}zL2n7bM8o8g#*F|Q%n>{#zGI3aoM5ptgqb|5#Q0-fuPveFm}*t#6J>nQI?04W zddadPl-27!^`1tRpwAVEqlr1diwI*)RCifevrPbt5Gp@fxs&zT5 zsb*ne&_BG~c(7H^P%7ADWn2!iMjp*h2XH3HT6VU72#$t`4=n-ZMCj(Lx2fTA@Q*v3DH1nr6oj-PQmZ9zCOcnn|~y1H8R1_aO#cRLv8n zA^SQ>qnD0V>X0{ZGw#)({*;uB(U$-bb3>y#gPQ0j{V0TAh2!q01pnET-gA>Z&%Zu& z{QmIumszVzi2m>gDlumvArvK|eWjErehNwr_*YQB+{U0n2iH{TJ z;qL1>Q|tNR;tK>w-Y~Xr!pxa~?@n`+EF(yvE$iV|s+c}C9kp5-ApELWNNyD z|D+=Q7PY%KH^%y&U#ewXB(vfZd=y2g6mLmY^!M=zO*K@jEGVFm+gRBYv6`7`j!j#_ z9w|2DzzCJJ^>~J#5j;E8*py74CK@&dIy0mkEqwTPE}}scXFHs_!v+39v(Q!~u%}FWO}FpFHX>#>99{bVQXu z&Mv05icalrL5O4IcpQ-%8V0q0)*4^oV6E1=wCFNkQG8D|Vcl#K3ekLmEmuno2}tcn+QcBWaoDND z?$>_WkP~3jJBVSpFIV5PxKA;nAt-PpDTxDvS|U0B~sCx$DrPuUWy1s-9;QX4FU@5U37&vhcuXyFpWC$dZ2bo2M?j zANK_Zrju>J;S;e;$Q-lXs>AJ;X+V(MnIVQV<}7RvF2tip0dAnk>SJRl?)-~WoU!77 zQ=Tzv)wwG*H6)RHIJxxBSAnc$34YukwX=MWwb+&MO&{6*3?R8{8xnSKM?Fx^SIqyB zbIrq9*-wfEPB-!(hD)U;417Yhr*_v$3yfCOLjgK9ct=m3wC4po@*K`;f?423NQ%Ha z=HQfTdxjl&#yC@aA?gUOwDc`m_JtKN%GtmX{+jhTzM{j)Zz!HLVWS zT3ud61ZuseM>#VB zB1v^H3>~f3ZuQ1y1W{>t-Z=ZAh`cL8Ph>}_y|h?Wg&}{_PP-`L`oK-Ig}U9hdlkA` zD(w7nYK?aP_vu?cAgjvw$DWY~|Nr`6dn+Ike-c>$`F=-2aTLj*LyZCcadEaCUHG~; z86DPAtoK5nu-&tR!-E*UKmtjQ&F-bed^U;yv{`=a-Q3MyR&EFcei`C7LwUEikDKv_ z{n2hUv{KSVf+2Ghr?p6~s8Uo}UNjM-Va{4f?=S0P)GQHiP&5mMDO6_~Oh#6NWhYTD zHVIY-Br?zR-A}*_d1E(u4)4jZiSX;qv}@p<)$5PHa8uof$- zN#h;PX!Sh`GyKY@#3`XavDTF!tlLp7pOnP|n7ydSTSeRN`9lT0{FsiXdyibTb1c%L zVA^GmC!c-pE7zzK?fNiiRLgGuZTzKsr@X+hJ&sngBnxa3+bfw(?G&G3Q%W|MUt{C{~s zF!W;nx?2MjfY!+%*n5u;$!Pee07wYZ@g^V02=j281Q-OI#l0q(9<@WCr<;o4(a|TM zH_t`S9?g&v-JRw*Z;u>5#?|UTBD=ggqWPrGOk$%Eut6-?OV>%E(R=5l*y|X#64&>rZ z#W3LPCfr7TgzQ0(qgidWUQd+uWMCx7o zEB>|%Jj&TVz$-D|qVAVU4!CF!@J}!yxFe4cX8SF|Y-XBWZzD>se-R!+{t?Wh6=}E7 zVI*Eoa1su_6K2`e8XfsS4OJM|U+&-7VS zIRJ0}JFs%}kcBm|$KkOHXW8Yj-C+KS#mq``V56%9am)P^?MzJPWU+*SyoQeWkRCz< zQ&Lq-Q>VTUJh=@7B#nHSC6HUHAey1!j}y>tP-yPh!o;992`-QHd7AI5t9 zPzm;}i0kMO6~Kl4TT`Y-BTU9Ku;r}*Q1TDl8m%S{+PFzk4&HGip;0#LkTx>X5q%>5 zvea2A%tl(PyC6CoWZ>)xHQQMu6n`UxQHJwS^%+zbld7C*CafaNLfh=(7&7eb)>jvC znLDJo2#ICn^BvWW7|$|a>!k)dOwPL;_Ao<@lzuJMoVs>;vkRhel4yyS2) zNMgz=@z?&pdF|R2kYSCb~_c?Vn#f0va))?V7TyrsA4t^o14=CVLW+YJt zornR!@R}SEh5X@8Mecwsv4(I7&TsC{FBAkUqM~hI4`ElK`EdgmwXTtz>9XPZVjTba zBi?BtsK{w&VnIK?b}XqbS5ujgFthngi(n$Qf0!GV*Ck3#A5=c-XwE4I2shGOBSw|T zij+DsI~26%8A9#jM#!kkG4k(|p=DlNOtp$^w;d!`3Z6v)Np-zYDWC&3J{ zwaUiwtA2L~pTeKQ%+q-puz^>p5WizwIVWT}a7;I6vmOl}V!9x!Q0+N)w0dK<>Zy?Q zIMqMK-zUY;#%$)=v;*}7l%0g)L@qrQ%(KKJ+7(26naCnPXDl!4!)l8vCvdPEi@Jw* z|6Y0vPmvHvkk-$$00p5yRzY+{Zx>_nKI_Xh)l_9kFz3dgjETw(U=}g;=}5EaiyMu4 z_K5!H6(p54QnUJxGgc8!K#+;aOOofhNq5c;z10R2IrtP1H4@T9A)rjBp`BPHrYhlL z+@cieQ3~0svr%Pi6*}fPW-L9x=CjjPl73d0y^9szowR56%tm}k>B)RtEMvOL*=5n6 z-O4NJdBneKC@(Ak6105naj(;SX_5pO7!J@7^!qDe`+jzeJ|J9eMX~dq_a4ty_&9?( zEDkVKBj$N0>Ka>58Y|PQq{Q2j-1e%45yo0bM~*k}vj%t;)h4!(={qG%V1_LSFm}aK zY-tE~MG&?}B;H1))pTEj@~LYqj3<1_=`$4^b24-b8Y}Do-qUr>x|NiG?ruc-9+TCz z;?EP^qy0SZdX`9sh!jt2^KgHyRrl?I`X8rO z8NK~qffuwrcv^i<^-sN;(~rF>En&Wk(?xUpXJ1i$BT!_#xy7-)Kt@ezB>Cmr;5qh^mji@urT}VzT*Om+_r%F`x$OqeakZ|EVfr%`L5IZXlLN1Lx$X$ z+~*?=bbBH!DkWE20Z&N_tCU_B5$>9N<-1b_)B4t9h0o5Fdg(TV#T=ZS;k;e9y5Pt( zcf%BKR`r}pq4b=}Y5!VT0!2?uu5S_u400^GsdDb9m9+E0!adTPK5T5=_*&)oy9xJV zF2%9jIC6B{IhfKk_L`{##PdAGvbj`=i^IWZR_QpWl7Pcg=0JJdXRWYv_wxuM9&rzRW2JGR-w|x_nY#<=SNhGv@xPUGak-)N>My zOneaxybJRv4`{BQkx7I>1a{^b!-nmXAIx>-%-v{b>i|3i&3>}pJSUmS2~`n_z^+yS z5F0W84=jO$-F%Y+=gUmi<5!s6KVLxR@N}V>dBECiGq5qIhN93#0IX18zN$3hPIm?d zV-!XFlLO}a%OLKmW?-;Ek-sboG(;JA1H1~@Hsm`!ZBY~!NrDxAkW>XLMBK-SZsJh| zutEn#h>3_B?HCwPO>9vHDV(GNHjo8$f7;~2gO;L~=q~SL-0fWZ~#j)X&6Bqf(AYY$jk0PJ03wGnXMds4rYbk)o%O?X5s6!3k zfXNPvon#Tm&!fx7m@-U0Xlej*iY)lxbYN7j0b(5#t3F$TR4GoDU7{+BI87QonpRme zOct=Q1)0SHI@Eabh9zRm!uB9RsmW9A4Z;2eABzjLU@_3Yb|{tzO}1YeB?~&EwGSvS z2b9-Gk@s+Bn7q;166{pOsgw*1jwq^ZTtTWtCL1hsmqk9p&jdx)T@RQl&dDjBieNJl zr|tj``9o2y>jP8GF7ag{X4W>)a%KhoKvyva1`M9A)97C%`B`O-U1bAu471WI(n_BRXdc33Qc~vQcM(m z%*7)yFC}Mk;$lTsaNBmW!75Q^;mHs)A-y`Vxw6QmkOqpmsncMpwYY?M85qRpg322J DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a79..ca025c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f3b75f3..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -205,7 +205,7 @@ fi 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, +# * DEFAULT_JVM_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. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From 3af48c31d4043437213afa462c4963c1dada6e3b Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Wed, 6 May 2026 09:57:16 -0300 Subject: [PATCH 4/8] bump gradle to 9.0.0 to jdk25 compatibility --- CLAUDE.md | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c7c61d..a9f615b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ These decisions are not up for debate without amending the corresponding ADR: - **Java only.** Single artifact, no Kotlin sources. Published JAR must not bring `kotlin-stdlib` as a transitive dep. JSpecify is compile-time only and doesn't count. (ADR-001) - **Kotlin consumers are first-class via interop, not via a Kotlin artifact.** A separate `marketdata-sdk-java-kotlin` extensions JAR (Option E) is deferred. (ADR-001) - **JDK 17 minimum.** Build with `javac --release 17`; no multi-release JAR. CI test matrix is `{17, 21, 25}` for forward-compat. (ADR-002) -- **Gradle 8.14, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Wrapper pinned to **Gradle 8.14** (the minimum that supports JDK 25 as a toolchain target — 8.12 had this version cap and 9.0+ would also work but adds breaking changes we don't need yet). The daemon itself runs on JDK 17 via `JAVA_HOME`; toolchain forks compile/test JDKs as needed. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) +- **Gradle 9.0, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Wrapper pinned to **Gradle 9.0.0** — the first release that supports **JDK 25** for both daemon and toolchain (8.x maxed at JDK 24). The daemon still runs on JDK 17 via `JAVA_HOME` for stability (CI workflows order `setup-java`'s `java-version` so the matrix JDK is first and 17 is last); toolchain forks compile/test JDKs as needed via `-PtestJdk=N`. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) - **`java.net.http.HttpClient` exclusively.** No third-party HTTP client (OkHttp, Apache) as a runtime dep — ever. HTTP/2 on (default). One shared `HttpClient` per `MarketDataClient`. Timeouts: 99s request, 2s connect. (ADR-004) - **Jackson (`jackson-databind`) for JSON.** Records-based response models (Jackson record support, 2.12+). The API's parallel-arrays wire format (e.g. `{"s":"ok","symbol":["AAPL","MSFT"],"price":[150.0,400.0]}`) is decoded via custom `JsonDeserializer` classes, *not* default reflection. Jackson is **not shaded** in v1; shading is held in reserve. (ADR-005) - **Sync + async parity per endpoint.** Every public endpoint exposes both `quote(...)` and `quoteAsync(...)`; async returns `CompletableFuture`. **Internal logic is async-first.** Sync methods are thin wrappers that call `.join()` and unwrap `CompletionException` to surface the underlying cause directly. Both surfaces share validation, retry, rate-limit, and concurrency-pool logic — no parallel implementations. Tests must cover both variants for every endpoint. (ADR-006) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..8bdaf60c75ab801e22807dde59e12a8735a34077 100644 GIT binary patch delta 37256 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 delta 35551 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..2a84e18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a9..ef07e01 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 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. From afe8eebdc0ceae773e2e3bd6e9d331242c0580b3 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 7 May 2026 15:32:20 -0300 Subject: [PATCH 5/8] change from java semaphore to custom AsynSemaphore --- .../com/marketdata/sdk/AsyncSemaphore.java | 95 +++++++++++ .../com/marketdata/sdk/HttpTransport.java | 59 +++++-- .../marketdata/sdk/AsyncSemaphoreTest.java | 153 ++++++++++++++++++ .../com/marketdata/sdk/HttpTransportTest.java | 147 +++++++++++++++++ 4 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/AsyncSemaphore.java create mode 100644 src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java create mode 100644 src/test/java/com/marketdata/sdk/HttpTransportTest.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..b1ebd6e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java @@ -0,0 +1,95 @@ +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() { + CompletableFuture next = null; + synchronized (lock) { + while (!waiters.isEmpty()) { + CompletableFuture w = waiters.pollFirst(); + if (!w.isDone()) { + next = w; + break; + } + } + if (next == null) { + available++; + } + } + if (next != null) { + next.complete(null); + } + } + + /** 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/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index a1780ed..51bb519 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -19,7 +19,6 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicReference; import org.jspecify.annotations.Nullable; @@ -54,7 +53,7 @@ final class HttpTransport implements AutoCloseable { private final HttpClient httpClient; private final ObjectMapper jsonMapper; - private final Semaphore concurrencyPermits; + private final AsyncSemaphore concurrencyPermits; private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); private final String baseUrl; @@ -63,18 +62,32 @@ 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, defaultHttpClient()); + } + + // 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 = baseUrl; this.apiVersion = apiVersion; this.userAgent = userAgent; this.token = token; - this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT); + this.concurrencyPermits = new AsyncSemaphore(CONCURRENCY_LIMIT); this.jsonMapper = buildJsonMapper(); - this.httpClient = - HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .version(HttpClient.Version.HTTP_2) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); + this.httpClient = httpClient; + } + + 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 request has succeeded yet. */ @@ -94,19 +107,33 @@ CompletableFuture executeAsync(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. + return concurrencyPermits.acquire().thenCompose(unused -> dispatch(uri, request, responseType)); + } + + private CompletableFuture dispatch(URI uri, HttpRequest request, Class responseType) { + CompletableFuture> sendFuture; try { - concurrencyPermits.acquire(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + 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( - "Interrupted while waiting for a concurrency permit", + "Request to " + uri + " failed before dispatch: " + t.getMessage(), new ErrorContext(null, uri.toString(), null), - e)); + t)); } - return httpClient - .sendAsync(request, BodyHandlers.ofByteArray()) + return sendFuture .whenComplete((r, t) -> concurrencyPermits.release()) .handle( (response, error) -> { 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..997f8cf --- /dev/null +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -0,0 +1,153 @@ +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 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); + } + + // ---------- 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/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java new file mode 100644 index 0000000..1bc062d --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -0,0 +1,147 @@ +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.Optional; +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 { + + /** + * 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()); + + 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); + } + + 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(); + } + } +} From 919c02f510beea99785f58ba646bede62fcfcbcf Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Fri, 8 May 2026 11:29:38 -0300 Subject: [PATCH 6/8] adds AsyncSemaphore IT --- .../com/marketdata/sdk/AsyncSemaphoreIT.java | 59 +++++++ .../com/marketdata/sdk/ConfigurationTest.java | 23 +++ .../marketdata/sdk/HttpStatusMapperTest.java | 83 +++++++++ .../marketdata/sdk/HttpTransportE2ETest.java | 102 +++++++++++ .../com/marketdata/sdk/HttpTransportTest.java | 106 +++++++++++ .../marketdata/sdk/MarketDataClientTest.java | 69 +++++++- .../sdk/MarketStatusDeserializerTest.java | 16 ++ .../marketdata/sdk/RateLimitHeadersTest.java | 164 ++++++++++++++++++ .../com/marketdata/sdk/RequestSpecTest.java | 56 ++++++ .../exception/MarketDataExceptionTest.java | 19 ++ 10 files changed, 691 insertions(+), 6 deletions(-) create mode 100644 src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java create mode 100644 src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java create mode 100644 src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java create mode 100644 src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java create mode 100644 src/test/java/com/marketdata/sdk/RequestSpecTest.java diff --git a/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java b/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java new file mode 100644 index 0000000..0676736 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/AsyncSemaphoreIT.java @@ -0,0 +1,59 @@ +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/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 7151ec1..8cda17f 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -130,6 +130,29 @@ 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"); 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..a7e5cef --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..79b9836 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java @@ -0,0 +1,102 @@ +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("//"); + } + + // ---------- 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/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 1bc062d..7017d9c 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -61,6 +61,38 @@ void permitReleasedWhenSendAsyncThrowsSynchronously() throws Exception { 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()); + + 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); + } + private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception { Field f = HttpTransport.class.getDeclaredField("concurrencyPermits"); f.setAccessible(true); @@ -144,4 +176,78 @@ 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 index 40b6fd5..ca51fbd 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -2,6 +2,12 @@ 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 { @@ -17,17 +23,68 @@ void buildsWithExplicitToken() { @Test void demoModeWhenNoTokenAvailable() { - // No apiKey passed to the constructor. Demo mode iff the env/dotenv - // cascade also yields nothing — true on any CI environment that - // doesn't export MARKETDATA_TOKEN. This assertion is conditional - // so the test stays valid in both cases. + // 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()) { - String envToken = System.getenv("MARKETDATA_TOKEN"); - boolean expectDemo = envToken == null || envToken.isBlank(); + 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, diff --git a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java index c08f4a8..cdb6c79 100644 --- a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java +++ b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java @@ -85,4 +85,20 @@ void rejectsResponseMissingArrays() { .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/RateLimitHeadersTest.java b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java new file mode 100644 index 0000000..b3074f5 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java @@ -0,0 +1,164 @@ +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/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java new file mode 100644 index 0000000..c192322 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RequestSpecTest.java @@ -0,0 +1,56 @@ +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/exception/MarketDataExceptionTest.java b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java index 9ce9f7b..16c7d39 100644 --- a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java +++ b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java @@ -98,6 +98,25 @@ void everySubtypeExposesBothConstructors() { } } + @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 From c924c1f70ff25edea3c462c05bbb033e8cf7281c Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 11 May 2026 11:03:36 -0300 Subject: [PATCH 7/8] add tests --- build.gradle.kts | 24 ++++++ .../com/marketdata/sdk/HttpTransport.java | 26 +++--- src/main/java/com/marketdata/sdk/Version.java | 13 ++- .../com/marketdata/sdk/HttpTransportTest.java | 80 +++++++++++++++++++ .../java/com/marketdata/sdk/VersionTest.java | 43 ++++++++++ 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/marketdata/sdk/VersionTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 1ab8216..24a5a70 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -110,6 +110,30 @@ tasks.jacocoTestReport { } } +// Aggregate coverage across unit tests and integration tests. Opt-in: not +// wired into `check` so PR builds stay fast and don't require the IT secret. +// Invoke as `MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew jacocoAggregateReport`. +tasks.register("jacocoAggregateReport") { + description = "Generates a JaCoCo report aggregating unit + integration test coverage." + group = "verification" + + dependsOn(tasks.test, integrationTestTask) + + sourceSets(sourceSets.main.get()) + executionData( + fileTree(layout.buildDirectory.dir("jacoco")) { + include("*.exec") + }, + ) + + reports { + xml.required = true + html.required = true + html.outputLocation = layout.buildDirectory.dir("reports/jacoco/aggregate/html") + xml.outputLocation = layout.buildDirectory.file("reports/jacoco/aggregate/jacoco.xml") + } +} + // Coverage ratchet (line coverage cannot drop more than 5 pp below // 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 diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 51bb519..be68e90 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -158,15 +158,22 @@ T executeSync(RequestSpec spec, Class responseType) { try { return executeAsync(spec, responseType).join(); } catch (CompletionException e) { - Throwable cause = e.getCause(); - if (cause instanceof MarketDataException mde) { - throw mde; - } - if (cause instanceof RuntimeException re) { - throw re; - } - throw new NetworkError("Unexpected failure invoking SDK", ErrorContext.empty(), cause); + throw asRuntime(e.getCause()); + } + } + + // 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 @@ -243,7 +250,8 @@ private static ObjectMapper buildJsonMapper() { return mapper; } - private static Throwable unwrap(Throwable t) { + // 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/Version.java b/src/main/java/com/marketdata/sdk/Version.java index db0241a..a487eb6 100644 --- a/src/main/java/com/marketdata/sdk/Version.java +++ b/src/main/java/com/marketdata/sdk/Version.java @@ -1,5 +1,7 @@ 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"). @@ -9,12 +11,17 @@ */ final class Version { - private static final String FALLBACK = "0.0.0-dev"; + static final String FALLBACK = "0.0.0-dev"; private Version() {} public static String current() { - String version = Version.class.getPackage().getImplementationVersion(); - return version != null && !version.isBlank() ? version : FALLBACK; + 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/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 7017d9c..ca7e3c2 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -93,6 +93,86 @@ void errorThrownSynchronouslyIsPreservedAsRootCause() throws Exception { assertThat(permits.availablePermits()).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); 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..8ab9742 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/VersionTest.java @@ -0,0 +1,43 @@ +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(); + } +} From 63fc0a69daf907eb31e76e728f3b09701397956e Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Mon, 11 May 2026 14:36:53 -0300 Subject: [PATCH 8/8] fix asyncSemaphore and other minors --- .../workflows/pr-integration-on-demand.yml | 67 +++++--- CLAUDE.md | 8 + README.md | 11 ++ .../com/marketdata/sdk/AsyncSemaphore.java | 31 ++-- .../com/marketdata/sdk/HttpTransport.java | 50 +++++- .../com/marketdata/sdk/MarketDataClient.java | 11 +- .../java/com/marketdata/sdk/RequestSpec.java | 4 +- src/main/java/com/marketdata/sdk/Version.java | 2 +- .../marketdata/sdk/AsyncSemaphoreTest.java | 77 +++++++++ .../marketdata/sdk/HttpTransportE2ETest.java | 17 ++ .../com/marketdata/sdk/HttpTransportTest.java | 157 ++++++++++++++++++ .../marketdata/sdk/MarketsResourceTest.java | 58 ++++++- 12 files changed, 444 insertions(+), 49 deletions(-) diff --git a/.github/workflows/pr-integration-on-demand.yml b/.github/workflows/pr-integration-on-demand.yml index 8d013e7..9fc5a51 100644 --- a/.github/workflows/pr-integration-on-demand.yml +++ b/.github/workflows/pr-integration-on-demand.yml @@ -1,15 +1,24 @@ name: Integration tests on demand -# Manually triggered by commenting on an open PR: -# `integrationtest` → JDK 17 only -# `integrationtestfull` → full matrix {17, 21, 25} +# Manually triggered by commenting on an open PR with a slash-command on +# the FIRST line of the comment body (everything after the first line is +# ignored): +# /integrationtest → JDK 17 only +# /integrationtestfull → full matrix {17, 21, 25} +# +# The slash + first-line constraint prevents accidental triggers from +# review comments that mention the workflow by name, quoted replies +# (`> /integrationtest`), pasted documentation, or stack traces. The +# Guard job below filters on `startsWith(... '/integrationtest')` and +# then a strict bash `case` validates the exact command — anything that +# slips through is rejected before any live-API request is made. # # Integration tests hit the live Market Data API, so we don't run them # automatically on every PR open/sync (saves API quota + CI minutes). # They ARE required for merge — branch protection on `main` should list # "Integration tests pass" as a required status check, which is the # aggregator job below. PRs cannot merge until a reviewer comments one -# of the two trigger phrases AND the resulting run is green. +# of the two slash-commands AND the resulting run is green. # # Important security note: workflows triggered by `issue_comment` always # run from the *default branch's* version of the workflow file, not from @@ -32,16 +41,15 @@ jobs: guard: name: Guard runs-on: ubuntu-latest - # Only fire on PR comments (not generic issue comments) that contain - # one of the two accepted slash-style commands. `contains` is - # substring match — note that `integrationtest` is itself a substring - # of `integrationtestfull`, so this OR matches both, and the matrix - # decision below disambiguates. + # Only fire on PR comments whose body starts with `/integrationtest`. + # `startsWith` rejects comments that merely mention the command in + # passing (quoted replies start with `>`, prose with anything else, + # so they don't match). The strict `case` in the matrix step below + # rejects anything that slips through (e.g. `/integrationtest-foo`) + # before any live-API request fires. if: | - github.event.issue.pull_request != null && ( - contains(github.event.comment.body, 'integrationtest') || - contains(github.event.comment.body, 'integrationtestfull') - ) + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/integrationtest') outputs: head_sha: ${{ steps.pr.outputs.head_sha }} jdks: ${{ steps.matrix.outputs.jdks }} @@ -96,16 +104,29 @@ jobs: env: BODY: ${{ github.event.comment.body }} run: | - # Check for the long form first because it contains 'integrationtest' as a substring. - if [[ "$BODY" == *"integrationtestfull"* ]]; then - echo 'jdks=["17","21","25"]' >> "$GITHUB_OUTPUT" - echo 'mode=full' >> "$GITHUB_OUTPUT" - echo "Trigger: integrationtestfull → matrix {17, 21, 25}" - else - echo 'jdks=["17"]' >> "$GITHUB_OUTPUT" - echo 'mode=single' >> "$GITHUB_OUTPUT" - echo "Trigger: integrationtest → JDK 17" - fi + # The if: filter above only guarantees the body starts with + # '/integrationtest'. We still need to disambiguate single vs + # full and reject anything that just shares the prefix + # (e.g. '/integrationtest-foo' or '/integrationtestlong'). + # Match on the first line only — trailing context in the + # comment body is ignored. + first_line=$(printf '%s' "$BODY" | head -n 1 | tr -d '[:space:]') + case "$first_line" in + /integrationtest) + echo 'jdks=["17"]' >> "$GITHUB_OUTPUT" + echo 'mode=single' >> "$GITHUB_OUTPUT" + echo "Trigger: /integrationtest → JDK 17" + ;; + /integrationtestfull) + echo 'jdks=["17","21","25"]' >> "$GITHUB_OUTPUT" + echo 'mode=full' >> "$GITHUB_OUTPUT" + echo "Trigger: /integrationtestfull → matrix {17, 21, 25}" + ;; + *) + echo "::error::Unrecognized command on first line: '$first_line' (expected '/integrationtest' or '/integrationtestfull')" + exit 1 + ;; + esac integration-tests: name: Integration tests (JDK ${{ matrix.java }}) diff --git a/CLAUDE.md b/CLAUDE.md index a9f615b..07cb5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,14 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( 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 to revisit when retry/timeout lands:** +- `HttpTransport.executeSync` only catches `CompletionException` from `.join()`, not `CancellationException`. Today the latter is unreachable — the user can't cancel a future they never see (the future is local to `executeSync`), no internal code cancels it, and `dispatch`'s `handle((response, error) -> ...)` translates every upstream error (including a hypothetical `CancellationException` from `sendAsync`) into `CompletionException(NetworkError)`. The gap becomes real once we add: + - `dispatched.orTimeout(99s)` / `completeOnTimeout` to enforce the §10 timeout strictly (these produce `CancellationException` on the downstream future). + - A retry coordinator (§9) that cancels in-flight futures when aborting a retry chain. + - A bump to JDK 21+ where `HttpClient.close()` cancels in-flight futures. + When any of those land, extend the catch in `executeSync` (or fold it into `asRuntime`) so cancellations don't escape as raw `RuntimeException` to sync callers. Tracked as Issue #2 of the 2026-05-11 review (`REVIEW-2026-05-11-markets-status.md`). +- `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. + ## 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. diff --git a/README.md b/README.md index 7d8bfd5..ee2f4c0 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,17 @@ install — the wrapper downloads the right Gradle version on first run. MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest ``` +On PRs, integration tests are not run automatically (live-API quota + +CI minutes). A reviewer with `write` access triggers them by posting a +slash-command on the **first line** of a PR comment: + +- `/integrationtest` — JDK 17 only. +- `/integrationtestfull` — full matrix `{17, 21, 25}`. + +The first-line rule means quoted replies (`> /integrationtest`) and +prose that merely mentions the command do not fire a run. Anything that +isn't an exact match is rejected before any request is made. + ## Package layout ``` diff --git a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java index b1ebd6e..7945108 100644 --- a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java +++ b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java @@ -61,22 +61,29 @@ CompletableFuture acquire() { * completed) without going through the counter. Otherwise the counter is incremented. */ void release() { - CompletableFuture next = null; - synchronized (lock) { - while (!waiters.isEmpty()) { - CompletableFuture w = waiters.pollFirst(); - if (!w.isDone()) { - next = w; - break; + // 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 == null) { - available++; + if (next.complete(null)) { + return; } } - if (next != null) { - next.complete(null); - } } /** Permits not currently held nor pending in the queue. */ diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index be68e90..fc4692f 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -17,6 +17,7 @@ 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.atomic.AtomicReference; @@ -90,7 +91,12 @@ private static HttpClient defaultHttpClient() { .build(); } - /** Latest client-level rate-limit snapshot, or {@code null} if no request has succeeded yet. */ + /** + * 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(); } @@ -111,7 +117,25 @@ CompletableFuture executeAsync(RequestSpec spec, Class responseType) { // 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. - return concurrencyPermits.acquire().thenCompose(unused -> dispatch(uri, request, responseType)); + 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) { @@ -145,7 +169,16 @@ private CompletableFuture dispatch(URI uri, HttpRequest request, Class new ErrorContext(null, uri.toString(), null), root)); } - latestRateLimits.set(RateLimitHeaders.parse(response.headers())); + // 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()); }); } @@ -203,9 +236,16 @@ private T processResponse(HttpResponse response, Class responseTy } 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(spec.path()); - if (!spec.path().endsWith("/")) { + sb.append(baseUrl).append('/').append(apiVersion).append('/').append(path); + if (!path.endsWith("/")) { sb.append('/'); } Map params = spec.queryParams(); diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 5cab986..85cc821 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -103,8 +103,8 @@ public MarketDataClient( 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; rate-limit initialization is skipped."); + "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)); } @@ -146,7 +146,12 @@ public boolean isValidateOnStartup() { return validateOnStartup; } - /** Latest client-level rate-limit snapshot, or {@code null} if none has been received yet. */ + /** + * 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(); } diff --git a/src/main/java/com/marketdata/sdk/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java index eae9538..428c30c 100644 --- a/src/main/java/com/marketdata/sdk/RequestSpec.java +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -46,7 +46,9 @@ Builder query(String key, Object value) { } RequestSpec build() { - return new RequestSpec(path, Collections.unmodifiableMap(queryParams)); + // 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/Version.java b/src/main/java/com/marketdata/sdk/Version.java index a487eb6..909c6c9 100644 --- a/src/main/java/com/marketdata/sdk/Version.java +++ b/src/main/java/com/marketdata/sdk/Version.java @@ -15,7 +15,7 @@ final class Version { private Version() {} - public static String current() { + static String current() { return resolve(Version.class.getPackage().getImplementationVersion()); } diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java index 997f8cf..ed03e6f 100644 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -6,6 +6,8 @@ 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 { @@ -131,6 +133,81 @@ void waitersAreServedFifo() { 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: + * + *

    + *
  • 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 diff --git a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java b/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java index 79b9836..f8a8a39 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java @@ -77,6 +77,23 @@ void pathEndingInSlashIsNotDoubled() { 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 { diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index ca7e3c2..fe94e71 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -14,6 +14,8 @@ 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; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -93,6 +95,67 @@ void errorThrownSynchronouslyIsPreservedAsRootCause() throws Exception { 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); + + 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 @@ -257,6 +320,100 @@ public WebSocket.Builder newWebSocketBuilder() { } } + /** + * 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 diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java index fc7c297..9db68cf 100644 --- a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java @@ -315,6 +315,45 @@ void allUnparseableRateLimitHeadersAreIgnoredAsAbsent() { } } + /** + * 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()); @@ -349,9 +388,20 @@ void errorResponseWithCfRayPropagatesRequestId() { */ @ParameterizedTest @EnumSource(CallMode.class) - void connectionRefusedProducesNetworkError(CallMode mode) { - // port 1 is privileged and rejects fast. - try (var client = new MarketDataClient("test-key", "http://127.0.0.1:1", null, false)) { + 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) @@ -359,7 +409,7 @@ void connectionRefusedProducesNetworkError(CallMode mode) { t -> { NetworkError ne = (NetworkError) t; assertThat(ne.getCause()).isNotNull(); - assertThat(ne.getRequestUrl()).contains("127.0.0.1:1"); + assertThat(ne.getRequestUrl()).contains("127.0.0.1:" + closedPort); }); } }