Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ Thumbs.db
*.log
hs_err_pid*
replay_pid*

# Claude personal config — local slash commands, settings, and skills.
# Anything the team wants to share should be added back explicitly via `!`,
# e.g. !.claude/settings.json for shared per-repo settings.
.claude/
32 changes: 28 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
**Already wired in:**
- §1.1 client object — `MarketDataClient` with two public constructors: a no-arg one for production (everything from the cascade) and a 4-arg `(apiKey, baseUrl, apiVersion, validateOnStartup)` for tests and short-lived runtimes. All fields `final` (immutable). Default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor.
- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). The 4-arg constructor's parameters feed step 1; the no-arg constructor skips it and starts at step 2.
- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`).
- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). When `validateOnStartup` is true and not in demo mode, the constructor hits `GET /user/` via `HttpTransport.validateToken` (single-attempt, no retry) so invalid tokens surface as `AuthenticationError` from the constructor instead of from the first business call.
- §7 logging: `MARKETDATA_LOGGING_LEVEL` (via cascade) is applied to the `com.marketdata.sdk` logger at client construction. When set, a `MarketDataLogFormatter` (producing the spec-mandated `{timestamp} - {logger_name} - {level} - {message}` shape, UTC second resolution) is installed on a dedicated `ConsoleHandler` and parent handlers are bypassed to avoid duplicate emission. When unset, the SDK does not touch the JVM logging config.
- §8 pre-flight rate-limit check: `HttpTransport.executeOnce` short-circuits with `RateLimitError` before acquiring a permit if the latest snapshot reports `remaining <= 0`. No-op for cold clients (snapshot starts null).
- §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`.
- §10 timeouts: `REQUEST_TIMEOUT = 99s` and `CONNECT_TIMEOUT = 2s` exposed as constants on `MarketDataClient`. Connect timeout is wired into the `HttpClient`; the per-request 99 s timeout is applied via `HttpRequest.Builder#timeout` in `HttpTransport.buildRequest`.
- §12 concurrency: 50-permit `AsyncSemaphore` on `HttpTransport` with acquire/release wired around every dispatch. The custom semaphore replaces `java.util.concurrent.Semaphore` so `executeAsync` never parks the caller's thread on a full pool (ADR-007).
Expand All @@ -82,9 +84,7 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
**Deliberately deferred (require the request/endpoint layer to land first):**
- §1.2 resource groupings (`client.stocks`, `client.options`, `client.funds`, `client.markets`, `client.utilities`).
- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding.
- §5 actual `/user/` startup validation call (the `validateOnStartup` flag is the seam; the call itself comes with the request layer).
- §7 honoring `MARKETDATA_LOGGING_LEVEL` and the spec's exact `{timestamp} - {logger_name} - {level} - {message}` format. Currently the SDK uses `java.util.logging` with default formatting; consumers can attach their own handler.
- §8 rate-limit header parsing, pre-flight check, request-scoped attachment.
- §8 request-scoped attachment of rate-limit metadata to the response object (the client-level snapshot is wired; the per-response carrier is not).
- §9 `/status/` cache workflow and `Retry-After` header override (retry/backoff itself lives in `RetryPolicy` and is wired; what is missing is the `/status/` pre-check before retrying 501–599 and respecting the server-specified `Retry-After` over the calculated exponential backoff).
- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold.

Expand All @@ -94,6 +94,30 @@ When picking up new work, check this list before reaching for the SDK requiremen
- `HttpTransport.buildUri` URL-encodes query-param values with `URLEncoder.encode(..., UTF_8)`, which is form-encoding semantics: spaces become `+`, not `%20`. Fine for today's typed params (dates, numerics) but a future endpoint that takes an arbitrary string (e.g. `symbol="BRK A"`) would round-trip differently against an RFC-3986-strict server. Switch to a path/query-segment-aware encoder when the first such param lands. Tracked as Issue #10 of the 2026-05-11 review.
- `Retry-After` server header is parsed and respected by neither `RetryPolicy` nor `HttpTransport`. Today every retry uses the calculated exponential backoff (`min(1s × 2^N, 30s)`). Implementing the override needs the response headers to reach `RetryPolicy.backoffDelay`, which today only sees the attempt index — most natural path is to surface a `Duration` on `ServerError` (or thread it through a separate channel) when 5xx responses carry the header. Follow-up of the §9 work.

## Test policy: unit tests are network-free

Unit tests under `src/test/` **must not** make real network calls. Anything that needs the live
API goes to `src/integrationTest/` (gated by `MARKETDATA_RUN_INTEGRATION_TESTS=true`).

This matters because §5 made `MarketDataClient`'s constructor perform a `GET /user/` when
`validateOnStartup=true` (the default). A test that builds `new MarketDataClient("any-token",
null, null, true)` now hits `api.marketdata.app` over the network — exactly the failure mode
this policy prevents.

Concrete rules for unit tests that construct `MarketDataClient`:

- Use the 4-arg constructor with `validateOnStartup=false` when verifying field wiring only.
- Use a local server (`com.sun.net.httpserver.HttpServer` in-process) and point `baseUrl` at it
when you genuinely need to exercise the validate-on-startup flow — see
`MarketDataClientStartupValidationTest` for the pattern.
- Tests that need the **real** API for smoke verification (e.g. the no-arg ctor end-to-end) go to
`src/integrationTest/.../MarketDataClientIT.java`.

No automated enforcement today (a future ArchUnit rule or a grep step in CI could close that
gap). Code review is the gate. Whenever you introduce a test that touches `new
MarketDataClient(..., true)` with a default URL, ask: "does this need to live in `integrationTest`
instead?"

