diff --git a/.gitignore b/.gitignore index a958c90..613c8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 34f022d..d62e4bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). @@ -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. @@ -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. diff --git a/src/integrationTest/java/com/marketdata/sdk/MarketDataClientIT.java b/src/integrationTest/java/com/marketdata/sdk/MarketDataClientIT.java new file mode 100644 index 0000000..375da53 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/MarketDataClientIT.java @@ -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. + * + *
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(); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 4549556..0a5767a 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -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; @@ -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. + * + *
Interaction between the §8 pre-flight check and the retry chain: 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.
*/
Equivalent to {@link #MarketDataClient(String, String, String, boolean) new
- * MarketDataClient(null, null, null, true)}.
+ * MarketDataClient(null, null, null, true)} — see that constructor's side-effects paragraph for
+ * the network IO performed when a token is present.
*/
public MarketDataClient() {
this(null, null, null, true);
@@ -68,6 +71,22 @@ public MarketDataClient() {
* baseUrl}, and {@code apiVersion} may be {@code null} to defer to the cascade in §4 for that
* single value.
*
+ * Side effects when {@code validateOnStartup=true}: the constructor performs
+ * a synchronous {@code GET /user/} HTTP request against {@code baseUrl} to verify the token. That
+ * means construction:
+ *
+ * Package-private + static so the constructor wires it and tests can drive it directly with a
+ * synthetic {@link Configuration} without depending on real process env vars.
+ *
+ * When the env var is unset (or blank, or malformed), the method is a no-op — the SDK inherits
+ * whatever {@code java.util.logging} configuration the host JVM has, which is what library code
+ * is expected to do.
+ *
+ * Side effect when the env var is set: the SDK logger's {@code
+ * useParentHandlers} is flipped to {@code false}, so handlers attached to the root logger no
+ * longer receive {@code com.marketdata.sdk.*} records. That prevents double emission (root
+ * handler with the JVM default formatter + our handler with the spec format) but means consumers
+ * that previously intercepted all logs through a root handler will stop seeing SDK records. To
+ * re-route, attach the consumer's handler directly to the SDK logger:
+ *
+ * Records emitted before this method completes use whatever formatter the JVM
+ * has by default, not {@link MarketDataLogFormatter}. Two paths produce such records:
+ *
+ * No behavior beyond {@link ConsoleHandler}; the type identity is the whole point.
+ */
+final class MarketDataConsoleHandler extends ConsoleHandler {
+
+ MarketDataConsoleHandler() {
+ setFormatter(new MarketDataLogFormatter());
+ }
+}
diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogFormatter.java b/src/main/java/com/marketdata/sdk/MarketDataLogFormatter.java
new file mode 100644
index 0000000..bc9ee45
--- /dev/null
+++ b/src/main/java/com/marketdata/sdk/MarketDataLogFormatter.java
@@ -0,0 +1,39 @@
+package com.marketdata.sdk;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.logging.Formatter;
+import java.util.logging.LogRecord;
+
+/**
+ * SDK requirements §7 log formatter. Produces lines shaped as {@code {timestamp} - {logger_name} -
+ * {level} - {message}}, e.g.
+ *
+ * Timestamps are in UTC ISO-8601 (second resolution) — the cross-language spec doesn't pick a
+ * timezone, so the universal one wins. If the spec ever mandates US/Eastern for logs the way it
+ * does for response dates, this is the only spot to change.
+ */
+final class MarketDataLogFormatter extends Formatter {
+
+ private static final DateTimeFormatter TIMESTAMP_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneId.of("UTC"));
+
+ @Override
+ public String format(LogRecord record) {
+ String timestamp = TIMESTAMP_FORMAT.format(Instant.ofEpochMilli(record.getMillis()));
+ String name = record.getLoggerName() == null ? "(anonymous)" : record.getLoggerName();
+ return timestamp
+ + " - "
+ + name
+ + " - "
+ + record.getLevel().getName()
+ + " - "
+ + formatMessage(record)
+ + System.lineSeparator();
+ }
+}
diff --git a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java b/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java
index 002a44c..08f2fb1 100644
--- a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java
+++ b/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java
@@ -273,6 +273,87 @@ void cancellationMidBackoffSkipsRemainingAttempts() throws Exception {
assertThat(permits.queueLength()).isZero();
}
+ // ---------- §8 pre-flight rate-limit check ----------
+
+ /**
+ * Spec §8: "If credits_remaining <= 0, throw RateLimitError immediately (no request)." Once a
+ * response seeds the snapshot with {@code remaining = 0}, the next call must fail fast without
+ * dispatching anything — saves both the round trip and a guaranteed 429 from the server.
+ */
+ @Test
+ void preflightFailsImmediatelyWhenRemainingIsZero() {
+ MultiResponseHttpClient client =
+ new MultiResponseHttpClient(
+ response(
+ 200,
+ "{\"value\":\"ok\"}",
+ Map.of(
+ "x-api-ratelimit-limit", "50000",
+ "x-api-ratelimit-remaining", "0",
+ "x-api-ratelimit-reset", "1735689600",
+ "x-api-ratelimit-consumed", "50000")));
+
+ HttpTransport transport = newTransport(client, fastPolicy(3));
+
+ // First call: snapshot is still null, request proceeds; response seeds remaining=0.
+ transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
+ assertThat(client.callCount()).isEqualTo(1);
+
+ // Second call: pre-flight check sees remaining=0 and fails fast without touching the wire.
+ assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class))
+ .isInstanceOf(RateLimitError.class)
+ .hasMessageContaining("Pre-flight");
+
+ assertThat(client.callCount())
+ .as("pre-flight must short-circuit before a second request hits the network")
+ .isEqualTo(1);
+ }
+
+ @Test
+ void preflightMessageOmitsResetSuffixWhenResetIsEpoch() {
+ // Partial rate-limit headers: server returned `remaining` without a `reset`, so
+ // RateLimitHeaders.parse defaulted reset to Instant.EPOCH. Rendering "(resets at
+ // 1970-01-01T00:00:00Z)" in the user-facing message looks like a bug — verify we omit it.
+ MultiResponseHttpClient client =
+ new MultiResponseHttpClient(
+ response(
+ 200,
+ "{\"value\":\"ok\"}",
+ Map.of(
+ "x-api-ratelimit-limit", "50000",
+ "x-api-ratelimit-remaining", "0",
+ "x-api-ratelimit-consumed", "50000")));
+
+ HttpTransport transport = newTransport(client, fastPolicy(3));
+ transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
+
+ assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class))
+ .isInstanceOf(RateLimitError.class)
+ .hasMessage("Pre-flight rate-limit check failed: 0 credits remaining");
+ }
+
+ @Test
+ void preflightAllowsRequestWhenRemainingPositive() {
+ MultiResponseHttpClient client =
+ new MultiResponseHttpClient(
+ response(
+ 200,
+ "{\"value\":\"ok\"}",
+ Map.of(
+ "x-api-ratelimit-limit", "50000",
+ "x-api-ratelimit-remaining", "100",
+ "x-api-ratelimit-reset", "1735689600",
+ "x-api-ratelimit-consumed", "49900")),
+ response(200, "{\"value\":\"ok\"}", Map.of()));
+
+ HttpTransport transport = newTransport(client, fastPolicy(3));
+
+ transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
+ transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
+
+ assertThat(client.callCount()).isEqualTo(2);
+ }
+
// ---------- permits are still conserved across retries ----------
@Test
diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientLoggingTest.java
new file mode 100644
index 0000000..9b873f6
--- /dev/null
+++ b/src/test/java/com/marketdata/sdk/MarketDataClientLoggingTest.java
@@ -0,0 +1,203 @@
+package com.marketdata.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Constructor;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises {@link MarketDataClient#configureLogging(Configuration)} in isolation. Driving it via a
+ * synthetic {@link Configuration} avoids the JVM env-var dependency that would otherwise make these
+ * tests flaky between CI and local runs.
+ */
+class MarketDataClientLoggingTest {
+
+ private Logger sdkLogger;
+ private Level previousLevel;
+ private boolean previousUseParent;
+ private Handler[] previousHandlers;
+
+ @BeforeEach
+ void snapshotLoggerState() {
+ sdkLogger = Logger.getLogger(MarketDataClient.SDK_LOGGER_NAME);
+ previousLevel = sdkLogger.getLevel();
+ previousUseParent = sdkLogger.getUseParentHandlers();
+ previousHandlers = sdkLogger.getHandlers().clone();
+ // Strip any handlers the previous test (or process-level setup) might have installed.
+ for (Handler h : previousHandlers) {
+ sdkLogger.removeHandler(h);
+ }
+ }
+
+ @AfterEach
+ void restoreLoggerState() {
+ // Drop anything configureLogging added during the test.
+ for (Handler h : sdkLogger.getHandlers()) {
+ sdkLogger.removeHandler(h);
+ }
+ sdkLogger.setLevel(previousLevel);
+ sdkLogger.setUseParentHandlers(previousUseParent);
+ for (Handler h : previousHandlers) {
+ sdkLogger.addHandler(h);
+ }
+ }
+
+ private static Configuration newConfig(Map Strategy: let {@code configureLogging} install its handler, then add a parallel capturing
+ * handler that reuses the same {@link MarketDataLogFormatter} so we observe the same line that
+ * goes to stderr. Emit a record on a child of the SDK logger and assert the captured line matches
+ * the spec shape exactly.
+ */
+ @Test
+ void emittedRecordsAreFormattedPerSpec() {
+ MarketDataClient.configureLogging(newConfig(Map.of(EnvVars.LOGGING_LEVEL, "FINE")));
+
+ java.util.logging.Handler installed =
+ Arrays.stream(sdkLogger.getHandlers())
+ .filter(h -> h instanceof MarketDataConsoleHandler)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("configureLogging did not install its handler"));
+
+ CapturingHandler capture = new CapturingHandler();
+ capture.setFormatter(installed.getFormatter());
+ capture.setLevel(Level.ALL);
+ sdkLogger.addHandler(capture);
+
+ Logger child = Logger.getLogger("com.marketdata.sdk.example");
+ child.fine("hello world");
+
+ assertThat(capture.formattedLines)
+ .as("end-to-end logging must produce the spec-mandated shape")
+ .anyMatch(
+ line ->
+ line.matches(
+ "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"
+ + " - com\\.marketdata\\.sdk\\.example - FINE - hello world"
+ + java.util.regex.Pattern.quote(System.lineSeparator())));
+ }
+
+ /**
+ * Minimal {@link java.util.logging.Handler} that runs the configured formatter and stashes the
+ * rendered string. Tests assert against {@link #formattedLines}; the raw records are not exposed
+ * because the formatter is what we actually care about end-to-end.
+ */
+ private static final class CapturingHandler extends java.util.logging.Handler {
+ final java.util.List
+ *
+ *
+ * Pass {@code false} for tests, latency-sensitive cold paths (e.g. serverless), or any context
+ * where a "pure" constructor without network IO is required. Demo mode (no token) also skips the
+ * call regardless of the flag.
+ *
* @param apiKey explicit API token, or {@code null} to resolve from {@code MARKETDATA_TOKEN} →
* {@code .env} → demo mode
* @param baseUrl override the API base URL, or {@code null} to resolve to {@link
@@ -75,8 +94,7 @@ public MarketDataClient() {
* @param apiVersion override the API version segment, or {@code null} to resolve to {@link
* Configuration#DEFAULT_API_VERSION}
* @param validateOnStartup whether to validate the token on construction by calling {@code
- * /user/} (SDK requirements §5). Pass {@code false} for short-lived runtimes where the
- * startup hit is undesirable.
+ * /user/} (SDK requirements §5). See the side-effects paragraph above.
*/
public MarketDataClient(
@Nullable String apiKey,
@@ -84,6 +102,10 @@ public MarketDataClient(
@Nullable String apiVersion,
boolean validateOnStartup) {
Configuration config = Configuration.loadFromProcess();
+ // SDK requirements §7: apply MARKETDATA_LOGGING_LEVEL (if set) before any LOG.log call
+ // below, otherwise the INFO line for client initialization would be filtered out when the
+ // user expected FINE.
+ configureLogging(config);
this.token = config.resolve(apiKey, EnvVars.TOKEN);
this.baseUrl =
trimTrailingSlash(
@@ -109,8 +131,14 @@ public MarketDataClient(
LOG.log(Level.FINE, "Token: {0}", Tokens.redact(this.token));
}
- // SDK requirements §5: validate on startup by default. The actual
- // /user/ call lands with the user resource; this flag is the seam.
+ // SDK requirements §5: validate the token at startup by hitting /user/. Skipped in demo
+ // mode (no token to validate) and when the caller opted out via the 4-arg constructor.
+ // Failure surfaces immediately as AuthenticationError (401) or NetworkError (unreachable
+ // server), so the caller never gets a half-constructed client.
+ if (this.validateOnStartup && !this.demoMode) {
+ this.transport.validateToken();
+ LOG.log(Level.FINE, "Token validated against /user/.");
+ }
}
// ---------------------------------------------------------------------
@@ -164,4 +192,91 @@ public void close() {
private static String trimTrailingSlash(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
+
+ // ---------------------------------------------------------------------
+ // SDK requirements §7 — logging level + formatter wiring
+ // ---------------------------------------------------------------------
+
+ /**
+ * Name of the package-level logger that controls every {@code com.marketdata.sdk.*} logger
+ * (children inherit unless they install their own handler).
+ */
+ static final String SDK_LOGGER_NAME = "com.marketdata.sdk";
+
+ /**
+ * Applies {@code MARKETDATA_LOGGING_LEVEL} (resolved through the config cascade) to the SDK's
+ * logger and installs a spec-shaped {@link MarketDataLogFormatter} on a dedicated handler if one
+ * isn't already installed. Idempotent — calling repeatedly only refreshes the level.
+ *
+ * {@code
+ * Logger.getLogger("com.marketdata.sdk").addHandler(myHandler);
+ * }
+ *
+ *
+ *
+ *
+ * These edges are accepted: warnings still reach the user (default JVM level is {@code INFO} ≤
+ * {@code WARNING}); they just don't carry the spec timestamp prefix. Spec-shaped output starts
+ * with the first record emitted after this method returns normally.
+ */
+ static void configureLogging(Configuration config) {
+ String requestedLevel = config.resolve(null, EnvVars.LOGGING_LEVEL);
+ if (requestedLevel == null || requestedLevel.isBlank()) {
+ return;
+ }
+ Level parsed;
+ try {
+ parsed = Level.parse(requestedLevel.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ LOG.warning("Ignoring invalid MARKETDATA_LOGGING_LEVEL=\"" + requestedLevel + "\"");
+ return;
+ }
+ Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME);
+ sdkLogger.setLevel(parsed);
+ if (!hasSdkHandler(sdkLogger)) {
+ Handler handler = new MarketDataConsoleHandler();
+ handler.setLevel(parsed);
+ sdkLogger.addHandler(handler);
+ // Bypass the root logger's default handlers; they would otherwise re-emit each record
+ // with the JVM default formatter and produce duplicate, badly-shaped lines.
+ sdkLogger.setUseParentHandlers(false);
+ } else {
+ for (Handler h : sdkLogger.getHandlers()) {
+ if (h instanceof MarketDataConsoleHandler) {
+ h.setLevel(parsed);
+ }
+ }
+ }
+ }
+
+ private static boolean hasSdkHandler(Logger logger) {
+ for (Handler h : logger.getHandlers()) {
+ if (h instanceof MarketDataConsoleHandler) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/src/main/java/com/marketdata/sdk/MarketDataConsoleHandler.java b/src/main/java/com/marketdata/sdk/MarketDataConsoleHandler.java
new file mode 100644
index 0000000..f8f4c1b
--- /dev/null
+++ b/src/main/java/com/marketdata/sdk/MarketDataConsoleHandler.java
@@ -0,0 +1,19 @@
+package com.marketdata.sdk;
+
+import java.util.logging.ConsoleHandler;
+
+/**
+ * Marker subclass of {@link ConsoleHandler} so {@link MarketDataClient#configureLogging} can tell
+ * its own handler apart from anything the host application or another library installed on the same
+ * logger. The previous discriminator inspected {@code getFormatter() instanceof
+ * MarketDataLogFormatter}, which is structurally accurate today but trips up the rare case of a
+ * consumer attaching {@code MarketDataLogFormatter} to a vanilla {@code ConsoleHandler}.
+ *
+ *
+ * 2026-05-12T13:42:18Z - com.marketdata.sdk.MarketDataClient - INFO - Initialized SDK 0.1.0
+ *
+ *
+ *