## 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.marketdata.sdk;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

/**
* Integration smoke for {@link MarketDataClient} construction against the live Market Data API.
* Gated by the {@code integrationTest} source set, which itself only runs when {@code
* MARKETDATA_RUN_INTEGRATION_TESTS=true}. Requires a valid {@code MARKETDATA_TOKEN} via env or
* {@code .env} — without one the no-arg ctor falls back to demo mode and skips {@code /user/}, so
* the smoke would assert nothing real.
*
* <p>These assertions used to live in {@code MarketDataClientTest} as unit tests, but after §5
* landed the no-arg ctor performs a real HTTP call ({@code GET /user/}) when {@code
* validateOnStartup=true}. Unit tests must not depend on the network; the smoke moved here so the
* real-API path stays exercised exactly once, under the integration gate.
*/
class MarketDataClientIT {

@Test
void noArgConstructorAppliesProductionDefaults() {
try (var client = new MarketDataClient()) {
assertThat(client.isValidateOnStartup())
.as(
"the no-arg ctor must be equivalent to `new MarketDataClient(null, null, null, true)`")
.isTrue();
assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/");

// baseUrl/apiVersion fall back to the documented defaults only when the cascade has no
// override. CI typically has no overrides; locally a developer may set MARKETDATA_BASE_URL
// for a staging environment, so gate those assertions on the env vars being unset.
if (System.getenv("MARKETDATA_BASE_URL") == null) {
assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL);
}
if (System.getenv("MARKETDATA_API_VERSION") == null) {
assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION);
}

// After /user/ ran, the rate-limit snapshot must be seeded — that confirms the §5 wiring
// travels end-to-end (request fired, response headers parsed, snapshot stored).
assertThat(client.getRateLimits())
.as("startup validation hit /user/ — snapshot should be seeded")
.isNotNull();
}
}
}
48 changes: 48 additions & 0 deletions src/main/java/com/marketdata/sdk/HttpTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.marketdata.sdk.exception.MarketDataException;
import com.marketdata.sdk.exception.NetworkError;
import com.marketdata.sdk.exception.ParseError;
import com.marketdata.sdk.exception.RateLimitError;
import com.marketdata.sdk.markets.MarketStatus;
import java.io.IOException;
import java.net.URI;
Expand Down Expand Up @@ -120,6 +121,14 @@ private static HttpClient defaultHttpClient() {
* RetryPolicy}: retries 501–599 and IOException-shaped {@link NetworkError}s with exponential
* backoff, surfaces every other failure immediately. Cancellation of the returned future bails
* out of any pending backoff and propagates to the current in-flight attempt.
*
* <p><strong>Interaction between the §8 pre-flight check and the retry chain:</strong> each
* attempt re-runs the pre-flight check against the latest rate-limit snapshot. If a transient 5xx
* triggers a backoff and another concurrent caller's response drains the snapshot to {@code
* remaining=0} during that window, the next attempt's pre-flight fires and the chain ends with
* {@link com.marketdata.sdk.exception.RateLimitError} — not the upstream 5xx. The synthetic error
* reflects what the server would have returned anyway (a guaranteed 429 once the quota was gone),
* but callers that grep for "last server status" should know the substitution can happen.
*/
<T> CompletableFuture<T> executeAsync(RequestSpec spec, Class<T> responseType) {
CompletableFuture<T> result = new CompletableFuture<>();
Expand Down Expand Up @@ -185,6 +194,25 @@ private <T> void attempt(
});
}

/**
* SDK requirements §5: hit {@code /user/} to confirm the token is valid. Used by {@link
* MarketDataClient}'s constructor when {@code validateOnStartup=true}. Single-attempt
* deliberately: a 3-attempt retry chain on the construction path would block the caller for
* 99s+1s+2s before reporting a bad token — worse UX than failing fast. The response body is
* irrelevant; what matters is that the status is 2xx (token valid) vs 401 (token invalid) vs a
* network exception.
*/
void validateToken() {
RequestSpec spec = RequestSpec.get("user").build();
try {
executeOnce(spec, java.util.Map.class).join();
} catch (CompletionException e) {
throw asRuntime(e.getCause());
} catch (CancellationException e) {
throw asRuntime(e);
}
}

/**
* Single-shot dispatch — one HTTP request, one permit lease, one response decode. Public retry
* orchestration lives in {@link #executeAsync}.
Expand All @@ -193,6 +221,26 @@ private <T> CompletableFuture<T> executeOnce(RequestSpec spec, Class<T> response
URI uri = buildUri(spec);
HttpRequest request = buildRequest(uri);

// SDK requirements §8: pre-flight check. If the last response we saw left us with no
// credits, fail fast — there's no point burning a permit and an RTT for what the server
// will reject as 429 anyway. The snapshot can only be `null` until the first response
// seeds it, so this is a no-op for cold clients.
RateLimits snapshot = latestRateLimits.get();
if (snapshot != null && snapshot.remaining() <= 0) {
// `reset == EPOCH` means the response carried partial rate-limit headers (e.g.
// remaining without reset) and `RateLimitHeaders.parse` defaulted the missing field to
// 0. Rendering "1970-01-01" in the user-facing message looks like a bug; omit the
// suffix when the value is meaningless.
String resetSuffix =
snapshot.reset().equals(java.time.Instant.EPOCH)
? ""
: " (resets at " + snapshot.reset() + ")";
return CompletableFuture.failedFuture(
new RateLimitError(
"Pre-flight rate-limit check failed: 0 credits remaining" + resetSuffix,
new ErrorContext(null, uri.toString(), null)));
}

// 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
Expand Down
Loading
Loading