diff --git a/.gitignore b/.gitignore index a958c90..e8e6587 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,16 @@ Thumbs.db .env .env.local +# Python (examples/mock-server) +.venv/ +__pycache__/ +*.pyc + # Logs / coverage *.log hs_err_pid* replay_pid* + + +.claude/reviews/ +.claude/prompts/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b35e92..28f49db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Default retry attempts corrected from 3 to 4 (one initial + three retries) to match SDK requirements §9.3 ("max 3 retries, yielding 4 total attempts"). +- `.env` parser now strips trailing inline `# comment` markers (quote-aware: a + `#` inside single/double quotes or adjacent to value chars stays part of the + value). Previously a line like `MARKETDATA_TOKEN=abc # prod` produced the + literal value `abc # prod`, which passes `validateApiKey` (printable ASCII) + and surfaces later as a confusing `AuthenticationError` far from the .env + source that caused it. +- `RequestHeaders` canonical constructor now rejects a `null` `headers` map + with a clear `NullPointerException` naming the field, replacing the bare + `Map.copyOf(null)` NPE that left consumers hunting for the offending + argument. The wire-format deserializer additionally intercepts a top-level + JSON `null` body via `JsonDeserializer#getNullValue` and surfaces it as a + `ParseError` carrying the endpoint URL, status, and request id — preventing + a malformed `/headers/` response from manifesting as an opaque NPE further + down the call stack. ### Added - Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain, diff --git a/CLAUDE.md b/CLAUDE.md index 4c89e15..4aff09b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository state -This repo currently contains **documentation only** — no Java sources, no build scripts. Branch `00_base_setup` is the pre-implementation phase: all foundational technical decisions are being captured as ADRs *before* code lands. There is therefore nothing to build, lint, or test yet. When implementation starts, the build will be Gradle (Kotlin DSL) per ADR-003 — see "Locked-in tech stack" below. +This repo contains a working Java SDK in active development on branch `clean-architecture-restart`. Gradle 9.0 (Kotlin DSL) build per ADR-003; ~48 main + ~28 test source files; full CI matrix per ADR-002. The transport, retry, rate-limit, status-cache, and exception layers are wired; only the `utilities` resource façade is implemented today — stocks/options/funds/markets resources are still to come (see "Deliberately deferred" below). Sibling repo: `../api/` is the backend (Python/Django). The Python SDK lives at `../../sdk-py/` (referenced from ADRs). The cross-language `sdk-requirements.md` is referenced as `../sdk-requirements.md` from inside `docs/`; it is canonical but not committed in this repo. @@ -62,14 +62,17 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( **Already wired in:** - §1.1 client object — `MarketDataClient` with two public constructors: a no-arg one for production (everything from the cascade) and a 4-arg `(apiKey, baseUrl, apiVersion, validateOnStartup)` for tests and short-lived runtimes. All fields `final` (immutable). Default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor. -- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). The 4-arg constructor's parameters feed step 1; the no-arg constructor skips it and starts at step 2. -- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). +- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). `baseUrl` and `apiVersion` are normalized (trailing/leading slashes stripped) and validated (scheme http/https, host present, no query/fragment/user-info on `baseUrl`; `[A-Za-z0-9._-]+` on `apiVersion`) at resolution time, so misconfigured cascade inputs fail at construction with a clear message instead of producing malformed URLs later. +- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). `runStartupValidation` fires a single `GET /user/` (unversioned, like `/status/` and `/headers/`) via `utilities.validateAuth()` with `RetryPolicy.noRetry()`, skipping in demo mode; the no-retry policy keeps construction snappy on a slow/down API. - §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`. +- §7 logging — `MarketDataLogging.configure(...)` installs `CanonicalLogFormatter` (the spec's `{timestamp} - {logger_name} - {level} - {message}` shape) on `com.marketdata.sdk` and applies the level from `MARKETDATA_LOGGING_LEVEL`. Detects consumer-pre-configured loggers (handler attached or level set) and backs off entirely so embedding the SDK doesn't clobber existing logging setups. +- §8 rate-limit tracking — `RateLimitHeaders.parse` reads the four `x-api-ratelimit-*` headers per response (all-or-nothing: a partial header set returns null rather than emitting a snapshot with phantom zeros); `HttpTransport.latestRateLimits` keeps the latest snapshot, exposed via `client.getRateLimits()`. §10.3 preflight (`HttpTransport.checkRateLimitPreflight`) fails fast on `remaining=0`, but only while `now < reset` — once the reset window elapses the request goes through so the next response refreshes the snapshot. +- §9 retry/backoff — `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not. §9.4 `Retry-After`: parsed from response headers via `RetryAfterHeader.parse` (supports both delta-seconds and HTTP-date), attached to `ServerError`, and honored by `RetryPolicy.backoffDelay` as an override of the calculated exponential delay. §9.5 `/status/` cache: `StatusCache` (stale-while-revalidate, 270s refresh / 300s expiry) gates retries on services reported offline; `HttpTransport.cacheAllowsRetry` has a self-referential bypass for `/status/` itself so the cache can never block its own refresh. - §10 timeouts: `REQUEST_TIMEOUT = 99s` and `CONNECT_TIMEOUT = 2s` exposed as constants on `MarketDataClient`. Connect timeout is wired into the `HttpClient`; the per-request 99 s timeout is applied via `HttpRequest.Builder#timeout` in `HttpTransport.buildRequest`. - §12 concurrency: 50-permit `AsyncSemaphore` on `HttpTransport` with acquire/release wired around every dispatch. The custom semaphore replaces `java.util.concurrent.Semaphore` so `executeAsync` never parks the caller's thread on a full pool (ADR-007). -- §9 retry/backoff: `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not. +- §13.5 response object — `Response` wrapper exposes typed `data()`, `rawBody()` (defensive copy), `statusCode()`, `requestUrl()`, `requestId()`, format predicates (`isJson()`/`isCsv()`/`isHtml()`), `isNoData()`, and `saveToFile(Path)`. Every resource endpoint returns `Response` so consumers get a uniform surface regardless of format. - §15 packaging: SemVer, MIT `LICENSE`, `CHANGELOG.md` in Keep a Changelog format, version auto-detected via JAR manifest (`Implementation-Version`). -- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option). +- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option); query strings stripped from log output (logged as `/path?…`) so request params never persist to logs — exception context retains the full URI for diagnostic use; `EnvVars.systemLookup()` restricts reads to the declared `MARKETDATA_*` keys. - ADR-002 CI: split into four workflows. - `.github/workflows/pull-request.yml` — runs on PR `opened`/`synchronize`/`reopened` (no pre-PR push trigger by design). JDK 17 only. Runs `./gradlew build` (unit tests + Spotless + JaCoCo) and uploads coverage to Codecov. **Does not** run integration tests — those are handled by the on-demand workflow below. - `.github/workflows/main.yml` — runs only on `push` to `main`. Two jobs: `verify` does the full forward-compat matrix `{17, 21, 25}` for unit tests via `-PtestJdk=N`; `integration-tests` does a parallel matrix `{17, 21, 25}` against the live API. Both are mandatory for the merge to be considered successful. The JDK 17 matrix entry of `verify` also uploads coverage to Codecov as the new baseline that PRs compare against. `integration-tests` fails the build if `MARKETDATA_TOKEN` secret is absent (it is required on main). @@ -79,21 +82,14 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - `-PtestJdk=N` is wired to **all** `Test` tasks (`test` and `integrationTest`) via `tasks.withType().configureEach { javaLauncher.set(...) }` in `build.gradle.kts`, so the matrix flag works uniformly across unit and integration tests. - Coverage ratchet lives in `codecov.yml`: project status with `target: auto, threshold: 5%` (cannot drop >5 pp vs base branch) plus a patch-coverage requirement of 70 % on new code. Requires a `CODECOV_TOKEN` repo secret — without it the upload step fails because workflows pass `fail_ci_if_error: true`. -**Deliberately deferred (require the request/endpoint layer to land first):** -- §1.2 resource groupings (`client.stocks`, `client.options`, `client.funds`, `client.markets`, `client.utilities`). -- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding. -- §5 actual `/user/` startup validation call (the `validateOnStartup` flag is the seam; the call itself comes with the request layer). -- §7 honoring `MARKETDATA_LOGGING_LEVEL` and the spec's exact `{timestamp} - {logger_name} - {level} - {message}` format. Currently the SDK uses `java.util.logging` with default formatting; consumers can attach their own handler. -- §8 rate-limit header parsing, pre-flight check, request-scoped attachment. -- §9 `/status/` cache workflow and `Retry-After` header override (retry/backoff itself lives in `RetryPolicy` and is wired; what is missing is the `/status/` pre-check before retrying 501–599 and respecting the server-specified `Retry-After` over the calculated exponential backoff). -- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold. +**Deliberately deferred (require the per-resource layer to land first):** +- §1.2 resource groupings — only `client.utilities()` is wired today; `client.stocks`, `client.options`, `client.funds`, `client.markets` still to come. +- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for resources beyond utilities. The plumbing (`ParallelArrays.zip` helper for the parallel-arrays shape, `JsonResponseParser`, `Response` wrapper) is in place — each new endpoint just declares its fields and row builder. +- §8 request-scoped rate-limit attachment — today the snapshot is client-level (via `client.getRateLimits()`); attaching a per-response snapshot to `Response` is a small follow-up when a consumer needs it. +- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until the resource layer lands so the threshold meaningfully ratchets functional code, not just scaffolding. When picking up new work, check this list before reaching for the SDK requirements doc — most foundational rules are already encoded in code; missing pieces are deferred deliberately, not by accident. -**Known latent gaps:** -- `HttpTransport.buildUri` URL-encodes query-param values with `URLEncoder.encode(..., UTF_8)`, which is form-encoding semantics: spaces become `+`, not `%20`. Fine for today's typed params (dates, numerics) but a future endpoint that takes an arbitrary string (e.g. `symbol="BRK A"`) would round-trip differently against an RFC-3986-strict server. Switch to a path/query-segment-aware encoder when the first such param lands. Tracked as Issue #10 of the 2026-05-11 review. -- `Retry-After` server header is parsed and respected by neither `RetryPolicy` nor `HttpTransport`. Today every retry uses the calculated exponential backoff (`min(1s × 2^N, 30s)`). Implementing the override needs the response headers to reach `RetryPolicy.backoffDelay`, which today only sees the attempt index — most natural path is to surface a `Duration` on `ServerError` (or thread it through a separate channel) when 5xx responses carry the header. Follow-up of the §9 work. - ## Acceptance checklist `docs/java-sdk-requirements.md` ends with an "Acceptance Checklist" mapping each Java-specific requirements section to verifiable items. Treat it as the definition of done for v1: when implementing, work toward making each box checkable, and use it as a self-review pass before declaring a section complete. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0a2151b --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +# Convenience wrapper around ./gradlew and the consumer-test / mock-server. +# Run `make` (no args) or `make help` for the target list. + +CONSUMER_DIR := examples/consumer-test +MOCK_DIR := examples/mock-server + +.DEFAULT_GOAL := help + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +.PHONY: help +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m\033[0m\n\nTargets:\n"} \ + /^# ===/ { in_section = 1; next } \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \ + /^## ---/ { printf "\n\033[33m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) + @echo "" + @echo "Typical workflow:" + @echo " make publish # publish SDK to mavenLocal" + @echo " make mock-server # in another terminal" + @echo " make demo-config # in a third terminal" + +# --------------------------------------------------------------------------- +## --- SDK build --- +# --------------------------------------------------------------------------- + +.PHONY: build +build: ## Full build: unit tests + Spotless + JaCoCo (JDK 17) + ./gradlew build + +.PHONY: test +test: ## Unit tests only + ./gradlew test + +.PHONY: spotless +spotless: ## Apply code formatting + ./gradlew spotlessApply + +.PHONY: clean +clean: ## Clean all Gradle outputs (SDK + consumer-test) + ./gradlew clean + cd $(CONSUMER_DIR) && ./gradlew clean + +.PHONY: publish +publish: ## Publish SDK to ~/.m2 (prereq for any demo) + ./gradlew publishToMavenLocal + +# --------------------------------------------------------------------------- +## --- Mock server --- +# --------------------------------------------------------------------------- + +.PHONY: mock-server +mock-server: ## Start the FastAPI mock server (blocks, Ctrl+C to stop) + cd $(MOCK_DIR) && ./run.sh + +# --------------------------------------------------------------------------- +## --- Consumer demos (need `make publish` first) --- +# --------------------------------------------------------------------------- + +.PHONY: demo-quickstart +demo-quickstart: ## Idiomatic per-resource usage tour (live API; grows as resources land) + cd $(CONSUMER_DIR) && ./gradlew runQuickstart + +.PHONY: demo-live +demo-live: ## Live API smoke (needs MARKETDATA_TOKEN in examples/consumer-test/.env) + cd $(CONSUMER_DIR) && ./gradlew runLive + +.PHONY: demo-config +demo-config: ## Demo mode, cascade, validation (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig + +.PHONY: demo-exceptions +demo-exceptions: ## Every MarketDataException subtype (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runExceptions + +.PHONY: demo-retry +demo-retry: ## Retry, Retry-After, preflight (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runRetry + +.PHONY: demo-response +demo-response: ## Response surface features (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runResponse + +.PHONY: demo-concurrency +demo-concurrency: ## 50-permit semaphore (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runConcurrency + +.PHONY: demos-all +demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency diff --git a/build.gradle.kts b/build.gradle.kts index 24a5a70..6823eb4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,6 +138,10 @@ tasks.register("jacocoAggregateReport") { // main's last value) is enforced in CI — see .github/workflows/pull-request.yml // and .github/scripts/check-coverage-delta.py. Not enforced locally so that // dev iteration isn't blocked while coverage is in flux. +// +// SDK requirements §15.3 mandates 100% line coverage with explicit ignore +// comments on untestable lines. Target deferred until business resources land +// and the defensive-guards cleanup pass can run together. spotless { java { diff --git a/docs/REFACTOR_REVIEW_GUIDE.md b/docs/REFACTOR_REVIEW_GUIDE.md new file mode 100644 index 0000000..996e4b7 --- /dev/null +++ b/docs/REFACTOR_REVIEW_GUIDE.md @@ -0,0 +1,1602 @@ +# Refactor Review Guide — `clean-architecture-restart` + +This guide walks a reviewer through the SDK foundation introduced on the `clean-architecture-restart` branch — a 54-commit, 92-file refactor. It is organized by **flow**, not by file. Each section names the parts of the code that participate in one slice of behavior, explains how they fit together, and calls out the non-obvious decisions. + +If you've never read this codebase, start with §1 (topology) → §2 (sync request flow) → §10 (subtle corners). That covers the load-bearing shape in under an hour. + +All file:line citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. + +## Table of contents + +- [Running it locally](#running-it-locally) +1. [SDK topology](#1-sdk-topology) +2. [Sync request flow end-to-end](#2-sync-request-flow-end-to-end) +3. [Construction flow](#3-construction-flow) +4. [Retry, `Retry-After`, and `StatusCache`](#4-retry-retry-after-and-statuscache) +5. [Rate-limit tracking + preflight](#5-rate-limit-tracking--preflight) +6. [Concurrency (`AsyncSemaphore`)](#6-concurrency-asyncsemaphore) +7. [`Response` + JSON parsing](#7-responset--json-parsing) +8. [Sealed exception hierarchy](#8-sealed-exception-hierarchy) +9. [Configuration & logging](#9-configuration--logging) +10. [Subtle corners (issue-driven)](#10-subtle-corners-issue-driven) + +--- + +## Running it locally + +Most sections below end with a **Verify** note pointing at a runnable demo. The demos live under `examples/consumer-test/` and use the scriptable mock server under `examples/mock-server/`. The repo's `Makefile` wraps both — running anything from the SDK root looks like this: + +```bash +make help # list all targets +make publish # publish SDK to ~/.m2 — prereq for any demo + +# Each mock-server demo needs the mock running in a separate terminal: +make mock-server # terminal 2 (blocks; Ctrl+C to stop) +make demo-config # terminal 3 — runs DemoAndConfigApp +make demo-retry # ...etc + +make demo-live # hits api.marketdata.app (needs MARKETDATA_TOKEN, no mock needed) +make demos-all # runs the five mock-server demos back-to-back +``` + +| Demo target | Section it exercises | +|---|---| +| `make demo-quickstart` | Idiomatic per-resource usage. Today `utilities` only; grows as `stocks`/`options`/`funds`/`markets` land. The "what does consumer code look like" demo. | +| `make demo-live` | End-to-end plumbing against the real API (§2 sync flow, §9 cascade, §8 rate-limit snapshot) | +| `make demo-config` | §3 construction, §9 configuration & logging | +| `make demo-exceptions` | §8 sealed exception hierarchy | +| `make demo-retry` | §4 retry + `Retry-After` + §5 preflight | +| `make demo-response` | §7 `Response` surface | +| `make demo-concurrency` | §6 `AsyncSemaphore` cap | + +Underneath, `make demo-X` is `cd examples/consumer-test && ./gradlew runX`, and `make publish` is `./gradlew publishToMavenLocal` — the Makefile is just convenience. If a target misbehaves, `make -n ` prints the underlying command without running it. + +The mock server (`make mock-server`) is a FastAPI app on `127.0.0.1:8765` that the demo apps POST scripted responses to via `/_admin/script`, then make a real SDK call against the same host. That's how scenarios like "503 → 503 → 200 recovers in ~3 s" are made deterministic. + +For unit tests: + +```bash +make test # ./gradlew test +make build # full build: tests + Spotless + JaCoCo +./gradlew -PtestJdk=21 test # JDK 21 unit tests (also works for 25) +``` + +The `-PtestJdk=N` toolchain flag has no Make wrapper — pass it to gradlew directly. CI runs the `{17, 21, 25}` matrix on push to `main`. + +--- + +## 1. SDK topology + +### 1.1 Package layout + +All internal classes live in the root package `com.marketdata.sdk` per [ADR-007](adr/ADR-007-internal-api-encapsulation.md). The "internal" boundary is enforced by Java's package-private visibility rather than a subpackage rename — classes that consumers must not reach drop the `public` modifier. Two subpackages exist: + +- `com.marketdata.sdk.exception` — public sealed hierarchy plus its `ErrorContext` record. Subpackage chosen so an `import com.marketdata.sdk.exception.*` brings in the full taxonomy. +- `com.marketdata.sdk.utilities` — public response models (`ApiStatus`, `ServiceStatus`, `User`, `RequestHeaders`). Each future resource will get a parallel subpackage. + +The repo at HEAD has **53 files in `src/main`** (~3,700 LoC of production code) and **34 test classes**. + +### 1.2 Public API surface + +What a consumer can `import`: + +``` +com.marketdata.sdk.MarketDataClient +com.marketdata.sdk.UtilitiesResource (returned from client.utilities()) +com.marketdata.sdk.Response +com.marketdata.sdk.RateLimitSnapshot + +com.marketdata.sdk.exception.MarketDataException (sealed) + ├── AuthenticationError + ├── BadRequestError + ├── NotFoundError + ├── RateLimitError + ├── ServerError + ├── NetworkError + └── ParseError +com.marketdata.sdk.exception.ErrorContext + +com.marketdata.sdk.utilities.ApiStatus +com.marketdata.sdk.utilities.ServiceStatus +com.marketdata.sdk.utilities.User +com.marketdata.sdk.utilities.RequestHeaders +``` + +Everything else is package-private and therefore unreachable from consumer code. This includes — deliberately — `Configuration`, `EnvVars`, `Tokens`, `Version`, `RequestSpec`, `HttpTransport`, `HttpDispatcher`, `AsyncSemaphore`, `RetryPolicy`, `RetryExecutor`, `RetryAfterHeader`, `StatusCache`, `RateLimitHeaders`, `JsonResponseParser`, `ParallelArrays`, `Format`, `DateFormat`, `Mode`, `DemoMode`, `DotEnvLoader`, `MarketDataLogging`, `CanonicalLogFormatter`, `MarketDataDates`, `HttpStatusMapper`, `HttpResponseEnvelope`, `RequestHeadersDeserializer`, `UserDeserializer`. + +`UtilitiesResource` is `public final` so the *type* can be named in `client.utilities()` return positions, but its constructor is package-private — consumers can hold a reference but cannot instantiate one. + +### 1.3 Inventory by layer + +#### Lifecycle & configuration + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `MarketDataClient` | public | 205 | Entry point. Holds `Configuration`, `HttpTransport`, `UtilitiesResource`. Drives the §4 cascade, §5 startup probe, and the §7 logger configuration. | +| `Configuration` | record (pkg) | 233 | Resolved configuration: `apiKey?`, `baseUrl`, `apiVersion`, `loggingLevel?`, `dateFormat?`. Owns the cascade (`resolve`), normalization, and validation. | +| `EnvVars` | pkg | ~30 | The only place that knows the `MARKETDATA_*` env-var names. `systemLookup()` restricts reads to the allowed set. | +| `DotEnvLoader` | pkg | 99 | `.env` reader with explicit `allowedKeys` filter and buffered `Warning`s. | +| `DemoMode` | pkg | 11 | One-line predicate: `apiKey == null`. | +| `Version` | pkg | 19 | `sdkVersion()` — JAR-manifest lookup with a build-time-injected fallback. | +| `Tokens` | pkg | 24 | `redact(token)` — `***…***` (≤8) / `***…***ABCD` (>8). | +| `MarketDataLogging` | pkg | 158 | Installs `CanonicalLogFormatter`. First-install-wins; consumer-pre-config detection. | +| `CanonicalLogFormatter` | pkg | 58 | JUL `Formatter` enforcing the §7 line shape. | +| `MarketDataDates` | pkg | 37 | Epoch-seconds → `ZonedDateTime` in America/New_York. | +| `Format`, `DateFormat`, `Mode` | pkg enums | 27–38 | Wire-format / date-format / mode enums with `wireValue()` accessors. | + +#### Transport & dispatch + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `HttpTransport` | pkg | 411 | Orchestrates one request: build URL/request, preflight gate, dispatch, retry, route response. Owns `latestRateLimits`. | +| `HttpDispatcher` | pkg | 185 | Single-shot send under the `AsyncSemaphore`. Maps transport errors to `NetworkError`. | +| `HttpResponseEnvelope` | record (pkg) | 25 | Format-agnostic response carrier: `body[]`, `statusCode`, `requestId?`, `headers`, `url`. | +| `HttpStatusMapper` | pkg | 84 | Status code → `MarketDataException` subtype. | +| `RequestSpec` | record (pkg) | 134 | Declarative GET spec: `path`, `queryParams` (ordered), `format`, `versioned`. Builder. | + +#### Retry, rate limiting, concurrency + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `RetryPolicy` | pkg | 191 | `shouldRetry(cause, attempt)` and `backoffDelay(cause, attempt)`. Static factories `defaults()` and `noRetry()`. | +| `RetryExecutor` | pkg | 151 | Generic retry-on-failure orchestrator for `Supplier>`. | +| `RetryAfterHeader` | pkg | 64 | Parser for delta-seconds + RFC 1123 HTTP-date. | +| `StatusCache` | pkg | 167 | Stale-while-revalidate cache of `/status/`. Gates retries. | +| `RateLimitHeaders` | pkg | 53 | All-or-nothing parser for the four `x-api-ratelimit-*` headers. | +| `RateLimitSnapshot` | public record | ~10 | `limit`, `remaining`, `reset`, `consumed`. | +| `AsyncSemaphore` | pkg | 146 | Async-safe 50-permit limiter, FIFO waiter queue. | + +#### Parsing & response + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `JsonResponseParser` | pkg | 89 | One `ObjectMapper` per client; resources self-register their `SimpleModule`. Pre-checks empty body. | +| `ParallelArrays` | pkg | 271 | `zip(...)` + `listDeserializer(...)` factory + strict `Row` accessors. | +| `RequestHeadersDeserializer` | pkg | 27 | Hand-written for the `{headers: {...}}` shape. | +| `UserDeserializer` | pkg | 54 | Hand-written for the `/user/` shape. | +| `Response` | public | 150 | Typed `data()`, defensive `rawBody()`, format predicates, `isNoData()`, `saveToFile()`, redacted `toString()`. | + +#### Resources + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `UtilitiesResource` | public (pkg ctor) | 144 | Sync + async pair for `/status/`, `/user/`, `/headers/` (all unversioned). Registers wire-format module on the parser. | +| `ApiStatus`, `ServiceStatus`, `User`, `RequestHeaders` | public records | <30 ea. | Response models. | + +#### Exceptions + +| Class | Visibility | Lines | Responsibility | +|---|---|---:|---| +| `MarketDataException` | public sealed | 96 | Base. Holds `ErrorContext`. `getRequestUrl` returns redacted URL. `getSupportInfo` formats the multi-line dump. | +| `ErrorContext` | public record | 17 | `requestId?`, `requestUrl`, `statusCode`, `timestamp`. | +| 7 permits | public final | 15–45 ea. | Subtypes. `RateLimitError` and `ServerError` carry an extra `retryAfter` Duration. | + +### 1.4 Dependency arrows + +A simplified view (not every static dependency — just the load-bearing ones): + +``` + MarketDataClient + │ + ┌───────────┼───────────────────┐ + ▼ ▼ ▼ + Configuration HttpTransport UtilitiesResource ──── JsonResponseParser + │ │ │ │ + │ │ ├─→ HttpDispatcher ─→ AsyncSemaphore │ + │ │ ├─→ RetryExecutor ──→ RetryPolicy ─→ RetryAfterHeader + │ │ ├─→ StatusCache ──────┐ ▲ │ + │ │ ├─→ HttpStatusMapper │ │ │ + │ │ ├─→ RateLimitHeaders │ │ │ + │ │ └─→ HttpResponseEnvelope ParallelArrays + │ │ (record) │ + │ ▼ ▼ + │ latestRateLimits ◀──── RateLimitSnapshot Response + │ + DotEnvLoader, EnvVars, Tokens, MarketDataLogging, CanonicalLogFormatter +``` + +`MarketDataClient` is the only class that knows about all three of `Configuration`, `HttpTransport`, and `UtilitiesResource`. Everything else is one layer of abstraction below. + +The `StatusCache → MarketDataClient → ... → HttpTransport → StatusCache` cycle (the cache's fetcher calls a `/status/` request through the transport) is resolved by a deferred reference — see §3 below. + +--- + +## 2. Sync request flow end-to-end + +This section traces a single line of consumer code: + +```java +Response r = client.utilities().status(); +``` + +through every class it touches, ending at the typed `Response`. + +### 2.1 Sequence diagram + +```mermaid +sequenceDiagram + autonumber + participant C as Consumer + participant U as UtilitiesResource + participant T as HttpTransport + participant R as RetryExecutor + participant D as HttpDispatcher + participant S as AsyncSemaphore + participant H as java.net.http.HttpClient + participant SC as StatusCache + participant P as JsonResponseParser + participant PA as ParallelArrays + + C->>U: status() + U->>U: statusAsync()
build RequestSpec + U->>T: executeAsync(spec) + T->>T: build URI + HttpRequest + T->>R: execute(supplier, shouldRetry) + + rect rgba(180,210,255,0.15) + Note over R,SC: attempt N + R->>T: supplier.get(attemptIdx, prevCause) + T->>T: checkRateLimitPreflight(uri) + alt remaining=0 & now>R: failedFuture(RateLimitError) + else allow + T->>D: dispatch(request) + D->>S: acquire() + S-->>D: permit (fast or slow) + D->>H: sendAsync() + H-->>D: HttpResponse + D->>S: release() + D-->>T: response + T->>T: routeAndEnvelope() + T->>T: parse rate-limit headers,
update latestRateLimits + alt 2xx or 404 + T-->>R: HttpResponseEnvelope + else 4xx/5xx + T-->>R: throw MarketDataException + end + end + R->>R: shouldRetry? + opt retriable + cache allows + R->>SC: cache.check(uri) + R-->>R: schedule next attempt (delayedExecutor) + end + end + + R-->>T: CompletableFuture + T-->>U: same future + U->>P: parser.parse(env, ApiStatus.class) + P->>PA: ParallelArrays.zip(root, fields, rowBuilder) + PA-->>P: List + P-->>U: ApiStatus + U->>U: Response.wrap(data, env, format) + U-->>T: CompletableFuture> + T->>T: joinSync(future)
unwrap CompletionException + T-->>U: Response + U-->>C: Response +``` + +### 2.2 Step-by-step walk + +#### Entry — `UtilitiesResource` + +`UtilitiesResource.status()` at `src/main/java/com/marketdata/sdk/UtilitiesResource.java:126` is a one-liner: `return transport.joinSync(statusAsync())`. It's a sync wrapper around the async surface, satisfying ADR-006's sync+async parity rule. + +`statusAsync()` at line 120 builds a `RequestSpec`: + +```java +RequestSpec spec = RequestSpec.get("status").unversioned().build(); +return executeAndWrap(spec, ApiStatus.class); +``` + +`unversioned()` flips the `versioned` flag in the builder; `/status/` lives at the API root, not under `/v1/`. `executeAndWrap` (line 132) hands the spec to the transport and composes a `thenApply` that turns the raw envelope into a typed `Response`: + +```java +return transport + .executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); +``` + +#### Transport orchestration — `HttpTransport.executeAsync` + +The orchestrator lives at `HttpTransport.java:153`. The interesting variant is the private 2-arg overload at line 167: + +```java +private CompletableFuture executeAsync( + RequestSpec spec, RetryExecutor executor) { + URI uri = buildUri(spec); + HttpRequest request = buildHttpRequest(uri, spec.format()); + RetryPolicy policy = executor.policy(); + return executor.execute( + (attemptIdx, previousCause) -> { + if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) { + return CompletableFuture.failedFuture(preflight); + } + } + return dispatcher + .dispatch(request) + .thenApply(response -> routeAndEnvelope(response, uri)); + }, + (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); +} +``` + +The lambda passed to `executor.execute` is what `RetryExecutor` invokes per attempt. The retry predicate is a composition of the policy's own decision with the `StatusCache`'s veto power (§9.5). + +#### URL building — `buildUri` + +`buildUri(spec)` at `HttpTransport.java:335` assembles `baseUrl + "/" + (versioned ? apiVersion + "/" : "") + path + "/" + (params ? "?…" : "")`. Two non-obvious pieces: + +- Leading-slash defensiveness on `path` (line 340). `RequestSpec`'s Javadoc says paths have no leading slash, but a caller mistake would produce `baseUrl//v1//path` — strip defensively. +- Trailing-slash insertion (line 349). Every endpoint URL the API exposes ends in `/`; consumers who write `"status"` instead of `"status/"` shouldn't be surprised. + +Query encoding uses a custom `encodeQueryComponent` (line 378) that replaces `+` with `%20` after `URLEncoder.encode`. `URLEncoder` emits `application/x-www-form-urlencoded` (`+` for spaces), which strict servers reject in query strings — the replacement is the canonical patch. + +#### Preflight — `checkRateLimitPreflight` + +`checkRateLimitPreflight(uri)` at `HttpTransport.java:221` is §10.3: + +```java +RateLimitSnapshot snap = latestRateLimits.get(); +if (snap == null || snap.remaining() > 0) return null; // allow +Instant now = clock.instant(); +if (!now.isBefore(snap.reset())) return null; // reset has elapsed → allow +return new RateLimitError(...); // block +``` + +Two cases produce `null` (= allow): no snapshot yet, or the snapshot's reset time has passed. The second case is critical — without it, a single response carrying `remaining=0` would freeze the client forever (every subsequent request short-circuits, no response ever updates the snapshot). See §5 for the full reasoning. + +The preflight is bypassed entirely when the previous attempt was a server-hinted retry (line 184). See §10.11. + +#### Dispatch + permit — `HttpDispatcher.dispatch` + +`dispatch(request)` at `HttpDispatcher.java:55`: + +```java +CompletableFuture permit = permits.acquire(); +CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); +dispatched.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + permit.cancel(false); + } +}); +return dispatched; +``` + +`permits.acquire()` is the `AsyncSemaphore` from §6. It returns an *already-completed* future on the fast path (a permit was available) or a *pending* future on the slow path (the request waits in FIFO until a peer calls `release()`). Either way no thread is parked. + +`send(request)` at line 75 calls `httpClient.sendAsync(...)` and registers `whenComplete((r, t) -> permits.release())` so the permit is released exactly once. The pre-send `try/catch` (line 80) handles `sendAsync` throwing synchronously (malformed request, OOM): release the permit explicitly because the `whenComplete` would never fire if the future never formed. + +Transport-layer failures (anything that's not an `HttpResponse`) are mapped to `NetworkError` via the `handle(...)` block at line 105. The `unwrap` helper at line 182 peels one layer of `CompletionException`; deeper nesting is handled later in `RetryPolicy.hasIoExceptionInCauseChain` (§4 / §10.10). + +#### Response routing — `routeAndEnvelope` + +`routeAndEnvelope(response, uri)` at `HttpTransport.java:289` decides what the status code means. + +First, rate-limit headers are parsed and the `latestRateLimits` `AtomicReference` is updated if (and only if) the four headers arrived together: + +```java +RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); +if (parsed != null) latestRateLimits.set(parsed); +``` + +The `if (parsed != null)` is load-bearing — see §5. + +Then status routing (line 304): + +```java +if ((status >= 200 && status < 300) || status == 404) { + return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); +} +``` + +**404 is a success.** The API uses HTTP 404 as the carrier for `{"s":"no_data"}` envelopes, which mean "we have nothing for that query" — successful, not error. The body is handed to the parser like any 2xx. The parser sees `s:"no_data"` and returns the empty container (§7). The `Response` ends up with `isNoData() == true` (`Response.java:113`). + +For 4xx/5xx (line 307+), a `Retry-After` is parsed up front (line 309) and attached to the resulting exception so that `RetryPolicy.backoffDelay` can honor it on the next attempt: + +```java +Duration retryAfter = response.headers().firstValue("Retry-After") + .flatMap(v -> RetryAfterHeader.parse(v, now)) + .orElse(null); +MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); +``` + +The exception is logged with `safeUri(uri)` — the query-stripped URL — and thrown. `RetryExecutor`'s `whenComplete` catches it. + +If `HttpStatusMapper.map(...)` returns `null` (only possible for 2xx, already handled), a defensive `ServerError("Unmapped status …")` is thrown. Belt-and-suspenders: a future mapper edit can't silently swallow an unknown status. + +#### Retry envelope — `RetryExecutor.execute` + +`RetryExecutor.execute(AttemptSupplier, BiPredicate)` at `RetryExecutor.java:78`: + +```java +CompletableFuture result = new CompletableFuture<>(); +AtomicReference> currentAttempt = new AtomicReference<>(); +result.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + CompletableFuture inFlight = currentAttempt.get(); + if (inFlight != null && !inFlight.isDone()) inFlight.cancel(false); + } +}); +attempt(supplier, shouldRetry, 0, null, result, currentAttempt); +return result; +``` + +One cancellation handler installed once on the *outer* `result`. The `currentAttempt` reference tracks whichever attempt is in flight; cancelling `result` cancels the live one. Previous attempts are already done by the time the next one overwrites the reference, so this avoids accumulating one handler per attempt. + +`attempt(...)` at line 99 is the recursive worker. The body is intentionally small: + +```java +if (result.isDone()) return; // caller cancelled or already failed +CompletableFuture dispatched = supplier.get(attemptIdx, previousCause); +currentAttempt.set(dispatched); +if (result.isCancelled() && !dispatched.isDone()) { + dispatched.cancel(false); + return; +} +dispatched.whenComplete((value, error) -> { + if (result.isDone()) return; + if (error == null) { result.complete(value); return; } + Throwable cause = unwrap(error); + if (shouldRetry.test(cause, attemptIdx)) { + long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); + CompletableFuture.delayedExecutor(delayMs, MILLISECONDS) + .execute(() -> attempt(supplier, shouldRetry, attemptIdx + 1, cause, result, currentAttempt)); + } else { + result.completeExceptionally(cause); + } +}); +``` + +`delayedExecutor` is the key. No scheduled thread pool to manage — `ForkJoinPool.commonPool` runs the next attempt after the delay elapses. + +The `currentAttempt.set(dispatched)` line is followed by an *immediate* re-check of cancellation. There's a TOCTOU window between the outer `isDone()` check and the `set()` call; the cancellation handler observes `currentAttempt`, so if cancellation fires in that window, it sees the previous (already-done) attempt and doesn't cancel the new one. The re-check closes the race. + +#### Parser — `JsonResponseParser.parse` + +`parse(env, type)` at `JsonResponseParser.java:55` is two paths: + +1. Empty body (line 60) — fast-path `ParseError` with a précis message before Jackson is invoked. See §10.9. +2. Otherwise — `mapper.readValue(env.body(), type)`. Jackson reads the body and invokes the registered deserializer for `type`. For `ApiStatus.class`, that's the deserializer produced by `ParallelArrays.listDeserializer(...)` in `UtilitiesResource.wireFormatModule()` (`UtilitiesResource.java:51`). + +If Jackson throws `IOException` (malformed JSON, type mismatch, etc.), the parser wraps it in `ParseError` (line 80). The message uses `safeUri(env.url())` — query-stripped — so a routine `logger.error(parseError.getMessage())` doesn't leak `?token=`. + +#### Parallel-arrays decode — `ParallelArrays.zip` + +`zip(p, root, fields, rowBuilder)` at `ParallelArrays.java:69`: + +```java +String envelopeStatus = root.path("s").asText(""); +if ("error".equals(envelopeStatus)) { + throw new JsonMappingException(p, "API responded with error: " + errmsg); +} +if ("no_data".equals(envelopeStatus)) { + return List.of(); +} +// validate columns: every requested field must be a present, equal-length array +// then build rows via rowBuilder(new IndexedRow(arrays, i)) +``` + +`s:"error"` short-circuits to a `JsonMappingException` carrying the server-supplied `errmsg`. The parent parser catches the `IOException` and re-wraps as `ParseError`. The consumer ends up with `ParseError.getMessage()` containing the server's `errmsg` — actionable. + +`s:"no_data"` returns the empty list and the wrapper record's compact constructor copies it: `ApiStatus(List.of())`. The consumer's `Response.data().services()` is empty; `Response.isNoData()` is `true` (status was 404). + +Column validation enforces presence and equal length (lines 83–102). Any deviation throws `JsonMappingException` → `ParseError`. The `Row` accessors are strict by default — see §10.4. + +#### Response wrap — `Response.wrap` + +`Response.wrap(data, envelope, format)` at `Response.java:54` builds the immutable `Response`: + +```java +return new Response<>( + data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); +``` + +The constructor at line 35 clones `rawBody` defensively. The accessor `rawBody()` clones again on every call (line 67) — see §10.2 for why that's not paranoid. + +#### Sync unwrap — `HttpTransport.joinSync` + +Back in `UtilitiesResource.status()`, `transport.joinSync(future)` at `HttpTransport.java:264`: + +```java +try { + return future.join(); +} catch (CompletionException e) { + throw asRuntime(e.getCause(), clock); +} catch (CancellationException e) { + throw asRuntime(e, clock); +} +``` + +`asRuntime(...)` at line 399 has three branches: +- `MarketDataException` → return verbatim. +- `RuntimeException` → return verbatim. +- Anything else → wrap as `NetworkError` with a `forNoResponse` context. + +The first branch is the one that fires in practice — the SDK always wraps failures as `MarketDataException` before they reach here. The other two are defensive guardrails so a future bug that lets some other type through doesn't surface as a confusing `CompletionException`. + +### 2.3 Branches a reviewer should think about + +| Branch | Path | Outcome | +|---|---|---| +| 200 OK with parseable body | `routeAndEnvelope → JsonResponseParser → ParallelArrays → Response.wrap` | Typed `Response` with `isNoData() == false`. | +| 200 OK with `s:"error"` body | `JsonResponseParser` → `ParallelArrays` throws | `ParseError` with server's `errmsg` in the message. | +| 200 OK with truncated parallel arrays | `ParallelArrays` length-check fails | `ParseError`. | +| 200 OK with empty body | `JsonResponseParser` empty-body pre-check | `ParseError` "Empty response body…". | +| 404 + `s:"no_data"` | Route to success envelope, parser zips to `List.of()` | `Response` with `isNoData() == true`. | +| 401 | `HttpStatusMapper.map(401, ...)` → `AuthenticationError` → `joinSync` unwraps | `AuthenticationError` thrown sync. | +| 503 (single shot) | `routeAndEnvelope` throws `ServerError(503, ...)`; retry retries | If recovers within budget: `Response`. Otherwise `ServerError`. | +| ConnectException buried in `ExecutionException` | `HttpDispatcher` wraps in `NetworkError`; `RetryPolicy.hasIoExceptionInCauseChain` walks chain | Retried (§10.10). | +| Caller cancels the returned `CompletableFuture` | `RetryExecutor.whenComplete` propagates cancel to `currentAttempt` | `CancellationException`. | + +### 2.4 Verify locally + +```bash +make publish +make demo-live # hits api.marketdata.app — needs MARKETDATA_TOKEN +``` + +`LiveSmokeApp` prints `client.toString` (token redacted), runs `/status/` sync + async to prove ADR-006 parity, fires three async calls in parallel and reports the elapsed wall-time (should be ≈ the slowest single call, not the sum), and dumps the final rate-limit snapshot. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java`. + +--- + +## 3. Construction flow + +This section walks through `new MarketDataClient(...)`. The constructor is dense — every line earns its place. + +### 3.1 Flowchart + +```mermaid +flowchart TD + A[ctor entry] --> B[DotEnvLoader.load with ALLOWED_KEYS] + B --> C{Configuration.resolve
cascade + validate} + C -- throws IAE --> X[attach warnings as suppressed
+ rethrow] + C -- OK --> D[Configuration record] + D --> E[MarketDataLogging.configure] + E --> F[replay buffered warnings
through logger] + F --> G[log INFO 'initialized'
with token redacted] + G --> H[AtomicReference<StatusCache>
cacheRef] + H --> I[HttpTransport.withDefaults
uses cacheRef::get] + I --> J{partial-construction guard} + J -- try --> K[new JsonResponseParser] + K --> L[new UtilitiesResource
registers wire-format module] + L --> M[new StatusCache
cacheRef.set] + J -- throws --> Y[transport.close + addSuppressed
+ rethrow] + M --> N{validateOnStartup?} + N -- no --> Z[ctor returns] + N -- yes --> O{DemoMode.isDemo} + O -- yes --> P[log skip] --> Z + O -- no --> Q[utilities.validateAuth
noRetry policy] + Q -- 200 --> Z + Q -- 401 --> R[AuthenticationError
close + addSuppressed
rethrow] + Q -- other --> R +``` + +### 3.2 Step-by-step walk + +#### Public 4-arg constructor + +`MarketDataClient(apiKey, baseUrl, apiVersion, validateOnStartup)` at `MarketDataClient.java:28` just delegates to the package-private 6-arg constructor with the production seams: + +```java +this(apiKey, baseUrl, apiVersion, validateOnStartup, + EnvVars.systemLookup(), Configuration.DEFAULT_DOTENV_PATH); +``` + +The two extra params (`env` lookup and `.env` path) are the seams that let tests drive the cascade hermetically — no `System.getenv` reads, no real filesystem. + +#### Buffer-then-replay warnings + +`MarketDataClient.java:54`: + +```java +List pendingWarnings = new ArrayList<>(); +try { + this.config = Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); +} catch (RuntimeException e) { + attachWarningsAsSuppressed(e, pendingWarnings); + throw e; +} +``` + +`DotEnvLoader` runs *inside* `Configuration.resolve` — i.e. before `MarketDataLogging.configure(...)` has had a chance to install the SDK's logger. If `DotEnvLoader` logged its warnings directly, they'd land on an unconfigured JUL logger with the wrong shape, possibly invisible. Buffering them and replaying after `configure` is the only way to get §7-shaped warnings on every rung of the cascade. + +Issue #25 wired the failure path: if `Configuration.resolve` itself throws (typically `IllegalArgumentException` on a bad `baseUrl`/`apiVersion`/`apiKey`), the warnings are attached as suppressed exceptions on the primary cause. Without this, the consumer would lose the `.env` warning that explained *why* the config went wrong. + +#### Configuration cascade + +`Configuration.resolve(...)` at `Configuration.java:40` does five things: + +1. `DotEnvLoader.load(dotEnvPath, warnings, EnvVars.ALLOWED_KEYS)` — read `.env` if present, filtering to allowed keys only. +2. `pickFirst(explicit, env, dotEnv)` for nullable values (`apiKey`, `loggingLevel`, `dateFormat`). `pickFirstOrDefault` for non-nullable ones (`baseUrl`, `apiVersion`). +3. `normalizeBaseUrl` — strip trailing slashes. +4. `normalizeApiVersion` — strip leading/trailing slashes. +5. `validateBaseUrl`, `validateApiVersion`, `validateApiKey`. + +`validateBaseUrl` (line 125) rejects empty strings, non-parseable URIs, non-http(s) schemes, missing hosts, and the presence of query/fragment/user-info. The `user-info` check is included because someone pasting `https://user:pass@api.marketdata.app` into `baseUrl` is almost always confused about where credentials go. + +`validateApiVersion` (line 207) checks against the `[A-Za-z0-9._-]+` regex — a single URL-safe path segment. This rejects `"v1/extra"`, `"%2Fv1"`, spaces, etc. + +`validateApiKey` (line 189) — issue #23 — checks every character is printable ASCII (`[0x20, 0x7E]`). CR/LF would be rejected later by `HttpRequest.Builder#header` with a generic IAE; rejecting here gives a clear constructor-time message that names the offset. NUL and high-bit bytes are also rejected because they're almost always a copy-paste mishap. Demo mode (`apiKey == null`) is exempted. + +#### Logger configuration + +`MarketDataClient.java:71`: + +```java +MarketDataLogging.configure(config.loggingLevel()); +for (DotEnvLoader.Warning w : pendingWarnings) { + LOGGER.log(w.level(), w.message(), w.cause()); +} +LOGGER.info(() -> "MarketDataClient initialized: baseUrl=" + ... + token=Tokens.redact(...)); +``` + +After `configure` returns, every subsequent log line (including the replay of buffered warnings) emits in the canonical §7 format. The `INFO` line at the end records the resolved configuration — including the token, which is run through `Tokens.redact` (§9.3). + +See §9.2 for `MarketDataLogging.configure`'s internals (consumer-pre-config detection, etc.). + +#### Deferred StatusCache reference + +`MarketDataClient.java:90`: + +```java +AtomicReference cacheRef = new AtomicReference<>(); +this.transport = HttpTransport.withDefaults( + config.baseUrl(), config.apiVersion(), + "marketdata-sdk-java/" + Version.sdkVersion(), + config.apiKey(), + cacheRef::get); +``` + +This is the **chicken-and-egg solution** that §10.12 covers in detail. Briefly: the transport needs the cache (to gate retries) and the cache needs the transport (to fetch `/status/`). The `AtomicReference` lets us build the transport first with a `Supplier` that returns `null` until the cache is constructed. The transport handles `null` gracefully — `cacheAllowsRetry` short-circuits to `true` when the supplier returns `null` (`HttpTransport.java:240`). + +#### Partial-construction guard + +`MarketDataClient.java:105`: + +```java +try { + JsonResponseParser parser = new JsonResponseParser(); + this.utilities = new UtilitiesResource(transport, parser); + cacheRef.set(new StatusCache( + () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); +} catch (Throwable t) { + try { transport.close(); } catch (Throwable closeFailure) { t.addSuppressed(closeFailure); } + throw t; +} +``` + +From the line where `this.transport = ...` succeeds, the transport is a live `AutoCloseable` holding the shared `HttpClient` and the 50-permit `AsyncSemaphore`. If any subsequent line in the constructor throws, the caller never receives a `MarketDataClient` reference, their try-with-resources never fires, and the transport leaks until GC. The explicit close-and-rethrow makes that impossible. + +Today, no line below `this.transport = ...` is expected to throw — `JsonResponseParser` is trivial, `UtilitiesResource`'s constructor just registers a module, `StatusCache`'s constructor just stores references. But the guard exists because a future refactor of any of those constructors could break the invariant silently. + +`UtilitiesResource`'s constructor at `UtilitiesResource.java:27` calls `parser.registerModule(wireFormatModule())`, which is where Jackson learns how to decode `ApiStatus`, `User`, `RequestHeaders`. This must happen before any `parse(...)` call — satisfied because resources are constructed before any HTTP request is made. + +#### Startup validation + +`MarketDataClient.java:120`: + +```java +if (validateOnStartup) { + runStartupValidation(); +} +``` + +`runStartupValidation()` at line 160: + +```java +if (DemoMode.isDemo(config)) { + LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); + return; +} +try { + utilities.validateAuth(); +} catch (Throwable t) { + try { close(); } catch (Throwable closeFailure) { t.addSuppressed(closeFailure); } + throw t; +} +``` + +`utilities.validateAuth()` at `UtilitiesResource.java:110`: + +```java +void validateAuth() { + transport.joinSync( + executeAndWrap(RequestSpec.get("user").build(), RetryPolicy.noRetry(), User.class)); +} +``` + +Three deliberate choices: + +1. **`RetryPolicy.noRetry()`** — a single attempt. A slow/down API surfaces here within the 99 s request timeout instead of burning the default budget (~6.75 min worst case). Consumers who need a tighter ceiling pass `validateOnStartup = false` and probe themselves. +2. **Result discarded** — only the throw shape matters. 401 → `AuthenticationError`, network failures → `NetworkError`, etc. +3. **Package-private and intent-named** — not a public `/user/` endpoint (that's `utilities.user()` with a custom retry path). Sharing the codepath but not the name keeps "auth probe" and "fetch user data" semantically distinct in the source. + +The constructor-level catch (`MarketDataClient.java:169`) closes the transport on any failure so a partially-constructed client doesn't leak. Same suppressed-exception pattern as the partial-construction guard above. + +### 3.3 Cases to call out + +| Scenario | Outcome | +|---|---| +| All cascade rungs empty, `apiKey = null` | Demo mode. `validateOnStartup` skipped. Constructor returns. | +| `validateOnStartup = true` + 200 | Constructor returns OK. `latestRateLimits` is populated from the `/user/` response headers as a side effect. | +| `validateOnStartup = true` + 401 | `AuthenticationError` thrown from constructor. Transport closed. | +| `validateOnStartup = true` + network failure | `NetworkError` thrown from constructor. Transport closed. (After the no-retry single attempt — no backoff burnt.) | +| `apiKey` contains CRLF | `IllegalArgumentException` at construct time, no transport allocated yet. `.env` warnings attached as suppressed. | +| `baseUrl = "not-a-url"` | `IllegalArgumentException` at construct time. | +| `.env` is unreadable | Warning collected, attached as suppressed if any later step throws; otherwise replayed through the logger as a WARNING. | + +### 3.4 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-config # terminal 3 +``` + +`DemoAndConfigApp` walks each construction-time scenario in order: demo mode (when the cascade resolves no token), token redaction at the 8-char hinge (short and long), explicit-wins cascade, IAE on malformed `baseUrl`, CRLF rejection on `apiKey`, `validateOnStartup` success against a 200, and `validateOnStartup` failing against a scripted 401. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java`. + +--- + +## 4. Retry, `Retry-After`, and `StatusCache` + +This section covers the §9 retry contract end-to-end. + +### 4.1 Decision flowchart + +```mermaid +flowchart TD + A[attempt N fails] --> B{cause is
MarketDataException?} + B -- no --> NO[no retry] + B -- yes --> C{type?} + + C -- NetworkError --> D{IOException in
cause chain
depth ≤ 16?} + D -- no --> NO + D -- yes --> R1[retry candidate] + + C -- ServerError --> E{statusCode in
501-599?} + E -- no --> NO + E -- yes --> R1 + + C -- RateLimitError
BadRequestError
AuthenticationError
NotFoundError
ParseError --> NO + + R1 --> F{attempt+1 < maxAttempts?} + F -- no --> NO + F -- yes --> G{StatusCache.check
== ALLOW?} + G -- BLOCK --> NO + G -- ALLOW --> H[backoffDelay] + + H --> I{cause is ServerError
with Retry-After?} + I -- no --> J[exponential:
initial × 2^attempt
capped at maxBackoff] + I -- yes --> K{retryAfter ≤
MAX_RETRY_AFTER
10 min?} + K -- yes --> L[use retryAfter] + K -- no --> M[log warning,
use exponential] + J --> Z[schedule attempt N+1
via delayedExecutor] + L --> Z + M --> Z +``` + +### 4.2 `RetryPolicy.shouldRetry` + +`RetryPolicy.java:79`: + +```java +boolean shouldRetry(Throwable cause, int attempt) { + if (attempt + 1 >= maxAttempts) return false; + return isRetriable(cause); +} +``` + +`attempt` is zero-indexed: `attempt == 0` means the original call just failed and we're considering the *first* retry. The default `maxAttempts = 4` means up to 3 retries (attempts at indices 0, 1, 2 schedule retries; attempt at index 3 surfaces). + +`isRetriable(cause)` at line 136 has three branches: + +1. **Not a `MarketDataException`** → false. Conservative: an unknown failure type doesn't get retried. +2. **`NetworkError`** → `hasIoExceptionInCauseChain(net.getCause())`. See §10.10 — the walk is critical under HTTP/2. +3. **`ServerError`** → `status in [501, 599]`. 500 is explicitly excluded (deterministic server bug — retrying just hits the same crash). The `0` sentinel from `ErrorContext.forNoResponse` falls outside the range, so a `ServerError` without an HTTP code (impossible today but defensible) is correctly excluded. + +`RateLimitError`, `AuthenticationError`, `BadRequestError`, `NotFoundError`, `ParseError` all return `false` — §9 says never retry 4xx, and `ParseError` is deterministic. + +### 4.3 `RetryPolicy.backoffDelay` + +`RetryPolicy.java:91`: + +```java +Duration backoffDelay(Throwable cause, int attempt) { + if (cause instanceof ServerError server) { + Duration override = server.getRetryAfter().orElse(null); + if (override != null) { + if (override.compareTo(MAX_RETRY_AFTER) > 0) { + LOGGER.warning(() -> "Server-supplied Retry-After of ... exceeds cap ... ignoring"); + } else { + return override; + } + } + } + return backoffDelay(attempt); +} +``` + +The override-or-exponential decision tree. The `MAX_RETRY_AFTER = 10 minutes` cap (issue #21, §10.6) is what prevents pathological values from freezing the next retry for hours. + +`backoffDelay(int attempt)` at line 119 is the pure exponential calculation with two saturation guards (line 128 and the rearranged inequality at line 132) so the math doesn't silently wrap on large attempt indices. Today `maxAttempts = 4` makes this defensive — but a consumer test that constructs a `RetryPolicy(100, ...)` shouldn't get `Long`-overflow surprises. + +### 4.4 `RetryAfterHeader.parse` + +`RetryAfterHeader.java:33`: + +```java +static Optional parse(String value, Instant now) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) return Optional.empty(); + Optional asSeconds = parseSeconds(trimmed); + if (asSeconds.isPresent()) return asSeconds; + return parseHttpDate(trimmed, now); +} +``` + +Order matters: `parseSeconds` first, then `parseHttpDate`. RFC 1123 dates contain non-digits so `Long.parseLong` fails fast and falls through. + +Both parsers `Math.max(0L, ...)` the result. Negative deltas and past dates clamp to `Duration.ZERO` — "retry now". Malformed values produce `Optional.empty()`, which the transport at `HttpTransport.java:313` flows through `.flatMap(...).orElse(null)` so the `ServerError` ends up with `retryAfter = null` and `backoffDelay` falls back to exponential. + +This file is intentionally cap-free. The 10-min cap lives at the policy layer (`RetryPolicy`); the parser stays a pure RFC 7231 implementation that any other code in the SDK could reuse without inheriting policy decisions. + +### 4.5 `RetryExecutor` + +Covered structurally in §2.2 ("Retry envelope"). Two invariants worth restating: + +1. **One cancellation handler.** The handler is installed once on the outer `result`. Each attempt is tracked in the `currentAttempt` `AtomicReference`; cancelling `result` cancels the live one. No handler accumulation across retries. +2. **Re-check after `currentAttempt.set`.** A race exists between the outer `isDone()` and the `set(...)`: if cancellation fires inside the window, the cancellation handler sees the *previous* (done) attempt and doesn't cancel the *new* one. The immediate re-check (`RetryExecutor.java:119`) closes the race. + +The supplier is invoked with `(attemptIdx, previousCause)`. `previousCause` is what makes the server-hinted-retry bypass possible — see §10.11. + +### 4.6 `StatusCache` + +`StatusCache.check(uri)` at `StatusCache.java:61`: + +```java +Snapshot snap = snapshot.get(); +Instant now = clock.instant(); +boolean refreshNeeded = snap == null || + Duration.between(snap.fetchedAt, now).compareTo(REFRESH_THRESHOLD) >= 0; +if (refreshNeeded) { + triggerRefresh(); + snap = snapshot.get(); // issue #19 — see §10.7 +} +boolean usable = snap != null && Duration.between(snap.fetchedAt, now).compareTo(EXPIRY) < 0; +if (!usable) return Decision.ALLOW; +String status = lookupService(snap, uri); +return "offline".equals(status) ? Decision.BLOCK : Decision.ALLOW; +``` + +The TTL is stale-while-revalidate: + +- `age < 270s` (REFRESH_THRESHOLD) — serve cached, no refresh. +- `270s ≤ age < 300s` (EXPIRY) — serve cached, fire async refresh. +- `age ≥ 300s` or no cache — treat as "unknown" → ALLOW (§9.5 says unknown allows retries) and fire async refresh. + +`triggerRefresh()` at line 90 is gated by an `AtomicBoolean` so concurrent retries on different services don't fire N refreshes. The fetcher is consulted via a `Supplier>` — see §10.12 for why this indirection exists. + +If the refresh future fails, the previous snapshot survives (§9.5 says cache persists across failed refreshes). The failure is logged at WARNING level so operators can detect a `/status/` outage instead of wondering why the SDK keeps blocking retries. + +`lookupService` at line 130 is the longest-prefix-match (§10.5). + +### 4.7 The full retry composition + +In `HttpTransport.executeAsync` (line 197) the retry predicate is: + +```java +(cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri) +``` + +`policy.shouldRetry` is the §9.3 decision. `cacheAllowsRetry(uri)` is the §9.5 veto (with the self-bypass for `/status/`, §10.7). Both must say "yes" for a retry to proceed. + +### 4.8 Cases to call out + +| Scenario | Wall-time | Outcome | +|---|---|---| +| 503 → 503 → 200 | ~3 s (1 s + 2 s backoff) | `Response`. | +| 503 → 503 → 503 → 503 | ~7 s (1 + 2 + 4) | `ServerError`. | +| 503 with `Retry-After: 5` → 200 | ~5 s | `Response`. Server hint honored. | +| 503 with `Retry-After: 86400` → 200 | ~1 s | `Response`. Cap engaged, fell back to exponential. The 86400 is still visible on the `ServerError.getRetryAfter()` for consumer-side inspection of the first-attempt failure. | +| 500 (single shot) | ~0 s | `ServerError`. Not retriable. | +| `ConnectException` buried in `ExecutionException → CompletionException → ConnectException` | ~7 s after 3 retries | `NetworkError`. Retried thanks to the cause-chain walk (§10.10). | +| `ParseError` from a 200 with malformed body | ~0 s | `ParseError`. Never retried. | +| `/status/` reports service offline → 503 on the affected URI | ~0 s | `ServerError`. Retry vetoed by `StatusCache`. | +| Caller cancels `CompletableFuture` mid-backoff | — | `CancellationException`. The scheduled next attempt is never run because `attempt()` checks `result.isDone()` first. | + +### 4.9 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-retry # terminal 3 +``` + +`RetryBehaviorApp` scripts: `503 → 503 → 200` (expect ~3 s recovery), `503 + Retry-After: 3 → 200` (~3 s, exponential bypassed), `503 + Retry-After: ` (HTTP-date honored), `503 + Retry-After: 86400` (cap engaged, falls back to ~1 s exponential), and the §10.3 preflight test (snapshot reports `remaining=0` → second call fails in 0 ms with 0 server-side requests). The wall-clock printed for each scenario is the proof. Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java`. + +--- + +## 5. Rate-limit tracking + preflight + +### 5.1 The data path + +The server sets four headers on every successful response: + +``` +x-api-ratelimit-limit: # plan-wide cap +x-api-ratelimit-remaining: # remaining in window +x-api-ratelimit-reset: # when remaining resets +x-api-ratelimit-consumed: # consumed in window +``` + +`RateLimitHeaders.parse(headers)` at `RateLimitHeaders.java:28` reads all four. The reader is **all-or-nothing**: if any header is missing or unparseable, `parse` returns `null`. The reasoning lives in the file's class Javadoc: + +> Returns `null` when the four headers do not arrive together. … A partial delivery is a server-side rate-limit-tracking outage, not legitimate data. Returning `null` … preserves the caller's last-known-good snapshot instead of clobbering it with phantom zeros — those would otherwise trip `checkRateLimitPreflight` into blocking subsequent requests with a fake `remaining=0`. + +`HttpTransport.routeAndEnvelope` (line 293) updates the snapshot only when the parser returns non-null: + +```java +RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); +if (parsed != null) latestRateLimits.set(parsed); +``` + +The reverse path — `client.getRateLimits()` at `MarketDataClient.java:184` — just returns `transport.getLatestRateLimits()`, which is the `AtomicReference.get()`. + +### 5.2 The preflight gate + +`HttpTransport.checkRateLimitPreflight(uri)` at line 221: + +```java +RateLimitSnapshot snap = latestRateLimits.get(); +if (snap == null || snap.remaining() > 0) return null; // allow +Instant now = clock.instant(); +if (!now.isBefore(snap.reset())) return null; // reset elapsed → allow +ErrorContext context = ErrorContext.forNoResponse(uri.toString(), now); +return new RateLimitError( + "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); +``` + +Three branches: + +1. **No snapshot yet** (cold start, or every response so far lacked rate-limit headers) → allow. The next response will populate it. +2. **`remaining > 0`** → allow. +3. **`remaining == 0` and `now < reset`** → block with `RateLimitError`. The exception's `ErrorContext` uses `forNoResponse` because no HTTP round-trip happened; `statusCode == 0`, `requestId == null`. + +The "reset elapsed → allow" branch on line 227 is what prevents the **stuck-forever** failure mode: without it, a single response carrying `remaining=0` would short-circuit every subsequent request, no request would ever reach the wire, and the snapshot would never refresh. By letting the request through once `now >= reset`, the server's response refreshes the snapshot. If the server hasn't actually replenished credits yet it will reject with 429, which costs one round-trip — strictly better than locking the client out indefinitely. + +`RateLimitError` is non-retriable per `RetryPolicy.isRetriable`. The retry executor sees the failed-future returned by the supplier, the policy says no, and the consumer gets the error directly. + +### 5.3 The server-hinted-retry bypass + +`HttpTransport.executeAsync` (line 184): + +```java +if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) return CompletableFuture.failedFuture(preflight); +} +``` + +`isServerHintedRetry(previousCause)` at line 206: + +```java +return previousCause instanceof ServerError server && server.getRetryAfter().isPresent(); +``` + +When a retry was scheduled because the previous attempt returned `503 + Retry-After: 5`, the server has just told us "come back at `now + 5s`". That directive is more authoritative than our snapshot for this specific retry. Without the bypass, a snapshot reporting `remaining=0` with a far-future `reset` would veto the server-orchestrated backoff — the retry would never reach the wire. See §10.11. + +### 5.4 Cases to call out + +| State | Behavior | +|---|---| +| First-ever call from a new `MarketDataClient` | Snapshot is `null`. Preflight allows. Response (if successful and carrying headers) populates the snapshot. | +| Snapshot says `remaining = 10, reset = now+1h` | Allow. | +| Snapshot says `remaining = 0, reset = now+1h` | Block with `RateLimitError` instantly. Server sees zero additional requests until the second condition fails. | +| Snapshot says `remaining = 0, reset = now-1s` | Allow. The response refreshes the snapshot. | +| Retry of a 503 with `Retry-After: 5`, snapshot says `remaining = 0, reset = now+1h` | Allow (bypass). Server's directive prevails. | +| Response without rate-limit headers (e.g., 500 from internal server error) | Snapshot is not updated. Last-known-good survives. | + +### 5.5 Verify locally + +Same demo as §4 — the preflight check is the last scenario in `RetryBehaviorApp`: + +```bash +make publish +make mock-server # terminal 2 +make demo-retry # terminal 3 — scroll to "§10.3 preflight" output +``` + +The demo scripts one 200 carrying `x-api-ratelimit-remaining: 0` and a far-future `reset`, then issues a second call. Expectation: the second call fails with `RateLimitError` in ~0 ms and the server logs zero additional requests (visible via `/_admin/stats`). Source: the `preflightBlocksWhenSnapshotExhausted` method. + +--- + +## 6. Concurrency (`AsyncSemaphore`) + +### 6.1 Why not `j.u.c.Semaphore` + +`java.util.concurrent.Semaphore.acquire()` is blocking. The caller's thread parks until a permit is available. That's incompatible with ADR-006's async-first design: `executeAsync` must return a `CompletableFuture` immediately, even when the pool is exhausted, so a consumer's `client.utilities().statusAsync().thenApply(...)` chain doesn't accidentally pin a thread inside the SDK. + +`AsyncSemaphore.acquire()` returns a `CompletableFuture`. Fast path: a permit is available → already-completed future. Slow path: a permit isn't available → pending future enqueued FIFO; it completes when some peer calls `release()`. Either way, no thread parks. + +### 6.2 Invariants + +From the class Javadoc at `AsyncSemaphore.java:16`: + +1. **Every permit is accounted for exactly once.** A permit is either in `available` (free counter), held by an in-flight caller (will be `release()`d), or pending in the waiter queue (will be released by completing the waiter's future). Never two of those at once. +2. **`CompletableFuture.complete(...)` always runs outside the lock.** Completing a future runs the caller's attached callbacks synchronously on the releasing thread. Doing that with a lock held is a deadlock waiting to happen. + +### 6.3 Acquire + +`AsyncSemaphore.acquire()` at line 54: + +```java +synchronized (lock) { + if (closed) return CompletableFuture.failedFuture(closedException()); + if (available > 0) { + available--; + return CompletableFuture.completedFuture(null); + } + CompletableFuture waiter = new CompletableFuture<>(); + waiters.addLast(waiter); + return waiter; +} +``` + +Notice the lock is held only for the counter decrement / enqueue. The completed future and the new pending future are both constructed inside the lock, but they're returned to the caller, who attaches their callbacks after the lock is released. + +### 6.4 Release + +`AsyncSemaphore.release()` at line 73: + +```java +while (true) { + CompletableFuture next = null; + synchronized (lock) { + while (!waiters.isEmpty()) { + CompletableFuture w = waiters.pollFirst(); + if (!w.isDone()) { next = w; break; } + } + if (next == null) { available++; return; } + } + if (next.complete(null)) return; + // else: waiter was cancelled in the gap; loop and try the next one +} +``` + +Three things going on: + +1. **Drain stale waiters.** Inside the lock, skip any waiter that's already done (cancelled). `pollFirst` removes them so they don't sit in the queue forever. +2. **Transfer permit outside the lock.** `next.complete(null)` runs the caller's callbacks; we don't want our lock held during that. +3. **Outer loop on cancellation race.** Between `pollFirst` (inside lock) and `complete` (outside lock), the waiter could have been cancelled. `complete` returns `false` in that case; we loop and try the next waiter. + +### 6.5 Close + +`AsyncSemaphore.close()` at line 128 is idempotent. It drains the queue inside the lock and completes the drained waiters (with `CancellationException`) outside the lock. Permits already held by in-flight callers can still be `release()`d harmlessly — the counter accepts it. + +### 6.6 Integration with `HttpDispatcher` + +`HttpDispatcher.dispatch` at line 55 acquires a permit, composes with `send`, and registers a cancellation handler that propagates cancellation to the *permit*: + +```java +CompletableFuture permit = permits.acquire(); +CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); +dispatched.whenComplete((r, t) -> { + if (t instanceof CancellationException) permit.cancel(false); +}); +``` + +Without the permit cancellation, a slow-path waiter cancelled by the caller would stay live in the semaphore queue. `release()` would later "transfer" the permit by completing the waiter, but `thenCompose`'s function wouldn't run (its dependent is already cancelled), and `send` — which registers the `release()` `whenComplete` — would never fire. The permit would never come back. + +### 6.7 Cases to call out + +| Scenario | Behavior | +|---|---| +| 60 parallel async calls, mock server delays each 800 ms | Semaphore admits 50 immediately; the other 10 sit in FIFO. After ~800 ms the first batch releases and the 10 are admitted. Total wall-time ≈ 1.6 s. Server-side `peak_in_flight = 50`. | +| One slow caller, others fast | The slow call holds its permit; the fast ones go through the fast path on each release. | +| Caller cancels a slow-path waiter | Waiter is set done. `release()` skips it on the next pass. Net effect: zero permits leaked. | +| `MarketDataClient.close()` called mid-flight | `AsyncSemaphore.close()` rejects future `acquire()` calls with `CancellationException` and drains queued waiters. In-flight HTTP sends are not cancelled (until JDK 21 brings `HttpClient.close()` — see ADR-002). | + +### 6.8 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-concurrency # terminal 3 +``` + +`ConcurrencyApp` scripts 60 identical 800-ms-delayed responses, fires 60 `statusAsync()` calls in parallel, and after `allOf(...).join()` reads `/_admin/stats` from the mock server. Expectation: `peak_in_flight == 50` (exactly — not less, not more) and total wall-time ≈ 1.6 s (two batches of 50 + 10). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java`. + +--- + +## 7. `Response` + JSON parsing + +### 7.1 `Response` surface + +`Response` at `Response.java:26` is the carrier consumers see for every successful call. + +Constructor (line 35) is private; only the package-private static factory `wrap(...)` at line 54 builds instances. Resources call it from their `executeAndWrap` (`UtilitiesResource.java:132`): + +```java +return transport.executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); +``` + +Public accessors: + +| Method | Returns | Notes | +|---|---|---| +| `data()` | `T` | Never null. Typed result of `parser.parse(envelope, T.class)`. | +| `rawBody()` | `byte[]` | **Defensive copy on every call** (line 67 — `return rawBody.clone()`). The constructor also clones on the way in (line 43). See §10.2. | +| `statusCode()` | `int` | 200, 203, or 404 today. | +| `requestId()` | `String?` | Cloudflare `cf-ray`, or null. | +| `requestUrl()` | `URI` | Absolute URL. | +| `isJson()` / `isCsv()` / `isHtml()` | `boolean` | Mutually exclusive (one is true). | +| `isNoData()` | `boolean` | `statusCode == 404`. | +| `saveToFile(Path)` | `void` | Writes `rawBody` verbatim. `UncheckedIOException` on write failure. | +| `toString()` | `String` | Status + format + bytes + redacted URL. **Never includes `data`.** See §10.2 / §10.3. | + +The `Format` enum is package-private. Consumers query format via the boolean predicates, not the enum. That keeps `Format` free to grow new values without breaking compiled consumers (a `switch (response.format())` would otherwise be a source-compatibility hazard). + +### 7.2 `JsonResponseParser` + +`JsonResponseParser` at `JsonResponseParser.java:26` owns one `ObjectMapper` per `MarketDataClient`. Jackson mappers are thread-safe and expensive to construct, so we build one and reuse. + +The parser is **resource-agnostic**: it doesn't know about `User`, `ApiStatus`, etc. Each `*Resource` self-registers its wire-format deserializers in its constructor via `parser.registerModule(...)`. The registration must complete before the first `parse(...)` call — satisfied today because resources are constructed before any HTTP traffic. + +`parse(env, type)` at line 55: + +```java +if (env.body().length == 0) { + ErrorContext context = ErrorContext.forResponse(...); + throw new ParseError("Empty response body from " + safeUri(...) + " — server returned 0 bytes ...", context); +} +try { + return mapper.readValue(env.body(), type); +} catch (IOException e) { + ErrorContext context = ErrorContext.forResponse(...); + throw new ParseError("Failed to decode response from " + safeUri(...) + ": " + e.getMessage(), context, e); +} +``` + +The empty-body pre-check (issue #29) is §10.9. The `safeUri` in the error messages is §16's query-string redaction. + +### 7.3 `ParallelArrays` + +Most API endpoints return parallel arrays — N equal-length arrays of column values plus a leading `s` envelope status: + +```json +{ "s": "ok", + "symbol": ["AAPL", "MSFT"], + "price": [150.0, 400.0] } +``` + +`ParallelArrays.listDeserializer(fields, rowBuilder, wrapper)` at line 143 returns a `JsonDeserializer` that: +1. Reads the body as a `JsonNode`. +2. Calls `zip(p, root, fields, rowBuilder)` to produce `List`. +3. Calls `wrapper.apply(rows)` to produce the container record (e.g. `ApiStatus::new`). + +The factory collapses the ~30-line boilerplate (extend `JsonDeserializer`, read tree, call zip, build record) to three pieces: column names, row builder, container wrapper. `UtilitiesResource.wireFormatModule()` at line 51 is the canonical use site: + +```java +m.addDeserializer( + ApiStatus.class, + ParallelArrays.listDeserializer( + List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"), + row -> new ServiceStatus( + row.text("service"), + row.text("status"), + row.bool("online"), + row.dbl("uptimePct30d"), + row.dbl("uptimePct90d"), + MarketDataDates.marketTimeFromEpochSecond(row.lng("updated"))), + ApiStatus::new)); +``` + +Future endpoints with parallel-arrays bodies follow this exact shape: three lines for the column list, one row builder lambda, one container constructor reference. + +### 7.4 `ParallelArrays.zip` + +`zip(p, root, fields, rowBuilder)` at line 69: + +```java +String envelopeStatus = root.path("s").asText(""); +if ("error".equals(envelopeStatus)) { + throw new JsonMappingException(p, "API responded with error: " + errmsg); +} +if ("no_data".equals(envelopeStatus)) return List.of(); +// validate columns: each field must be a present, equal-length array +// then build rows: rows.add(rowBuilder.build(new IndexedRow(arrays, i))); +return rows; +``` + +Three envelope cases: + +- `s:"error"` — `JsonMappingException` carrying the server's `errmsg`. Caught and re-wrapped as `ParseError` by the parser. Consumer sees the server's diagnostic in the exception message. +- `s:"no_data"` — empty list. The container record's compact constructor copies it (`ApiStatus(List.of()) → services = List.copyOf(List.of())`). Combined with the 404 status, the consumer sees `response.isNoData() == true` and `response.data().services().isEmpty()`. +- Anything else (typically `"ok"`) — normal field validation runs. + +Field validation enforces presence and equal-length. Any deviation throws `JsonMappingException` → `ParseError`. The error messages name the failing column, so a server-side regression that drops `online` or returns mismatched lengths produces an actionable diagnostic. + +### 7.5 Strict `Row` accessors + +`Row.text(field)`, `.bool(field)`, `.dbl(field)`, `.lng(field)` all throw `JsonMappingException` if the cell is null, missing, or the wrong JSON type (`ParallelArrays.java:197+`). The reasoning lives in the class Javadoc at line 30: + +> The previous lenient behavior — substituting `""`, `false`, `0.0`, `0` for missing cells — masked real server bugs: e.g. a regression that dropped the `online` column would have silently flipped every service to `online=false`, propagating to `StatusCache` decisions and blocking retries across the board. + +If a future endpoint legitimately has nullable columns, `textOr(field, default)` overloads can be added then — explicitly, per-column. Pre-emptive lenience is rejected. + +### 7.6 Cases to call out + +| Body | Status | Outcome | +|---|---|---| +| Parallel-arrays `s:"ok"` | 200/203 | `Response` with typed `data()`. | +| `{"s":"no_data"}` | 404 | `Response` with empty container, `isNoData() == true`. No exception. | +| `{"s":"error", "errmsg":"…"}` | any | `ParseError` containing the server's `errmsg`. | +| Truncated arrays (one column shorter than the others) | 200 | `ParseError` with the offending column name. | +| Empty body (proxy stripped) | 200 | `ParseError` with the "Empty response body" message. | +| Malformed JSON | 200 | `ParseError` wrapping Jackson's `IOException`. | +| Body with the wrong type for a column (e.g. `"true"` string where boolean expected) | 200 | `ParseError` from the strict `Row` accessor. | + +### 7.7 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-response # terminal 3 +``` + +`ResponseFeaturesApp` exercises: `isJson/isCsv/isHtml` mutual exclusion, the 404 + `{"s":"no_data"}` success envelope (`isNoData() == true`), `rawBody()` defensive-copy (mutating the returned array doesn't affect a second call), `saveToFile(...)` round-trip, and `toString()` log-safety (no `data`, query string redacted). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java`. + +--- + +## 8. Sealed exception hierarchy + +### 8.1 The seven permits + +`MarketDataException.java:10`: + +```java +public abstract sealed class MarketDataException extends RuntimeException + permits AuthenticationError, + BadRequestError, + NotFoundError, + RateLimitError, + ServerError, + NetworkError, + ParseError { + // ... +} +``` + +The list is fixed at **seven**. Per [ADR-002](adr/ADR-002-minimum-jdk-version.md), the JDK-17 floor was chosen specifically so this hierarchy could be sealed — adding an 8th permit in a future major version must break consumer `switch` exhaustiveness at compile time. That's the contract a sealed type promises; adding a permit without amending ADR-002 would forfeit it. A reviewer who sees a PR with an 8th permit (or a removed one) should check for an accompanying ADR amendment. + +`RuntimeException` (not `Exception`) is the base — checked exceptions in resource façades would force consumers into ceremonial `try/catch` for every call. The sealed hierarchy gives them the same compile-time safety without the boilerplate. + +### 8.2 `ErrorContext` + +`ErrorContext.java:6`: + +```java +public record ErrorContext( + @Nullable String requestId, String requestUrl, int statusCode, Instant timestamp) { + + public static ErrorContext forResponse(String requestUrl, int statusCode, @Nullable String requestId, Instant timestamp); + public static ErrorContext forNoResponse(String requestUrl, Instant timestamp); +} +``` + +`forResponse` is for HTTP-level errors (4xx/5xx). `forNoResponse` is for failures that didn't produce a response (`NetworkError` from `ConnectException`, `RateLimitError` from preflight). The latter sets `statusCode = 0` and `requestId = null` — the `0` sentinel is what `RetryPolicy.isRetriable` checks against to exclude `ServerError(statusCode=0)` from its 501–599 retriable range. + +### 8.3 `MarketDataException` API + +| Method | Returns | Notes | +|---|---|---| +| `getContext()` | `ErrorContext` | Full context with the **raw** `requestUrl`. Use this when the consumer has discretion to log/process the full URI. | +| `getRequestId()` | `@Nullable String` | `cf-ray` header value, or null. | +| `getRequestUrl()` | `String` | **Query-redacted** URL (line 44). The query string is replaced by `?…`. Mirrors `safeUri` in dispatchers/parsers — every getter that might land in ambient logs respects §16. | +| `getStatusCode()` | `int` | 0 for `forNoResponse`. | +| `getTimestamp()` | `Instant` | When the SDK observed the failure. | +| `getExceptionType()` | `String` | Simple class name (`"ServerError"`, etc.) for logging / dashboarding. | +| `getSupportInfo()` | `String` | Multi-line dump (line 75); ready to paste into a support ticket. | + +`getSupportInfo()` formats with a fixed-width label column and a timestamp in `America/New_York`: + +``` +--- MARKET DATA SUPPORT INFO --- +request_id: 76a40b21d5e1c0a4-IAD +request_url: /user/?… +status_code: 401 +timestamp: 2026-05-21 09:12:34 +message: Authentication failed +exception_type: AuthenticationError +-------------------------------- +``` + +The Eastern timezone is hard-coded (`MarketDataException.java:90`). See §10.14. + +### 8.4 `HttpStatusMapper` + +`HttpStatusMapper.map(statusCode, context, retryAfter)` at `HttpStatusMapper.java:43`: + +```java +return switch (statusCode) { + case 400 -> new BadRequestError("Bad request", context); + case 401 -> new AuthenticationError("Authentication failed", context); + case 404 -> new NotFoundError("Not found", context); + case 429 -> new RateLimitError("Rate limit exceeded", context, null, retryAfter); + default -> mapByRange(statusCode, context, retryAfter); +}; +``` + +`mapByRange` at line 57 handles the broad buckets: + +- 500–599 → `ServerError` (retriable per `RetryPolicy.isRetriable`). +- 4xx (other than 401/404/429) → `BadRequestError` with the actual status in the message. +- 3xx → `BadRequestError` "Unhandled redirect" (the `HttpClient` is configured with `NORMAL` redirect following; a 3xx surviving means the redirect couldn't be followed). +- 1xx → `BadRequestError` "Unexpected informational response" (defensive; `HttpClient` handles `100 Continue` itself). +- Negative / >599 → `BadRequestError` "Unexpected HTTP status". + +**The `case 404 -> NotFoundError` is currently dead code.** `HttpTransport.routeAndEnvelope` short-circuits all 404 responses to the success envelope (line 304) before `map(...)` is consulted. Resolving this — either by requiring an `s:"no_data"` body before routing 404 as success, or by removing `NotFoundError` from the sealed permits via an ADR amendment — is a follow-up. See PR.md "Out of scope / known caveats". + +### 8.5 Consumer-side routing + +A consumer routes by the sealed hierarchy. With JDK 21+ pattern switches: + +```java +try { + client.utilities().user(); +} catch (MarketDataException e) { + String label = switch (e) { + case AuthenticationError a -> "AUTH"; + case BadRequestError b -> "BAD_REQUEST"; + case NotFoundError n -> "NOT_FOUND"; + case RateLimitError r -> "RATE_LIMITED (retryAfter=" + r.getRetryAfter() + ")"; + case ServerError s -> "SERVER (status=" + s.getStatusCode() + ")"; + case NetworkError n -> "NETWORK"; + case ParseError p -> "PARSE"; + }; + // routed +} +``` + +The switch is exhaustive: no `default` clause is needed because the sealed hierarchy is closed. On the SDK's minimum JDK 17, the same routing uses an `instanceof` chain. + +### 8.6 Cases to call out + +| Wire condition | Mapped exception | +|---|---| +| HTTP 401 | `AuthenticationError`. | +| HTTP 400 | `BadRequestError`. | +| HTTP 403, 422, 405, etc. | `BadRequestError` with the actual status in the message. | +| HTTP 429 + `Retry-After: 60` | `RateLimitError` with the parsed Duration on `getRetryAfter()`. **Not retried** by the policy. | +| HTTP 500 | `ServerError`. **Not retriable** (statusCode == 500 is not in [501, 599]). | +| HTTP 503 + `Retry-After: 5` | `ServerError` with `getRetryAfter().isPresent()` and `getStatusCode() == 503`. **Retriable**. | +| Preflight blocked (snapshot remaining=0) | `RateLimitError` with `statusCode == 0` and `requestId == null`. | +| Connection refused | `NetworkError` with the `ConnectException` as cause. | +| 200 + body Jackson can't parse | `ParseError` with the underlying `IOException` as cause. | + +### 8.7 Verify locally + +```bash +make publish +make mock-server # terminal 2 +make demo-exceptions # terminal 3 +``` + +`ExceptionsApp` round-trips each sealed permit through a scripted scenario: 401 → `AuthenticationError`, 400 → `BadRequestError`, 429 + `Retry-After` → `RateLimitError`, 500 → `ServerError` (1 server-side request, no retries), 503×4 → `ServerError` after ~7 s (3 retries with exponential backoff), malformed JSON → `ParseError`, empty body → `ParseError` with the precise message, connection refused → `NetworkError` after retries. The final scenario uses an `instanceof` chain over the sealed type to prove the routing surface (the JDK 21+ pattern-switch equivalent is in source comments as reference). Source: `examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java`. + +`NotFoundError` is currently unreachable end-to-end (see §8.4 — `HttpTransport.routeAndEnvelope` short-circuits all 404 to the success branch). The demo notes this explicitly and skips that scenario rather than fabricating it. + +--- + +## 9. Configuration & logging + +### 9.1 Cascade rungs + +Already covered structurally in §3.2 ("Configuration cascade"). The order is: **explicit → env var (`MARKETDATA_*`) → `.env` → default**. + +`Configuration.pickFirst(...)` (line 220) and `pickFirstOrDefault(...)` (line 229) walk the rungs in that order. The first non-null, non-blank candidate wins. Blank strings (`" "`) are treated as absent — a `.env` file with `MARKETDATA_TOKEN=` should not produce a blank token. + +`EnvVars.ALLOWED_KEYS` is the explicit whitelist passed to `DotEnvLoader.load(...)`. Any key in the `.env` that isn't on the list is silently skipped — that's the §16 minimization rule (the SDK doesn't read env vars it doesn't know about). + +`EnvVars.systemLookup()` returns a `Function` that only reads from the allowed set, with a `null` for unknown keys. SDK code that needs to read env vars goes through this function, never `System.getenv()` directly. + +### 9.2 `MarketDataLogging.configure` + +`MarketDataLogging.java:63`: + +```java +static void configure(@Nullable String levelSpec) { + Level requested = parseLevel(levelSpec); + if (configured.get()) { + // idempotency: first install wins + return; + } + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + if (sdkLogger.getHandlers().length > 0 || sdkLogger.getLevel() != null) { + // consumer pre-config detected — back off entirely + // DO NOT latch `configured` here + return; + } + if (!configured.compareAndSet(false, true)) return; // lost the race + Handler handler = new ConsoleHandler(); + handler.setFormatter(new CanonicalLogFormatter()); + handler.setLevel(Level.ALL); + sdkLogger.addHandler(handler); + sdkLogger.setUseParentHandlers(false); + sdkLogger.setLevel(requested); +} +``` + +Two paths matter: + +1. **First call, no consumer pre-config.** Install the SDK's `ConsoleHandler` + `CanonicalLogFormatter`. Set `useParentHandlers = false` so the JDK's default root handler doesn't re-emit each record with `SimpleFormatter`'s shape. Latch `configured` so subsequent calls are no-ops. +2. **Consumer pre-config detected.** If the consumer (or another library) already attached a handler or set a level on `com.marketdata.sdk` before `MarketDataClient` was constructed, the SDK installs nothing. Crucially, `configured` is **not** latched on this path. The consumer might later remove their handler / clear their level — a subsequent `configure(...)` call should be allowed to install the SDK defaults then. Latching here would freeze the SDK out for the lifetime of the process. + +`parseLevel(...)` at line 113 maps `DEBUG/INFO/WARNING/ERROR` (case-insensitive) to JUL levels (`FINE/INFO/WARNING/SEVERE`). Unknown values fall back to `INFO` with a logged warning — silent fallback was the worst of both worlds (consumer types something wrong, sees `INFO` output instead of the `DEBUG` they expected, with no breadcrumb). + +### 9.3 `Tokens.redact` + +`Tokens.java:16`: + +```java +static String redact(@Nullable String token) { + if (token == null || token.length() <= 8) return "***…***"; + return "***…***" + token.substring(token.length() - 4); +} +``` + +The 8-char hinge is the design decision. At ≤ 4 chars, the last-4 *is* the full token. At 5–7 chars, the last-4 is 57–80% of the value — still too leaky. At ≥ 9 chars, the last-4 is at most 44% of the token, enough material for a human to disambiguate which token is in use without enabling someone with log access to use it. + +The function is called from: +- `MarketDataClient.toString()` (line 199) and the INFO log (line 75). +- Tests that verify redaction. + +It is **not** called from exception messages — that path goes through `safeUri` instead, which strips entire query strings. Tokens never make it onto query strings via SDK code (they're always in `Authorization` headers), so there's no SDK-emitted message that needs `Tokens.redact` for a query-string token. The two surfaces handle disjoint risks. + +### 9.4 Query-string redaction + +`HttpDispatcher.safeUri(URI)` at `HttpDispatcher.java:146`: + +```java +String path = uri.getPath(); +if (path == null) return uri.toString(); // opaque URI fallback +return uri.getRawQuery() != null ? path + "?…" : path; +``` + +Used everywhere a URL might land in ambient logs: +- `HttpDispatcher` request/response log lines. +- `HttpTransport.routeAndEnvelope` exception log. +- `MarketDataException.getRequestUrl()` (via `redactQuery` mirroring the same convention — see §10.3). +- `Response.toString()` (line 147). +- `JsonResponseParser.parse` error message (line 67, 82). + +The full URI is preserved on `ErrorContext.requestUrl` for consumer-side diagnostic access via `e.getContext().requestUrl()`. Ambient logs ≠ exception context. + +### 9.5 `CanonicalLogFormatter` + +The §7 shape: `{timestamp} - {logger_name} - {level} - {message}`. Implementation at `CanonicalLogFormatter.java`. Tested by `CanonicalLogFormatterTest`. + +### 9.6 Cases to call out + +| Scenario | Behavior | +|---|---| +| No env vars, no `.env`, no explicit args | Demo mode (apiKey null). `baseUrl` and `apiVersion` fall through to defaults. INFO logger. | +| `MARKETDATA_BASE_URL` set, explicit `baseUrl = null` | env var wins. | +| `MARKETDATA_BASE_URL` set, explicit `baseUrl = "https://prod"` | explicit wins. | +| `.env` carries `MARKETDATA_TOKEN`, env var doesn't, no explicit | `.env` wins. | +| `.env` is unreadable | Warning collected. Logged after `configure(...)` runs; attached as suppressed if `resolve` throws downstream. | +| Consumer ran `Logger.getLogger("com.marketdata.sdk").addHandler(myHandler)` before `new MarketDataClient(...)` | SDK detects, installs nothing, `configured` stays `false`. | +| Consumer later removes their handler, then constructs a second client | Now SDK installs its handler — first-install wins from that point. | +| `MARKETDATA_LOGGING_LEVEL=lolwut` | `INFO` with a logged warning. | + +### 9.7 Verify locally + +Configuration cascade and the token-redaction hinge are exercised by `make demo-config` (same demo as §3.4 — `DemoAndConfigApp`). + +End-to-end logging coverage is not in a dedicated demo today: there is no `LoggingApp` that toggles `MARKETDATA_LOGGING_LEVEL` between runs and verifies the canonical-format output or the consumer-pre-config detection. A reviewer who wants to eyeball the logger shape can: + +```bash +MARKETDATA_LOGGING_LEVEL=DEBUG make demo-live +``` + +and inspect stderr for the `{timestamp} - com.marketdata.sdk - {level} - {message}` shape and the FINE-level retry attempts. The consumer-pre-config detection is covered by `MarketDataLoggingTest` in unit tests rather than a runnable demo. + +--- + +## 10. Subtle corners (issue-driven) + +These are the spots a reviewer would otherwise rabbit-hole on. Each one names the file, what looks weird, and why. + +### 10.1 Token redaction hinge at 8 characters + +`Tokens.java:17`. Tokens ≤ 8 chars are fully masked (`***…***`); >8 chars get the last-4 suffix (`***…***ABCD`). The hinge is set because for short tokens the suffix would be most of the value: at 4 chars it's the whole thing; at 5–7 it's 57–80%. >8 caps the suffix's share of the token at 44%. Reviewer: don't be tempted to "always show the last 4" — that defeats the whole point for short tokens. + +### 10.2 Defensive copy on both ends of `rawBody` + +`Response.java:43` (constructor clones in) and `Response.java:67` (getter clones out). Belt-and-suspenders. The wrap factory passes `envelope.body()` directly; if a later refactor accidentally lets the envelope's body be a mutable buffer, the constructor's clone is the firewall. The getter's clone is what prevents a consumer's `byte[] b = response.rawBody(); b[0] = 'X';` from poisoning the next `rawBody()` call. The `toString` redaction (§10.3) is a separate firewall on a separate surface. + +### 10.3 Query-string redaction in two layers + +There are two redaction surfaces: + +- **Ambient logs** — `HttpDispatcher.safeUri(URI)` (line 146). Used wherever the SDK *itself* logs a URL. Replaces the query string with `?…`. +- **Exception getter** — `MarketDataException.getRequestUrl()` (line 44, via `redactQuery(String)`). Used when a consumer's `logger.error("Failed: " + e.getRequestUrl())` would otherwise persist the raw URL. + +Both surfaces converge on the same shape (`path?…`) but live in different places because the URL types differ (`URI` for fresh dispatches, `String` for stored `ErrorContext.requestUrl`). `getContext().requestUrl()` still exposes the raw URL — that's the discretionary path for diagnostic code that knows what it's doing. + +### 10.4 Strict deserialization in `ParallelArrays.Row` + +`ParallelArrays.java:160+`. Every accessor throws `JsonMappingException` on null, missing, or wrong-type cells. The class Javadoc explains why: a previous lenient version silently substituted sentinel values, masking server-side regressions (a dropped `online` column flipping every service to `online=false`). A reviewer who's tempted to add `textOr(field, default)` overloads should do so per-column-per-endpoint, only when the field is *contractually* nullable on that endpoint — not as a global escape hatch. + +### 10.5 Status path canonicalization to trailing slash (issue #18) + +`StatusCache.java:130`. `lookupService` does longest-path-prefix matching. Without trailing-slash normalization, a key `/v1/stock` would falsely match `/v1/stocks/quotes/AAPL/` (path-component boundary not respected). Canonicalization happens at *snapshot construction* (line 161) so keys are stored with a trailing slash; the lookup also appends a slash to the input path before comparing. One malformed/truncated server-side entry can no longer block retries for an unrelated service. + +### 10.6 `Retry-After` 10-minute cap (issue #21) + +`RetryPolicy.java:44`. A compromised or buggy backend that emits `Retry-After: 9999999999` would otherwise freeze the next attempt for ~292 billion years inside `CompletableFuture.delayedExecutor`. The cap is intentionally generous (10 minutes) so legitimate "come back in an hour" hints would still… wait, no, anything above 10 min falls back to exponential. The raw value remains visible on `ServerError.getRetryAfter()` so consumers can decide for themselves (surface to a human, schedule a real cool-off via a job runner). The cap only controls the SDK's *automatic* wait. + +### 10.7 Re-check after `triggerRefresh` in `StatusCache` (issue #19) + +`StatusCache.java:69`. When a refresh is needed, `triggerRefresh()` fires the fetcher and returns immediately — production fetchers go through `HttpTransport`, which is async. But in tests, a stub fetcher returning `CompletableFuture.completedFuture(...)` completes *synchronously* — the `whenComplete` populating `snapshot` runs inside the same call. Without the post-trigger re-read, the local `snap` variable is still the null we captured before `triggerRefresh`, and `check` always answers ALLOW on a cold start even when the cache *now* says BLOCK. The fix is one line: re-read `snapshot.get()` after `triggerRefresh()`. Production path is unaffected (the future is genuinely async; the re-read just observes the still-null snapshot). + +### 10.8 Printable-ASCII validation of `apiKey` rejecting CRLF (issue #23) + +`Configuration.java:189`. Tokens with CR/LF embedded — usually from a `.env` file edited on Windows or copy-pasted from an email — would later be rejected by `HttpRequest.Builder#header` with a generic `IllegalArgumentException` from the bowels of `HttpClient`, far from the actual configuration source. Validating at constructor time produces a clear, sourced message: "apiKey contains an invalid character at offset N (code point 0xXX)". The rule (`[0x20, 0x7E]`) is permissive enough for every legitimate token shape while ruling out NUL, DEL, high-bit bytes, and CR/LF. + +### 10.9 Empty-body pre-check before Jackson (issue #29) + +`JsonResponseParser.java:60`. A zero-length body produces a generic `"No content to map"` from Jackson — diagnostically thin, often confusing in the presence of a body-stripping proxy. The pre-check produces a précis message: `"Empty response body from /user/?… — server returned 0 bytes (a proxy may have stripped the payload, or the endpoint replied without one)"`. The message names the actual symptom and the most common cause. + +### 10.10 Full IOException cause-chain walk for retry classification (issue #15) + +`RetryPolicy.java:173`. `HttpClient` under HTTP/2 multiplexing — particularly on certain JDK versions — can present an `IOException` nested under an `ExecutionException` or `CompletionException` wrapper that `HttpDispatcher`'s single-level `unwrap` doesn't peel. Without the walk, legitimate transport failures fall out of retry silently — the SDK loses §9 resilience under exactly the load conditions that need it. + +The walk is depth-capped at 16 and detects self-cycles. Both are defensive: `Throwable.getCause()` cycles are theoretically impossible but cheap to guard. + +### 10.11 Preflight bypass on server-hinted retry + +`HttpTransport.java:184` + `:206`. A retry following a `503 + Retry-After: 5` is server-orchestrated — the server has told us "come back at `now + 5s`". Our local rate-limit snapshot (whose `reset` may be unrelated and hours in the future) must not veto that directive. The bypass detects this exact case: `previousCause instanceof ServerError server && server.getRetryAfter().isPresent()`. Only ServerError-with-parsed-Retry-After qualifies — a generic 503 retry still goes through preflight. + +### 10.12 Deferred `StatusCache` construction via `AtomicReference` supplier + +`MarketDataClient.java:90` + `HttpTransport.java:60`. The cache's fetcher uses `utilities.statusAsync()`, which goes through `HttpTransport`. So: + +- Transport needs cache (to gate retries). +- Cache needs `utilities`. +- `utilities` needs transport. + +Chicken-and-egg. The resolution is `cacheRef::get` — a `Supplier` passed to the transport that returns `null` until the cache is constructed below. `HttpTransport.cacheAllowsRetry` (line 240) handles `null` gracefully by short-circuiting to `true`. This means: + +- During construction (before `cacheRef.set(...)`), the transport behaves as if there's no cache. +- After construction (`cacheRef.set(...)`), every subsequent call sees the cache. + +The startup validation (`runStartupValidation` → `utilities.validateAuth`) runs *after* `cacheRef.set(...)` but uses `RetryPolicy.noRetry()` anyway, so the cache wouldn't be consulted on its retry path even if it weren't there. + +### 10.13 `joinSync` unwraps `CompletionException` + +`HttpTransport.java:264`. `CompletableFuture.join()` wraps any failure as `CompletionException`. Per ADR-006 the SDK's sync contract is to surface `MarketDataException` directly. `joinSync` catches `CompletionException`, calls `asRuntime(e.getCause(), clock)`, and re-throws. + +`asRuntime(cause)` (line 399) has three branches: + +- `cause` is `MarketDataException` → return it (the common path). +- `cause` is some other `RuntimeException` → return it (defensive — shouldn't happen in production). +- Anything else → wrap as `NetworkError` with `forNoResponse` context. + +The two non-`MarketDataException` branches are unreachable from the public API today; they exist so a future bug doesn't surface as a confusing `CompletionException` to the consumer. + +### 10.14 `getSupportInfo` timestamps hard-coded to America/New_York + +`MarketDataException.java:90`: + +```java +private static final DateTimeFormatter EASTERN_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("America/New_York")); +``` + +The market-data API operates in Eastern time. Support staff reading a pasted `getSupportInfo()` dump should be able to correlate against their tooling without timezone math. The `Instant` is preserved on `ErrorContext.timestamp()` for consumers who want UTC. This is a deliberate divergence from "always use UTC in interfaces" — the support-info dump is for humans, not machines, and the humans live in ET. diff --git a/examples/consumer-test/.gitignore b/examples/consumer-test/.gitignore new file mode 100644 index 0000000..ec5c286 --- /dev/null +++ b/examples/consumer-test/.gitignore @@ -0,0 +1,17 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +out/ +.vscode/ + +# OS +.DS_Store + +# Local config (may contain MARKETDATA_TOKEN) +.env +.env.local diff --git a/examples/consumer-test/README.md b/examples/consumer-test/README.md new file mode 100644 index 0000000..ccf9915 --- /dev/null +++ b/examples/consumer-test/README.md @@ -0,0 +1,112 @@ +# consumer-test + +A collection of small runnable apps that exercise every consumer-facing +behavior of `marketdata-sdk-java`. Each app stands alone — pick the one that +matches the scenario you want to see, run it, read the console output. + +Lives under `examples/` rather than as a `src/test` source set on purpose: it +consumes the SDK as an *external* artifact (via `mavenLocal`), so the demos +exercise exactly the shape a published JAR exposes — no accidental package- +private leaks, no access to internal seams. + +## One-time setup + +```bash +# 1. Publish the SDK to your local Maven cache. Run from the SDK root +# (two directories up). The Makefile wraps it: +cd ../.. +make publish + +# 2. (For runLive only) put your token in this directory's .env: +echo "MARKETDATA_TOKEN=your-token-here" > examples/consumer-test/.env +``` + +## Running + +From the SDK root, the easy path is `make` (see `make help` for the full list): + +```bash +make demo-quickstart # idiomatic per-resource tour — live API, no mock +make demo-live # full plumbing smoke — live API, no mock +make demo-config # config, validation, demo mode — needs mock server +make demo-exceptions # every MarketDataException subtype — needs mock server +make demo-retry # retry, Retry-After, preflight — needs mock server +make demo-response # Response features — needs mock server +make demo-concurrency # 50-permit semaphore — needs mock server +make demos-all # the five mock-server demos back-to-back +``` + +Or directly from this directory, bypassing the Makefile: + +```bash +./gradlew tasks --group "consumer demos" # list all apps +./gradlew runLive # same as `make demo-live` +./gradlew runDemoConfig # etc. +``` + +Apps that say "needs mock server" require the mock running in another +terminal. Easiest: + +```bash +make mock-server # from the SDK root +# or, equivalently: +cd ../mock-server && ./run.sh +``` + +Without it the demo fails fast with a clear "server not reachable" message. + +## What each app shows + +| App | Scenario | What you should see | +|---|---|---| +| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers). | +| **LiveSmokeApp** | The happy path against the real API | client.toString redacted; sync + async parity on status/user/headers; parallel calls completing in ≈ slowest-single-call wall-time; final rate-limit snapshot populated | +| **DemoAndConfigApp** | Construction-time behavior | demo-mode skip; §16 token redaction (short ≤8 → full; >8 → ***…***ABCD); cascade (explicit wins); IAE on invalid baseUrl / CRLF API key; validateOnStartup 200 vs 401 paths | +| **ExceptionsApp** | Every sealed exception subtype | 401 → AuthenticationError; 400 → BadRequestError; 429 → RateLimitError (+ Retry-After); 500 → ServerError (no retry); 503×4 → ServerError after ≈7s; malformed JSON → ParseError; empty body → ParseError (§29 fix message); connection refused → NetworkError after retries; ADR-002 sealed routing via instanceof | +| **RetryBehaviorApp** | The §9 retry contract | 503→503→200 recovers in ≈3s; Retry-After delta overrides exponential; Retry-After HTTP-date honored; pathological Retry-After (1 day) capped at 10 min — falls back to exponential; §10.3 preflight blocks the 2nd request when snapshot reports remaining=0 (0ms wall-time, 0 server-side requests) | +| **ResponseFeaturesApp** | §13.5 Response surface | isJson / isCsv / isHtml mutually exclusive; 404 + `{"s":"no_data"}` returns successfully with isNoData=true; rawBody() is a defensive copy (consumer mutations don't leak); saveToFile() writes verbatim; toString() omits data + redacts query (§16) | +| **ConcurrencyApp** | §12 / ADR-007 50-permit semaphore | 60 parallel calls; server observes peak in-flight = exactly 50; total wall-time ≈ 2× per-call delay (two batches of 50+10) | + +## Adding a section to QuickstartApp + +`QuickstartApp` is the only app in this directory designed to be **extended** as +the SDK grows. The other six prove a fixed contract; this one is the running +catalog of "what each resource looks like in consumer code". + +When a new resource lands on the SDK: + +1. Open `src/main/java/com/marketdata/consumer/QuickstartApp.java`. +2. Uncomment the matching placeholder line in `main(...)` (e.g. + `// stocksExamples(client);`). +3. Implement `xxxExamples(MarketDataClient client)` following the + `utilitiesExamples` shape: one `Console.step` + one short SDK call + one + `Console.ok` per typical use case. Catch `AuthenticationError` separately + when the endpoint needs a token, so the demo stays runnable in demo mode. +4. Keep each example to **3–5 lines of SDK code** — the goal is "what you'd + copy-paste into your own app", not exhaustive coverage. Edge cases belong + in the other demos. + +## How the mock server fits + +The mock server (FastAPI, `../mock-server/`) is what makes the +non-live demos deterministic. Each demo POSTs a list of scripted responses +to `/_admin/script`, then makes its real SDK call against the same host — +the server's catch-all pops exactly the scripted response. Apps that need to +see specific status codes, headers (Retry-After, x-api-ratelimit-*), or +timing behavior depend on this scripting. + +The control plane (`/_admin/*`) is hit with a plain `java.net.http.HttpClient`, +not the SDK — keeping the SDK's surface uncluttered. + +> Note: `MockServerControl` forces HTTP/1.1 because uvicorn's HTTP/2 upgrade +> handling drops POST bodies during the negotiation. The SDK itself stays on +> its ADR-004 HTTP/2 default — only the admin client downgrades. + +## Caveats + +- The local `.env` has a token that's been used during development. If the + token is no longer valid against api.marketdata.app, `runLive` will show + `AuthenticationError` on calls that need it (status/ stays public). +- "Demo mode" (no token at all) is hard to see if you have a token in your + env or `.env` — the cascade picks it up. The demo detects this and prints + a skip note rather than constructing a misleading client. diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts new file mode 100644 index 0000000..8de5ec1 --- /dev/null +++ b/examples/consumer-test/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + application +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { + implementation("com.marketdata:marketdata-sdk-java:0.1.0-SNAPSHOT") +} + +// Default `./gradlew run` lands on the live-API smoke. The other apps are +// reachable via the named tasks below — each one is its own self-contained +// scenario walk-through. +application { + mainClass = "com.marketdata.consumer.LiveSmokeApp" +} + +// Each demo gets its own JavaExec task in the same Gradle group so +// `./gradlew tasks --group "consumer demos"` lists them all. +val demoApps = mapOf( + "runQuickstart" to ("com.marketdata.consumer.QuickstartApp" to + "Idiomatic per-resource usage. Grows as new resources land."), + "runLive" to ("com.marketdata.consumer.LiveSmokeApp" to + "Live API smoke (needs MARKETDATA_TOKEN)."), + "runDemoConfig" to ("com.marketdata.consumer.DemoAndConfigApp" to + "Demo mode, configuration cascade, validation. Needs mock server."), + "runExceptions" to ("com.marketdata.consumer.ExceptionsApp" to + "Round-trip each MarketDataException subtype. Needs mock server."), + "runRetry" to ("com.marketdata.consumer.RetryBehaviorApp" to + "Retry policy, Retry-After header, preflight gate. Needs mock server."), + "runResponse" to ("com.marketdata.consumer.ResponseFeaturesApp" to + "Response surface: predicates, isNoData, rawBody, saveToFile, toString. Needs mock server."), + "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to + "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server.") +) + +demoApps.forEach { (taskName, app) -> + val (mainClassName, taskDescription) = app + tasks.register(taskName) { + group = "consumer demos" + description = taskDescription + mainClass = mainClassName + classpath = sourceSets["main"].runtimeClasspath + // Inherit stdio so the demo's println output is visible in the console + // and matches what a real consumer would see when they run their own app. + standardInput = System.`in` + } +} + diff --git a/examples/consumer-test/gradle/wrapper/gradle-wrapper.jar b/examples/consumer-test/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/examples/consumer-test/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties b/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..cea7a79 --- /dev/null +++ b/examples/consumer-test/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/consumer-test/gradlew b/examples/consumer-test/gradlew new file mode 100755 index 0000000..f3b75f3 --- /dev/null +++ b/examples/consumer-test/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/consumer-test/gradlew.bat b/examples/consumer-test/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/examples/consumer-test/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/consumer-test/settings.gradle.kts b/examples/consumer-test/settings.gradle.kts new file mode 100644 index 0000000..88ac292 --- /dev/null +++ b/examples/consumer-test/settings.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "consumer-test" + +dependencyResolutionManagement { + repositories { + // mavenLocal first so the SNAPSHOT we just published is found before + // hitting Maven Central (where it doesn't exist). + mavenLocal() + mavenCentral() + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java new file mode 100644 index 0000000..77a4abf --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java @@ -0,0 +1,93 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Response; +import com.marketdata.sdk.utilities.ApiStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * §12 / ADR-007 concurrency: the SDK's AsyncSemaphore holds at most 50 + * in-flight HTTP requests. This demo fires 60 calls in parallel against the + * mock server, asks each one to hang for 800 ms, and then reads + * /_admin/stats — the {@code peak_in_flight} the server observed should be + * exactly 50. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Each scenario runs once for {@link CallMode#SYNC} and once for {@link CallMode#ASYNC} so we - * satisfy SDK requirements §13's "tests must cover both sync and async variants for every endpoint" - * against the real wire. - */ -class MarketsStatusIT { - - @ParameterizedTest - @EnumSource(CallMode.class) - void todayStatusReturnsAtLeastOneEntry(CallMode mode) { - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = mode.statusNoArgs(client.markets()); - - // The endpoint always returns at least one entry for "today" — even on weekends/holidays - // there's a row with status="closed". - assertThat(status.days()).isNotEmpty(); - assertThat(status.days().get(0).date()).isNotNull(); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void historicalRangeReturnsExpectedDays(CallMode mode) { - LocalDate from = LocalDate.now().minusDays(7); - LocalDate to = LocalDate.now().minusDays(1); - - try (var client = new MarketDataClient(null, null, null, false)) { - MarketStatus status = mode.statusForRange(client.markets(), from, to); - - assertThat(status.days()).hasSizeBetween(1, 7); - assertThat(status.days()) - .allSatisfy(d -> assertThat(d.date()).isBetween(from.minusDays(1), to.plusDays(1))); - } - } -} diff --git a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java index 7945108..b58b240 100644 --- a/src/main/java/com/marketdata/sdk/AsyncSemaphore.java +++ b/src/main/java/com/marketdata/sdk/AsyncSemaphore.java @@ -1,7 +1,10 @@ package com.marketdata.sdk; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; /** @@ -29,6 +32,7 @@ final class AsyncSemaphore { private final Object lock = new Object(); private final Deque> waiters = new ArrayDeque<>(); private int available; + private boolean closed; AsyncSemaphore(int permits) { if (permits < 0) { @@ -43,9 +47,15 @@ final class AsyncSemaphore { *

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

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

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

Completion of drained waiters runs outside the lock for the same reason {@link + * #release()} does it that way: completing a future runs callbacks synchronously, and we never + * want those running with our lock held. + */ + void close() { + List> drained; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + drained = new ArrayList<>(waiters); + waiters.clear(); + } + for (CompletableFuture w : drained) { + w.completeExceptionally(closedException()); + } + } + + private static CancellationException closedException() { + return new CancellationException("AsyncSemaphore is closed"); + } } diff --git a/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java new file mode 100644 index 0000000..94e737e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/CanonicalLogFormatter.java @@ -0,0 +1,58 @@ +package com.marketdata.sdk; + +import java.time.format.DateTimeFormatter; +import java.util.logging.Formatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +/** + * JUL {@link Formatter} producing the canonical SDK log format mandated by §9.1: + * + *

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

Two normalizations matter: + * + *

    + *
  • Timestamp: rendered in {@code America/New_York} with millisecond precision + * and the offset, matching the date-handling convention from §13.4. Looks like {@code + * 2026-05-19T14:23:45.123-04:00}. + *
  • Level: JUL's native level names ({@code FINE}, {@code SEVERE}) are mapped + * back to the spec's vocabulary ({@code DEBUG}, {@code ERROR}). Anything below {@link + * Level#FINE} also collapses to {@code DEBUG}; anything above {@link Level#SEVERE} to {@code + * ERROR}. + *
+ */ +final class CanonicalLogFormatter extends Formatter { + + private static final DateTimeFormatter TS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); + + @Override + public String format(LogRecord record) { + String timestamp = TS_FORMAT.format(record.getInstant().atZone(MarketDataDates.MARKET_ZONE)); + return timestamp + + " - " + + record.getLoggerName() + + " - " + + levelLabel(record.getLevel()) + + " - " + + formatMessage(record) + + System.lineSeparator(); + } + + static String levelLabel(Level level) { + int n = level.intValue(); + if (n <= Level.FINE.intValue()) { + return "DEBUG"; + } + if (n < Level.WARNING.intValue()) { + return "INFO"; + } + if (n < Level.SEVERE.intValue()) { + return "WARNING"; + } + return "ERROR"; + } +} diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index a2dbc6f..aaa2bf3 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -1,107 +1,233 @@ package com.marketdata.sdk; -import java.io.IOException; -import java.nio.file.Files; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; -/** - * Resolves SDK configuration values per the cascade in SDK requirements §4: {@code explicit value → - * MARKETDATA_* env var → .env file in CWD → built-in default}. - * - *

The single canonical construction path is {@link #loadFromProcess()}, which snapshots the live - * environment and the {@code .env} file once. The constructor is strictly private — there is no - * production-callable backdoor for injecting arbitrary maps. Tests reach the private constructor - * via reflection (see {@code ConfigurationTest}); this is by design so a developer can't - * accidentally take a shortcut around the canonical load path. - */ -final class Configuration { +record Configuration( + @Nullable String apiKey, + String baseUrl, + String apiVersion, + @Nullable String loggingLevel, + @Nullable String dateFormat) { - public static final String DEFAULT_BASE_URL = "https://api.marketdata.app"; - public static final String DEFAULT_API_VERSION = "v1"; - private static final Path DEFAULT_DOTENV_PATH = Paths.get(".env"); + static final String DEFAULT_BASE_URL = "https://api.marketdata.app"; + static final String DEFAULT_API_VERSION = "v1"; + static final Path DEFAULT_DOTENV_PATH = Path.of(".env"); - private final Map systemEnv; - private final Map dotEnv; + private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); + private static final Pattern API_VERSION_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); - private Configuration(Map systemEnv, Map dotEnv) { - this.systemEnv = Map.copyOf(systemEnv); - this.dotEnv = Map.copyOf(dotEnv); + /** + * Convenience overload that discards any {@link DotEnvLoader.Warning}s. Used by tests and any + * call site that does not need to replay them through a freshly-configured logger. + */ + static Configuration resolve( + @Nullable String explicitApiKey, + @Nullable String explicitBaseUrl, + @Nullable String explicitApiVersion, + Function env, + Path dotEnvPath) { + return resolve(explicitApiKey, explicitBaseUrl, explicitApiVersion, env, dotEnvPath, w -> {}); + } + + static Configuration resolve( + @Nullable String explicitApiKey, + @Nullable String explicitBaseUrl, + @Nullable String explicitApiVersion, + Function env, + Path dotEnvPath, + Consumer warnings) { + Map dotEnv = DotEnvLoader.load(dotEnvPath, warnings, EnvVars.ALLOWED_KEYS); + String apiKey = pickFirst(explicitApiKey, env.apply(EnvVars.TOKEN), dotEnv.get(EnvVars.TOKEN)); + String baseUrl = + pickFirstOrDefault( + DEFAULT_BASE_URL, + explicitBaseUrl, + env.apply(EnvVars.BASE_URL), + dotEnv.get(EnvVars.BASE_URL)); + String apiVersion = + pickFirstOrDefault( + DEFAULT_API_VERSION, + explicitApiVersion, + env.apply(EnvVars.API_VERSION), + dotEnv.get(EnvVars.API_VERSION)); + String loggingLevel = + pickFirst(env.apply(EnvVars.LOGGING_LEVEL), dotEnv.get(EnvVars.LOGGING_LEVEL)); + String dateFormat = pickFirst(env.apply(EnvVars.DATE_FORMAT), dotEnv.get(EnvVars.DATE_FORMAT)); + String normalizedBaseUrl = normalizeBaseUrl(baseUrl); + String normalizedApiVersion = normalizeApiVersion(apiVersion); + validateBaseUrl(normalizedBaseUrl); + validateApiVersion(normalizedApiVersion); + validateApiKey(apiKey); + return new Configuration( + apiKey, normalizedBaseUrl, normalizedApiVersion, loggingLevel, dateFormat); } /** - * Production factory: snapshots {@code System.getenv()} and reads {@code ./.env} once. Call - * during client construction. + * Strip trailing slashes from {@code baseUrl} so {@code HttpTransport.buildUri} can append {@code + * "/" + apiVersion + "/" + path} unconditionally. A user-supplied {@code + * "https://api.marketdata.app/"} would otherwise produce a double-slash like {@code + * "https://api.marketdata.app//v1/..."} that some routers reject and others silently canonicalize + * — either way, an annoying source of "looks right but isn't" failures. */ - public static Configuration loadFromProcess() { - return new Configuration(System.getenv(), readDotEnvFile(DEFAULT_DOTENV_PATH)); + static String normalizeBaseUrl(String raw) { + String trimmed = raw.trim(); + int end = trimmed.length(); + while (end > 0 && trimmed.charAt(end - 1) == '/') { + end--; + } + return trimmed.substring(0, end); } - /** Cascade: explicit → system env → .env → {@code null}. */ - public @Nullable String resolve(@Nullable String explicit, String envKey) { - if (isPresent(explicit)) { - return explicit; + /** + * Strip leading and trailing slashes from {@code apiVersion} so the segment composes cleanly + * regardless of how the user spelled it ({@code "v1"}, {@code "/v1"}, {@code "v1/"}, {@code + * "/v1/"}). + */ + static String normalizeApiVersion(String raw) { + String trimmed = raw.trim(); + int start = 0; + int end = trimmed.length(); + while (start < end && trimmed.charAt(start) == '/') { + start++; } - String fromSystem = systemEnv.get(envKey); - if (isPresent(fromSystem)) { - return fromSystem; + while (end > start && trimmed.charAt(end - 1) == '/') { + end--; } - String fromDotEnv = dotEnv.get(envKey); - return isPresent(fromDotEnv) ? fromDotEnv : null; + return trimmed.substring(start, end); } - /** Same as {@link #resolve} but returns {@code defaultValue} when the cascade yields nothing. */ - public String resolveOrDefault(@Nullable String explicit, String envKey, String defaultValue) { - String resolved = resolve(explicit, envKey); - return resolved != null ? resolved : defaultValue; - } - - private static boolean isPresent(@Nullable String value) { - return value != null && !value.isBlank(); + /** + * Validate that {@code baseUrl} (already normalized — no trailing slashes, no surrounding + * whitespace) is a usable HTTP origin. The point is to fail at construction with a clear message + * instead of letting {@link java.net.http.HttpClient} surface a cryptic {@code + * IllegalArgumentException} the first time a request is sent. + * + *

Rules: + * + *

    + *
  • Non-empty (post-normalize {@code "////"} collapses to empty). + *
  • Parseable as a {@link URI}. + *
  • Scheme is exactly {@code http} or {@code https} — schemes like {@code file:}, {@code + * ftp:}, or {@code javascript:} have no business here. + *
  • Host is present (rules out scheme-only inputs like {@code "https://"}). + *
  • No query, fragment, or user-info — those belong on a request, not the origin, and their + * presence is almost always a copy-paste mistake that would mangle the constructed URL. + *
+ */ + static void validateBaseUrl(String baseUrl) { + if (baseUrl.isEmpty()) { + throw new IllegalArgumentException( + "baseUrl must not be empty; expected an http or https URL like " + DEFAULT_BASE_URL); + } + URI uri; + try { + uri = new URI(baseUrl); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' is not a valid URI: " + e.getMessage(), e); + } + String scheme = uri.getScheme(); + if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(java.util.Locale.ROOT))) { + throw new IllegalArgumentException( + "baseUrl '" + + baseUrl + + "' must use scheme http or https (got " + + (scheme == null ? "" : scheme) + + ")"); + } + if (uri.getHost() == null) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' is missing a host (e.g. api.marketdata.app)"); + } + if (uri.getRawQuery() != null) { + throw new IllegalArgumentException( + "baseUrl '" + baseUrl + "' must not contain a query string"); + } + if (uri.getRawFragment() != null) { + throw new IllegalArgumentException("baseUrl '" + baseUrl + "' must not contain a fragment"); + } + if (uri.getRawUserInfo() != null) { + throw new IllegalArgumentException( + "baseUrl '" + + baseUrl + + "' must not contain user-info — credentials belong on the" + + " request, not the origin"); + } } /** - * Reads a {@code .env}-style file: lines like {@code KEY=value}, {@code #} for comments, - * surrounding single or double quotes stripped. Package-private so tests can target an arbitrary - * {@link Path} (e.g. inside a JUnit {@code @TempDir}) instead of CWD. + * Validate {@code apiVersion} (already normalized — no leading/trailing slashes, no surrounding + * whitespace) as a single, URL-safe path segment. Rejects anything outside {@code [A-Za-z0-9._-]} + * — that's permissive enough for {@code v1}, {@code v2}, {@code v1.0}, {@code beta-1}, etc., + * while ruling out embedded slashes ({@code "v1/extra"}), spaces, already percent-encoded values + * ({@code "%2Fv1"}), and path-traversal tokens ({@code ".."} fails because {@code .} alone is + * allowed but the result becomes a literal {@code ".."} segment — that's still legitimate enough + * to send and the server will reject it; the regex's job is just to keep us from emitting + * malformed URLs). + */ + /** + * Issue #23: reject API keys with characters that would later be rejected by {@link + * java.net.http.HttpRequest.Builder#header} ({@code IllegalArgumentException} on CR/LF) or carry + * the smell of a copy-paste mishap (embedded NUL, control chars, high-bit bytes). Failing here + * gives the caller a clear message at construct time; without the check, a token loaded from a + * {@code .env} with stray {@code \r\n} surfaces as a generic {@code IllegalArgumentException} + * from {@code HttpClient} on the very first request — far from the actual configuration source. + * + *

Rule: every character must be printable ASCII ({@code [0x20, 0x7E]}). That covers every + * legitimate token shape ({@code letters/digits/.-_+=/}) while ruling out CR/LF/NUL, DEL, and any + * accidentally pasted high-bit byte from a non-UTF-8 file. Demo mode (apiKey == null) is + * preserved untouched. */ - static Map readDotEnvFile(Path path) { - if (!Files.isRegularFile(path)) { - return Map.of(); + static void validateApiKey(@Nullable String apiKey) { + if (apiKey == null) { + return; // demo mode — no token to validate } - Map result = new HashMap<>(); - try { - for (String raw : Files.readAllLines(path)) { - String line = raw.trim(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - int eq = line.indexOf('='); - if (eq < 1) { - continue; - } - String key = line.substring(0, eq).trim(); - String value = stripQuotes(line.substring(eq + 1).trim()); - result.put(key, value); + for (int i = 0; i < apiKey.length(); i++) { + char c = apiKey.charAt(i); + if (c < 0x20 || c > 0x7E) { + throw new IllegalArgumentException( + "apiKey contains an invalid character at offset " + + i + + " (code point 0x" + + Integer.toHexString(c) + + "). Tokens must be printable ASCII; check for stray CR/LF or non-UTF-8 bytes in" + + " the source (env var, .env file, or constructor argument)."); } - } catch (IOException ignored) { - return Map.of(); } - return Map.copyOf(result); } - private static String stripQuotes(String value) { - if (value.length() < 2) { - return value; + static void validateApiVersion(String apiVersion) { + if (apiVersion.isEmpty()) { + throw new IllegalArgumentException( + "apiVersion must not be empty; expected a path segment like " + DEFAULT_API_VERSION); } - char first = value.charAt(0); - char last = value.charAt(value.length() - 1); - if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { - return value.substring(1, value.length() - 1); + if (!API_VERSION_PATTERN.matcher(apiVersion).matches()) { + throw new IllegalArgumentException( + "apiVersion '" + + apiVersion + + "' must match [A-Za-z0-9._-]+ (a single URL-safe path segment)"); } - return value; + } + + private static @Nullable String pickFirst(@Nullable String... candidates) { + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + return candidate; + } + } + return null; + } + + private static String pickFirstOrDefault(String fallback, @Nullable String... candidates) { + String picked = pickFirst(candidates); + return picked != null ? picked : fallback; } } diff --git a/src/main/java/com/marketdata/sdk/DateFormat.java b/src/main/java/com/marketdata/sdk/DateFormat.java new file mode 100644 index 0000000..8211440 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DateFormat.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk; + +/** + * Date/time serialization format for response payloads. Controlled via {@code ?dateformat=}. + * + *

    + *
  • {@link #UNIX} — epoch seconds (default). + *
  • {@link #TIMESTAMP} — ISO-8601-style timestamp string. + *
  • {@link #SPREADSHEET} — Excel/Sheets-compatible serial date number. + *
+ */ +public enum DateFormat { + UNIX("unix"), + TIMESTAMP("timestamp"), + SPREADSHEET("spreadsheet"); + + private final String wireValue; + + DateFormat(String wireValue) { + this.wireValue = wireValue; + } + + /** The value sent in the {@code ?dateformat=} query parameter. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/DemoMode.java b/src/main/java/com/marketdata/sdk/DemoMode.java new file mode 100644 index 0000000..0a02ac5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DemoMode.java @@ -0,0 +1,11 @@ +package com.marketdata.sdk; + +final class DemoMode { + + static boolean isDemo(Configuration config) { + String apiKey = config.apiKey(); + return apiKey == null || apiKey.isBlank(); + } + + private DemoMode() {} +} diff --git a/src/main/java/com/marketdata/sdk/DotEnvLoader.java b/src/main/java/com/marketdata/sdk/DotEnvLoader.java new file mode 100644 index 0000000..9d1e344 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/DotEnvLoader.java @@ -0,0 +1,146 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.logging.Level; +import org.jspecify.annotations.Nullable; + +/** + * Loads {@code .env} key=value pairs from disk. {@code .env} is the third tier of the configuration + * cascade (after explicit args and env vars), and is optional — a missing file is normal and never + * reports a warning. However, an existing file that the SDK fails to read is suspicious: + * the user placed a {@code .env} expecting it to apply, and silently falling through to defaults + * would surface later as a confusing {@code AuthenticationError} with no breadcrumb. + * + *

Warnings are collected into a caller-supplied sink rather than emitted via the SDK logger + * directly. The loader runs inside {@link Configuration#resolve} which itself runs before + * {@link MarketDataLogging#configure}, so logging from here would land on an unconfigured JUL + * logger — wrong format, possibly invisible. {@link MarketDataClient} drains the sink after + * configuring logging, so the breadcrumb reaches its intended destination. + * + *

Supported syntax: {@code KEY=value} pairs, full-line {@code #} comments, blank lines, single- + * or double-quote-wrapped values (quotes are stripped, inner whitespace preserved), and trailing + * inline {@code # comment} markers — recognized only when the {@code #} is outside any quoted span + * and preceded by whitespace (or sits at the start of the value). A {@code #} adjacent to + * value chars stays part of the value, so URLs with fragments and tokens that contain {@code #} + * survive intact. + */ +final class DotEnvLoader { + + /** Diagnostic emitted by the loader, replayed by {@link MarketDataClient} after logging setup. */ + record Warning(Level level, String message, @Nullable Throwable cause) {} + + /** + * Parse {@code path} into an immutable map of {@code key → value} pairs. + * + *

{@code allowedKeys} is the allowlist: when non-null, keys outside the set are dropped during + * parsing and never materialize in the returned map. This mirrors the defensive principle of + * {@link EnvVars#systemLookup} — the SDK does not need to retain the consumer's unrelated secrets + * ({@code AWS_SECRET_ACCESS_KEY}, {@code GITHUB_TOKEN}, etc.) in memory just because they + * happened to share a {@code .env} file with our config. Passing {@code null} disables the + * filter; that surface exists for tests that exercise the parser independently of the cascade. + */ + static Map load( + Path path, Consumer warnings, @Nullable Set allowedKeys) { + if (!Files.exists(path)) { + return Map.of(); + } + if (!Files.isReadable(path)) { + warnings.accept( + new Warning( + Level.WARNING, + "Found .env at " + + path + + " but it is not readable (permission denied?) — falling back to env" + + " vars/defaults.", + null)); + return Map.of(); + } + Map result = new LinkedHashMap<>(); + try { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + if (allowedKeys != null && !allowedKeys.contains(key)) { + continue; + } + String afterEq = trimmed.substring(eq + 1).trim(); + String value = stripQuotes(stripInlineComment(afterEq).trim()); + result.put(key, value); + } + } catch (IOException e) { + warnings.accept( + new Warning( + Level.WARNING, + "Failed to read .env at " + path + " — falling back to env vars/defaults.", + e)); + return Map.of(); + } + return Map.copyOf(result); + } + + /** + * Strip a trailing inline comment from {@code value} if present. An inline comment is a {@code #} + * that is (a) outside any single- or double-quoted span, and (b) preceded by whitespace or sits + * at the very start of {@code value}. A {@code #} adjacent to value chars (e.g. {@code pa#ss}, + * {@code "https://x.example/#frag"} unquoted as {@code https://x.example/#frag}) is part of the + * value, not a comment marker — matching python-dotenv and dotenv-java conventions, which keep + * URLs and hash-containing tokens intact unless the author put a space before the {@code #}. + * + *

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

Trailing whitespace left behind between the value and the stripped {@code #} is removed by + * the caller's {@code trim()}. + */ + private static String stripInlineComment(String value) { + boolean inSingle = false; + boolean inDouble = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (inSingle) { + if (c == '\'') { + inSingle = false; + } + } else if (inDouble) { + if (c == '"') { + inDouble = false; + } + } else if (c == '\'') { + inSingle = true; + } else if (c == '"') { + inDouble = true; + } else if (c == '#' && (i == 0 || Character.isWhitespace(value.charAt(i - 1)))) { + return value.substring(0, i); + } + } + return value; + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private DotEnvLoader() {} +} diff --git a/src/main/java/com/marketdata/sdk/EnvVars.java b/src/main/java/com/marketdata/sdk/EnvVars.java index e7ba768..71ee2a5 100644 --- a/src/main/java/com/marketdata/sdk/EnvVars.java +++ b/src/main/java/com/marketdata/sdk/EnvVars.java @@ -1,21 +1,32 @@ package com.marketdata.sdk; -/** - * Names of the {@code MARKETDATA_*} environment variables consulted by the SDK. Mirrors SDK - * requirements §4. - */ +import java.util.Set; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + final class EnvVars { - public static final String TOKEN = "MARKETDATA_TOKEN"; - public static final String BASE_URL = "MARKETDATA_BASE_URL"; - public static final String API_VERSION = "MARKETDATA_API_VERSION"; - public static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; - public static final String OUTPUT_FORMAT = "MARKETDATA_OUTPUT_FORMAT"; - public static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; - public static final String COLUMNS = "MARKETDATA_COLUMNS"; - public static final String ADD_HEADERS = "MARKETDATA_ADD_HEADERS"; - public static final String USE_HUMAN_READABLE = "MARKETDATA_USE_HUMAN_READABLE"; - public static final String MODE = "MARKETDATA_MODE"; + static final String TOKEN = "MARKETDATA_TOKEN"; + static final String BASE_URL = "MARKETDATA_BASE_URL"; + static final String API_VERSION = "MARKETDATA_API_VERSION"; + static final String LOGGING_LEVEL = "MARKETDATA_LOGGING_LEVEL"; + static final String DATE_FORMAT = "MARKETDATA_DATE_FORMAT"; + + static final Set ALLOWED_KEYS = + Set.of(TOKEN, BASE_URL, API_VERSION, LOGGING_LEVEL, DATE_FORMAT); + + /** + * Lookup function over the SDK-relevant environment variables. Restricts reads to {@link + * #ALLOWED_KEYS} so the {@link Function} can be passed around safely — any other key returns + * {@code null} without touching {@code System.getenv}. Today's only caller ({@link + * Configuration#resolve}) already invokes with just the {@code MARKETDATA_*} keys; the + * restriction is defensive: a future caller that accidentally tries to read {@code PATH} or + * {@code AWS_SECRET_ACCESS_KEY} through this seam would silently get {@code null} instead of + * leaking the value. + */ + static Function systemLookup() { + return key -> ALLOWED_KEYS.contains(key) ? System.getenv(key) : null; + } private EnvVars() {} } diff --git a/src/main/java/com/marketdata/sdk/Format.java b/src/main/java/com/marketdata/sdk/Format.java new file mode 100644 index 0000000..972f057 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Format.java @@ -0,0 +1,38 @@ +package com.marketdata.sdk; + +/** + * Wire response format negotiated via the {@code ?format=} query parameter. + * + *

Package-private by design. SDK consumers never reference this enum directly — + * resource façades expose a method per format (e.g. {@code stocks.candles(...)} returns a decoded + * record from a JSON response; {@code stocks.candlesAsCsv(...)} returns the raw CSV). That keeps + * the format choice surfaced as a method selection rather than a parameter the user has to import + * {@code Format} for. + */ +enum Format { + JSON("json", "application/json"), + CSV("csv", "text/csv"), + // §13.5: the API can return HTML for endpoints like the marketing/error pages a misrouted + // request lands on; the spec requires `Response.isHtml()` to identify those responses without + // exposing the format enum itself. No resource façade ships HTML as a first-class output today, + // but the wire-level wiring (Accept header + ?format=html) is in place for when one does. + HTML("html", "text/html"); + + private final String wireValue; + private final String mediaType; + + Format(String wireValue, String mediaType) { + this.wireValue = wireValue; + this.mediaType = mediaType; + } + + /** The value sent in the {@code ?format=} query parameter. */ + String wireValue() { + return wireValue; + } + + /** The media type sent in the {@code Accept} request header. */ + String mediaType() { + return mediaType; + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java new file mode 100644 index 0000000..957b2d5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -0,0 +1,185 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.NetworkError; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.logging.Logger; + +/** + * Single-shot HTTP dispatch with global concurrency limiting. + * + *

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

Failures that originate inside {@code HttpClient.sendAsync} (transport errors, sync-thrown + * bugs) are mapped to {@link NetworkError} so the upstream retry layer sees a single, typed shape. + * Status-code interpretation lives in {@link HttpTransport}, not here — this class is below the + * "what does HTTP 4xx mean" abstraction. + */ +final class HttpDispatcher implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + + private final HttpClient httpClient; + private final AsyncSemaphore permits; + private final Clock clock; + + HttpDispatcher(HttpClient httpClient, int concurrencyLimit) { + this(httpClient, concurrencyLimit, Clock.systemUTC()); + } + + HttpDispatcher(HttpClient httpClient, int concurrencyLimit, Clock clock) { + this.httpClient = httpClient; + this.permits = new AsyncSemaphore(concurrencyLimit); + this.clock = clock; + } + + /** + * Send one request. The returned future completes with the raw response on success, or fails with + * a {@link NetworkError} for transport-level problems. Cancellation of the returned future + * propagates to the underlying send and, if the dispatch hasn't started yet because we're queued + * behind the concurrency pool, removes the waiter from the semaphore. + */ + CompletableFuture> dispatch(HttpRequest request) { + CompletableFuture permit = permits.acquire(); + CompletableFuture> dispatched = + permit.thenCompose(unused -> send(request)); + + // Cancellation of `dispatched` doesn't propagate to `permit` by default, so a slow-path + // waiter would stay live in the semaphore queue; release() would later "transfer" the + // permit by completing the waiter, but thenCompose's function wouldn't run (its dependent + // is already cancelled), and send — which registers whenComplete(release) — would never + // fire. Cancelling `permit` here makes AsyncSemaphore.release skip the waiter. + dispatched.whenComplete( + (r, t) -> { + if (t instanceof CancellationException) { + permit.cancel(false); + } + }); + + return dispatched; + } + + private CompletableFuture> send(HttpRequest request) { + LOGGER.fine(() -> "GET " + safeUri(request.uri())); + Instant start = clock.instant(); + + CompletableFuture> sendFuture; + try { + sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray()); + } catch (Throwable t) { + // sendAsync threw synchronously (malformed request, internal NPE, OOM). The future + // never formed, so the whenComplete below would never fire — release the permit here + // to prevent a permanent leak that would degrade the pool to deadlock. + permits.release(); + LOGGER.warning( + () -> + "Request to " + + safeUri(request.uri()) + + " failed before dispatch: " + + t.getMessage()); + if (t instanceof Error err) { + throw err; + } + return CompletableFuture.failedFuture( + new NetworkError( + "Request to " + safeUri(request.uri()) + " failed before dispatch: " + t.getMessage(), + ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), + t)); + } + + return sendFuture + .whenComplete((r, t) -> permits.release()) + .handle( + (response, error) -> { + long elapsedMs = Duration.between(start, clock.instant()).toMillis(); + if (error != null) { + Throwable root = unwrap(error); + LOGGER.warning( + () -> + "Request to " + + safeUri(request.uri()) + + " failed after " + + elapsedMs + + "ms: " + + root.getMessage()); + throw new CompletionException( + new NetworkError( + "Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(), + ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), + root)); + } + LOGGER.fine( + () -> + "Response " + + response.statusCode() + + " from " + + safeUri(request.uri()) + + " in " + + elapsedMs + + "ms"); + return response; + }); + } + + /** + * Returns a log-safe rendition of {@code uri}: just the path, with a literal {@code "?…"} + * appended when the URI had a query string. The query is omitted so log lines never persist + * potentially-sensitive request parameters (PII like {@code account_id}, competitive-signal data + * like queried symbols, or a hypothetical future {@code ?token=}). + * + *

Exception context (via {@link ErrorContext}) still carries the full URI: that surface is for + * consumer code that has context to decide what to do with it; ambient logs are not. + */ + static String safeUri(URI uri) { + String path = uri.getPath(); + if (path == null) { + // Opaque URIs (scheme:opaque, no //authority) — defensive fallback. Won't happen for + // requests built by this SDK, but log-safety helpers must never throw. + return uri.toString(); + } + return uri.getRawQuery() != null ? path + "?…" : path; + } + + /** Permits not currently held nor queued. Exposed for diagnostics and tests. */ + int availablePermits() { + return permits.availablePermits(); + } + + /** Number of pending waiters on the semaphore's slow path. */ + int queueLength() { + return permits.queueLength(); + } + + /** + * Drains the semaphore's waiter queue and rejects subsequent {@link #dispatch} calls; waiters + * fail with {@link java.util.concurrent.CancellationException} so the chained future of every + * pending caller resolves cleanly instead of leaking forever. + * + *

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

Idempotent. + */ + @Override + public void close() { + permits.close(); + } + + private static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } +} diff --git a/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java b/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java new file mode 100644 index 0000000..b3145cd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HttpResponseEnvelope.java @@ -0,0 +1,25 @@ +package com.marketdata.sdk; + +import java.net.URI; +import java.net.http.HttpHeaders; +import org.jspecify.annotations.Nullable; + +/** + * Format-agnostic envelope returned by {@link HttpTransport} to resources. + * + *

The transport's job ends here: it confirms the response was a success-shaped HTTP status + * (200/203/404 per the API contract), maps real errors (4xx/5xx) to typed exceptions, and hands the + * raw bytes back. Whether the body is JSON, CSV, or HTML is the resource's decision — the transport + * does not parse it. + * + * @param body raw response bytes, exactly as received from the wire. May be empty. + * @param statusCode the HTTP status code (one of 200, 203, 404). + * @param requestId server-provided request id (e.g. Cloudflare {@code cf-ray}), {@code null} when + * the response did not carry one. Useful when the resource's own parser fails and needs to + * build an {@code ErrorContext}. + * @param headers full response headers, in case a resource needs to read content-type, encoding, + * pagination, etc. + * @param url the absolute URL the response came from. Useful for error contexts. + */ +record HttpResponseEnvelope( + byte[] body, int statusCode, @Nullable String requestId, HttpHeaders headers, URI url) {} diff --git a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java index dff9f0f..d21cead 100644 --- a/src/main/java/com/marketdata/sdk/HttpStatusMapper.java +++ b/src/main/java/com/marketdata/sdk/HttpStatusMapper.java @@ -4,34 +4,81 @@ import com.marketdata.sdk.exception.BadRequestError; import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NotFoundError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; 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 @Nullable MarketDataException map(int statusCode, ErrorContext context) { + return map(statusCode, context, null); + } - 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); + /** + * Maps an HTTP status to its typed exception. When {@code retryAfter} is non-null, it is attached + * to the resulting {@link ServerError} (so the retry policy can honor §9.4) and to the resulting + * {@link RateLimitError} (so consumers receiving a 429 can read the server's directive even + * though the SDK does not retry 429 itself, per RFC 6585). + * + *

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

    + *
  • 5xx → {@link ServerError} (retryable). + *
  • 4xx (other than 401/404/429) → {@link BadRequestError} with the status + * code in the message — covers 403 (permission), 422 (validation), 405, 418, 451, etc. + *
  • 3xx → {@link BadRequestError} with a "redirect" message. The transport's + * {@code HttpClient} follows redirects per {@code NORMAL} policy, so a 3xx escaping that + * means the redirect could not be followed (e.g., cross-protocol, max redirects). Surfaces + * as a non-retryable error — retrying would just hit the same redirect. + *
  • 1xx → {@link BadRequestError} defensively. {@code HttpClient} handles + * {@code 100 Continue} itself, so reaching here is a server-protocol oddity. + *
  • Anything else (negative, > 599, etc.) → {@link BadRequestError} with the raw status. + *
+ */ + static @Nullable MarketDataException map( + int statusCode, ErrorContext context, @Nullable Duration retryAfter) { + if (statusCode >= 200 && statusCode < 300) { + return null; + } + return switch (statusCode) { + case 400 -> new BadRequestError("Bad request", context); + case 401 -> new AuthenticationError("Authentication failed", context); + case 404 -> new NotFoundError("Not found", context); + case 429 -> new RateLimitError("Rate limit exceeded", context, null, retryAfter); + default -> mapByRange(statusCode, context, retryAfter); }; } - private static @Nullable String emptyToNull(@Nullable String s) { - return (s == null || s.isBlank()) ? null : s; + private static MarketDataException mapByRange( + int statusCode, ErrorContext context, @Nullable Duration retryAfter) { + if (statusCode >= 500 && statusCode <= 599) { + return new ServerError("Server error: " + statusCode, context, null, retryAfter); + } + if (statusCode >= 400 && statusCode <= 499) { + return new BadRequestError("Client error: HTTP " + statusCode, context); + } + if (statusCode >= 300 && statusCode <= 399) { + // followRedirects(NORMAL) drains the standard cases; a 3xx surviving here means the + // redirect could not be followed (cross-protocol, max-redirects hit, etc.). Retrying + // would hit the same redirect, so route through the non-retryable BadRequestError + // bucket with a message that points at the likely culprit. + return new BadRequestError( + "Unhandled redirect: HTTP " + + statusCode + + " — the SDK follows standard redirects; this response was not followed." + + " Check baseUrl or proxy configuration.", + context); + } + if (statusCode >= 100 && statusCode <= 199) { + return new BadRequestError("Unexpected informational response: HTTP " + statusCode, context); + } + return new BadRequestError("Unexpected HTTP status: " + statusCode, context); } + + private HttpStatusMapper() {} } diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 4549556..bb7c440 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -1,47 +1,49 @@ 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 com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; 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.Clock; import java.time.Duration; +import java.time.Instant; import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.logging.Logger; 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. + *

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

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

Per ADR-006 the design is async-first: {@link #executeAsync} is the canonical path; {@link * #executeSync} is a thin wrapper that calls {@link CompletableFuture#join()} and unwraps any - * {@link CompletionException} so the caller sees the underlying 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. + * {@link CompletionException} so the caller sees the underlying {@link MarketDataException} + * directly. */ final class HttpTransport implements AutoCloseable { + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + /** SDK requirements §10: fixed 99-second per-request timeout. */ static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); @@ -53,48 +55,78 @@ final class HttpTransport implements AutoCloseable { private static final String CF_RAY = "cf-ray"; - private final HttpClient httpClient; - private final ObjectMapper jsonMapper; - private final AsyncSemaphore concurrencyPermits; - private final RetryPolicy retryPolicy; - private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); + private final HttpDispatcher dispatcher; + private final RetryExecutor retryExecutor; + private final Supplier<@Nullable StatusCache> statusCacheSupplier; + private final Clock clock; + private final AtomicReference<@Nullable RateLimitSnapshot> latestRateLimits = + new AtomicReference<>(); private final String baseUrl; private final String apiVersion; private final String userAgent; private final @Nullable String token; - HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) { - this(baseUrl, apiVersion, userAgent, token, defaultHttpClient(), RetryPolicy.defaults()); - } + /** URI the StatusCache's own fetcher targets; matched verbatim by cacheAllowsRetry. */ + private final URI statusEndpointUri; - // Package-private constructor used by tests to inject a stubbed HttpClient - // (e.g. one whose sendAsync throws synchronously, to verify permit release). + /** + * Canonical constructor — all dependencies explicit. Production code uses {@link + * #withDefaults(String, String, String, String, Supplier)} which assembles real defaults; tests + * call this directly with stubs. + * + *

The {@code statusCacheSupplier} is consulted on every {@link #executeAsync} call so {@link + * MarketDataClient} can construct the cache after the transport (the cache's fetcher + * uses the transport via {@link UtilitiesResource} — the chicken-and-egg is resolved by a + * deferred reference). Pass {@code () -> null} when no §9.5 gate is desired (e.g. tests). + * + *

The {@code clock} drives the §10.3 preflight's {@code reset}-window check; tests pass a + * fixed clock to assert the time-based gate behavior deterministically. + */ HttpTransport( String baseUrl, String apiVersion, String userAgent, @Nullable String token, - HttpClient httpClient) { - this(baseUrl, apiVersion, userAgent, token, httpClient, RetryPolicy.defaults()); + HttpDispatcher dispatcher, + RetryExecutor retryExecutor, + Supplier<@Nullable StatusCache> statusCacheSupplier, + Clock clock) { + this.baseUrl = baseUrl; + this.apiVersion = apiVersion; + this.userAgent = userAgent; + this.token = token; + this.dispatcher = dispatcher; + this.retryExecutor = retryExecutor; + this.statusCacheSupplier = statusCacheSupplier; + this.clock = clock; + // Derive from baseUrl so a path-prefixed base (e.g. https://corp/proxy) still matches the + // /status/ self-referential bypass. Hardcoding "/status/" would silently stop working in + // that case. + this.statusEndpointUri = buildUri(RequestSpec.get("status").unversioned().build()); } - // Package-private constructor used by retry tests to drive sub-millisecond backoffs. - HttpTransport( + /** + * Production factory: assembles a real {@link HttpDispatcher} (50-permit pool + JDK {@link + * HttpClient}), a default {@link RetryExecutor} (4 attempts, exponential 1s→30s), and {@link + * Clock#systemUTC()} for the preflight reset-window check. + */ + static HttpTransport withDefaults( String baseUrl, String apiVersion, String userAgent, @Nullable String token, - HttpClient httpClient, - RetryPolicy retryPolicy) { - this.baseUrl = baseUrl; - this.apiVersion = apiVersion; - this.userAgent = userAgent; - this.token = token; - this.concurrencyPermits = new AsyncSemaphore(CONCURRENCY_LIMIT); - this.jsonMapper = buildJsonMapper(); - this.httpClient = httpClient; - this.retryPolicy = retryPolicy; + Supplier<@Nullable StatusCache> statusCacheSupplier) { + Clock clock = Clock.systemUTC(); + return new HttpTransport( + baseUrl, + apiVersion, + userAgent, + token, + new HttpDispatcher(defaultHttpClient(), CONCURRENCY_LIMIT, clock), + new RetryExecutor(RetryPolicy.defaults()), + statusCacheSupplier, + clock); } private static HttpClient defaultHttpClient() { @@ -106,220 +138,198 @@ private static HttpClient defaultHttpClient() { } /** - * 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. + * Latest client-level rate-limit snapshot, or {@code null} if no rate-limit-bearing response has + * arrived yet. Successful responses without rate-limit headers do not clear it. */ - @Nullable RateLimits getLatestRateLimits() { + @Nullable RateLimitSnapshot getLatestRateLimits() { return latestRateLimits.get(); } /** - * Async-first request execution with retry. Orchestrates one or more attempts according to {@link - * RetryPolicy}: retries 501–599 and IOException-shaped {@link NetworkError}s with exponential - * backoff, surfaces every other failure immediately. Cancellation of the returned future bails - * out of any pending backoff and propagates to the current in-flight attempt. + * Async-first request execution with retry. Returns the raw response envelope on success (HTTP + * 200/203/404); 4xx and 5xx responses surface as the corresponding {@link MarketDataException} + * subtype via {@link HttpStatusMapper}, possibly after retries. */ - CompletableFuture executeAsync(RequestSpec spec, Class responseType) { - CompletableFuture result = new CompletableFuture<>(); - // One cascade-cancel handler installed once: whichever attempt is currently in flight is - // tracked in `currentDispatched`; cancelling `result` cancels that. Previous attempts in - // the chain are already done by the time the next one updates the reference, so this - // avoids accumulating a handler per attempt. - AtomicReference<@Nullable CompletableFuture> currentDispatched = new AtomicReference<>(); - result.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - CompletableFuture inFlight = currentDispatched.get(); - if (inFlight != null && !inFlight.isDone()) { - inFlight.cancel(false); - } - } - }); - attempt(spec, responseType, 0, result, currentDispatched); - return result; + CompletableFuture executeAsync(RequestSpec spec) { + return executeAsync(spec, retryExecutor); } - private void attempt( - RequestSpec spec, - Class responseType, - int attemptIdx, - CompletableFuture result, - AtomicReference<@Nullable CompletableFuture> currentDispatched) { - if (result.isDone()) { - // Caller cancelled (or completed exceptionally from a previous attempt's whenComplete). - // Don't burn another HTTP request. - return; - } - CompletableFuture dispatched = executeOnce(spec, responseType); - currentDispatched.set(dispatched); - - // If the caller cancelled `result` between attempts (during a backoff window), the handler - // installed in executeAsync has fired but `currentDispatched` was either null or pointing - // to the previous (already-done) attempt — so the new one was never cancelled. Check here - // and propagate immediately. - if (result.isCancelled() && !dispatched.isDone()) { - dispatched.cancel(false); - return; - } + /** + * Like {@link #executeAsync(RequestSpec)}, but uses the caller's {@link RetryPolicy} instead of + * the transport's default. Used by callers that need a different retry budget for one specific + * call — e.g. {@link MarketDataClient}'s startup validation, which uses {@link + * RetryPolicy#noRetry()} so a slow/down API surfaces immediately to the constructor. + */ + CompletableFuture executeAsync(RequestSpec spec, RetryPolicy policy) { + return executeAsync(spec, new RetryExecutor(policy)); + } - dispatched.whenComplete( - (value, error) -> { - if (result.isDone()) { - return; - } - if (error == null) { - result.complete(value); - return; - } - Throwable cause = unwrap(error); - if (retryPolicy.shouldRetry(cause, attemptIdx)) { - long delayMs = retryPolicy.backoffDelay(attemptIdx).toMillis(); - CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) - .execute( - () -> attempt(spec, responseType, attemptIdx + 1, result, currentDispatched)); - } else { - result.completeExceptionally(cause); + private CompletableFuture executeAsync( + RequestSpec spec, RetryExecutor executor) { + URI uri = buildUri(spec); + HttpRequest request = buildHttpRequest(uri, spec.format()); + RetryPolicy policy = executor.policy(); + return executor.execute( + (attemptIdx, previousCause) -> { + // §10.3: pre-flight gate — if our latest snapshot says credits are exhausted, fail + // fast without hitting the wire. RateLimitError is non-retriable per §11.2, so the + // retry executor will surface it directly. + // + // Exception: when the previous attempt failed with a ServerError carrying an explicit + // Retry-After (§9.4), the server has just told us "come back at ". + // That directive is more authoritative than our snapshot for this specific retry; + // honoring it is exactly what §9.4 demands. Without this bypass, a 503 + Retry-After + // after a snapshot that reports remaining=0 with a far-future reset would sabotage the + // server-orchestrated backoff — the retry would never reach the wire. + if (!isServerHintedRetry(previousCause)) { + RateLimitError preflight = checkRateLimitPreflight(uri); + if (preflight != null) { + return CompletableFuture.failedFuture(preflight); + } } - }); + return dispatcher + .dispatch(request) + .thenApply(response -> routeAndEnvelope(response, uri)); + }, + // §9.5: gate retries on retryable server errors through the /status/ cache. Even if the + // policy says yes, an "offline" cache entry for this URI's service blocks the retry so + // the caller fails fast instead of hammering a known-down service. + (cause, attempt) -> policy.shouldRetry(cause, attempt) && cacheAllowsRetry(uri)); } /** - * Single-shot dispatch — one HTTP request, one permit lease, one response decode. Public retry - * orchestration lives in {@link #executeAsync}. + * Was the previous attempt's failure a server-side directive to come back at a specific time? + * Only 5xx responses carrying a parsed {@code Retry-After} qualify — that's the case where the + * server has explicitly scheduled our retry, and our local rate-limit snapshot (whose {@code + * reset} may be unrelated and far in the future) must not veto it. */ - private CompletableFuture executeOnce(RequestSpec spec, Class responseType) { - URI uri = buildUri(spec); - HttpRequest request = buildRequest(uri); - - // ADR-007: acquire returns a CompletableFuture instead of parking the caller's thread. - // When permits are available the future is already completed (fast path) and thenCompose - // runs synchronously; when the pool is exhausted the future completes later, on the - // thread that calls release() — the caller's thread is never blocked here. - CompletableFuture permit = concurrencyPermits.acquire(); - CompletableFuture dispatched = - permit.thenCompose(unused -> dispatch(uri, request, responseType)); - - // Cancellation of `dispatched` doesn't propagate to `permit` by default, so a slow-path - // waiter would stay live in the semaphore queue; release() would later "transfer" the - // permit by completing the waiter, but thenCompose's function wouldn't run (its - // dependent is already cancelled), and dispatch — which registers whenComplete(release) - // — would never fire. Cancelling `permit` here makes AsyncSemaphore.release skip the - // waiter. The narrow race where the waiter is cancelled between release()'s pollFirst - // and complete() is handled inside release() itself by retrying. - dispatched.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - permit.cancel(false); - } - }); - - return dispatched; + private static boolean isServerHintedRetry(@Nullable Throwable previousCause) { + return previousCause instanceof ServerError server && server.getRetryAfter().isPresent(); } - private CompletableFuture dispatch(URI uri, HttpRequest request, Class responseType) { - CompletableFuture> sendFuture; - try { - sendFuture = httpClient.sendAsync(request, BodyHandlers.ofByteArray()); - } catch (Throwable t) { - // sendAsync threw synchronously (e.g. malformed request, internal NPE, OOM). - // The future never formed, so whenComplete will not fire — release the permit - // here to prevent a permanent leak that would degrade the pool to deadlock. - concurrencyPermits.release(); - if (t instanceof Error err) { - throw err; - } - return CompletableFuture.failedFuture( - new NetworkError( - "Request to " + uri + " failed before dispatch: " + t.getMessage(), - new ErrorContext(null, uri.toString(), null), - t)); + /** + * Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits + * and the snapshot's {@code reset} timestamp is still in the future. Returns {@code + * null} when the request is allowed (credits available, no snapshot yet, or the reset window has + * elapsed — the snapshot is stale and the next response's headers will refresh it). + * + *

Without the reset check, a single response carrying {@code remaining=0} would freeze the + * client forever: the preflight would short-circuit every subsequent request, no request would + * reach the wire, and the snapshot would never refresh — even after the server replenished + * credits at the reset time. + */ + private @Nullable RateLimitError checkRateLimitPreflight(URI uri) { + RateLimitSnapshot snap = latestRateLimits.get(); + if (snap == null || snap.remaining() > 0) { + return null; } + Instant now = clock.instant(); + if (!now.isBefore(snap.reset())) { + // now >= reset → window has elapsed; let the request through so the response refreshes + // the snapshot. If the server hasn't actually replenished yet it will reject with 429, + // which costs one round-trip — strictly less harmful than locking out indefinitely. + return null; + } + ErrorContext context = ErrorContext.forNoResponse(uri.toString(), now); + return new RateLimitError( + "Rate limit exhausted: 0 requests remaining (resets at " + snap.reset() + ")", context); + } - return sendFuture - .whenComplete((r, t) -> concurrencyPermits.release()) - .handle( - (response, error) -> { - if (error != null) { - Throwable root = unwrap(error); - throw new CompletionException( - new NetworkError( - "Request to " + uri + " failed: " + root.getMessage(), - new ErrorContext(null, uri.toString(), null), - root)); - } - // Only overwrite the snapshot when the response carried parseable rate-limit - // headers. The API's rate-limit middleware can silently swallow its own errors - // and respond without headers; clobbering with null on every such response would - // make `client.getRateLimits()` flicker between populated and null across - // consecutive calls. Spec §8 says "update client-level snapshot" — implicitly only - // when there is something to update. - RateLimits parsed = RateLimitHeaders.parse(response.headers()); - if (parsed != null) { - latestRateLimits.set(parsed); - } - return processResponse(response, responseType, uri.toString()); - }); + private boolean cacheAllowsRetry(URI uri) { + StatusCache cache = statusCacheSupplier.get(); + if (cache == null) { + return true; // pre-wire state or test setup without a cache + } + // Self-referential bypass: the cache's own fetcher targets statusEndpointUri. If we + // consulted the cache for retries of that fetch and the snapshot reported /status/ offline + // (or any wildcard match grazed it), the retry would be blocked — and because no + // successful fetch can land, the snapshot would stay frozen in that "offline" state + // forever. Skip the cache for the /status/ URI so the §9.5 gate cannot trap its own + // refresh. + if (statusEndpointUri.equals(uri)) { + return true; + } + return cache.check(uri) == StatusCache.Decision.ALLOW; } /** * Sync wrapper around {@link #executeAsync}. Per ADR-006, calls {@code .join()} and unwraps - * {@link CompletionException} so callers see the underlying {@link MarketDataException} directly. - * - *

{@link CancellationException} can in principle escape {@code .join()} as a sibling of {@link - * CompletionException} (not nested), so it's caught explicitly. Today no internal code cancels - * the future {@code executeSync} owns, but covering it keeps the contract honest if a future - * change (timeout watchdog, retry coordinator) starts cancelling internally. + * {@link CompletionException} so callers see the underlying {@link MarketDataException}. */ - T executeSync(RequestSpec spec, Class responseType) { + HttpResponseEnvelope executeSync(RequestSpec spec) { + return joinSync(executeAsync(spec)); + } + + /** Instance bridge for resources: uses this transport's {@link Clock} for fallback errors. */ + T joinSync(CompletableFuture future) { try { - return executeAsync(spec, responseType).join(); + return future.join(); } catch (CompletionException e) { - throw asRuntime(e.getCause()); + throw asRuntime(e.getCause(), clock); } catch (CancellationException e) { - throw asRuntime(e); + throw asRuntime(e, clock); } } - // Visible for tests: under our current SDK design, executeAsync always wraps failures as - // MarketDataException so the `MDE` branch is the only one reached from the public surface. - // The other two branches are defensive guardrails — extracted so they can be exercised - // directly by tests rather than relying on a synthetic public-API path. - static RuntimeException asRuntime(@Nullable Throwable cause) { - if (cause instanceof MarketDataException mde) { - return mde; - } - if (cause instanceof RuntimeException re) { - return re; - } - return new NetworkError("Unexpected failure invoking SDK", ErrorContext.empty(), cause); - } - @Override public void close() { - // java.net.http.HttpClient gained explicit close() in JDK 21; until - // the SDK's minimum bumps to 21+ this is a no-op (ADR-002). + // Drains the dispatcher's semaphore so pending waiters surface CancellationException instead + // of hanging forever. java.net.http.HttpClient gained explicit close() in JDK 21; until the + // SDK's minimum bumps to 21+ in-flight HTTP sends still run to completion (ADR-002). + dispatcher.close(); } - private T processResponse(HttpResponse response, Class responseType, String url) { + // ---------- private helpers ---------- + + /** + * Status routing + rate-limit snapshot update. Runs inside the retry supplier so a 5xx that we'd + * retry surfaces here as a thrown exception that {@link RetryExecutor} can catch and pass to the + * policy. + */ + private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI uri) { + // Only overwrite when the response carried parseable rate-limit headers — the API + // occasionally responds without them on its own internal errors; clobbering with null + // would make `getLatestRateLimits()` flicker. + RateLimitSnapshot parsed = RateLimitHeaders.parse(response.headers()); + if (parsed != null) { + latestRateLimits.set(parsed); + } + int status = response.statusCode(); String requestId = response.headers().firstValue(CF_RAY).orElse(null); - // 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); - } + // Any 2xx is treated as success — the API uses 200 and 203 today, but a future endpoint + // returning 201/204 should still hand the body to the resource. 404 is the API's + // no_data convention: the body still carries a typed payload the resource interprets. + if ((status >= 200 && status < 300) || status == 404) { + return new HttpResponseEnvelope(response.body(), status, requestId, response.headers(), uri); + } + Instant now = clock.instant(); + ErrorContext context = ErrorContext.forResponse(uri.toString(), status, requestId, now); + Duration retryAfter = + response + .headers() + .firstValue("Retry-After") + .flatMap(v -> RetryAfterHeader.parse(v, now)) + .orElse(null); + MarketDataException ex = HttpStatusMapper.map(status, context, retryAfter); + if (ex != null) { + LOGGER.warning( + () -> + "Request to " + + HttpDispatcher.safeUri(uri) + + " returned HTTP " + + status + + ": " + + ex.getMessage()); + throw ex; } - throw HttpStatusMapper.toException(status, url, requestId); + // Mapper only returns null for 2xx, which the branch above already handled. Belt & + // suspenders for the impossible case so a future mapper edit can't silently swallow. + // §16: route the URI through safeUri so getMessage() — accessible to any consumer that + // logs the exception — never carries query strings (token, account_id, symbols, …). + throw new ServerError( + "Unmapped status " + status + " from " + HttpDispatcher.safeUri(uri), context); } private URI buildUri(RequestSpec spec) { @@ -331,8 +341,12 @@ private URI buildUri(RequestSpec spec) { path = path.substring(1); } StringBuilder sb = new StringBuilder(); - sb.append(baseUrl).append('/').append(apiVersion).append('/').append(path); - if (!path.endsWith("/")) { + sb.append(baseUrl).append('/'); + if (spec.versioned()) { + sb.append(apiVersion).append('/'); + } + sb.append(path); + if (!path.isEmpty() && !path.endsWith("/")) { sb.append('/'); } Map params = spec.queryParams(); @@ -343,42 +357,55 @@ private URI buildUri(RequestSpec spec) { if (!first) { sb.append('&'); } - sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) + sb.append(encodeQueryComponent(e.getKey())) .append('=') - .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + .append(encodeQueryComponent(e.getValue())); first = false; } } return URI.create(sb.toString()); } - private HttpRequest buildRequest(URI uri) { + /** + * RFC 3986 percent-encoding for a query-string component. {@link URLEncoder} emits {@code + * application/x-www-form-urlencoded} bytes — i.e. spaces become {@code +} — which is the wrong + * dialect for query strings: strict servers treat {@code ?symbol=BRK A} and {@code + * ?symbol=BRK%20A} as equivalent but {@code ?symbol=BRK+A} as a literal {@code +}. Replacing + * {@code +} with {@code %20} after encoding is the canonical patch: {@link URLEncoder} only emits + * {@code +} for the space character (everything else that needs encoding is already {@code %XX}), + * so the substitution is unambiguous. + */ + private static String encodeQueryComponent(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private HttpRequest buildHttpRequest(URI uri, Format format) { HttpRequest.Builder b = HttpRequest.newBuilder(uri) .GET() .timeout(REQUEST_TIMEOUT) .header("User-Agent", userAgent) - .header("Accept", "application/json"); + .header("Accept", format.mediaType()); if (token != null) { b.header("Authorization", "Bearer " + token); } return b.build(); } - /** - * Builds the {@link ObjectMapper} used to decode every wire body. Per ADR-007 the wire-format - * deserializers register here, not via annotations on the response records. - */ - private static ObjectMapper buildJsonMapper() { - ObjectMapper mapper = new ObjectMapper(); - SimpleModule wireModule = new SimpleModule("marketdata-wire"); - wireModule.addDeserializer(MarketStatus.class, new MarketStatusDeserializer()); - mapper.registerModule(wireModule); - return mapper; - } - - // Package-private so the unwrap-when-nested-and-when-not branches are reachable from tests. - static Throwable unwrap(Throwable t) { - return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + // Visible for tests: under the current SDK design, executeAsync always wraps failures as + // MarketDataException so the MDE branch is the only one reached from the public surface. + // The other two branches are defensive guardrails — extracted so they can be exercised + // directly by tests rather than relying on a synthetic public-API path. + static RuntimeException asRuntime(@Nullable Throwable cause, Clock clock) { + if (cause instanceof MarketDataException mde) { + return mde; + } + if (cause instanceof RuntimeException re) { + return re; + } + return new NetworkError( + "Unexpected failure invoking SDK", + ErrorContext.forNoResponse("(unknown)", clock.instant()), + cause); } } diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java new file mode 100644 index 0000000..76bc8ed --- /dev/null +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -0,0 +1,89 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.ParseError; +import java.io.IOException; +import java.time.Clock; + +/** + * Decodes {@link HttpResponseEnvelope} bodies into typed records. + * + *

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

Resources that need raw bytes (CSV, HTML) skip this class entirely and read {@link + * HttpResponseEnvelope#body()} directly. + */ +final class JsonResponseParser { + + private final ObjectMapper mapper; + private final Clock clock; + + JsonResponseParser() { + this(Clock.systemUTC()); + } + + JsonResponseParser(Clock clock) { + this.mapper = new ObjectMapper(); + this.clock = clock; + } + + /** + * Attach a Jackson {@link Module} (typically a {@code SimpleModule} populated with one resource's + * deserializers). Resources call this from their constructor to wire their wire-format mappings + * without coupling the parser to their domain types. Idempotent for modules sharing the same + * type-id (Jackson skips duplicates). + */ + void registerModule(Module module) { + mapper.registerModule(module); + } + + /** + * Decode an envelope's body into the requested type. Throws {@link ParseError} when Jackson + * cannot read the body — the error context carries the envelope's url, status, and request id for + * the consumer's diagnostics. + */ + T parse(HttpResponseEnvelope env, Class type) { + // Issue #29: a zero-length body surfaces from Jackson as a generic "No content to map" + // MismatchedInputException — diagnostically thin, often confusing in the presence of a + // body-stripping proxy. Pre-check so the failure carries a precise, actionable message that + // names the actual symptom ("empty response body") instead of looking like a corruption. + if (env.body().length == 0) { + ErrorContext context = + ErrorContext.forResponse( + env.url().toString(), env.statusCode(), env.requestId(), clock.instant()); + throw new ParseError( + "Empty response body from " + + HttpDispatcher.safeUri(env.url()) + + " — server returned 0 bytes (a proxy may have stripped the payload, or the" + + " endpoint replied without one)", + context); + } + try { + return mapper.readValue(env.body(), type); + } catch (IOException e) { + ErrorContext context = + ErrorContext.forResponse( + env.url().toString(), env.statusCode(), env.requestId(), clock.instant()); + // §16: getMessage() is consumer-accessible and routinely logged. Strip query strings so + // tokens/account_ids/symbols never persist through this surface. The full URI remains + // available on the ErrorContext for callers with the right discretion. + throw new ParseError( + "Failed to decode response from " + + HttpDispatcher.safeUri(env.url()) + + ": " + + e.getMessage(), + context, + e); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 85cc821..986c7c4 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -1,158 +1,187 @@ package com.marketdata.sdk; -import java.time.Duration; -import java.util.logging.Level; +import java.nio.file.Path; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import java.util.logging.Logger; import org.jspecify.annotations.Nullable; -/** - * Entry point to the Market Data Java SDK. - * - *

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

Two constructors: - * - *

    - *
  • {@link #MarketDataClient()} — production path. Resolves everything from the cascade in §4 - * ({@code MARKETDATA_*} environment variable → value in a {@code .env} file → built-in - * default). With no token in the cascade, enters demo mode — authenticated endpoints - * will fail and the {@code Authorization} header is omitted. - *
  • {@link #MarketDataClient(String, String, String, boolean)} — explicit-control path for - * tests and short-lived runtimes. Each parameter may still be {@code null} to defer to the - * cascade for that single value. - *
- * - *

Instances are immutable: every field is {@code final} and assigned in the constructor. - */ public final class MarketDataClient implements AutoCloseable { - /** SDK requirements §10: fixed 99-second per-request timeout. */ - public static final Duration REQUEST_TIMEOUT = HttpTransport.REQUEST_TIMEOUT; - - /** SDK requirements §10: fixed 2-second connect timeout. */ - public static final Duration CONNECT_TIMEOUT = HttpTransport.CONNECT_TIMEOUT; - - /** SDK requirements §12: maximum concurrent in-flight requests per client. */ - public static final int CONCURRENCY_LIMIT = HttpTransport.CONCURRENCY_LIMIT; - - private static final Logger LOG = Logger.getLogger(MarketDataClient.class.getName()); + // §7: one logger for the whole SDK (com.marketdata.sdk). Consumers configure or attach handlers + // to that single name; consolidating here keeps MarketDataLogging's consumer-pre-config + // detection and useParentHandlers=false guard aware of every emission path. Parity with the + // Python SDK (single marketdata.logger). + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + private final Configuration config; private final HttpTransport transport; + private final UtilitiesResource utilities; - private final @Nullable String token; - private final String baseUrl; - private final String apiVersion; - private final String userAgent; - private final boolean demoMode; - private final boolean validateOnStartup; - - // Resources — eagerly constructed; one record-shaped object per resource group. - private final MarketsResource markets; - - /** - * Production constructor. Resolves all settings from the configuration cascade in SDK - * requirements §4 (env var → {@code .env} → built-in default) and enables startup validation. - * - *

Equivalent to {@link #MarketDataClient(String, String, String, boolean) new - * MarketDataClient(null, null, null, true)}. - */ public MarketDataClient() { this(null, null, null, true); } - /** - * Explicit-control constructor for tests and short-lived runtimes. Each of {@code apiKey}, {@code - * baseUrl}, and {@code apiVersion} may be {@code null} to defer to the cascade in §4 for that - * single value. - * - * @param apiKey explicit API token, or {@code null} to resolve from {@code MARKETDATA_TOKEN} → - * {@code .env} → demo mode - * @param baseUrl override the API base URL, or {@code null} to resolve to {@link - * Configuration#DEFAULT_BASE_URL} - * @param apiVersion override the API version segment, or {@code null} to resolve to {@link - * Configuration#DEFAULT_API_VERSION} - * @param validateOnStartup whether to validate the token on construction by calling {@code - * /user/} (SDK requirements §5). Pass {@code false} for short-lived runtimes where the - * startup hit is undesirable. - */ public MarketDataClient( @Nullable String apiKey, @Nullable String baseUrl, @Nullable String apiVersion, boolean validateOnStartup) { - Configuration config = Configuration.loadFromProcess(); - this.token = config.resolve(apiKey, EnvVars.TOKEN); - this.baseUrl = - trimTrailingSlash( - config.resolveOrDefault(baseUrl, EnvVars.BASE_URL, Configuration.DEFAULT_BASE_URL)); - this.apiVersion = - config.resolveOrDefault(apiVersion, EnvVars.API_VERSION, Configuration.DEFAULT_API_VERSION); - this.demoMode = this.token == null; - this.validateOnStartup = validateOnStartup; - this.userAgent = "marketdata-sdk-java/" + Version.current(); - - this.transport = new HttpTransport(this.baseUrl, this.apiVersion, this.userAgent, this.token); - this.markets = new MarketsResource(this.transport); - - LOG.log( - Level.INFO, - "Initialized Market Data SDK {0} (baseUrl={1}, apiVersion={2}, demoMode={3})", - new Object[] {Version.current(), this.baseUrl, this.apiVersion, this.demoMode}); - if (this.demoMode) { - LOG.warning( - "No API token provided — running in demo mode. Authenticated endpoints will fail with" - + " AuthenticationError on first call."); - } else if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Token: {0}", Tokens.redact(this.token)); - } - - // SDK requirements §5: validate on startup by default. The actual - // /user/ call lands with the user resource; this flag is the seam. + this( + apiKey, + baseUrl, + apiVersion, + validateOnStartup, + EnvVars.systemLookup(), + Configuration.DEFAULT_DOTENV_PATH); } - // --------------------------------------------------------------------- - // Resource accessors - // --------------------------------------------------------------------- - - /** Façade for the {@code /v1/markets/*} endpoint group. */ - public MarketsResource markets() { - return markets; - } - - // --------------------------------------------------------------------- - // Configuration accessors - // --------------------------------------------------------------------- - - public String getBaseUrl() { - return baseUrl; - } + /** + * Package-private ctor with the env-lookup and dotEnv-path seams exposed so tests can drive the + * configuration cascade hermetically. The public 4-arg ctor delegates here with {@link + * EnvVars#systemLookup()} and {@link Configuration#DEFAULT_DOTENV_PATH}. + */ + MarketDataClient( + @Nullable String apiKey, + @Nullable String baseUrl, + @Nullable String apiVersion, + boolean validateOnStartup, + Function env, + Path dotEnvPath) { + // Collect warnings from the configuration cascade (e.g. an unreadable .env) instead of + // letting DotEnvLoader log them directly. The loader runs BEFORE MarketDataLogging.configure + // — emitting WARNINGs there would land on an unconfigured JUL logger (wrong format, + // possibly invisible), undermining the breadcrumb the WARNING exists to provide. + List pendingWarnings = new ArrayList<>(); + try { + this.config = + Configuration.resolve(apiKey, baseUrl, apiVersion, env, dotEnvPath, pendingWarnings::add); + } catch (RuntimeException e) { + // Issue #25: if resolve fails (typically IAE — invalid baseUrl/apiVersion/apiKey from the + // cascade), the consumer would otherwise lose any .env warnings collected so far. That + // hides the real story: e.g. "your .env was unreadable, so the missing baseUrl fell + // through to a default that conflicts with your explicit apiVersion". Attach each warning + // as a suppressed exception so the diagnostic trail surfaces in the same stack trace. + attachWarningsAsSuppressed(e, pendingWarnings); + throw e; + } + MarketDataLogging.configure(config.loggingLevel()); + for (DotEnvLoader.Warning w : pendingWarnings) { + LOGGER.log(w.level(), w.message(), w.cause()); + } + LOGGER.info( + () -> + "MarketDataClient initialized: baseUrl=" + + config.baseUrl() + + ", apiVersion=" + + config.apiVersion() + + ", token=" + + Tokens.redact(config.apiKey()) + + ", demoMode=" + + DemoMode.isDemo(config)); + + // §9.5: the status cache pre-checks /status/ before retrying 5xx. The cache's fetcher uses + // `utilities.statusAsync()`, which goes through this transport — a chicken-and-egg. We + // resolve it with a deferred reference: the transport reads the cache through a supplier, + // which returns null until the cache is constructed (just below this transport instance). + AtomicReference cacheRef = new AtomicReference<>(); + this.transport = + HttpTransport.withDefaults( + config.baseUrl(), + config.apiVersion(), + "marketdata-sdk-java/" + Version.sdkVersion(), + config.apiKey(), + cacheRef::get); + // Partial-construction guard: from here on the transport is a live AutoCloseable that holds + // the shared HttpClient and the 50-permit AsyncSemaphore. If any subsequent constructor + // throws (today none do, but a future change in UtilitiesResource / StatusCache could), + // the caller never receives a reference, their try-with-resources never fires, and the + // transport leaks until GC. Close it explicitly and surface the close failure (if any) as + // a suppressed exception on the primary cause — same pattern runStartupValidation already + // uses for the validation path. + try { + JsonResponseParser parser = new JsonResponseParser(); + this.utilities = new UtilitiesResource(transport, parser); + cacheRef.set( + new StatusCache( + () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); + } catch (Throwable t) { + try { + transport.close(); + } catch (Throwable closeFailure) { + t.addSuppressed(closeFailure); + } + throw t; + } - public String getApiVersion() { - return apiVersion; + if (validateOnStartup) { + runStartupValidation(); + } } - public String getUserAgent() { - return userAgent; + /** + * Attach each pending {@code .env} warning to {@code primary} as a suppressed exception so the + * diagnostic trail survives a configuration-resolve failure. {@link Throwable#getCause()} would + * conflict with the actual cause of the IAE; suppressed is the right surface for "additional + * context the consumer should see alongside the primary failure". + */ + private static void attachWarningsAsSuppressed( + RuntimeException primary, List warnings) { + for (DotEnvLoader.Warning w : warnings) { + Throwable wrapper = + new RuntimeException("[.env " + w.level() + "] " + w.message(), w.cause()); + primary.addSuppressed(wrapper); + } } - public boolean isDemoMode() { - return demoMode; + /** System endpoints documented at the API root: {@code /headers/} (and more to come). */ + public UtilitiesResource utilities() { + return utilities; } - public boolean isValidateOnStartup() { - return validateOnStartup; + /** + * Fire a single call to {@code GET /user/} to confirm the token is accepted and a billing plan is + * attached (SDK requirements §5). A 401 surfaces as {@link + * com.marketdata.sdk.exception.AuthenticationError} directly via the sync wrapper. On any failure + * we close the transport before re-throwing so a partially-constructed client doesn't leak its + * HttpClient — the caller's try-with-resources is never triggered if the constructor itself + * fails. + * + *

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

Package-private so the demo-mode skip can be tested hermetically (i.e. without depending on + * whether {@code MARKETDATA_TOKEN} is set in the runner's environment). + */ + void runStartupValidation() { + if (DemoMode.isDemo(config)) { + LOGGER.info(() -> "validateOnStartup skipped: demo mode is active (no token configured)."); + return; + } + // Intent-named auth probe in UtilitiesResource — single-attempt so a slow/down API surfaces + // here within seconds instead of burning the default retry budget (~6.75 min). + try { + utilities.validateAuth(); + } catch (Throwable t) { + try { + close(); + } catch (Throwable closeFailure) { + t.addSuppressed(closeFailure); + } + throw t; + } } /** - * 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. + * Latest rate-limit snapshot recorded from any successful response. Returns {@code null} until + * the first rate-limit-bearing response has arrived — a real {@code remaining=0} reported by the + * server stays observable as {@code snapshot.remaining() == 0}, distinct from "no snapshot yet". */ - public @Nullable RateLimits getRateLimits() { + public @Nullable RateLimitSnapshot getRateLimits() { return transport.getLatestRateLimits(); } @@ -161,7 +190,16 @@ public void close() { transport.close(); } - private static String trimTrailingSlash(String url) { - return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + @Override + public String toString() { + return "MarketDataClient[baseUrl=" + + config.baseUrl() + + ", apiVersion=" + + config.apiVersion() + + ", apiKey=" + + Tokens.redact(config.apiKey()) + + ", demoMode=" + + DemoMode.isDemo(config) + + "]"; } } diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java new file mode 100644 index 0000000..8627eb5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -0,0 +1,37 @@ +package com.marketdata.sdk; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + * Conventions for date/time fields surfaced to SDK consumers (§13.4). + * + *

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

    + *
  • Market-data timestamps — anything that comes from the wire and represents + * a moment in market time (quote times, candle bars, service uptime updates, etc.). Surfaced + * as {@link ZonedDateTime} in {@link #MARKET_ZONE} so the consumer sees the moment the way a + * trader thinks about it without converting. {@link #marketTimeFromEpochSecond} is the + * canonical conversion from the API's Unix-seconds wire format. + *
  • SDK-internal timestamps — when the SDK constructed an error, when a log + * record was emitted, etc. These stay as {@link Instant} (zone-neutral) because they aren't + * market data and converting them to Eastern would imply a semantic they don't have. Display + * layers ({@code MarketDataException.getSupportInfo()}, {@link CanonicalLogFormatter}) render + * those internal timestamps in {@link #MARKET_ZONE} for presentation consistency, but the + * canonical value remains an {@code Instant}. + *
+ */ +final class MarketDataDates { + + /** America/New_York — the zone the API uses for market hours and the SDK surfaces. */ + static final ZoneId MARKET_ZONE = ZoneId.of("America/New_York"); + + private MarketDataDates() {} + + /** Convert a Unix epoch-second timestamp (the API's wire format) to market-zone time. */ + static ZonedDateTime marketTimeFromEpochSecond(long epochSecond) { + return Instant.ofEpochSecond(epochSecond).atZone(MARKET_ZONE); + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java new file mode 100644 index 0000000..b2b416c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -0,0 +1,158 @@ +package com.marketdata.sdk; + +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jspecify.annotations.Nullable; + +/** + * Global, idempotent JUL configuration for the SDK (§9). Installs one {@link ConsoleHandler} with + * {@link CanonicalLogFormatter} on the SDK root logger ({@code com.marketdata.sdk}) and applies the + * level resolved from the {@code MARKETDATA_LOGGING_LEVEL} env var (default {@code INFO}). + * + *

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

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

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

Consumer-config detection: the SDK logger lives in a JVM-wide registry, so a + * consumer (or another lib) may have already attached a handler or set a level on it before any + * {@link MarketDataClient} is constructed. {@link #configure(String)} detects that and backs off + * entirely — no handler added, no {@code useParentHandlers} flipped, no level overridden. This + * makes the constructor's logging side-effect conditional: install the spec-default behavior only + * when no other code has expressed an opinion. + */ +final class MarketDataLogging { + + static final String SDK_LOGGER_NAME = "com.marketdata.sdk"; + static final Level DEFAULT_LEVEL = Level.INFO; + + /** + * Latched to {@code true} only when {@link #configure(String)} successfully installs the SDK + * handler. Calls that return because the consumer had pre-configured the logger do not + * latch — if the consumer later releases control (clears their handler / level), a subsequent + * {@code configure(...)} can still install the SDK defaults. "First call wins" applies to the + * install, not to the skip. + */ + private static final AtomicBoolean configured = new AtomicBoolean(false); + + private MarketDataLogging() {} + + /** + * Install the SDK's handler + formatter on the SDK root logger. Idempotent — first successful + * install wins; subsequent calls are no-ops. Also backs off entirely when the SDK logger already + * carries a handler or an explicit level (see class docs): the consumer has taken control, the + * SDK respects it; that path does not latch the idempotency flag, so the SDK can still + * install later if the consumer's control is released. + * + * @param levelSpec a level string from {@code MARKETDATA_LOGGING_LEVEL} ({@code DEBUG}, {@code + * INFO}, {@code WARNING}, {@code ERROR}, case-insensitive), or {@code null} for the default + * {@link #DEFAULT_LEVEL}. + */ + static void configure(@Nullable String levelSpec) { + Level requested = parseLevel(levelSpec); + if (configured.get()) { + // SDK already installed by an earlier call. Subsequent calls don't replace the handler + // (first-install-wins) but may diagnose level mismatches at DEBUG so a stale-logging + // surprise has a breadcrumb. + Level installed = Logger.getLogger(SDK_LOGGER_NAME).getLevel(); + if (installed != null && !installed.equals(requested)) { + LOG.fine( + () -> + "MarketDataLogging.configure called with level " + + requested.getName() + + " but logger is already configured at " + + installed.getName() + + "; ignoring (first-install-wins)."); + } + return; + } + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + if (sdkLogger.getHandlers().length > 0 || sdkLogger.getLevel() != null) { + // Consumer (or another library) already configured the SDK logger. Respect that entirely: + // don't add our ConsoleHandler (would double-emit), don't flip useParentHandlers (would + // break their parent-handler routing), don't overwrite the level they explicitly chose. + // Crucially, do NOT latch `configured` here — the consumer can release control later + // (remove their handler, clear their level), and a subsequent configure() call should be + // allowed to install the SDK defaults at that point. Latching would freeze the SDK out + // for the lifetime of the process even after the consumer-pre-config disappeared. + return; + } + // Claim the install slot. Losing the race with another concurrent configure() means another + // thread is already installing — treat as idempotent skip. + if (!configured.compareAndSet(false, true)) { + return; + } + Handler handler = new ConsoleHandler(); + handler.setFormatter(new CanonicalLogFormatter()); + // ConsoleHandler defaults its own filter to INFO; lower it so the logger's level is the + // single source of truth for what gets emitted. + handler.setLevel(Level.ALL); + sdkLogger.addHandler(handler); + sdkLogger.setUseParentHandlers(false); + sdkLogger.setLevel(requested); + } + + private static final java.util.logging.Logger LOG = + java.util.logging.Logger.getLogger(SDK_LOGGER_NAME); + + static final java.util.Set VALID_LEVEL_NAMES = + java.util.Set.of("DEBUG", "INFO", "WARNING", "ERROR"); + + static Level parseLevel(@Nullable String levelSpec) { + if (levelSpec == null) { + return DEFAULT_LEVEL; + } + String normalized = levelSpec.trim().toUpperCase(Locale.ROOT); + Level resolved = + switch (normalized) { + case "DEBUG" -> Level.FINE; + case "INFO" -> Level.INFO; + case "WARNING" -> Level.WARNING; + case "ERROR" -> Level.SEVERE; + default -> null; + }; + if (resolved != null) { + return resolved; + } + // Unknown spec — fall back to the default level, but loudly. A silent fallback was the + // worst of both worlds: the consumer typed something wrong and saw INFO output instead of + // the DEBUG they expected, with no breadcrumb pointing at the typo. + LOG.warning( + () -> + "Unrecognized MARKETDATA_LOGGING_LEVEL value '" + + levelSpec + + "'; expected one of " + + VALID_LEVEL_NAMES + + ". Falling back to " + + DEFAULT_LEVEL.getName() + + "."); + return DEFAULT_LEVEL; + } + + /** + * Test-only seam: clear the installed handler and the idempotency flag so subsequent tests can + * {@link #configure(String)} with different levels. Not part of the public contract; not + * thread-safe. + */ + static void resetForTests() { + Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME); + for (Handler h : sdkLogger.getHandlers()) { + sdkLogger.removeHandler(h); + } + sdkLogger.setUseParentHandlers(true); + sdkLogger.setLevel(null); + configured.set(false); + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java b/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java deleted file mode 100644 index 5dd4815..0000000 --- a/src/main/java/com/marketdata/sdk/MarketStatusDeserializer.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.marketdata.sdk; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.marketdata.sdk.markets.DailyStatus; -import com.marketdata.sdk.markets.MarketStatus; -import java.io.IOException; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; - -/** - * Jackson deserializer for the {@code /v1/markets/status/} parallel-arrays wire format. - * - *

Wire shape (success): - * - *

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

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

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

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

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

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

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

Sync. The async sibling is {@link #statusAsync()}. - */ - public MarketStatus status() { - return transport.executeSync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); - } - - /** Async variant of {@link #status()}. */ - public CompletableFuture statusAsync() { - return transport.executeAsync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); - } - - /** - * Market status for a single trading day. Equivalent to {@code GET - * /v1/markets/status/?date=YYYY-MM-DD}. - * - * @param date the trading day to look up; sent in ISO-8601 format - */ - public MarketStatus status(LocalDate date) { - return transport.executeSync(forDate(date), MarketStatus.class); - } - - /** Async variant of {@link #status(LocalDate)}. */ - public CompletableFuture statusAsync(LocalDate date) { - return transport.executeAsync(forDate(date), MarketStatus.class); - } - - /** - * Market status for a closed date range. Equivalent to {@code GET - * /v1/markets/status/?from=YYYY-MM-DD&to=YYYY-MM-DD}. Both endpoints are inclusive. - * - * @param from start of the range (inclusive) - * @param to end of the range (inclusive) - * @throws IllegalArgumentException if {@code from} is after {@code to} - */ - public MarketStatus status(LocalDate from, LocalDate to) { - return transport.executeSync(forRange(from, to), MarketStatus.class); - } - - /** Async variant of {@link #status(LocalDate, LocalDate)}. */ - public CompletableFuture statusAsync(LocalDate from, LocalDate to) { - return transport.executeAsync(forRange(from, to), MarketStatus.class); - } - - private static RequestSpec forDate(LocalDate date) { - return RequestSpec.get(STATUS_PATH).query("date", ISO_DATE.format(date)).build(); - } - - private static RequestSpec forRange(LocalDate from, LocalDate to) { - if (from.isAfter(to)) { - throw new IllegalArgumentException("from (" + from + ") must not be after to (" + to + ")"); - } - return RequestSpec.get(STATUS_PATH) - .query("from", ISO_DATE.format(from)) - .query("to", ISO_DATE.format(to)) - .build(); - } -} diff --git a/src/main/java/com/marketdata/sdk/Mode.java b/src/main/java/com/marketdata/sdk/Mode.java new file mode 100644 index 0000000..19530ee --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Mode.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk; + +/** + * Data-freshness tier requested for the response. Controlled via {@code ?mode=}. + * + *

    + *
  • {@link #LIVE} — current market data (default). + *
  • {@link #DELAYED} — exchange-delayed data, typically 15 minutes. + *
  • {@link #CACHED} — last cached snapshot; lowest cost, highest staleness. + *
+ */ +public enum Mode { + LIVE("live"), + DELAYED("delayed"), + CACHED("cached"); + + private final String wireValue; + + Mode(String wireValue) { + this.wireValue = wireValue; + } + + /** The value sent in the {@code ?mode=} query parameter. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java new file mode 100644 index 0000000..7ffabcc --- /dev/null +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -0,0 +1,271 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Helper for deserializing the API's parallel-arrays wire format. Almost every endpoint that + * returns multiple rows uses this shape: N equal-length arrays of column values plus a leading + * {@code "s"} envelope status, e.g. + * + *
{@code
+ * { "s": "ok",
+ *   "symbol": ["AAPL", "MSFT"],
+ *   "price":  [150.0,   400.0] }
+ * }
+ * + *

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

Cell-level accessors on {@link Row} are strict by default: a {@code null} + * cell or a value of the wrong JSON type raises {@link JsonMappingException} (which surfaces as + * {@link com.marketdata.sdk.exception.ParseError} upstream). The previous lenient behavior — + * substituting {@code ""}, {@code false}, {@code 0.0}, {@code 0} for missing cells — masked real + * server bugs: e.g. a regression that dropped the {@code online} column would have silently flipped + * every service to {@code online=false}, propagating to {@link StatusCache} decisions and blocking + * retries across the board. If a future endpoint has legitimately-nullable columns, add explicit + * {@code textOr(field, default)} overloads then — not pre-emptively. + */ +final class ParallelArrays { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String ENVELOPE_ERROR = "error"; + + private ParallelArrays() {} + + /** + * Zip the parallel arrays under {@code root} into a list of rows via {@code rowBuilder}. + * + *

Envelope handling: + * + *

    + *
  • {@code "s":"error"} → {@link JsonMappingException} carrying the server-side {@code + * errmsg}. The parent parser turns it into a {@link + * com.marketdata.sdk.exception.ParseError}. + *
  • {@code "s":"no_data"} → empty list. The backend uses this envelope (paired with HTTP 404, + * see {@code HttpTransport.routeAndEnvelope}) for "the query has no results"; the data + * arrays are deliberately omitted in that case. Returning an empty list lets the resource + * wrap it in its container type ({@code new ApiStatus(emptyList)}, etc.) so the consumer + * reaches {@link Response#isNoData()} and {@link Response#data()} normally instead of + * hitting a spurious {@code "missing field"} error from the field-validation loop. + *
  • Any other status (typically {@code "ok"}) → normal field validation. + *
+ * + * @throws JsonMappingException if the envelope reports {@code "s":"error"}, a required field is + * absent or not an array, or arrays have mismatched lengths. + */ + static List zip(JsonParser p, JsonNode root, List fields, RowBuilder rowBuilder) + throws IOException { + + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if (ENVELOPE_ERROR.equals(envelopeStatus)) { + String errmsg = root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"); + throw new JsonMappingException(p, "API responded with error: " + errmsg); + } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return List.of(); + } + + Map arrays = new LinkedHashMap<>(); + int expected = -1; + for (String field : fields) { + JsonNode node = root.get(field); + if (node == null || !node.isArray()) { + throw new JsonMappingException(p, "missing or non-array field: " + field); + } + if (expected == -1) { + expected = node.size(); + } else if (node.size() != expected) { + throw new JsonMappingException( + p, + "mismatched lengths: " + + field + + "=" + + node.size() + + " vs expected=" + + expected + + " (from first column " + + fields.get(0) + + ")"); + } + arrays.put(field, node); + } + + int rowCount = Math.max(expected, 0); + List rows = new ArrayList<>(rowCount); + for (int i = 0; i < rowCount; i++) { + rows.add(rowBuilder.build(new IndexedRow(arrays, i))); + } + return rows; + } + + /** + * Builds one row from the {@code Row} accessor at a fixed index. Allowed to throw {@link + * IOException} so {@link Row} accessors can surface {@link JsonMappingException} for strict cell + * validation. + */ + @FunctionalInterface + interface RowBuilder { + T build(Row row) throws IOException; + } + + /** + * Build a {@link JsonDeserializer} for a parallel-arrays response: parses the tree, zips the + * columns into rows, then wraps the resulting list in the container record. Lets each + * response-shape declaration be a single call instead of a hand-written deserializer class — the + * ~30-line boilerplate (extend {@code JsonDeserializer}, read tree, call {@code zip}, build + * record) collapses to the three pieces that actually differ per endpoint: column names, per-row + * constructor, container wrapper. + * + *

{@code wrapper} is typically the record constructor reference (e.g. {@code ApiStatus::new}). + * Receives an immutable list — the record's compact constructor can {@code List.copyOf} for + * defensive copy without surprises about mutability. + * + * @param fields names of the parallel arrays expected under the response root, in the order the + * {@link RowBuilder} will reference them + * @param rowBuilder how to materialize one element of the resulting list from a {@link Row} + * @param wrapper how to wrap the resulting list of rows in the container response record + * @param per-row element type produced by {@code rowBuilder} + * @param container response type + */ + static JsonDeserializer listDeserializer( + List fields, RowBuilder rowBuilder, Function, T> wrapper) { + return new JsonDeserializer() { + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + List rows = zip(p, root, fields, rowBuilder); + return wrapper.apply(rows); + } + }; + } + + /** + * Strict typed accessors over one row of the parallel arrays. Each accessor verifies that the + * cell is present and is of the expected JSON type; otherwise a {@link JsonMappingException} is + * raised so the row never silently degrades to a sentinel value. + */ + interface Row { + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON string. + */ + String text(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON boolean. + */ + boolean bool(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON number. + */ + double dbl(String field) throws JsonMappingException; + + /** + * @throws JsonMappingException if the cell is null, missing, or not a JSON number. + */ + long lng(String field) throws JsonMappingException; + + /** + * Raw access for custom conversions (e.g. nested objects or non-trivial date parsing). Returns + * the node verbatim — the caller decides how to validate. + */ + JsonNode node(String field); + } + + private static final class IndexedRow implements Row { + private final Map arrays; + private final int index; + + IndexedRow(Map arrays, int index) { + this.arrays = arrays; + this.index = index; + } + + @Override + public String text(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isTextual()) { + throw typeMismatch(field, "string", cell); + } + return cell.asText(); + } + + @Override + public boolean bool(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isBoolean()) { + throw typeMismatch(field, "boolean", cell); + } + return cell.asBoolean(); + } + + @Override + public double dbl(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asDouble(); + } + + @Override + public long lng(String field) throws JsonMappingException { + JsonNode cell = requirePresent(field); + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asLong(); + } + + @Override + public JsonNode node(String field) { + return cell(field); + } + + private JsonNode cell(String field) { + JsonNode array = arrays.get(field); + if (array == null) { + throw new IllegalArgumentException( + "Row accessor asked for unknown field '" + + field + + "'; declared columns are " + + arrays.keySet()); + } + return array.get(index); + } + + private JsonNode requirePresent(String field) throws JsonMappingException { + JsonNode cell = cell(field); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + throw new JsonMappingException(null, "null cell at field '" + field + "' row " + index); + } + return cell; + } + + private JsonMappingException typeMismatch(String field, String expected, JsonNode actual) { + return new JsonMappingException( + null, + "expected " + + expected + + " at field '" + + field + + "' row " + + index + + " but got " + + actual.getNodeType()); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java index 909cffa..544dc59 100644 --- a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java +++ b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java @@ -6,11 +6,15 @@ /** * 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. + * request (SDK requirements §8.2) into a {@link RateLimitSnapshot}. * - *

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

Returns {@code null} when the four headers do not arrive together (all absent, partial, or any + * value unparseable). The §8.2 contract is that the four headers ship as a set; a partial delivery + * is a server-side rate-limit-tracking outage, not legitimate data. Returning {@code null} on + * partial responses preserves the caller's last-known-good snapshot in {@link + * HttpTransport#latestRateLimits} instead of clobbering it with phantom zeros — those would + * otherwise trip {@link HttpTransport#checkRateLimitPreflight} into blocking subsequent requests + * with a fake {@code remaining=0}. */ final class RateLimitHeaders { @@ -21,19 +25,16 @@ final class RateLimitHeaders { private RateLimitHeaders() {} - static @Nullable RateLimits parse(HttpHeaders headers) { + static @Nullable RateLimitSnapshot parse(HttpHeaders headers) { Long limit = readLong(headers, LIMIT); Long remaining = readLong(headers, REMAINING); Long reset = readLong(headers, RESET); Long consumed = readLong(headers, CONSUMED); - if (limit == null && remaining == null && reset == null && consumed == null) { + 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); + return new RateLimitSnapshot( + limit.intValue(), remaining.intValue(), Instant.ofEpochSecond(reset), consumed.intValue()); } private static @Nullable Long readLong(HttpHeaders headers, String name) { diff --git a/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java new file mode 100644 index 0000000..e281c92 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RateLimitSnapshot.java @@ -0,0 +1,5 @@ +package com.marketdata.sdk; + +import java.time.Instant; + +public record RateLimitSnapshot(int limit, int remaining, Instant reset, int consumed) {} diff --git a/src/main/java/com/marketdata/sdk/RateLimits.java b/src/main/java/com/marketdata/sdk/RateLimits.java deleted file mode 100644 index ef8b12a..0000000 --- a/src/main/java/com/marketdata/sdk/RateLimits.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.marketdata.sdk; - -import java.time.Instant; - -/** - * Snapshot of the API rate-limit state, parsed from the {@code x-api-ratelimit-*} response headers. - * - *

Per SDK requirements §8, this is a client-level snapshot and is non-deterministic under - * concurrent use; per-request metadata is attached to each response separately. - * - * @param limit total credits available in the current window - * @param remaining credits left in the current window - * @param reset instant at which {@code remaining} resets to {@code limit} - * @param consumed credits consumed by the most recent request - */ -public record RateLimits(long limit, long remaining, Instant reset, long consumed) {} diff --git a/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java new file mode 100644 index 0000000..a26cb77 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestHeadersDeserializer.java @@ -0,0 +1,55 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.io.IOException; +import java.util.Map; + +/** + * Wire-format deserializer for {@link RequestHeaders}. The server returns a flat JSON object — + * {@code {"accept":"*\/*","cf-ray":"abc-123",...}} — and we wrap it in a {@code RequestHeaders} + * record at the Jackson layer rather than via an annotation on the record (per ADR-007: response + * records don't carry {@code @JsonDeserialize}; deserializers register programmatically on the + * parser's {@code ObjectMapper}). + * + *

A literal JSON {@code null} body — or any other path that would leave the parser holding a + * {@code null} map — is short-circuited to a {@link JsonMappingException} so {@link + * JsonResponseParser} surfaces it as a {@link com.marketdata.sdk.exception.ParseError} with the + * request's URL/status/id attached. Without the guard, Jackson would return {@code null} from the + * top-level {@code readValue} (via {@link #getNullValue}, its standard null-routing seam) and the + * NPE would surface much later — uncaught by the parser's {@code catch (IOException)} and far less + * useful to a consumer trying to diagnose a malformed response. + */ +final class RequestHeadersDeserializer extends JsonDeserializer { + + private static final TypeReference> MAP_OF_STRINGS = new TypeReference<>() {}; + + @Override + public RequestHeaders deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + Map raw = p.readValueAs(MAP_OF_STRINGS); + if (raw == null) { + // Defense in depth: a top-level JSON null is intercepted by getNullValue(ctxt) below before + // deserialize() is ever called, so this branch is reachable only via a pathological future + // Jackson behavior. Better to fail with a clean JsonMappingException than to let the null + // reach the record's requireNonNull and bypass the parser's IOException catch. + throw JsonMappingException.from(p, "expected a JSON object for /headers/ body, got null map"); + } + return new RequestHeaders(raw); + } + + /** + * Jackson routes a top-level JSON {@code null} through this seam instead of calling {@link + * #deserialize}. Default behavior returns {@code null}; we instead throw so the wire-null case + * produces a {@link com.marketdata.sdk.exception.ParseError} with the endpoint URL in scope, + * matching the failure shape of any other malformed body. + */ + @Override + public RequestHeaders getNullValue(DeserializationContext ctxt) throws JsonMappingException { + throw JsonMappingException.from( + ctxt, "expected a JSON object for /headers/ body, got JSON null"); + } +} diff --git a/src/main/java/com/marketdata/sdk/RequestSpec.java b/src/main/java/com/marketdata/sdk/RequestSpec.java index 428c30c..45daef8 100644 --- a/src/main/java/com/marketdata/sdk/RequestSpec.java +++ b/src/main/java/com/marketdata/sdk/RequestSpec.java @@ -2,21 +2,37 @@ import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** * Declarative description of an HTTP GET request the SDK wants to make. * *

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

Universal query parameters (per SDK requirements §3) are surfaced as typed builder methods so + * resources don't reach for {@link Builder#query} for the common cross-cutting cases. * * @param path API-relative path with no leading {@code /v1/} prefix and no trailing slash, e.g. - * {@code "markets/status"}. The transport adds the base URL, version prefix, and trailing - * slash. + * {@code "markets/status"}. The transport adds the base URL, version prefix (when {@link + * #versioned} is true), and trailing slash. * @param queryParams ordered query parameters (insertion order preserved for predictable URLs in * tests). Values are URL-encoded by the transport. + * @param format wire response format. The transport mirrors this in the {@code Accept} request + * header; the {@code ?format=} query param is also written into {@code queryParams} by the + * builder when set. + * @param versioned when true, the transport interpolates the API version segment between base URL + * and path (the default, used by every {@code /v1/...} endpoint); when false, the path is + * appended directly to the base URL. The handful of system endpoints documented at the API root + * — {@code /status/}, {@code /headers/} — opt into the unversioned form. */ -record RequestSpec(String path, Map queryParams) { +record RequestSpec(String path, Map queryParams, Format format, boolean versioned) { + + static final Format DEFAULT_FORMAT = Format.JSON; RequestSpec { // Preserve insertion order — Map.copyOf would defensively copy but @@ -32,12 +48,14 @@ static Builder get(String path) { static final class Builder { private final String path; private final Map queryParams = new LinkedHashMap<>(); + private Format format = DEFAULT_FORMAT; + private boolean versioned = true; private Builder(String path) { this.path = path; } - /** Adds a query parameter only if {@code value} is non-null. */ + /** Adds an arbitrary query parameter; skipped if {@code value} is null. */ Builder query(String key, Object value) { if (value != null) { queryParams.put(key, value.toString()); @@ -45,10 +63,72 @@ Builder query(String key, Object value) { return this; } + /** + * Marks this request as targeting the unversioned root of the API ({@code + * https://api.marketdata.app/path/}), rather than the default {@code + * https://api.marketdata.app/v1/path/}. Only a handful of system endpoints — {@code /status/} + * and {@code /headers/} — live there. + */ + Builder unversioned() { + this.versioned = false; + return this; + } + + /** Sets the wire response format ({@code ?format=}) and the matching Accept header. */ + Builder format(Format format) { + this.format = format; + queryParams.put("format", format.wireValue()); + return this; + } + + /** Sets {@code ?dateformat=} controlling date/time serialization. */ + Builder dateformat(DateFormat fmt) { + queryParams.put("dateformat", fmt.wireValue()); + return this; + } + + /** Sets {@code ?mode=} controlling data freshness tier. */ + Builder mode(Mode mode) { + queryParams.put("mode", mode.wireValue()); + return this; + } + + /** Sets {@code ?headers=true|false} controlling the CSV header row. */ + Builder headers(boolean include) { + queryParams.put("headers", String.valueOf(include)); + return this; + } + + /** Sets {@code ?human=true|false} for human-readable attribute names. */ + Builder human(boolean human) { + queryParams.put("human", String.valueOf(human)); + return this; + } + + /** Sets {@code ?columns=...} as a comma-joined list. No-op when {@code cols} is empty. */ + Builder columns(List cols) { + if (!cols.isEmpty()) { + queryParams.put("columns", String.join(",", cols)); + } + return this; + } + + /** Sets {@code ?limit=}. */ + Builder limit(int limit) { + queryParams.put("limit", String.valueOf(limit)); + return this; + } + + /** Sets {@code ?offset=}. */ + Builder offset(int offset) { + queryParams.put("offset", String.valueOf(offset)); + return this; + } + RequestSpec build() { // Pass the raw LinkedHashMap — the record's compact constructor defensively copies and // wraps it as unmodifiable, so wrapping here too would just rebuild a redundant view. - return new RequestSpec(path, queryParams); + return new RequestSpec(path, queryParams, format, versioned); } } } diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java new file mode 100644 index 0000000..3d254dc --- /dev/null +++ b/src/main/java/com/marketdata/sdk/Response.java @@ -0,0 +1,150 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, + * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), + * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} + * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. + * + *

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

Immutable. {@link #rawBody()} returns a defensive copy on every call. + * + * @param the typed deserialization of {@link #rawBody()}. + */ +public final class Response { + + private final T data; + private final byte[] rawBody; + private final Format format; + private final int statusCode; + private final @Nullable String requestId; + private final URI requestUrl; + + private Response( + T data, + byte[] rawBody, + Format format, + int statusCode, + @Nullable String requestId, + URI requestUrl) { + this.data = Objects.requireNonNull(data, "data"); + this.rawBody = Objects.requireNonNull(rawBody, "rawBody").clone(); + this.format = Objects.requireNonNull(format, "format"); + this.statusCode = statusCode; + this.requestId = requestId; + this.requestUrl = Objects.requireNonNull(requestUrl, "requestUrl"); + } + + /** + * Package-private factory used by resource façades. Resources parse the envelope's body to a + * typed {@code T}, then wrap. + */ + static Response wrap(T data, HttpResponseEnvelope envelope, Format format) { + return new Response<>( + data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); + } + + /** The typed deserialization. Never {@code null}. */ + public T data() { + return data; + } + + /** + * Defensive copy of the raw response bytes. Mutating the result does not affect this response. + */ + public byte[] rawBody() { + return rawBody.clone(); + } + + /** HTTP status code (currently one of 200, 203, 404). */ + public int statusCode() { + return statusCode; + } + + /** Absolute URL the response came from. */ + public URI requestUrl() { + return requestUrl; + } + + /** + * Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when the response did + * not carry one — useful when correlating with the support team. Matches the nullability shape of + * {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can branch + * the same way regardless of which surface carries the id. + */ + public @Nullable String requestId() { + return requestId; + } + + public boolean isJson() { + return format == Format.JSON; + } + + public boolean isCsv() { + return format == Format.CSV; + } + + /** + * Whether the response body is HTML — typically a misrouted request that landed on the API's + * web-server tier (a marketing or error page) rather than the API tier. Consumers can use this to + * short-circuit JSON-shaped parsing and log the {@link #rawBody()} for diagnosis. + */ + public boolean isHtml() { + return format == Format.HTML; + } + + /** + * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 + * for that envelope (it is a successful "we have nothing for that query", not an error), so we + * gate on the status code rather than parsing the body. + */ + public boolean isNoData() { + return statusCode == 404; + } + + /** + * Write the raw body verbatim to {@code path}, creating or overwriting it. The on-disk content + * matches what the server sent — if you requested {@code ?format=csv}, you get CSV. Errors are + * rewrapped as {@link UncheckedIOException} so {@code saveToFile} fits naturally inside a fluent + * call chain. + */ + public void saveToFile(Path path) { + try { + Files.write(path, rawBody); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write response body to " + path, e); + } + } + + /** + * Log-safe representation: status, format, byte count, and the request URL with the query string + * redacted (§16 — token, account_id, symbol queries must not persist through {@code toString}). + * {@code data} is intentionally omitted: consumers that need the payload have {@link #data()}; + * embedding it here would let a routine {@code log.info(response)} leak a {@code RequestHeaders} + * map (Authorization, client IP) or whatever else the future resource models carry. + */ + @Override + public String toString() { + return "Response[status=" + + statusCode + + ", format=" + + format.name().toLowerCase(java.util.Locale.ROOT) + + ", bytes=" + + rawBody.length + + ", url=" + + HttpDispatcher.safeUri(requestUrl) + + "]"; + } +} diff --git a/src/main/java/com/marketdata/sdk/RetryAfterHeader.java b/src/main/java/com/marketdata/sdk/RetryAfterHeader.java new file mode 100644 index 0000000..8e65f94 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RetryAfterHeader.java @@ -0,0 +1,64 @@ +package com.marketdata.sdk; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +/** + * Parses the HTTP {@code Retry-After} response header. Per RFC 7231 §7.1.3 the value is either: + * + *

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

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

This parser intentionally does not cap the result at any upper bound — the + * spec says "override calculated backoff with server value", taking the server's directive at face + * value. A future revision can introduce a cap if pathological values become an operational + * concern. + */ +final class RetryAfterHeader { + + private RetryAfterHeader() {} + + /** Parse the header value against {@code now} (used only when the value is an HTTP-date). */ + static Optional parse(String value, Instant now) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + Optional asSeconds = parseSeconds(trimmed); + if (asSeconds.isPresent()) { + return asSeconds; + } + return parseHttpDate(trimmed, now); + } + + private static Optional parseSeconds(String value) { + try { + long seconds = Long.parseLong(value); + // Negative deltas violate the spec but pop up in the wild; treat as "retry now". + return Optional.of(Duration.ofSeconds(Math.max(0L, seconds))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private static Optional parseHttpDate(String value, Instant now) { + try { + Instant target = DateTimeFormatter.RFC_1123_DATE_TIME.parse(value, Instant::from); + long delaySeconds = ChronoUnit.SECONDS.between(now, target); + return Optional.of(Duration.ofSeconds(Math.max(0L, delaySeconds))); + } catch (DateTimeParseException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java new file mode 100644 index 0000000..13f17c4 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -0,0 +1,151 @@ +package com.marketdata.sdk; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiPredicate; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; + +/** + * Wraps a {@link Supplier} of {@link CompletableFuture}s with retry-on-failure semantics governed + * by {@link RetryPolicy}. Knows nothing about HTTP, JSON, or {@code MarketDataException} subtypes — + * it just observes which causes the policy says are retriable and schedules subsequent attempts + * after the policy's backoff. + * + *

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

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

Two supplier shapes are supported: {@link Supplier} for callers that don't need per-attempt + * context, and {@link AttemptSupplier} for callers that need to inspect the previous attempt's + * cause — used by {@link HttpTransport} to bypass the §10.3 preflight when retrying on an explicit + * server-side {@code Retry-After} directive (§9.4). + */ +final class RetryExecutor { + + private final RetryPolicy policy; + + RetryExecutor(RetryPolicy policy) { + this.policy = policy; + } + + /** Visible to callers that need to compose their own retry predicate. */ + RetryPolicy policy() { + return policy; + } + + /** + * Builds the future for one attempt. The {@code attemptIdx} starts at 0 for the first attempt; + * {@code previousCause} is the (unwrapped) cause that triggered this retry, or {@code null} on + * the first attempt. + */ + @FunctionalInterface + interface AttemptSupplier { + CompletableFuture get(int attemptIdx, @Nullable Throwable previousCause); + } + + /** + * Drive {@code supplier} with retry. Each invocation of the supplier represents one attempt; if + * the resulting future fails with a retriable cause, {@code supplier} is invoked again after the + * policy-determined backoff. The retry decision uses the policy's own {@code shouldRetry}. + */ + CompletableFuture execute(Supplier> supplier) { + return execute(supplier, policy::shouldRetry); + } + + /** + * Like {@link #execute(Supplier)}, but the caller supplies a custom retry predicate. Used when an + * external gate (e.g. the {@code /status/} pre-check from §9.5) needs to veto a retry the policy + * would otherwise allow. {@link RetryPolicy#backoffDelay} still controls timing. + */ + CompletableFuture execute( + Supplier> supplier, BiPredicate shouldRetry) { + return execute((attemptIdx, previousCause) -> supplier.get(), shouldRetry); + } + + /** + * Like {@link #execute(Supplier, BiPredicate)} but the supplier receives the attempt index and + * the previous attempt's cause so it can adjust behavior across retries — e.g. skip preflight + * checks when the previous failure carried an explicit server-side {@code Retry-After}. + */ + CompletableFuture execute( + AttemptSupplier supplier, BiPredicate shouldRetry) { + CompletableFuture result = new CompletableFuture<>(); + // One cancellation handler installed once: whichever attempt is currently in flight is + // tracked in `currentAttempt`; cancelling `result` cancels that. Previous attempts are + // already done by the time the next one overwrites the reference, so this avoids + // accumulating one handler per attempt. + AtomicReference<@Nullable CompletableFuture> currentAttempt = new AtomicReference<>(); + result.whenComplete( + (r, t) -> { + if (t instanceof CancellationException) { + CompletableFuture inFlight = currentAttempt.get(); + if (inFlight != null && !inFlight.isDone()) { + inFlight.cancel(false); + } + } + }); + attempt(supplier, shouldRetry, 0, null, result, currentAttempt); + return result; + } + + private void attempt( + AttemptSupplier supplier, + BiPredicate shouldRetry, + int attemptIdx, + @Nullable Throwable previousCause, + CompletableFuture result, + AtomicReference<@Nullable CompletableFuture> currentAttempt) { + if (result.isDone()) { + // Caller cancelled (or completed exceptionally from a previous attempt's whenComplete). + // Don't invoke the supplier again. Checking isDone() (not just isCancelled()) avoids + // running a fresh attempt after the previous one's whenComplete completed `result`. + return; + } + CompletableFuture dispatched = supplier.get(attemptIdx, previousCause); + currentAttempt.set(dispatched); + + // Race: `result.cancel(...)` may have fired between the isDone() check above and the + // currentAttempt.set() call. The cancellation handler in execute() observes + // currentAttempt under that race: if it sees the previous (already-done) attempt, it + // doesn't cancel the new one. Re-check after publishing the new attempt. + if (result.isCancelled() && !dispatched.isDone()) { + dispatched.cancel(false); + return; + } + + dispatched.whenComplete( + (value, error) -> { + if (result.isDone()) { + return; + } + if (error == null) { + result.complete(value); + return; + } + Throwable cause = unwrap(error); + if (shouldRetry.test(cause, attemptIdx)) { + long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); + CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) + .execute( + () -> + attempt( + supplier, shouldRetry, attemptIdx + 1, cause, result, currentAttempt)); + } else { + result.completeExceptionally(cause); + } + }); + } + + // Package-private so the unwrap-when-nested-and-when-not branches are reachable from tests. + static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } +} diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java index 9b3eb65..3ad2de6 100644 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -5,6 +5,7 @@ import com.marketdata.sdk.exception.ServerError; import java.io.IOException; import java.time.Duration; +import java.util.logging.Logger; /** * Decides which failures get retried and how long to wait between attempts. Per SDK requirements @@ -20,9 +21,28 @@ * *

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

§9.4 {@code Retry-After} override: when the failing cause is a {@link ServerError} that + * carries a server-supplied delay, {@link #backoffDelay(Throwable, int)} returns that delay + * verbatim instead of the exponential calculation. §9.5 {@code /status/} cache pre-check is handled + * at the {@code HttpTransport} layer via {@code StatusCache}, not here — that gate depends on + * external runtime state this class doesn't see. */ final class RetryPolicy { + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + + /** + * Issue #21: hard cap on the server-supplied {@code Retry-After} the SDK will honor for its + * automatic retry. A compromised or buggy backend that emits {@code Retry-After: 9999999999} + * would otherwise freeze the next attempt for ~292 billion years inside the {@code + * delayedExecutor}. Above this threshold the SDK logs a warning and falls back to its calculated + * exponential backoff — the server's hint is still visible to consumers via {@code + * ServerError.getRetryAfter()} so they can decide on their own (e.g. surface to humans, schedule + * a real cool-off) without the SDK silently holding a thread for hours. + */ + static final Duration MAX_RETRY_AFTER = Duration.ofMinutes(10); + private final int maxAttempts; private final Duration initialBackoff; private final Duration maxBackoff; @@ -41,6 +61,16 @@ static RetryPolicy defaults() { return new RetryPolicy(4, Duration.ofSeconds(1), Duration.ofSeconds(30)); } + /** + * Single-attempt policy: {@code shouldRetry} always returns {@code false}. Useful for calls where + * retrying does more harm than failing fast — e.g. the startup validation in {@link + * MarketDataClient}, where a slow/down API should surface to the constructor within seconds + * rather than burning the full ~6.75 min default retry budget before throwing. + */ + static RetryPolicy noRetry() { + return new RetryPolicy(1, Duration.ZERO, Duration.ZERO); + } + /** * Whether the SDK should retry after {@code cause}, given that {@code attempt} attempts have * already been spent (zero-indexed: {@code attempt == 0} means the original call just failed and @@ -53,6 +83,35 @@ boolean shouldRetry(Throwable cause, int attempt) { return isRetriable(cause); } + /** + * Backoff before the next attempt, honoring a server-supplied {@code Retry-After} when the cause + * is a {@link ServerError} that carried one (§9.4). Otherwise falls back to the exponential + * calculation from {@link #backoffDelay(int)}. + */ + Duration backoffDelay(Throwable cause, int attempt) { + if (cause instanceof ServerError server) { + Duration override = server.getRetryAfter().orElse(null); + if (override != null) { + if (override.compareTo(MAX_RETRY_AFTER) > 0) { + // Issue #21: server-supplied delay exceeds the SDK's hard cap. Log and fall through + // to the calculated exponential backoff. The unbounded value is still preserved on the + // ServerError instance for consumer inspection — only the SDK's own automatic wait is + // capped. + LOGGER.warning( + () -> + "Server-supplied Retry-After of " + + override.toSeconds() + + "s exceeds cap of " + + MAX_RETRY_AFTER.toSeconds() + + "s; ignoring and using calculated exponential backoff"); + } else { + return override; + } + } + } + return backoffDelay(attempt); + } + /** * Backoff before the next attempt. {@code attempt == 0} means "before the first retry", i.e. the * delay applied right after the original call failed. @@ -85,17 +144,48 @@ private static boolean isRetriable(Throwable cause) { // ConnectException, HttpTimeoutException, ...) and sync-throws from httpClient.sendAsync // (NPE, IllegalArgumentException — bugs, not network). Retry only the former; the latter // is deterministic and just burns the backoff for the same crash. - return net.getCause() instanceof IOException; + // + // Issue #15: walk the full cause chain rather than only checking the direct cause. JDK + // HttpClient under HTTP/2 multiplexing (and some JDK versions) can present an IOException + // nested under an ExecutionException/CompletionException wrapper that HttpDispatcher's + // single-level unwrap doesn't peel. Without the walk those legitimate transport failures + // fall out of retry silently — the SDK loses §9 resilience only under load. + return hasIoExceptionInCauseChain(net.getCause()); } if (cause instanceof ServerError server) { - Integer status = server.getStatusCode(); - // Spec §9: 500 is not retriable; 501–599 are. A null status means "we threw a ServerError - // without a real HTTP code" — that's only the synthetic-path of HttpStatusMapper today, so - // don't retry it. - return status != null && status >= 501 && status <= 599; + int status = server.getStatusCode(); + // Spec §9: 500 is not retriable; 501–599 are. The 0 sentinel comes from + // ErrorContext.forNoResponse — a ServerError without a real HTTP code — and falls outside + // the range, so the same check excludes it naturally. + return status >= 501 && status <= 599; } // AuthenticationError, BadRequestError, RateLimitError, NotFoundError, ParseError: §9 says // never retry 4xx, and ParseError is deterministic. return false; } + + /** + * Walks {@code t}'s {@link Throwable#getCause()} chain looking for an {@link IOException}. + * Returns {@code false} for a {@code null} root. The walk caps at a defensive depth and tracks + * visited frames so a malformed cycle (theoretically impossible per {@link Throwable}'s contract, + * but cheap to guard) cannot spin the retry decision. + */ + private static boolean hasIoExceptionInCauseChain( + @org.jspecify.annotations.Nullable Throwable t) { + Throwable current = t; + int depth = 0; + while (current != null && depth < 16) { + if (current instanceof IOException) { + return true; + } + Throwable next = current.getCause(); + if (next == current) { + // Self-cycle — getCause() of an exception that wraps itself. Bail out. + return false; + } + current = next; + depth++; + } + return false; + } } diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java new file mode 100644 index 0000000..6395901 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -0,0 +1,167 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jspecify.annotations.Nullable; + +/** + * Client-side cache of the {@code /status/} endpoint used to gate retries against services the API + * has reported offline (SDK requirements §9.5). + * + *

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

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

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

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

Decision matrix per {@link Decision}: {@code offline} services {@link Decision#BLOCK} retries; + * everything else (including the {@code unknown} that comes from a stale or empty cache) {@link + * Decision#ALLOW}s them, per §9.5. + */ +final class StatusCache { + + private static final Logger LOGGER = Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + + static final Duration REFRESH_THRESHOLD = Duration.ofSeconds(270); + static final Duration EXPIRY = Duration.ofSeconds(300); + + private final Supplier> fetcher; + private final Clock clock; + private final AtomicReference<@Nullable Snapshot> snapshot = new AtomicReference<>(); + private final AtomicBoolean refreshInFlight = new AtomicBoolean(false); + + StatusCache(Supplier> fetcher, Clock clock) { + this.fetcher = fetcher; + this.clock = clock; + } + + /** Whether retrying on {@code uri} is allowed by the cache. */ + Decision check(URI uri) { + Snapshot snap = snapshot.get(); + Instant now = clock.instant(); + + boolean refreshNeeded = + snap == null || Duration.between(snap.fetchedAt, now).compareTo(REFRESH_THRESHOLD) >= 0; + if (refreshNeeded) { + triggerRefresh(); + // Issue #19: re-read after triggerRefresh in case the fetcher completed synchronously (a + // test stub returning CompletableFuture.completedFuture, or any synchronous-by-design + // implementation). Without this re-read, a cold start always answers ALLOW — the local + // `snap` reference is still the null we captured above, even though `snapshot.get()` may + // now hold a fresh value populated by the synchronous whenComplete that ran inside + // triggerRefresh. The bug surfaces in production as "first request after startup against a + // known-offline service always burns the retry budget". + snap = snapshot.get(); + } + + boolean usable = snap != null && Duration.between(snap.fetchedAt, now).compareTo(EXPIRY) < 0; + if (!usable) { + // Stale or empty → "unknown" → allow per §9.5. + return Decision.ALLOW; + } + + String status = lookupService(snap, uri); + return "offline".equals(status) ? Decision.BLOCK : Decision.ALLOW; + } + + /** Manually trigger a refresh. Visible for tests; production calls only via {@link #check}. */ + void triggerRefresh() { + if (!refreshInFlight.compareAndSet(false, true)) { + return; // already refreshing + } + CompletableFuture future; + try { + future = fetcher.get(); + } catch (Throwable t) { + // Sync-throw from the fetcher (rare — most failures arrive as a failed future). Log so a + // permanently-broken fetcher doesn't degrade silently into "stale snapshot forever". + LOGGER.log(Level.WARNING, "StatusCache fetcher threw synchronously; snapshot persists.", t); + refreshInFlight.set(false); + return; + } + future.whenComplete( + (apiStatus, err) -> { + try { + if (err == null && apiStatus != null) { + snapshot.set(Snapshot.from(apiStatus, clock.instant())); + } else if (err != null) { + // On error: cache persists — §9.5 "Cache persists across failed refresh attempts" — + // but the failure is logged so operators can detect a /status/ outage instead of + // wondering why the SDK keeps blocking retries against a stale snapshot. + LOGGER.log(Level.WARNING, "StatusCache refresh failed; snapshot persists.", err); + } + } finally { + refreshInFlight.set(false); + } + }); + } + + /** + * Find the cached status for the service whose path is the longest prefix of {@code uri}'s path. + * Returns {@code null} when no service matches. + * + *

Issue #18: keys are normalized to end with {@code /} at snapshot-construction time and we + * also append {@code /} to the input path before comparing. Without this, a key {@code /v1/stock} + * would falsely match {@code /v1/stocks/quotes/AAPL/} (path component boundary not respected) and + * one malformed/truncated server-side entry could block retries for an unrelated service. + */ + private static @Nullable String lookupService(Snapshot snap, URI uri) { + String path = uri.getPath(); + if (path == null) { + return null; + } + String normalizedPath = path.endsWith("/") ? path : path + "/"; + String bestKey = null; + for (String key : snap.serviceToStatus.keySet()) { + if (normalizedPath.startsWith(key) && (bestKey == null || key.length() > bestKey.length())) { + bestKey = key; + } + } + return bestKey == null ? null : snap.serviceToStatus.get(bestKey); + } + + /** Decision the gate returns to the retry executor. */ + enum Decision { + /** Cache permits a retry: service is online, unknown, or out-of-scope. */ + ALLOW, + /** Cache marked the affected service offline — fail immediately without retrying. */ + BLOCK + } + + /** Immutable snapshot of one /status/ response, indexed by service path. */ + private record Snapshot(Instant fetchedAt, Map serviceToStatus) { + static Snapshot from(ApiStatus apiStatus, Instant fetchedAt) { + Map map = new HashMap<>(apiStatus.services().size()); + for (ServiceStatus s : apiStatus.services()) { + // Issue #18: store with a trailing slash so path-boundary matching is correct. The + // server's `service` field is not contractually trailing-slashed; canonicalizing here + // (rather than in lookupService) keeps the hot read path simple. + String key = s.service().endsWith("/") ? s.service() : s.service() + "/"; + map.put(key, s.status()); + } + return new Snapshot(fetchedAt, Map.copyOf(map)); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/Tokens.java b/src/main/java/com/marketdata/sdk/Tokens.java index a17948a..4e101cf 100644 --- a/src/main/java/com/marketdata/sdk/Tokens.java +++ b/src/main/java/com/marketdata/sdk/Tokens.java @@ -2,35 +2,23 @@ import org.jspecify.annotations.Nullable; -/** - * Token redaction helpers. SDK requirements §5 / §16: API tokens must never appear in log output - * verbatim. - */ final class Tokens { - /** - * Minimum number of asterisks emitted before the trailing 4 chars, matching the SDK requirements - * §7 example ({@code ************************************YKT0}). - */ - private static final int MIN_MASK_LENGTH = 32; - - private static final int VISIBLE_TAIL = 4; - - private Tokens() {} + private static final String REDACTED = "***…***"; /** - * Returns a redacted form of {@code token} suitable for logging. The last 4 characters are - * preserved; the rest is replaced with asterisks padded to at least {@value #MIN_MASK_LENGTH} - * characters. + * Redact a token for log/diagnostic output. Returns {@code ***…***} alone when the token is + * absent or short enough that exposing the trailing 4 characters would reveal most of the value + * (length ≤ 8 — at 4 chars the suffix is the whole token; at 5–7 it's 57–80%). Only tokens with + * >8 characters get the {@code ***…***ABCD} form, which is enough material to disambiguate + * which token is in use without leaking it. */ - public static String redact(@Nullable String token) { - if (token == null || token.isBlank()) { - return "(none)"; - } - if (token.length() <= VISIBLE_TAIL) { - return "*".repeat(token.length()); + static String redact(@Nullable String token) { + if (token == null || token.length() <= 8) { + return REDACTED; } - int hidden = Math.max(token.length() - VISIBLE_TAIL, MIN_MASK_LENGTH); - return "*".repeat(hidden) + token.substring(token.length() - VISIBLE_TAIL); + return REDACTED + token.substring(token.length() - 4); } + + private Tokens() {} } diff --git a/src/main/java/com/marketdata/sdk/UserDeserializer.java b/src/main/java/com/marketdata/sdk/UserDeserializer.java new file mode 100644 index 0000000..08dd531 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UserDeserializer.java @@ -0,0 +1,54 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.utilities.User; +import java.io.IOException; + +/** + * Wire-format deserializer for {@link User}. The server uses HTTP-header-styled keys in the JSON + * body ({@code "x-ratelimit-requests-remaining"} etc.); this class maps them to the record's + * camelCase fields here rather than via {@code @JsonProperty} on the record, keeping all wire + * coupling out of the public response type (ADR-007). + * + *

Strict by default — same reasoning as {@link ParallelArrays}: a silent default for a missing + * numeric field would hide server bugs at the worst time (e.g. construction-time + * validateOnStartup), surfacing later as "quota apparently exhausted" with no breadcrumb. The empty + * string is the server's legitimate signal for "real-time options access" so {@code + * optionsDataPermissions} only requires that the field be a JSON string, not that it be non-empty. + */ +final class UserDeserializer extends JsonDeserializer { + + private static final String REMAINING_KEY = "x-ratelimit-requests-remaining"; + private static final String LIMIT_KEY = "x-ratelimit-requests-limit"; + private static final String OPTIONS_PERMS_KEY = "x-options-data-permissions"; + + @Override + public User deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + int remaining = readInt(p, root, REMAINING_KEY); + int limit = readInt(p, root, LIMIT_KEY); + String optionsPerms = readString(p, root, OPTIONS_PERMS_KEY); + return new User(remaining, limit, optionsPerms); + } + + private static int readInt(JsonParser p, JsonNode root, String key) throws JsonMappingException { + JsonNode node = root.get(key); + if (node == null || !node.isIntegralNumber()) { + throw new JsonMappingException(p, "missing or non-integer field: " + key); + } + return node.asInt(); + } + + private static String readString(JsonParser p, JsonNode root, String key) + throws JsonMappingException { + JsonNode node = root.get(key); + if (node == null || !node.isTextual()) { + throw new JsonMappingException(p, "missing or non-string field: " + key); + } + return node.asText(); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java new file mode 100644 index 0000000..2716871 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -0,0 +1,149 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; +import com.marketdata.sdk.utilities.User; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * System endpoints documented at {@code https://api.marketdata.app/docs/api/utilities/}. None of + * them are versioned ({@code /v1/}); they live at the API root. + * + *

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

Every endpoint returns a {@link Response} carrying both the typed model and the raw body so + * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without + * the resource caring about format choice. + */ +public final class UtilitiesResource { + + private final HttpTransport transport; + private final JsonResponseParser parser; + + UtilitiesResource(HttpTransport transport, JsonResponseParser parser) { + this.transport = transport; + this.parser = parser; + // §9 / ADR-007: resources own their wire-format deserializer registration. Registering here + // (in the resource that ships the response models) keeps the parser resource-agnostic and + // lets future resources (stocks, options, funds, markets) add their wire formats without + // editing a central file. + parser.registerModule(wireFormatModule()); + } + + /** + * Build the Jackson module that maps this resource's response records ({@link RequestHeaders}, + * {@link User}, {@link ApiStatus}) to their custom deserializers. Each call returns a fresh + * {@link SimpleModule}; tests that need the same wiring without constructing a full resource can + * register this directly on a bare parser. + */ + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-utilities"); + m.addDeserializer(RequestHeaders.class, new RequestHeadersDeserializer()); + m.addDeserializer(User.class, new UserDeserializer()); + // §11 parallel-arrays decoding via the declarative factory (issue #10): no hand-written + // JsonDeserializer subclass — just the column list, row builder, and container wrapper. The + // pattern scales to every future parallel-arrays endpoint (stocks/candles, options/chain, …) + // without copy-pasting the ~30-line deserializer skeleton. + m.addDeserializer( + ApiStatus.class, + ParallelArrays.listDeserializer( + List.of("service", "status", "online", "uptimePct30d", "uptimePct90d", "updated"), + row -> + new ServiceStatus( + row.text("service"), + row.text("status"), + row.bool("online"), + row.dbl("uptimePct30d"), + row.dbl("uptimePct90d"), + MarketDataDates.marketTimeFromEpochSecond(row.lng("updated"))), + ApiStatus::new)); + return m; + } + + /** + * Async: fetch the request headers the server received for this call, with sensitive values (e.g. + * {@code Authorization}) redacted server-side. Useful for diagnosing auth issues from a deployed + * consumer. + */ + public CompletableFuture> headersAsync() { + RequestSpec spec = RequestSpec.get("headers").unversioned().build(); + return executeAndWrap(spec, RequestHeaders.class); + } + + /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ + public Response headers() { + return transport.joinSync(headersAsync()); + } + + /** + * Async: fetch the caller's current quota state and data-tier permissions. Returns a 401 (as + * {@link com.marketdata.sdk.exception.AuthenticationError}) when no billing plan is associated + * with the token — the typical use case for {@code validateOnStartup}. + * + *

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

Package-private and intent-named: not part of the public API and not an "endpoint" in the + * §1.2 sense, so ADR-006's sync+async parity does not apply. + */ + void validateAuth() { + transport.joinSync( + executeAndWrap( + RequestSpec.get("user").unversioned().build(), RetryPolicy.noRetry(), User.class)); + } + + /** + * Async: fetch the per-service health snapshot of the API. Unversioned ({@code /status/} lives at + * the API root) and public — works without a token. The server refreshes the snapshot every five + * minutes; polling more often than that is wasted work. + */ + public CompletableFuture> statusAsync() { + RequestSpec spec = RequestSpec.get("status").unversioned().build(); + return executeAndWrap(spec, ApiStatus.class); + } + + /** Sync wrapper for {@link #statusAsync()}. */ + public Response status() { + return transport.joinSync(statusAsync()); + } + + // ---------- internal helpers ---------- + + private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { + return transport + .executeAsync(spec) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + } + + private CompletableFuture> executeAndWrap( + RequestSpec spec, RetryPolicy policy, Class type) { + return transport + .executeAsync(spec, policy) + .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/Version.java b/src/main/java/com/marketdata/sdk/Version.java index 909c6c9..d7b3b8c 100644 --- a/src/main/java/com/marketdata/sdk/Version.java +++ b/src/main/java/com/marketdata/sdk/Version.java @@ -2,26 +2,18 @@ import org.jspecify.annotations.Nullable; -/** - * Reads the SDK's version from the JAR manifest's {@code Implementation-Version} attribute (SDK - * requirements §15: "version must be automatically detected from package metadata"). - * - *

Falls back to {@code "0.0.0-dev"} when the class is not loaded from a JAR (e.g. running tests - * from class files). - */ final class Version { static final String FALLBACK = "0.0.0-dev"; - private Version() {} - - static String current() { - return resolve(Version.class.getPackage().getImplementationVersion()); + static String sdkVersion() { + Package pkg = Version.class.getPackage(); + return resolve(pkg == null ? null : pkg.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; + static String resolve(@Nullable String rawVersion) { + return (rawVersion == null || rawVersion.isBlank()) ? FALLBACK : rawVersion; } + + private Version() {} } diff --git a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java index 6efa76f..85c3fbb 100644 --- a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java +++ b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java @@ -2,11 +2,12 @@ import org.jspecify.annotations.Nullable; -/** The API rejected the credentials (HTTP 401). */ public final class AuthenticationError extends MarketDataException { + private static final long serialVersionUID = 1L; + public AuthenticationError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null); } public AuthenticationError(String message, ErrorContext context, @Nullable Throwable cause) { diff --git a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java index 9e4ae68..fd30e6f 100644 --- a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java +++ b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java @@ -2,11 +2,12 @@ import org.jspecify.annotations.Nullable; -/** The request was malformed or invalid (HTTP 400 / 422). */ public final class BadRequestError extends MarketDataException { + private static final long serialVersionUID = 1L; + public BadRequestError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null); } public BadRequestError(String message, ErrorContext context, @Nullable Throwable cause) { diff --git a/src/main/java/com/marketdata/sdk/exception/ErrorContext.java b/src/main/java/com/marketdata/sdk/exception/ErrorContext.java index b84e509..074e81c 100644 --- a/src/main/java/com/marketdata/sdk/exception/ErrorContext.java +++ b/src/main/java/com/marketdata/sdk/exception/ErrorContext.java @@ -1,24 +1,17 @@ package com.marketdata.sdk.exception; +import java.time.Instant; import org.jspecify.annotations.Nullable; -/** - * Diagnostic context attached to a {@link MarketDataException}, carrying the fields required by SDK - * requirements §6.2. - * - *

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

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

Subtypes use {@link ErrorContext#empty()} for client-side validation errors that occur before - * any HTTP request is dispatched. - */ public abstract sealed class MarketDataException extends RuntimeException permits AuthenticationError, BadRequestError, @@ -24,57 +16,81 @@ public abstract sealed class MarketDataException extends RuntimeException NetworkError, ParseError { - private static final ZoneId EASTERN = ZoneId.of("America/New_York"); - private static final DateTimeFormatter TIMESTAMP_FORMAT = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final long serialVersionUID = 1L; - private final @Nullable String requestId; - private final @Nullable String requestUrl; - private final @Nullable Integer statusCode; - private final ZonedDateTime timestamp; + private final ErrorContext context; protected MarketDataException(String message, ErrorContext context, @Nullable Throwable cause) { super(message, cause); - this.requestId = context.requestId(); - this.requestUrl = context.requestUrl(); - this.statusCode = context.statusCode(); - this.timestamp = ZonedDateTime.now(EASTERN); + this.context = context; + } + + public ErrorContext getContext() { + return context; } public @Nullable String getRequestId() { - return requestId; + return context.requestId(); + } + + /** + * The request URL with any query string redacted (replaced by {@code ?…}). Mirrors the SDK's + * ambient-log policy — query strings can carry PII (account IDs), competitive signal (queried + * symbols), or hypothetical future credentials, none of which should land in consumer logs just + * because someone called {@code logger.error("Request failed: " + ex.getRequestUrl())}. The full + * URI (with query) is preserved internally; use {@link #getContext()} when raw access is + * genuinely needed for diagnostics that won't be persisted. + */ + public String getRequestUrl() { + return redactQuery(context.requestUrl()); } - public @Nullable String getRequestUrl() { - return requestUrl; + private static String redactQuery(String rawUrl) { + try { + URI uri = new URI(rawUrl); + if (uri.getRawQuery() == null) { + return rawUrl; + } + int qIndex = rawUrl.indexOf('?'); + return qIndex < 0 ? rawUrl : rawUrl.substring(0, qIndex) + "?…"; + } catch (URISyntaxException e) { + // Defensive: never throw from a getter. If the stored URL is malformed, return verbatim — + // it's the consumer's problem to diagnose, but not one to compound by hiding everything. + return rawUrl; + } } - public @Nullable Integer getStatusCode() { - return statusCode; + public int getStatusCode() { + return context.statusCode(); } - public ZonedDateTime getTimestamp() { - return timestamp; + public Instant getTimestamp() { + return context.timestamp(); } public String getExceptionType() { return getClass().getSimpleName(); } - /** - * Multi-line, human-readable summary of the error and its context, intended to be copy-pasted - * into a support ticket. Never contains the API token or request body. - */ public String getSupportInfo() { - StringBuilder sb = new StringBuilder(256); - sb.append("Market Data SDK Error\n"); - sb.append("---------------------\n"); - sb.append("Type: ").append(getExceptionType()).append('\n'); - sb.append("Message: ").append(getMessage()).append('\n'); - sb.append("Status code: ").append(statusCode != null ? statusCode : "(n/a)").append('\n'); - sb.append("Request ID: ").append(requestId != null ? requestId : "(n/a)").append('\n'); - sb.append("Request URL: ").append(requestUrl != null ? requestUrl : "(n/a)").append('\n'); - sb.append("Timestamp: ").append(timestamp.format(TIMESTAMP_FORMAT)).append(" (US/Eastern)"); - return sb.toString(); + String requestId = getRequestId(); + String message = getMessage(); + return String.join( + System.lineSeparator(), + "--- MARKET DATA SUPPORT INFO ---", + formatField("request_id:", requestId == null ? "(not provided)" : requestId), + formatField("request_url:", getRequestUrl()), + formatField("status_code:", String.valueOf(getStatusCode())), + formatField("timestamp:", EASTERN_FORMATTER.format(getTimestamp())), + formatField("message:", message == null ? "" : message), + formatField("exception_type:", getExceptionType()), + "--------------------------------"); + } + + private static final DateTimeFormatter EASTERN_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("America/New_York")); + + private static String formatField(String name, String value) { + return String.format("%-16s%s", name, value); } } diff --git a/src/main/java/com/marketdata/sdk/exception/NetworkError.java b/src/main/java/com/marketdata/sdk/exception/NetworkError.java index 8d318de..3cefa0f 100644 --- a/src/main/java/com/marketdata/sdk/exception/NetworkError.java +++ b/src/main/java/com/marketdata/sdk/exception/NetworkError.java @@ -2,11 +2,12 @@ import org.jspecify.annotations.Nullable; -/** Transport-level failure: connection refused, DNS error, timeout, TLS, etc. */ public final class NetworkError extends MarketDataException { + private static final long serialVersionUID = 1L; + public NetworkError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null); } public NetworkError(String message, ErrorContext context, @Nullable Throwable cause) { diff --git a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java b/src/main/java/com/marketdata/sdk/exception/NotFoundError.java index 3f050cd..9f5acac 100644 --- a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java +++ b/src/main/java/com/marketdata/sdk/exception/NotFoundError.java @@ -2,17 +2,12 @@ import org.jspecify.annotations.Nullable; -/** - * The requested resource was not found (HTTP 404). - * - *

Per SDK requirements §9.1, most endpoints translate 404 into an empty no-data response rather - * than throwing this exception. It exists for the cases where 404 truly indicates a programming - * error. - */ public final class NotFoundError extends MarketDataException { + private static final long serialVersionUID = 1L; + public NotFoundError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null); } public NotFoundError(String message, ErrorContext context, @Nullable Throwable cause) { diff --git a/src/main/java/com/marketdata/sdk/exception/ParseError.java b/src/main/java/com/marketdata/sdk/exception/ParseError.java index 205c59c..c58fd32 100644 --- a/src/main/java/com/marketdata/sdk/exception/ParseError.java +++ b/src/main/java/com/marketdata/sdk/exception/ParseError.java @@ -2,11 +2,12 @@ import org.jspecify.annotations.Nullable; -/** The API response could not be decoded into the expected model. */ public final class ParseError extends MarketDataException { + private static final long serialVersionUID = 1L; + public ParseError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null); } public ParseError(String message, ErrorContext context, @Nullable Throwable cause) { diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java index ba4ca54..8bbcdc3 100644 --- a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java +++ b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java @@ -1,15 +1,43 @@ package com.marketdata.sdk.exception; +import java.time.Duration; +import java.util.Optional; import org.jspecify.annotations.Nullable; -/** The client exceeded the API's rate limit (HTTP 429). */ public final class RateLimitError extends MarketDataException { + private static final long serialVersionUID = 1L; + + private final @Nullable Duration retryAfter; + public RateLimitError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null, null); } public RateLimitError(String message, ErrorContext context, @Nullable Throwable cause) { + this(message, context, cause, null); + } + + /** + * Construct a rate-limit error that carries the server-specified {@code Retry-After} hint (SDK + * requirements §9.4). RFC 6585 defines {@code Retry-After} for 429 responses; consumers can + * inspect this value to schedule their own backoff before the next call. + */ + public RateLimitError( + String message, + ErrorContext context, + @Nullable Throwable cause, + @Nullable Duration retryAfter) { super(message, context, cause); + this.retryAfter = retryAfter; + } + + /** + * The value parsed from the server's {@code Retry-After} response header, when present. Empty + * when the header was absent or the error was raised by the SDK's local preflight gate (no server + * response). + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); } } diff --git a/src/main/java/com/marketdata/sdk/exception/ServerError.java b/src/main/java/com/marketdata/sdk/exception/ServerError.java index 8ae929b..57ea5d3 100644 --- a/src/main/java/com/marketdata/sdk/exception/ServerError.java +++ b/src/main/java/com/marketdata/sdk/exception/ServerError.java @@ -1,15 +1,42 @@ package com.marketdata.sdk.exception; +import java.time.Duration; +import java.util.Optional; import org.jspecify.annotations.Nullable; -/** The API returned a 5xx response. */ public final class ServerError extends MarketDataException { + private static final long serialVersionUID = 1L; + + private final @Nullable Duration retryAfter; + public ServerError(String message, ErrorContext context) { - super(message, context, null); + this(message, context, null, null); } public ServerError(String message, ErrorContext context, @Nullable Throwable cause) { + this(message, context, cause, null); + } + + /** + * Construct a server error that carries the server-specified {@code Retry-After} hint (SDK + * requirements §9.4). When present, the retry policy uses this value instead of the calculated + * exponential backoff before the next attempt. + */ + public ServerError( + String message, + ErrorContext context, + @Nullable Throwable cause, + @Nullable Duration retryAfter) { super(message, context, cause); + this.retryAfter = retryAfter; + } + + /** + * The value parsed from the server's {@code Retry-After} response header, when present. Otherwise + * empty (the policy falls back to its calculated backoff). + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); } } diff --git a/src/main/java/com/marketdata/sdk/exception/package-info.java b/src/main/java/com/marketdata/sdk/exception/package-info.java index 456d22e..70d7169 100644 --- a/src/main/java/com/marketdata/sdk/exception/package-info.java +++ b/src/main/java/com/marketdata/sdk/exception/package-info.java @@ -1,10 +1,3 @@ -/** - * Sealed exception hierarchy thrown by the SDK. - * - *

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

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

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

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

{@code @NullMarked} applies at the package level — every type, parameter, return, and field is - * non-null by default. Mark nullable items explicitly with {@link - * org.jspecify.annotations.Nullable}. - */ @NullMarked package com.marketdata.sdk; diff --git a/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java b/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java new file mode 100644 index 0000000..051e14e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ApiStatus.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.utilities; + +import java.util.List; + +/** + * Response shape for {@code GET /status/} — the per-service health snapshot of the Market Data API. + * + *

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

The status data is updated every 5 minutes server-side; clients that poll more frequently than + * that are wasting requests on cached results. + * + * @param services one entry per service the API exposes. Empty when the server has no services to + * report. + */ +public record ApiStatus(List services) { + + public ApiStatus { + services = List.copyOf(services); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java new file mode 100644 index 0000000..c8846d8 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java @@ -0,0 +1,26 @@ +package com.marketdata.sdk.utilities; + +import java.util.Map; +import java.util.Objects; + +/** + * Response shape for {@code GET /headers/} — the request headers echoed back by the server (with + * sensitive values like {@code Authorization} redacted server-side). + * + *

Used for diagnosing auth and routing issues; the SDK does not interpret the contents, it just + * surfaces them. + * + * @param headers all headers the server received, lower-cased keys to values. The map is + * defensively copied and immutable. Never {@code null} — the package is {@code @NullMarked}, + * and the canonical constructor rejects a {@code null} argument with a {@link + * NullPointerException} naming the field. The wire-format deserializer pre-checks for a JSON + * {@code null} token and surfaces a {@link com.marketdata.sdk.exception.ParseError} instead, so + * consumers never see a bare NPE from the wire path. + */ +public record RequestHeaders(Map headers) { + + public RequestHeaders { + Objects.requireNonNull(headers, "headers"); + headers = Map.copyOf(headers); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java new file mode 100644 index 0000000..c8fd6d5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java @@ -0,0 +1,27 @@ +package com.marketdata.sdk.utilities; + +import java.time.ZonedDateTime; + +/** + * Health of a single API service, as reported by {@code GET /status/}. + * + *

The {@code /status/} endpoint returns the parallel-arrays wire format used across the Market + * Data API; the SDK's deserializer zips those arrays into a record per service so consumers iterate + * naturally instead of indexing into six parallel collections. + * + * @param service service path the API exposes, e.g. {@code "/v1/stocks/quotes/"}. + * @param status status label as a string (today: {@code "online"} or {@code "offline"}; left + * stringly-typed so a future tier the server adds doesn't break this enum). + * @param online convenience boolean parallel to {@link #status} — server-supplied, not derived. + * @param uptimePct30d uptime fraction in the last 30 days, in the range {@code [0.0, 1.0]}. + * @param uptimePct90d uptime fraction in the last 90 days, in the range {@code [0.0, 1.0]}. + * @param updated when this entry was last refreshed server-side, in {@code America/New_York} (the + * SDK's canonical market-data zone per §13.4 — see {@code MarketDataDates}). + */ +public record ServiceStatus( + String service, + String status, + boolean online, + double uptimePct30d, + double uptimePct90d, + ZonedDateTime updated) {} diff --git a/src/main/java/com/marketdata/sdk/utilities/User.java b/src/main/java/com/marketdata/sdk/utilities/User.java new file mode 100644 index 0000000..bcfb9ae --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/User.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk.utilities; + +/** + * Response shape for {@code GET /user/} — the caller's current quota and data-tier permissions. + * + *

The numeric fields duplicate information that arrives on every response via the {@code + * x-api-ratelimit-*} headers (see {@link com.marketdata.sdk.RateLimitSnapshot}); the dedicated + * endpoint is mostly useful for a quota check that doesn't consume a request against the more + * expensive business endpoints. + * + * @param requestsRemaining how many requests the caller can still make in the current quota window. + * @param requestsLimit total requests allowed in the current quota window. + * @param optionsDataPermissions data-tier label for options — empty string for real-time access, + * {@code "OPRA data delayed 15 minutes"} otherwise. + */ +public record User(int requestsRemaining, int requestsLimit, String optionsDataPermissions) {} diff --git a/src/main/java/com/marketdata/sdk/utilities/package-info.java b/src/main/java/com/marketdata/sdk/utilities/package-info.java new file mode 100644 index 0000000..986e54d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records for the {@code utilities} resource — {@link + * com.marketdata.sdk.utilities.RequestHeaders} for the {@code /headers/} endpoint, with more to + * land as the resource grows. + */ +@org.jspecify.annotations.NullMarked +package com.marketdata.sdk.utilities; diff --git a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java index ed03e6f..1c9c3b6 100644 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CyclicBarrier; import org.junit.jupiter.api.RepeatedTest; @@ -133,14 +134,14 @@ 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) ---------- + // ---------- race between release() and waiter cancellation ---------- /** * Regression for the TOCTOU race in {@link AsyncSemaphore#release()} between {@code pollFirst()} * (inside the lock) and {@code complete(null)} (outside the lock). If the polled waiter is - * cancelled in that window, {@code complete(null)} returns false and — 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. + * cancelled in that window, {@code complete(null)} returns false and — without the retry loop — + * the permit would be silently lost: it was already removed from the counter when release() + * "transferred" it, and the cancelled waiter never delivers it anywhere. * *

The race is timing-sensitive; we coordinate two threads through a {@link CyclicBarrier} and * repeat the scenario many times so at least some iterations hit the bad window. The invariant we @@ -208,6 +209,74 @@ private static void awaitBarrier(CyclicBarrier barrier) { } } + // ---------- close ---------- + + @Test + void closeCompletesAllQueuedWaitersWithCancellation() { + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); // pool empty + + CompletableFuture w1 = sem.acquire(); + CompletableFuture w2 = sem.acquire(); + CompletableFuture w3 = sem.acquire(); + + sem.close(); + + // CompletableFuture#join unwraps CancellationException specifically: it surfaces directly + // rather than being wrapped in CompletionException. That's the same propagation downstream + // observers see, so we assert the bare exception shape here. + for (CompletableFuture w : List.of(w1, w2, w3)) { + assertThat(w).isCompletedExceptionally(); + assertThatThrownBy(w::join) + .isInstanceOf(CancellationException.class) + .hasMessageContaining("closed"); + } + assertThat(sem.queueLength()).isZero(); + } + + @Test + void acquireAfterCloseReturnsFailedFutureImmediately() { + AsyncSemaphore sem = new AsyncSemaphore(5); + sem.close(); + + CompletableFuture failed = sem.acquire(); + + assertThat(failed).isCompletedExceptionally(); + assertThatThrownBy(failed::join) + .isInstanceOf(CancellationException.class) + .hasMessageContaining("closed"); + assertThat(sem.queueLength()).isZero(); + } + + @Test + void closeIsIdempotent() { + AsyncSemaphore sem = new AsyncSemaphore(1); + CompletableFuture waiter = sem.acquire(); // takes the only permit + CompletableFuture queued = sem.acquire(); + + sem.close(); + sem.close(); // must be safe + + // First close completed the queued waiter; the second close has nothing to do. + assertThat(queued).isCompletedExceptionally(); + // And the in-flight holder of the permit can still release without exploding. + assertThat(waiter).isCompleted(); + sem.release(); + } + + @Test + void releaseAfterCloseDoesNotExplode() { + // After close the queue is empty, so release() falls through to the counter. Critical for + // the cancel-permit-after-close path: HttpDispatcher cancels the permit when its dispatched + // future is cancelled, and that cancellation may race close(). + AsyncSemaphore sem = new AsyncSemaphore(1); + sem.acquire(); + sem.close(); + + sem.release(); + assertThat(sem.availablePermits()).isOne(); + } + // ---------- argument validation ---------- @Test diff --git a/src/test/java/com/marketdata/sdk/CallMode.java b/src/test/java/com/marketdata/sdk/CallMode.java deleted file mode 100644 index d9144bc..0000000 --- a/src/test/java/com/marketdata/sdk/CallMode.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.marketdata.sdk; - -import com.marketdata.sdk.markets.MarketStatus; -import java.time.LocalDate; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; - -/** - * Drives a {@code /v1/markets/*} call through either the sync or async surface. ASYNC mode unwraps - * {@link CompletionException} so caller-visible behavior matches sync (per ADR-006: sync wraps - * {@code .join()} and surfaces the underlying cause directly). - * - *

Lives in the unit-test source set so it is reusable from the integration-test source set — - * {@code integrationTest}'s compileClasspath includes the unit-test output (see {@code - * build.gradle.kts}). Package-private intentionally: only test classes in {@code - * com.marketdata.sdk} need it. - */ -enum CallMode { - SYNC { - @Override - MarketStatus statusNoArgs(MarketsResource r) { - return r.status(); - } - - @Override - MarketStatus statusForDate(MarketsResource r, LocalDate date) { - return r.status(date); - } - - @Override - MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { - return r.status(from, to); - } - }, - ASYNC { - @Override - MarketStatus statusNoArgs(MarketsResource r) { - return joinUnwrapping(r.statusAsync()); - } - - @Override - MarketStatus statusForDate(MarketsResource r, LocalDate date) { - return joinUnwrapping(r.statusAsync(date)); - } - - @Override - MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { - return joinUnwrapping(r.statusAsync(from, to)); - } - }; - - abstract MarketStatus statusNoArgs(MarketsResource r); - - abstract MarketStatus statusForDate(MarketsResource r, LocalDate date); - - abstract MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to); - - private static T joinUnwrapping(CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException e) { - if (e.getCause() instanceof RuntimeException re) { - throw re; - } - throw e; - } - } -} diff --git a/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java new file mode 100644 index 0000000..b8f97b0 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/CanonicalLogFormatterTest.java @@ -0,0 +1,81 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import org.junit.jupiter.api.Test; + +class CanonicalLogFormatterTest { + + private static LogRecord recordAt(Level level, String logger, String message, Instant when) { + LogRecord r = new LogRecord(level, message); + r.setLoggerName(logger); + r.setInstant(when); + return r; + } + + @Test + void formatProducesCanonicalShape() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + // Production records all carry "com.marketdata.sdk" as the logger name (§7 consolidation); + // the formatter renders whatever name the record carries — this fixture uses the production + // value so reading it doesn't imply per-class sub-loggers exist. + LogRecord r = + recordAt( + Level.INFO, + "com.marketdata.sdk", + "Sending GET to https://api/v1/markets/status/", + Instant.parse("2026-05-19T18:00:00Z")); + + String out = fmt.format(r); + + // {timestamp} - {logger_name} - {level} - {message}\n + String[] parts = out.split(" - ", 4); + assertThat(parts).hasSize(4); + assertThat(parts[1]).isEqualTo("com.marketdata.sdk"); + assertThat(parts[2]).isEqualTo("INFO"); + assertThat(parts[3]).startsWith("Sending GET to https://api/v1/markets/status/"); + assertThat(out).endsWith(System.lineSeparator()); + } + + @Test + void timestampIsRenderedInEasternZone() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + // 18:00 UTC → 14:00 Eastern (EDT in May). + Instant when = Instant.parse("2026-05-19T18:00:00Z"); + LogRecord r = recordAt(Level.INFO, "logger", "msg", when); + + String out = fmt.format(r); + String timestamp = out.substring(0, out.indexOf(" - ")); + + ZonedDateTime parsed = ZonedDateTime.parse(timestamp, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + assertThat(parsed.toInstant()).isEqualTo(when); + // Eastern in May is UTC-04:00 (DST). + assertThat(timestamp).endsWith("-04:00"); + assertThat(timestamp).startsWith("2026-05-19T14:00:00.000"); + } + + @Test + void julLevelsMapToSpecVocabulary() { + assertThat(CanonicalLogFormatter.levelLabel(Level.FINEST)).isEqualTo("DEBUG"); + assertThat(CanonicalLogFormatter.levelLabel(Level.FINE)).isEqualTo("DEBUG"); + assertThat(CanonicalLogFormatter.levelLabel(Level.CONFIG)).isEqualTo("INFO"); + assertThat(CanonicalLogFormatter.levelLabel(Level.INFO)).isEqualTo("INFO"); + assertThat(CanonicalLogFormatter.levelLabel(Level.WARNING)).isEqualTo("WARNING"); + assertThat(CanonicalLogFormatter.levelLabel(Level.SEVERE)).isEqualTo("ERROR"); + } + + @Test + void allFourSpecLevelsRoundTripThroughTheFormatter() { + CanonicalLogFormatter fmt = new CanonicalLogFormatter(); + Instant now = Instant.now(); + assertThat(fmt.format(recordAt(Level.FINE, "x", "m", now))).contains(" - DEBUG - "); + assertThat(fmt.format(recordAt(Level.INFO, "x", "m", now))).contains(" - INFO - "); + assertThat(fmt.format(recordAt(Level.WARNING, "x", "m", now))).contains(" - WARNING - "); + assertThat(fmt.format(recordAt(Level.SEVERE, "x", "m", now))).contains(" - ERROR - "); + } +} diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 8cda17f..0ae3160 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -1,165 +1,509 @@ package com.marketdata.sdk; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.io.IOException; -import java.lang.reflect.Constructor; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class ConfigurationTest { - /** - * Reflection bridge to {@code Configuration}'s private constructor. Tests need to inject custom - * environment maps; production code cannot — that's the entire point of keeping the constructor - * private. Encapsulating the reflection here keeps individual tests clean. - */ - private static Configuration newConfig( - Map systemEnv, Map dotEnv) { - try { - Constructor ctor = - Configuration.class.getDeclaredConstructor(Map.class, Map.class); - ctor.setAccessible(true); - return ctor.newInstance(systemEnv, dotEnv); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Could not construct Configuration via reflection — has the private ctor signature" - + " changed?", - e); - } + private static final Function NO_ENV = key -> null; + + private static Function envOf(Map values) { + return values::get; + } + + private static Path noDotEnv(@TempDir Path tmp) { + return tmp.resolve("missing.env"); } @Test - void explicitWinsOverEverything() { + void resolve_uses_explicit_values_when_provided(@TempDir Path tmp) { Configuration config = - newConfig( - Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv")); + Configuration.resolve( + "explicit-key", + "https://explicit.example", + "v9", + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v0")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("explicit-key"); + assertThat(config.baseUrl()).isEqualTo("https://explicit.example"); + assertThat(config.apiVersion()).isEqualTo("v9"); + } + + @Test + void resolve_falls_back_to_env_when_explicit_missing(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v2")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("env-key"); + assertThat(config.baseUrl()).isEqualTo("https://env.example"); + assertThat(config.apiVersion()).isEqualTo("v2"); + } - assertThat(config.resolve("explicit-value", "MARKETDATA_TOKEN")).isEqualTo("explicit-value"); + @Test + void resolve_falls_back_to_dotenv_when_explicit_and_env_missing(@TempDir Path tmp) + throws IOException { + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + MARKETDATA_API_VERSION=v3 + """); + + Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(config.apiKey()).isEqualTo("dotenv-key"); + assertThat(config.baseUrl()).isEqualTo("https://dotenv.example"); + assertThat(config.apiVersion()).isEqualTo("v3"); + } + + @Test + void resolve_ignores_non_marketdata_keys_in_dotenv(@TempDir Path tmp) throws IOException { + // The cascade hands DotEnvLoader the EnvVars allowlist; secrets unrelated to the SDK (AWS + // creds, GitHub tokens, etc.) must not leak into the SDK's memory just because they share a + // .env file with the MARKETDATA_* keys. Spec §16's allowlist principle (defined for + // System.getenv via EnvVars.systemLookup) extends to .env reads. + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=marketdata-key + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + """); + + Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(config.apiKey()).isEqualTo("marketdata-key"); + // No accessor exposes non-MARKETDATA values — but verify the cascade did not pluck them + // through some accidental future path by snapshotting the record's toString. + assertThat(config.toString()).doesNotContain("supersecret").doesNotContain("leaked"); } @Test - void envVarWinsOverDotEnv() { + void resolve_uses_defaults_for_base_url_and_api_version_when_nothing_provided(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.baseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); + assertThat(config.apiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); + } + + @Test + void resolve_leaves_api_key_null_when_nothing_provided(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.apiKey()).isNull(); + } + + @Test + void resolve_treats_blank_explicit_as_missing(@TempDir Path tmp) { Configuration config = - newConfig( - Map.of("MARKETDATA_TOKEN", "from-env"), Map.of("MARKETDATA_TOKEN", "from-dotenv")); + Configuration.resolve( + " ", + "", + "\t", + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example", + EnvVars.API_VERSION, "v2")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isEqualTo("env-key"); + assertThat(config.baseUrl()).isEqualTo("https://env.example"); + assertThat(config.apiVersion()).isEqualTo("v2"); + } - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-env"); + @Test + void resolve_explicit_beats_env_beats_dotenv_beats_default(@TempDir Path tmp) throws IOException { + Path dotEnv = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + """); + + Configuration withExplicit = + Configuration.resolve( + "explicit-key", + "https://explicit.example", + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example")), + dotEnv); + + assertThat(withExplicit.apiKey()).isEqualTo("explicit-key"); + assertThat(withExplicit.baseUrl()).isEqualTo("https://explicit.example"); + + Configuration withoutExplicit = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, "env-key", + EnvVars.BASE_URL, "https://env.example")), + dotEnv); + + assertThat(withoutExplicit.apiKey()).isEqualTo("env-key"); + assertThat(withoutExplicit.baseUrl()).isEqualTo("https://env.example"); + + Configuration onlyDotEnv = Configuration.resolve(null, null, null, NO_ENV, dotEnv); + + assertThat(onlyDotEnv.apiKey()).isEqualTo("dotenv-key"); + assertThat(onlyDotEnv.baseUrl()).isEqualTo("https://dotenv.example"); } @Test - void fallsBackToDotEnvWhenEnvVarMissing() { - Configuration config = newConfig(Map.of(), Map.of("MARKETDATA_TOKEN", "from-dotenv")); + void resolve_picks_up_logging_level_and_date_format_from_env(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.LOGGING_LEVEL, "DEBUG", + EnvVars.DATE_FORMAT, "unix")), + noDotEnv(tmp)); + + assertThat(config.loggingLevel()).isEqualTo("DEBUG"); + assertThat(config.dateFormat()).isEqualTo("unix"); + } - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv"); + @Test + void resolve_leaves_logging_level_and_date_format_null_when_unset(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.loggingLevel()).isNull(); + assertThat(config.dateFormat()).isNull(); } @Test - void blankExplicitDoesNotCount() { - Configuration config = newConfig(Map.of("MARKETDATA_TOKEN", "from-env"), Map.of()); + void resolve_env_lookup_returning_blank_is_treated_as_missing(@TempDir Path tmp) { + Configuration config = + Configuration.resolve( + null, + null, + null, + envOf( + Map.of( + EnvVars.TOKEN, " ", + EnvVars.BASE_URL, "")), + noDotEnv(tmp)); + + assertThat(config.apiKey()).isNull(); + assertThat(config.baseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); + } - assertThat(config.resolve(" ", "MARKETDATA_TOKEN")).isEqualTo("from-env"); + // ---------- normalization ---------- + + @Test + void resolve_strips_trailing_slashes_from_baseUrl(@TempDir Path tmp) { + // Single trailing slash from the user is the common copy-paste mistake; multiple slashes + // (e.g. "https://x///") are pathological but cheap to handle and avoid surprises. + Configuration single = + Configuration.resolve(null, "https://api.example.com/", null, NO_ENV, noDotEnv(tmp)); + Configuration many = + Configuration.resolve(null, "https://api.example.com///", null, NO_ENV, noDotEnv(tmp)); + Configuration whitespaced = + Configuration.resolve(null, " https://api.example.com/ ", null, NO_ENV, noDotEnv(tmp)); + + assertThat(single.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(many.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(whitespaced.baseUrl()).isEqualTo("https://api.example.com"); } @Test - void blankEnvVarFallsThroughToDotEnv() { + void resolve_strips_leading_and_trailing_slashes_from_apiVersion(@TempDir Path tmp) { + // "v1", "/v1", "v1/", and "/v1/" should all collapse to the same canonical form so URI + // composition is independent of the user's spelling. + Configuration leading = Configuration.resolve(null, null, "/v1", NO_ENV, noDotEnv(tmp)); + Configuration trailing = Configuration.resolve(null, null, "v1/", NO_ENV, noDotEnv(tmp)); + Configuration both = Configuration.resolve(null, null, "/v1/", NO_ENV, noDotEnv(tmp)); + Configuration whitespaced = + Configuration.resolve(null, null, " /v1/ ", NO_ENV, noDotEnv(tmp)); + + assertThat(leading.apiVersion()).isEqualTo("v1"); + assertThat(trailing.apiVersion()).isEqualTo("v1"); + assertThat(both.apiVersion()).isEqualTo("v1"); + assertThat(whitespaced.apiVersion()).isEqualTo("v1"); + } + + @Test + void resolve_default_baseUrl_already_has_no_trailing_slash(@TempDir Path tmp) { + Configuration config = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + + assertThat(config.baseUrl()).doesNotEndWith("/"); + } + + // ---------- validation: baseUrl ---------- + + @Test + void resolve_rejects_baseUrl_without_scheme(@TempDir Path tmp) { + // The classic "I forgot https://" mistake — URI.create accepts it as a relative path, but + // HttpClient.send then surfaces a cryptic "URI is not absolute". Fail at construction. + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve(null, "api.marketdata.app", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("scheme http or https"); + } + + @Test + void resolve_rejects_baseUrl_with_disallowed_scheme(@TempDir Path tmp) { + // file://, ftp://, javascript: — schemes the SDK has no business opening. + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve(null, "file:///etc/passwd", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("scheme http or https"); + } + + @Test + void resolve_accepts_http_and_https(@TempDir Path tmp) { + Configuration https = + Configuration.resolve(null, "https://api.example.com", null, NO_ENV, noDotEnv(tmp)); + Configuration http = + Configuration.resolve(null, "http://localhost:9000", null, NO_ENV, noDotEnv(tmp)); + + assertThat(https.baseUrl()).isEqualTo("https://api.example.com"); + assertThat(http.baseUrl()).isEqualTo("http://localhost:9000"); + } + + @Test + void resolve_accepts_baseUrl_with_path_prefix(@TempDir Path tmp) { + // Self-hosted / reverse-proxy setups: the API lives under /marketdata-proxy on a corp host. Configuration config = - newConfig(Map.of("MARKETDATA_TOKEN", " "), Map.of("MARKETDATA_TOKEN", "from-dotenv")); + Configuration.resolve( + null, "https://corp.example.com/marketdata-proxy", null, NO_ENV, noDotEnv(tmp)); - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-dotenv"); + assertThat(config.baseUrl()).isEqualTo("https://corp.example.com/marketdata-proxy"); } @Test - void resolveReturnsNullWhenAllSourcesEmpty() { - Configuration config = newConfig(Map.of(), Map.of()); + void resolve_rejects_baseUrl_missing_host(@TempDir Path tmp) { + // Opaque URIs ({@code scheme:opaque}, no {@code //authority}) parse fine but expose a null + // host — those are the inputs the "missing a host" guard exists to catch. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "https:opaque", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("missing a host"); + } - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isNull(); + @Test + void resolve_rejects_baseUrl_with_invalid_syntax(@TempDir Path tmp) { + // "https://" normalizes to "https:" which fails URI.parse outright — the test verifies the + // "is not a valid URI" branch fires with a clear message rather than letting the syntax + // exception bubble up unwrapped. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "https://", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("is not a valid URI"); } @Test - void resolveOrDefaultReturnsDefaultWhenAllEmpty() { - Configuration config = newConfig(Map.of(), Map.of()); + void resolve_rejects_baseUrl_with_query_string(@TempDir Path tmp) { + // Query belongs on requests, not the origin. Letting it through would corrupt every URL the + // transport composes (`?token=abc/v1/markets/status/`). + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://api.example.com?token=x", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("query string"); + } - assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default")) - .isEqualTo("https://default"); + @Test + void resolve_rejects_baseUrl_with_fragment(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://api.example.com#frag", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("fragment"); + } + + @Test + void resolve_rejects_baseUrl_with_user_info(@TempDir Path tmp) { + // user:pass@host has Basic-auth semantics the SDK does not support — and would leak + // credentials into log lines that include the URL. + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + null, "https://user:pass@api.example.com", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("user-info"); + } + + @Test + void resolve_rejects_baseUrl_that_normalizes_to_empty(@TempDir Path tmp) { + // "////" passes pickFirstOrDefault (not blank), normalizes to "", then validation must + // reject the empty result rather than silently falling through. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, "////", null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("must not be empty"); + } + + // ---------- validation: apiVersion ---------- + + @Test + void resolve_accepts_valid_apiVersion_shapes(@TempDir Path tmp) { + // Permissive enough for the realistic variants — semver-ish, branch-tagged, etc. + for (String version : new String[] {"v1", "v2", "v1.0", "v2.1.0", "beta-1", "alpha_2"}) { + Configuration config = Configuration.resolve(null, null, version, NO_ENV, noDotEnv(tmp)); + assertThat(config.apiVersion()).isEqualTo(version); + } } @Test - void resolveOrDefaultPrefersResolvedValue() { - Configuration config = newConfig(Map.of("MARKETDATA_BASE_URL", "https://explicit"), Map.of()); + void resolve_rejects_apiVersion_with_embedded_slash(@TempDir Path tmp) { + // Mid-string slashes survive the leading/trailing strip, but they'd inject extra path + // segments — "v1/extra" → /v1/extra/markets/status/ which the server treats as a different + // resource. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "v1/extra", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); + } - assertThat(config.resolveOrDefault(null, "MARKETDATA_BASE_URL", "https://default")) - .isEqualTo("https://explicit"); + @Test + void resolve_rejects_apiVersion_with_spaces(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "v 1", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); } - // ---------- .env file parsing ---------- + @Test + void resolve_rejects_apiVersion_already_percent_encoded(@TempDir Path tmp) { + // Double-encoding territory — "%2F" would become "%252F" on the wire and the server would + // see the literal text, not a slash. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "%2Fv1", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("[A-Za-z0-9._-]+"); + } @Test - void readsAndParsesDotEnvFile(@TempDir Path tmp) throws IOException { - Path dotenv = tmp.resolve(".env"); - Files.writeString( - dotenv, - """ - # comment line — should be ignored - MARKETDATA_TOKEN=plain-token - MARKETDATA_BASE_URL="https://staging.example.com" - QUOTED_SINGLE='single-quoted' - EMPTY_VALUE= + void resolve_rejects_apiVersion_that_normalizes_to_empty(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, "////", NO_ENV, noDotEnv(tmp))) + .withMessageContaining("must not be empty"); + } - # blank line above - BAD_LINE_NO_EQUALS - =BAD_LINE_NO_KEY - """); + @Test + void resolve_validates_values_from_dotenv_too(@TempDir Path tmp) throws IOException { + // The validator runs after the cascade picks a value — bad input from env vars or .env files + // must surface the same IAE, not slip through because the cascade source was non-explicit. + Path dotEnv = Files.writeString(tmp.resolve(".env"), "MARKETDATA_BASE_URL=not-a-url\n"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(null, null, null, NO_ENV, dotEnv)) + .withMessageContaining("scheme http or https"); + } - Map parsed = Configuration.readDotEnvFile(dotenv); + // ---------- §16 / issue #23: apiKey character validation ---------- - assertThat(parsed) - .containsEntry("MARKETDATA_TOKEN", "plain-token") - .containsEntry("MARKETDATA_BASE_URL", "https://staging.example.com") - .containsEntry("QUOTED_SINGLE", "single-quoted") - .containsEntry("EMPTY_VALUE", "") - .doesNotContainKey("# comment line — should be ignored") - .doesNotContainKey("BAD_LINE_NO_EQUALS"); + /** + * A token loaded from a .env file with a stray CRLF must be rejected at construction. Without + * this gate, the failure surfaces only at the first request as a cryptic IAE from {@code + * HttpRequest.Builder#header}, miles away from the actual source of the bad input. + */ + @Test + void resolve_rejects_apiKey_with_carriage_return(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> Configuration.resolve("good-prefix\rbad", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character") + .withMessageContaining("offset 11"); } @Test - void missingDotEnvReturnsEmpty(@TempDir Path tmp) { - assertThat(Configuration.readDotEnvFile(tmp.resolve(".env"))).isEmpty(); + void resolve_rejects_apiKey_with_newline(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("token\nmore", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); } @Test - void 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' - """); + void resolve_rejects_apiKey_with_tab(@TempDir Path tmp) { + // Tab (0x09) is below 0x20 — also rejected. Real tokens never contain tabs; if one appears + // it's a copy-paste artifact from a spreadsheet cell or formatted document. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("token\tmore", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } - Map parsed = Configuration.readDotEnvFile(dotenv); + @Test + void resolve_rejects_apiKey_with_high_bit_byte(@TempDir Path tmp) { + // Non-ASCII (e.g. UTF-8 multi-byte) — almost always means the .env was decoded with the wrong + // charset and the original token is unusable anyway. Failing fast with a clear message beats + // a stream of authentication failures from the server. + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve("tokén-ABCD", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); + } - assertThat(parsed) - .containsEntry("UNCLOSED_DOUBLE", "\"abc") - .containsEntry("UNCLOSED_SINGLE", "'abc") - .containsEntry("MIXED_QUOTES", "\"abc'"); + @Test + void resolve_rejects_apiKey_with_nul_byte(@TempDir Path tmp) { + // A literal NUL (0x00) - far below the 0x20 floor; canonical "this token is corrupt". Built + // at runtime so the test source file does not carry an embedded NUL byte itself. + String tokenWithNul = "token" + (char) 0x00 + "more"; + assertThatIllegalArgumentException() + .isThrownBy(() -> Configuration.resolve(tokenWithNul, null, null, NO_ENV, noDotEnv(tmp))) + .withMessageContaining("invalid character"); } @Test - void dotEnvParsingIntegratesWithCascade(@TempDir Path tmp) throws IOException { - Path dotenv = tmp.resolve(".env"); - Files.writeString(dotenv, "MARKETDATA_TOKEN=from-real-dotenv\n"); + void resolve_accepts_apiKey_with_printable_ascii(@TempDir Path tmp) { + // Regression guard: tokens that legitimately use the full printable ASCII range + // (letters, digits, `.-_+/=` and friends) must not be rejected. + Configuration cfg = + Configuration.resolve("ABCdef-123_token.with+slashes/=", null, null, NO_ENV, noDotEnv(tmp)); + assertThat(cfg.apiKey()).isEqualTo("ABCdef-123_token.with+slashes/="); + } - Configuration config = newConfig(Map.of(), Configuration.readDotEnvFile(dotenv)); + @Test + void resolve_does_not_validate_null_apiKey(@TempDir Path tmp) { + // Demo mode: no token at all is a supported cascade outcome; validation must not flag it. + Configuration cfg = Configuration.resolve(null, null, null, NO_ENV, noDotEnv(tmp)); + assertThat(cfg.apiKey()).isNull(); + } - assertThat(config.resolve(null, "MARKETDATA_TOKEN")).isEqualTo("from-real-dotenv"); + /** + * The error message must NOT echo the token. The token's offset and the offending code point are + * enough for diagnostics; the token itself never appears in {@code getMessage()} (§16). + */ + @Test + void apiKey_validation_error_does_not_leak_token(@TempDir Path tmp) { + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Configuration.resolve( + "supersecret-prefix\rsuffix-leak", null, null, NO_ENV, noDotEnv(tmp))) + .withMessageNotContaining("supersecret-prefix") + .withMessageNotContaining("suffix-leak"); } } diff --git a/src/test/java/com/marketdata/sdk/DemoModeTest.java b/src/test/java/com/marketdata/sdk/DemoModeTest.java new file mode 100644 index 0000000..06fae6c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/DemoModeTest.java @@ -0,0 +1,41 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DemoModeTest { + + @Test + void is_demo_when_api_key_is_null() { + Configuration config = configWithApiKey(null); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_demo_when_api_key_is_empty() { + Configuration config = configWithApiKey(""); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_demo_when_api_key_is_blank() { + Configuration config = configWithApiKey(" "); + + assertThat(DemoMode.isDemo(config)).isTrue(); + } + + @Test + void is_not_demo_when_api_key_present() { + Configuration config = configWithApiKey("real-token-YKT0"); + + assertThat(DemoMode.isDemo(config)).isFalse(); + } + + private static Configuration configWithApiKey(String apiKey) { + return new Configuration( + apiKey, Configuration.DEFAULT_BASE_URL, Configuration.DEFAULT_API_VERSION, null, null); + } +} diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java new file mode 100644 index 0000000..6d8031a --- /dev/null +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -0,0 +1,455 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +class DotEnvLoaderTest { + + /** + * Convenience wrapper for parser-level tests: no warning sink, no allowlist (the parser is + * exercised independently of the cascade's allowlist). + */ + private static Map load(Path path) { + return DotEnvLoader.load(path, w -> {}, null); + } + + @Test + void load_returns_empty_when_file_missing(@TempDir Path tmp) { + Path missing = tmp.resolve("does-not-exist.env"); + + Map result = load(missing); + + assertThat(result).isEmpty(); + } + + @Test + void load_missing_file_does_not_warn(@TempDir Path tmp) { + // The cascade explicitly tolerates a missing .env — that's the common case, not an error. + // Emitting a WARNING here would spam every consumer that runs without a .env file. + Path missing = tmp.resolve("does-not-exist.env"); + List warnings = new ArrayList<>(); + + DotEnvLoader.load(missing, warnings::add, null); + + assertThat(warnings).isEmpty(); + } + + @Test + @DisabledOnOs(OS.WINDOWS) // POSIX permissions are unreliable on Windows file systems + void load_unreadable_file_emits_warning_and_returns_empty(@TempDir Path tmp) throws IOException { + // Existing-but-unreadable is suspicious: the user dropped a .env expecting it to apply, but + // the SDK can't open it. Silent fallback would surface much later as a confusing + // AuthenticationError. Emit a Warning with the path so the breadcrumb is obvious. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("---------")); + try { + List warnings = new ArrayList<>(); + Map result = DotEnvLoader.load(file, warnings::add, null); + + assertThat(result).isEmpty(); + assertThat(warnings) + .singleElement() + .satisfies( + w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("not readable").contains(file.toString()); + assertThat(w.cause()).isNull(); + }); + } finally { + // Restore so @TempDir cleanup can delete the file. + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------")); + } + } + + @Test + void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) throws Exception { + // Files.exists + isReadable can pass and the actual read still fail (NFS drop, decoded-bytes + // encoding mismatch, etc.). A directory passed as the path is a portable way to make + // Files.readAllLines blow up after the readability check succeeds. + Path asDir = Files.createDirectory(tmp.resolve("env-as-dir")); + List warnings = new ArrayList<>(); + + Map result = DotEnvLoader.load(asDir, warnings::add, null); + + assertThat(result).isEmpty(); + assertThat(warnings) + .singleElement() + .satisfies( + w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("Failed to read .env").contains(asDir.toString()); + assertThat(w.cause()).isNotNull(); + }); + } + + @Test + void load_returns_empty_for_empty_file(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), ""); + + assertThat(load(file)).isEmpty(); + } + + @Test + void load_parses_simple_key_value_pairs(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=abc123 + MARKETDATA_BASE_URL=https://example.com + """); + + Map result = load(file); + + assertThat(result) + .containsEntry("MARKETDATA_TOKEN", "abc123") + .containsEntry("MARKETDATA_BASE_URL", "https://example.com"); + } + + @Test + void load_strips_double_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc 123\"\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_strips_single_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN='abc 123'\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_does_not_strip_mismatched_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc'\n"); + + assertThat(load(file)).containsEntry("TOKEN", "\"abc'"); + } + + @Test + void load_ignores_comment_lines(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + # a comment + TOKEN=abc + #TOKEN=should-be-ignored + """); + + Map result = load(file); + + assertThat(result).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_ignores_comment_lines_with_leading_whitespace(@TempDir Path tmp) throws IOException { + // The parser trims each line before checking the `#` prefix, so indented full-line comments + // (common when commenting out a block inside an aligned section) are skipped too. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + # leading-spaces comment + \t# leading-tab comment + TOKEN=abc + """); + + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + + TOKEN=abc + + BASE_URL=https://x + + """); + + assertThat(load(file)).containsEntry("TOKEN", "abc").containsEntry("BASE_URL", "https://x"); + } + + // ---------- inline comments ---------- + + @Test + void load_strips_inline_comment_after_whitespace(@TempDir Path tmp) throws IOException { + // The motivating bug: `TOKEN=abc # my note` previously yielded the literal value + // "abc # my note", which validateApiKey lets through (printable ASCII) and surfaces later + // as a confusing AuthenticationError far from the .env file that caused it. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc123 # production token\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc123"); + } + + @Test + void load_strips_inline_comment_after_tab(@TempDir Path tmp) throws IOException { + // Any Unicode whitespace before `#` qualifies — tabs are common in hand-aligned .env files. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc123\t# tab-separated comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc123"); + } + + @Test + void load_keeps_hash_when_not_preceded_by_whitespace(@TempDir Path tmp) throws IOException { + // `#` adjacent to value chars is part of the value (python-dotenv / dotenv-java convention). + // Critical for URLs with fragments and tokens that legitimately contain `#`. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + TOKEN=abc#123 + BASE_URL=https://example.com/path#frag + """); + + assertThat(load(file)) + .containsEntry("TOKEN", "abc#123") + .containsEntry("BASE_URL", "https://example.com/path#frag"); + } + + @Test + void load_keeps_hash_inside_double_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc # not a comment\"\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_keeps_hash_inside_single_quotes(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN='abc # not a comment'\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_strips_inline_comment_after_closing_quote(@TempDir Path tmp) throws IOException { + // Quoted value followed by a real comment outside the quotes: the comment is stripped and the + // quotes are removed normally. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"abc 123\" # the real comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc 123"); + } + + @Test + void load_records_empty_value_when_value_is_blank(@TempDir Path tmp) throws IOException { + // `KEY=` and `KEY= ` both produce an empty-string entry. The cascade's pickFirst() treats + // blank values as unset, so this is functionally equivalent to omitting the key — but the + // parser still records it. Two reasons: (1) it documents the user's intent (they wrote the + // key, so it's part of the file's shape), and (2) it keeps the parser symmetric with the + // `KEY=#comment` case, which also yields "". + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + EMPTY_BARE= + EMPTY_SPACES=\s\s\s + KEPT=value + """); + + Map result = load(file); + assertThat(result) + .containsEntry("EMPTY_BARE", "") + .containsEntry("EMPTY_SPACES", "") + .containsEntry("KEPT", "value"); + } + + @Test + void load_strips_inline_comment_after_closing_single_quote(@TempDir Path tmp) throws IOException { + // Symmetry with `load_strips_inline_comment_after_closing_quote` (the double-quote variant): + // the walk treats single and double quotes the same way, so a `#` inside `'…'` is preserved + // and a `#` after the closing `'` with whitespace before it is a comment. + Path file = + Files.writeString(tmp.resolve(".env"), "TOKEN='abc # not a comment' # the real comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc # not a comment"); + } + + @Test + void load_strips_value_when_hash_is_first_non_whitespace_char(@TempDir Path tmp) + throws IOException { + // `KEY=#comment` and `KEY= # comment` both leave an empty value. The cascade's + // pickFirst() treats blank values as unset, so this is functionally equivalent to omitting + // the line — the empty entry is still recorded for symmetry with `KEY=`. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + EMPTY1=#comment-immediately + EMPTY2= # comment after spaces + KEPT=value + """); + + Map result = load(file); + assertThat(result) + .containsEntry("EMPTY1", "") + .containsEntry("EMPTY2", "") + .containsEntry("KEPT", "value"); + } + + @Test + void load_first_unquoted_hash_wins_over_later_ones(@TempDir Path tmp) throws IOException { + // `value # first-comment # second` → everything from the first qualifying `#` onward is + // comment. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=value # first-comment # second\n"); + + assertThat(load(file)).containsEntry("TOKEN", "value"); + } + + @Test + void load_keeps_hash_in_value_then_strips_later_comment(@TempDir Path tmp) throws IOException { + // The first `#` is adjacent to value chars (not a comment); the second `#` is preceded by + // whitespace (a comment) — only the trailing portion is stripped. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=value#part more # real-comment\n"); + + assertThat(load(file)).containsEntry("TOKEN", "value#part more"); + } + + @Test + void load_keeps_hash_outside_quotes_after_closing_quote_without_whitespace(@TempDir Path tmp) + throws IOException { + // `"x"#y` — `#` is preceded by the closing quote, not whitespace, so it's part of the value. + // stripQuotes does nothing here (last char isn't a matching quote), so the literal pair-of- + // quotes-plus-hash-tail is preserved as authored. + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=\"x\"#y\n"); + + assertThat(load(file)).containsEntry("TOKEN", "\"x\"#y"); + } + + @Test + void load_last_assignment_wins_for_duplicate_keys(@TempDir Path tmp) throws IOException { + // Lines are processed top-to-bottom and stored in a LinkedHashMap, so a later assignment + // overwrites an earlier one for the same key. This documents the file as authoritative in + // line order — useful when a user commits a base `.env` and overrides a single line at the + // bottom for a local run. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + TOKEN=first + TOKEN=second + TOKEN=third + """); + + assertThat(load(file)).containsEntry("TOKEN", "third"); + } + + @Test + void load_keeps_equals_signs_in_value(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=a=b=c\n"); + + assertThat(load(file)).containsEntry("TOKEN", "a=b=c"); + } + + @Test + void load_skips_lines_without_equals(@TempDir Path tmp) throws IOException { + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + this-is-not-a-pair + TOKEN=abc + also-not-a-pair + """); + + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_skips_lines_starting_with_equals(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "=novalue\nTOKEN=abc\n"); + + assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); + } + + @Test + void load_trims_whitespace_around_key_and_value(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), " TOKEN = abc \n"); + + assertThat(load(file)).containsEntry("TOKEN", "abc"); + } + + @Test + void load_returns_immutable_map(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + + Map result = load(file); + + assertThat(result).isUnmodifiable(); + } + + // ---------- allowlist filter (defense for unrelated secrets) ---------- + + @Test + void load_with_allowlist_drops_keys_outside_the_set(@TempDir Path tmp) throws IOException { + // A consumer's .env can legitimately contain secrets unrelated to the SDK (AWS creds, OAuth + // tokens for other services, etc.). The loader must not retain those in memory just because + // they happened to share a file — the SDK only needs the MARKETDATA_* keys it declares. + Path file = + Files.writeString( + tmp.resolve(".env"), + """ + MARKETDATA_TOKEN=abc123 + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + MARKETDATA_BASE_URL=https://example.com + """); + + Map result = + DotEnvLoader.load(file, w -> {}, Set.of("MARKETDATA_TOKEN", "MARKETDATA_BASE_URL")); + + assertThat(result) + .containsOnlyKeys("MARKETDATA_TOKEN", "MARKETDATA_BASE_URL") + .containsEntry("MARKETDATA_TOKEN", "abc123") + .containsEntry("MARKETDATA_BASE_URL", "https://example.com"); + // The disallowed values are not retained anywhere reachable from the returned map. + assertThat(result.values()).noneMatch(v -> v.contains("supersecret") || v.contains("leaked")); + } + + @Test + void load_with_empty_allowlist_returns_empty(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "MARKETDATA_TOKEN=abc\n"); + + Map result = DotEnvLoader.load(file, w -> {}, Set.of()); + + assertThat(result).isEmpty(); + } + + @Test + void load_with_null_allowlist_admits_everything(@TempDir Path tmp) throws IOException { + // Null allowlist = parser-only mode (test surface). Filtering is the caller's job — for + // production the cascade always passes EnvVars.ALLOWED_KEYS. + Path file = Files.writeString(tmp.resolve(".env"), "FOO=bar\nMARKETDATA_TOKEN=abc\n"); + + Map result = DotEnvLoader.load(file, w -> {}, null); + + assertThat(result).containsEntry("FOO", "bar").containsEntry("MARKETDATA_TOKEN", "abc"); + } + + @Test + void load_successful_read_does_not_warn(@TempDir Path tmp) throws IOException { + Path file = Files.writeString(tmp.resolve(".env"), "TOKEN=abc\n"); + List warnings = new ArrayList<>(); + + DotEnvLoader.load(file, warnings::add, null); + + assertThat(warnings).isEmpty(); + } +} diff --git a/src/test/java/com/marketdata/sdk/EnvVarsTest.java b/src/test/java/com/marketdata/sdk/EnvVarsTest.java new file mode 100644 index 0000000..944c3ea --- /dev/null +++ b/src/test/java/com/marketdata/sdk/EnvVarsTest.java @@ -0,0 +1,47 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class EnvVarsTest { + + @Test + void systemLookupReturnsNullForKeysOutsideTheAllowlist() { + // The classic risk: a Function handed to other code could be invoked with + // arbitrary keys and silently read AWS_SECRET_ACCESS_KEY or PATH from the process env. + // The lookup must refuse anything outside the MARKETDATA_* set, even if the real env has it. + Function lookup = EnvVars.systemLookup(); + + assertThat(lookup.apply("PATH")).isNull(); // PATH is virtually always set in real envs + assertThat(lookup.apply("HOME")).isNull(); + assertThat(lookup.apply("FOOBAR_DOES_NOT_EXIST")).isNull(); + assertThat(lookup.apply("")).isNull(); + } + + @Test + void systemLookupAllowsExactlyTheDeclaredMarketdataKeys() { + // Regression guard: if a new MARKETDATA_* constant is added to EnvVars, it must also be + // wired into ALLOWED_KEYS, or systemLookup() silently swallows reads of the new key. + assertThat(EnvVars.ALLOWED_KEYS) + .containsExactlyInAnyOrder( + EnvVars.TOKEN, + EnvVars.BASE_URL, + EnvVars.API_VERSION, + EnvVars.LOGGING_LEVEL, + EnvVars.DATE_FORMAT); + } + + @Test + void systemLookupForAllowedKeyMatchesSystemGetenv() { + // Best-effort sanity: for an allowed key the lookup must mirror System.getenv. We can't + // force a MARKETDATA_* var to be set on the test JVM, so just assert that whatever the + // process has (likely null) is what the lookup returns — i.e., no extra filtering or + // transformation is applied on top of System.getenv for permitted keys. + Function lookup = EnvVars.systemLookup(); + + assertThat(lookup.apply(EnvVars.TOKEN)).isEqualTo(System.getenv(EnvVars.TOKEN)); + assertThat(lookup.apply(EnvVars.BASE_URL)).isEqualTo(System.getenv(EnvVars.BASE_URL)); + } +} diff --git a/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java new file mode 100644 index 0000000..49f607f --- /dev/null +++ b/src/test/java/com/marketdata/sdk/HttpDispatcherTest.java @@ -0,0 +1,255 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.NetworkError; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +class HttpDispatcherTest { + + private static final int LIMIT = 4; + + private static HttpRequest req() { + return HttpRequest.newBuilder(URI.create("http://localhost/ping")).GET().build(); + } + + // ---------- happy path ---------- + + @Test + void dispatchReturnsResponseAndReleasesPermit() { + HttpClient client = + new TestHttpClients.StubHttpClient() { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + HttpResponse resp = + TestHttpClients.response( + 200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true), request.uri()); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + }; + HttpDispatcher dispatcher = new HttpDispatcher(client, LIMIT); + + HttpResponse resp = dispatcher.dispatch(req()).join(); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(new String(resp.body())).isEqualTo("ok"); + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + assertThat(dispatcher.queueLength()).isZero(); + } + + // ---------- sync-throw guard ---------- + + /** + * If {@code sendAsync} throws synchronously (malformed request, internal NPE, OOM), the future + * never forms; without explicit permit release in the catch block, every such failure leaks a + * permit. With {@code LIMIT + extras} calls against a stub that always throws, a leak would + * deadlock the pool once {@code LIMIT} requests had accumulated. + */ + @Test + void permitReleasedWhenSendAsyncThrowsSynchronously() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.SyncThrowing(), LIMIT); + + int n = LIMIT + 3; + for (int i = 0; i < n; i++) { + CompletableFuture> f = dispatcher.dispatch(req()); + assertThat(f).isCompletedExceptionally(); + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class) + .hasMessageContaining("before dispatch"); + } + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + /** + * Sync-thrown {@link Error} (e.g. simulated OOM) must surface with its type preserved; wrapping + * it as {@link NetworkError} would mask a JVM-level crash. Permit must still be released. + */ + @Test + void errorThrownSynchronouslyIsPreservedAsRootCause() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.ErrorThrowing(), LIMIT); + + CompletableFuture> f = dispatcher.dispatch(req()); + + assertThat(f).isCompletedExceptionally(); + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasRootCauseInstanceOf(OutOfMemoryError.class); + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + // ---------- async failure mapped to NetworkError ---------- + + @Test + void asyncIoExceptionMappedToNetworkError() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, LIMIT); + + CompletableFuture> f = dispatcher.dispatch(req()); + client.failAll(new IOException("connect refused")); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class); + + NetworkError err = (NetworkError) f.handle((r, e) -> e.getCause()).join(); + assertThat(err.getCause()).isInstanceOf(IOException.class); + assertThat(err.getMessage()).contains("connect refused"); + + assertThat(dispatcher.availablePermits()).isEqualTo(LIMIT); + } + + // ---------- slow-path cancellation ---------- + + /** + * When the pool is saturated, an extra {@code dispatch} call's permit acquire queues a waiter in + * the semaphore. The thing {@link HttpDispatcher} adds on top of {@link AsyncSemaphore} here is + * propagating cancellation: when the caller cancels the dispatch future, the waiter must be + * marked cancelled so a later {@code release} skips it instead of transferring a permit into the + * void. + */ + @Test + void cancellingQueuedDispatchMarksWaiterAndPermitReturnsToPool() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, /* concurrencyLimit */ 1); + + CompletableFuture> inflight = dispatcher.dispatch(req()); + assertThat(dispatcher.availablePermits()).isZero(); + + CompletableFuture> queued = dispatcher.dispatch(req()); + assertThat(dispatcher.queueLength()).isOne(); + + queued.cancel(false); + + HttpResponse ok = + TestHttpClients.response( + 200, + "ok".getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/ping")); + client.completeAll(ok); + + assertThat(inflight).isCompleted(); + assertThat(queued).isCancelled(); + + // The cancelled waiter was skipped on release; the permit returns to the pool rather + // than being silently lost. + assertThat(dispatcher.queueLength()).isZero(); + assertThat(dispatcher.availablePermits()).isOne(); + } + + // ---------- close drains queued waiters ---------- + + /** + * Without {@code close()} drain, a queued waiter sits in the semaphore forever when the owning + * client is shut down — the {@code thenCompose} chain hanging off it never resolves and the + * caller's future is leaked. After close, every queued future must fail with {@link + * CancellationException} so the consumer's await unblocks cleanly. + */ + @Test + void closeDrainsQueuedDispatchesWithCancellation() { + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + // Concurrency = 1 so the second dispatch is guaranteed to queue. + HttpDispatcher dispatcher = new HttpDispatcher(client, 1); + + CompletableFuture> inflight = dispatcher.dispatch(req()); + CompletableFuture> queued = dispatcher.dispatch(req()); + + assertThat(dispatcher.queueLength()).isOne(); + + dispatcher.close(); + + // The queued waiter was sitting in the semaphore, downstream of a thenCompose. Closing the + // semaphore completes it with CancellationException; the queued future surfaces it as a + // CompletionException -> CancellationException, matching how a cancelled future propagates + // through the rest of the pipeline. + assertThat(queued).isCompletedExceptionally(); + // The semaphore-level future failed with CancellationException directly, but the dispatcher + // chains it through thenCompose: that propagation wraps in CompletionException (per + // CompletionStage contract — only the original cancel propagates "bare"). + assertThatThrownBy(queued::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(CancellationException.class); + + // The in-flight send remains running (HttpClient.close() is JDK 21+). We let it complete so + // the test doesn't leave an orphan future. + HttpResponse ok = + TestHttpClients.response( + 200, + "ok".getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/ping")); + client.completeAll(ok); + assertThat(inflight).isCompleted(); + } + + @Test + void dispatchAfterCloseFailsImmediately() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.Controllable(), 4); + dispatcher.close(); + + CompletableFuture> failed = dispatcher.dispatch(req()); + + assertThat(failed).isCompletedExceptionally(); + assertThatThrownBy(failed::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(CancellationException.class); + } + + @Test + void closeIsIdempotent() { + HttpDispatcher dispatcher = new HttpDispatcher(new TestHttpClients.Controllable(), 4); + dispatcher.close(); + dispatcher.close(); // must be safe + } + + // ---------- safeUri (log redaction) ---------- + + @Test + void safeUriReturnsPathWhenNoQueryString() { + assertThat(HttpDispatcher.safeUri(URI.create("http://localhost/v1/markets/status/"))) + .isEqualTo("/v1/markets/status/"); + } + + @Test + void safeUriOmitsQueryStringWithEllipsisMarker() { + // The "?…" suffix preserves the signal that the call carried params (useful for diagnostics) + // without persisting their values to logs. Symbols, account IDs, date ranges, hypothetical + // future "?token=" — all stay out of the log line. + assertThat( + HttpDispatcher.safeUri( + URI.create("http://localhost/v1/stocks/quotes/?symbol=AAPL&from=2024-01-01"))) + .isEqualTo("/v1/stocks/quotes/?…"); + } + + @Test + void safeUriHandlesEmptyQueryDefensively() { + // URI like "/foo?" — empty query but the marker character is present. URI.getRawQuery() + // returns "" (non-null) in that case, so the suffix still applies. + assertThat(HttpDispatcher.safeUri(URI.create("http://localhost/foo?"))).isEqualTo("/foo?…"); + } + + @Test + void safeUriFallsBackToToStringForOpaqueUri() { + // Opaque URIs (scheme:opaque, no //authority) have a null path. Won't be built by the SDK + // for real requests, but the helper must never throw — that would convert a log call into + // a runtime failure. + assertThat(HttpDispatcher.safeUri(URI.create("mailto:user@example.com"))) + .isEqualTo("mailto:user@example.com"); + } +} diff --git a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java index a7e5cef..c83039d 100644 --- a/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java +++ b/src/test/java/com/marketdata/sdk/HttpStatusMapperTest.java @@ -1,83 +1,175 @@ package com.marketdata.sdk; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; import com.marketdata.sdk.exception.AuthenticationError; import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.ErrorContext; import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NotFoundError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.time.Duration; +import java.time.Instant; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; class HttpStatusMapperTest { - private static final String URL = "https://api.marketdata.app/v1/test/"; - private static final String RAY = "ray-1"; + private static final Instant TS = Instant.parse("2026-05-15T12:00:00Z"); - // ---------- switch coverage: each case + default ---------- + private static ErrorContext context(int statusCode) { + return ErrorContext.forResponse("https://api.example", statusCode, "req-1", TS); + } - @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"); + @ParameterizedTest + @ValueSource(ints = {200, 201, 203, 204, 299}) + void returns_null_for_two_xx_status(int statusCode) { + assertThat(HttpStatusMapper.map(statusCode, context(statusCode))).isNull(); } - @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"); + @ParameterizedTest + @MethodSource("statusToExceptionType") + void maps_status_code_to_expected_subtype( + int statusCode, Class expectedType) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(expectedType); + assertThat(exception.getStatusCode()).isEqualTo(statusCode); } - @Test - void status401MapsToAuthenticationError() { - MarketDataException e = HttpStatusMapper.toException(401, URL, RAY); - assertThat(e).isInstanceOf(AuthenticationError.class); - assertThat(e.getStatusCode()).isEqualTo(401); + static Stream statusToExceptionType() { + return Stream.of( + arguments(400, BadRequestError.class), + arguments(401, AuthenticationError.class), + arguments(404, NotFoundError.class), + arguments(429, RateLimitError.class), + arguments(500, ServerError.class), + arguments(501, ServerError.class), + arguments(502, ServerError.class), + arguments(503, ServerError.class), + arguments(599, ServerError.class)); } - @Test - void status429MapsToRateLimitError() { - MarketDataException e = HttpStatusMapper.toException(429, URL, RAY); - assertThat(e).isInstanceOf(RateLimitError.class); - assertThat(e.getStatusCode()).isEqualTo(429); + @ParameterizedTest + @ValueSource(ints = {402, 403, 405, 418, 422, 451}) + void maps_unhandled_four_xx_to_bad_request_error(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); } - @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); - } + @ParameterizedTest + @ValueSource(ints = {402, 403, 405, 418}) + void unhandled_four_xx_message_includes_the_status_code(int statusCode) { + // The mapper differentiates the failure mode within the message (e.g. "Client error: HTTP + // 403") so consumers can branch on getMessage() / getStatusCode() even though the type is the + // shared BadRequestError bucket dictated by ADR-002's canonical 7-permit hierarchy. + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isNotNull(); + assertThat(exception.getMessage()) + .contains("Client error") + .contains(String.valueOf(statusCode)); + } + + // ---------- 3xx redirects ---------- + + @ParameterizedTest + @ValueSource(ints = {301, 302, 303, 304, 307, 308}) + void maps_three_xx_to_bad_request_with_redirect_message(int statusCode) { + // HttpClient is configured with followRedirects(NORMAL); a 3xx escaping that means the + // redirect could not be followed (cross-protocol, max-redirects hit, etc.). Treat as + // BadRequestError so the retry layer does not loop on the same redirect, with a message + // that points the user at the likely cause. + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("Unhandled redirect") + .contains(String.valueOf(statusCode)) + .contains("baseUrl"); + assertThat(exception.getStatusCode()).isEqualTo(statusCode); + } + + // ---------- 1xx informational ---------- + + @ParameterizedTest + @ValueSource(ints = {100, 101, 102}) + void maps_one_xx_to_bad_request_with_informational_message(int statusCode) { + // HttpClient handles 100 Continue internally — reaching the mapper with a 1xx means the + // server is doing something protocol-weird. Surface with a clear "informational" message + // rather than the generic "Unexpected status code". + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("informational") + .contains(String.valueOf(statusCode)); + } + + // ---------- out-of-range fallback ---------- + + @ParameterizedTest + @ValueSource(ints = {0, -1, 600, 999}) + void maps_out_of_range_to_bad_request_with_unexpected_message(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(BadRequestError.class); + assertThat(exception.getMessage()) + .contains("Unexpected HTTP status") + .contains(String.valueOf(statusCode)); } - // ---------- emptyToNull: null vs blank vs valid ---------- + // ---------- 5xx messages include the actual status ---------- + + @ParameterizedTest + @ValueSource(ints = {500, 502, 503, 504, 599}) + void server_error_message_includes_the_actual_status(int statusCode) { + @Nullable MarketDataException exception = HttpStatusMapper.map(statusCode, context(statusCode)); + + assertThat(exception).isExactlyInstanceOf(ServerError.class); + assertThat(exception.getMessage()).contains(String.valueOf(statusCode)); + } + + // ---------- §9.4 Retry-After on 429 (RFC 6585) ---------- @Test - void nullRequestIdIsPropagatedAsNull() { - MarketDataException e = HttpStatusMapper.toException(500, URL, null); - assertThat(e.getRequestId()).isNull(); + void rate_limit_error_carries_retry_after_when_present() { + Duration retryAfter = Duration.ofSeconds(45); + + @Nullable MarketDataException exception = HttpStatusMapper.map(429, context(429), retryAfter); + + assertThat(exception).isExactlyInstanceOf(RateLimitError.class); + RateLimitError rle = (RateLimitError) exception; + assertThat(rle.getRetryAfter()).contains(retryAfter); } @Test - void 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(); + void rate_limit_error_retry_after_is_empty_when_absent() { + @Nullable MarketDataException exception = HttpStatusMapper.map(429, context(429), null); + + assertThat(exception).isExactlyInstanceOf(RateLimitError.class); + RateLimitError rle = (RateLimitError) exception; + assertThat(rle.getRetryAfter()).isEmpty(); } @Test - void validRequestIdIsPreserved() { - MarketDataException e = HttpStatusMapper.toException(500, URL, "ray-abc"); - assertThat(e.getRequestId()).isEqualTo("ray-abc"); + void error_carries_the_full_context() { + ErrorContext ctx = context(401); + + MarketDataException exception = HttpStatusMapper.map(401, ctx); + + assertThat(exception).isNotNull(); + assertThat(exception.getContext()).isEqualTo(ctx); + assertThat(exception.getRequestUrl()).isEqualTo("https://api.example"); + assertThat(exception.getRequestId()).isEqualTo("req-1"); + assertThat(exception.getTimestamp()).isEqualTo(TS); } } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java b/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java deleted file mode 100644 index f8a8a39..0000000 --- a/src/test/java/com/marketdata/sdk/HttpTransportE2ETest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * End-to-end tests for {@link HttpTransport} that exercise URI shapes and status codes the public - * resource façades don't naturally hit (status 203, trailing-slash paths). Uses the JDK's built-in - * {@link HttpServer} to avoid any extra mocking dependencies. - */ -class HttpTransportE2ETest { - - private HttpServer server; - private final AtomicReference capturedUri = new AtomicReference<>(); - private RouteHandler handler; - - /** Minimal record matching {@code {"value": "..."}} so we can verify a successful decode. */ - record Echo(@JsonProperty("value") String value) {} - - @BeforeEach - void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - handler = new RouteHandler(); - server.createContext("/", handler); - server.start(); - } - - @AfterEach - void stopServer() { - server.stop(0); - } - - private HttpTransport newTransport() { - int port = server.getAddress().getPort(); - return new HttpTransport("http://127.0.0.1:" + port, "v1", "test/0.0", null); - } - - /** - * Status 203 (Non-Authoritative Information) is treated identically to 200 by the transport — - * decoding the body and returning the result. The check {@code status == 200 || status == 203 || - * status == 404} in {@code processResponse} is the only place 203 appears, and without an - * explicit test the 203 leg is dead from JaCoCo's perspective. - */ - @Test - void status203IsTreatedAsSuccess() { - handler.setResponse(203, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - } - - /** - * When the {@link RequestSpec#path()} already ends with a slash, the transport must not append - * another one. Covers the {@code endsWith("/")} → true branch in {@code buildUri}. - */ - @Test - void pathEndingInSlashIsNotDoubled() { - handler.setResponse(200, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("ping/").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(capturedUri.get().getPath()).isEqualTo("/v1/ping/"); - assertThat(capturedUri.get().getPath()).doesNotContain("//"); - } - - /** - * RequestSpec's Javadoc says paths should not start with {@code /}, but a caller mistake would - * otherwise produce {@code /v1//ping/} (double slash, which some HTTP routers reject). The - * transport strips the leading slash defensively so a path of {@code "/ping"} produces the same - * URL as {@code "ping"}. - */ - @Test - void pathStartingWithSlashIsStripped() { - handler.setResponse(200, "{\"value\":\"ok\"}"); - - Echo result = newTransport().executeSync(RequestSpec.get("/ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(capturedUri.get().getPath()).isEqualTo("/v1/ping/"); - assertThat(capturedUri.get().getPath()).doesNotContain("//"); - } - - // ---------- in-process server plumbing ---------- - - private final class RouteHandler implements HttpHandler { - private int statusCode = 200; - private String body = "{}"; - - void setResponse(int code, String body) { - this.statusCode = code; - this.body = body; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - capturedUri.set(exchange.getRequestURI()); - - byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(statusCode, bodyBytes.length); - exchange.getResponseBody().write(bodyBytes); - exchange.getResponseBody().close(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java b/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java deleted file mode 100644 index 002a44c..0000000 --- a/src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java +++ /dev/null @@ -1,548 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import java.io.IOException; -import java.net.Authenticator; -import java.net.CookieHandler; -import java.net.ProxySelector; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.WebSocket; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.function.Supplier; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import org.junit.jupiter.api.Test; - -/** - * Exercises retry behavior. Uses a scripted {@link HttpClient} stub so a single test can drive a - * sequence of responses (e.g. 503, 503, 200) without spinning up an in-process HTTP server or - * waiting on real backoff durations. - */ -class HttpTransportRetryTest { - - /** Tiny response shape for body-decode assertions. */ - record Echo(@JsonProperty("value") String value) {} - - /** Retry policy with sub-millisecond delays so the suite stays under a second. */ - private static RetryPolicy fastPolicy(int maxAttempts) { - return new RetryPolicy(maxAttempts, Duration.ofMillis(1), Duration.ofMillis(5)); - } - - private static HttpTransport newTransport(MultiResponseHttpClient client, RetryPolicy policy) { - return new HttpTransport("http://stub.local", "v1", "test/0.0", null, client, policy); - } - - // ---------- happy paths ---------- - - @Test - void transientServer5xxRetriesAndEventuallySucceeds() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - - Echo result = - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(client.callCount()).isEqualTo(3); - } - - @Test - void networkFailuresRetryAndEventuallySucceed() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - failedResponse(new IOException("connect refused")), - failedResponse(new IOException("connect refused")), - response(200, "{\"value\":\"ok\"}")); - - Echo result = - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class); - - assertThat(result.value()).isEqualTo("ok"); - assertThat(client.callCount()).isEqualTo(3); - } - - // ---------- non-retriable paths fail immediately ---------- - - @Test - void status500FailsImmediatelyWithoutRetry() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(500, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(ServerError.class); - - // Exactly one attempt — 500 is in the retriable status space but the spec specifically - // excludes it (see §9: "501-599 retry; 500 no retry"). - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void authenticationErrorFailsImmediately() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(401, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(AuthenticationError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void badRequestFailsImmediately() { - MultiResponseHttpClient client = new MultiResponseHttpClient(response(400, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(BadRequestError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - @Test - void rateLimitErrorFailsImmediately() { - // Spec §9 explicitly says "Never retry rate limit errors." Even though the API may send - // Retry-After on 429, the SDK propagates immediately rather than blocking the caller. - MultiResponseHttpClient client = new MultiResponseHttpClient(response(429, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(RateLimitError.class); - assertThat(client.callCount()).isEqualTo(1); - } - - // ---------- exhaustion ---------- - - @Test - void exhaustedRetriesPropagatesLastError() { - // 4 stub responses — only 3 should be consumed before maxAttempts is hit and we give up. - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(503, "{}"), response(503, "{}")); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(ServerError.class) - .satisfies(t -> assertThat(((ServerError) t).getStatusCode()).isEqualTo(503)); - - assertThat(client.callCount()) - .as("maxAttempts=3 must cap total calls — including the original attempt") - .isEqualTo(3); - } - - @Test - void exhaustedRetriesOnNetworkErrorsPropagatesLastError() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - failedResponse(new IOException("kaboom")), - failedResponse(new IOException("kaboom")), - failedResponse(new IOException("kaboom"))); - - assertThatThrownBy( - () -> - newTransport(client, fastPolicy(3)) - .executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(NetworkError.class); - - assertThat(client.callCount()).isEqualTo(3); - } - - // ---------- sync-throw bugs do NOT retry ---------- - - /** - * If {@code httpClient.sendAsync} throws synchronously (malformed request, internal NPE, {@code - * IllegalArgumentException}), the failure is wrapped as {@code NetworkError} but its cause is not - * an {@link IOException}. {@link RetryPolicy} treats that as non-retriable: a deterministic bug - * doesn't get better with 1s+2s of backoff. - */ - @Test - void synchronousThrowDoesNotRetry() { - SyncThrowingHttpClient client = new SyncThrowingHttpClient(); - HttpTransport transport = - new HttpTransport("http://stub.local", "v1", "test/0.0", null, client, fastPolicy(3)); - - assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class)) - .isInstanceOf(NetworkError.class) - .hasMessageContaining("before dispatch") - .hasCauseInstanceOf(IllegalArgumentException.class); - - assertThat(client.callCount()) - .as("a sync-throw is deterministic — retrying just burns backoff for the same crash") - .isEqualTo(1); - } - - // ---------- rate-limit snapshot consistency under retry ---------- - - /** - * If attempt 1 returns 503 with rate-limit headers and attempt 2 returns 200 without them, the - * snapshot must reflect attempt 1's values (Issue #4 conservation rule applies cross-attempt, not - * just cross-request). - */ - @Test - void rateLimitSnapshotPreservedAcrossRetryAttempts() { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response( - 503, - "{}", - Map.of( - "x-api-ratelimit-limit", "50000", - "x-api-ratelimit-remaining", "12345", - "x-api-ratelimit-reset", "1735689600", - "x-api-ratelimit-consumed", "37655")), - response(200, "{\"value\":\"ok\"}", Map.of())); - - HttpTransport transport = newTransport(client, fastPolicy(3)); - transport.executeSync(RequestSpec.get("ping").build(), Echo.class); - - RateLimits snapshot = transport.getLatestRateLimits(); - assertThat(snapshot).isNotNull(); - assertThat(snapshot.remaining()) - .as("the snapshot must keep the headers from the 503 attempt, not be cleared by the 200") - .isEqualTo(12345L); - } - - // ---------- mid-backoff cancellation ---------- - - /** - * Cancelling the returned future while a backoff is pending must (a) skip the next attempt and - * (b) leave the permit pool intact. The cascade-cancel chain is the trickiest piece of {@link - * HttpTransport}; this test is the explicit regression for it. - */ - @Test - void cancellationMidBackoffSkipsRemainingAttempts() throws Exception { - // Use a slow policy so we have a real backoff window to cancel into. 200 ms is short enough - // to keep the test fast but long enough to reliably interleave the cancel. - RetryPolicy slowPolicy = new RetryPolicy(3, Duration.ofMillis(200), Duration.ofSeconds(1)); - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - HttpTransport transport = newTransport(client, slowPolicy); - - java.util.concurrent.CompletableFuture future = - transport.executeAsync(RequestSpec.get("ping").build(), Echo.class); - - // Let attempt 1 run and fail (503 → schedule retry with 200 ms backoff). Then cancel before - // the delayedExecutor fires the second attempt. - Thread.sleep(50); - boolean cancelled = future.cancel(false); - assertThat(cancelled).isTrue(); - - // Give the would-be next attempt plenty of time to fire if cancellation didn't stop it. - Thread.sleep(400); - - assertThat(client.callCount()) - .as("after mid-backoff cancellation, no further attempts may run") - .isEqualTo(1); - - AsyncSemaphore permits = readSemaphore(transport); - assertThat(permits.availablePermits()) - .as("permit lent to attempt 1 must have come back to the pool") - .isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - assertThat(permits.queueLength()).isZero(); - } - - // ---------- permits are still conserved across retries ---------- - - @Test - void permitsReturnToPoolAfterEveryAttemptRegardlessOfOutcome() throws Exception { - MultiResponseHttpClient client = - new MultiResponseHttpClient( - response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}")); - HttpTransport transport = newTransport(client, fastPolicy(3)); - - transport.executeSync(RequestSpec.get("ping").build(), Echo.class); - - AsyncSemaphore permits = readSemaphore(transport); - assertThat(permits.availablePermits()) - .as("after a 3-attempt retry chain, every permit must be back in the pool") - .isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - } - - // ---------- helpers ---------- - - private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception { - java.lang.reflect.Field f = HttpTransport.class.getDeclaredField("concurrencyPermits"); - f.setAccessible(true); - return (AsyncSemaphore) f.get(t); - } - - private static Supplier>> response(int code, String body) { - return response(code, body, Map.of()); - } - - private static Supplier>> response( - int code, String body, Map headers) { - return () -> CompletableFuture.completedFuture(new StubHttpResponse(code, body, headers)); - } - - private static Supplier>> failedResponse(Throwable t) { - return () -> CompletableFuture.failedFuture(t); - } - - /** - * {@link HttpClient} that returns scripted responses in order. Each invocation of {@code - * sendAsync} pops the next supplier and invokes it. Running out of script entries throws — that - * surfaces "we retried more times than the test expected" as a clear failure. - */ - private static final class MultiResponseHttpClient extends HttpClient { - private final Deque>>> script; - private int callCount = 0; - - @SafeVarargs - MultiResponseHttpClient(Supplier>>... responses) { - this.script = new ArrayDeque<>(List.of(responses)); - } - - int callCount() { - return callCount; - } - - @SuppressWarnings("unchecked") - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - callCount++; - Supplier>> next = script.pollFirst(); - if (next == null) { - return CompletableFuture.failedFuture( - new AssertionError("Retry overshot the test script — call #" + callCount)); - } - return (CompletableFuture>) (CompletableFuture) next.get(); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** - * Stub {@link HttpClient} whose {@code sendAsync} throws {@link IllegalArgumentException} - * synchronously. Used by {@link #synchronousThrowDoesNotRetry()} to drive the pre-dispatch-fault - * path. - */ - private static final class SyncThrowingHttpClient extends HttpClient { - private int callCount = 0; - - int callCount() { - return callCount; - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - callCount++; - throw new IllegalArgumentException("simulated synchronous throw from sendAsync"); - } - - @Override - public Optional cookieHandler() { - return Optional.empty(); - } - - @Override - public Optional connectTimeout() { - return Optional.empty(); - } - - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } - - @Override - public Optional proxy() { - return Optional.empty(); - } - - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } - - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public Optional authenticator() { - return Optional.empty(); - } - - @Override - public Version version() { - return Version.HTTP_1_1; - } - - @Override - public Optional executor() { - return Optional.empty(); - } - - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } - - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } - } - - /** Minimal {@link HttpResponse} stub — just the bits {@code HttpTransport} reads. */ - private static final class StubHttpResponse implements HttpResponse { - private final int status; - private final byte[] body; - private final HttpHeaders headers; - - StubHttpResponse(int status, String body, Map headers) { - this.status = status; - this.body = body.getBytes(StandardCharsets.UTF_8); - Map> multi = new java.util.HashMap<>(); - headers.forEach((k, v) -> multi.put(k, new ArrayList<>(List.of(v)))); - this.headers = HttpHeaders.of(multi, (a, b) -> true); - } - - @Override - public int statusCode() { - return status; - } - - @Override - public HttpRequest request() { - return null; - } - - @Override - public Optional> previousResponse() { - return Optional.empty(); - } - - @Override - public HttpHeaders headers() { - return headers; - } - - @Override - public byte[] body() { - return body; - } - - @Override - public Optional sslSession() { - return Optional.empty(); - } - - @Override - public URI uri() { - return URI.create("http://stub.local/v1/ping/"); - } - - @Override - public HttpClient.Version version() { - return HttpClient.Version.HTTP_1_1; - } - } -} diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index bd69e6a..259a6c4 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -3,497 +3,1009 @@ 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 com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; +import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.net.http.WebSocket; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; -import java.util.Optional; -// These tests cover the SINGLE-ATTEMPT semantics of executeAsync. Retry behavior is exercised -// separately in HttpTransportRetryTest; here we explicitly disable retry so a permit-release -// assertion reflects exactly one HTTP call per executeAsync invocation. +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; import org.junit.jupiter.api.Test; class HttpTransportTest { - /** Policy with a single attempt — disables retry so each test asserts one HTTP call only. */ + /** RetryPolicy with a single attempt so each test's HTTP-call count is unambiguous. */ private static final RetryPolicy NO_RETRY = new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); - /** - * 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. - */ + private static HttpTransport newTransport(HttpClient client) { + return newTransport(client, Clock.systemUTC()); + } + + private static HttpTransport newTransport(HttpClient client, Clock clock) { + return new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY), + () -> null, + clock); + } + + // ---------- URL & header composition ---------- + @Test - void permitReleasedWhenSendAsyncThrowsSynchronously() throws Exception { - HttpTransport transport = - new HttpTransport( - "http://localhost", "v1", "test/0.0", null, new SyncThrowingHttpClient(), NO_RETRY); - - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); - assertThat(initial).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - - int n = HttpTransport.CONCURRENCY_LIMIT + 5; - for (int i = 0; i < n; i++) { - CompletableFuture f = - transport.executeAsync(RequestSpec.get("ping").build(), Object.class); - - assertThat(f).isCompletedExceptionally(); - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasCauseInstanceOf(NetworkError.class) - .hasMessageContaining("before dispatch"); - } + void buildsUrlWithBaseVersionPathTrailingSlashAndEncodedQuery() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport + .executeAsync( + RequestSpec.get("markets/status") + .query("date", "2024-05-01") + .query("country", "US") + .build()) + .join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.uri().toString()) + .isEqualTo("http://localhost/v1/markets/status/?date=2024-05-01&country=US"); + } - // 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); + @Test + void sendsAuthorizationUserAgentAndAcceptHeaders() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").format(Format.CSV).build()).join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.headers().firstValue("Authorization")).contains("Bearer secret-token"); + assertThat(sent.headers().firstValue("User-Agent")).contains("test/0.0"); + assertThat(sent.headers().firstValue("Accept")).contains("text/csv"); + assertThat(sent.timeout()).contains(HttpTransport.REQUEST_TIMEOUT); } - /** - * 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 { + void noAuthorizationHeaderWhenTokenIsNull() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); HttpTransport transport = new HttpTransport( - "http://localhost", "v1", "test/0.0", null, new ErrorThrowingHttpClient(), NO_RETRY); + "http://localhost", + "v1", + "test/0.0", + null, + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured.get(0).headers().firstValue("Authorization")).isEmpty(); + } - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); + @Test + void unversionedSpecOmitsTheVersionSegment() { + // /status/ and /headers/ are documented at the API root, not under /v1/. The transport must + // honor the spec's unversioned flag so those system endpoints reach the right URL. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - CompletableFuture f = - transport.executeAsync(RequestSpec.get("ping").build(), Object.class); + transport.executeAsync(RequestSpec.get("status").unversioned().build()).join(); - assertThat(f).isCompletedExceptionally(); - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasRootCauseInstanceOf(OutOfMemoryError.class) - .hasRootCauseMessage("simulated synchronous Error from sendAsync"); + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/status/"); + } + + @Test + void emptyPathDoesNotProduceDoubleSlash() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - // 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); + transport.executeAsync(RequestSpec.get("").build()).join(); + + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/v1/"); } - /** - * Regression for the slow-path cancellation leak (Issue #1, Component A). When the pool is - * saturated, {@code acquire()} returns a pending waiter that is enqueued. The future the caller - * actually sees is the downstream {@code thenCompose} result, NOT the waiter. Cancelling the - * downstream does not propagate to the waiter (standard CompletableFuture semantics), so - * the waiter is still alive when {@code release()} runs — release() "transfers" the permit by - * completing the waiter, but the {@code thenCompose} function never executes because its - * dependent future is already cancelled. Result: the permit is lost forever. - * - *

This test saturates the pool with {@link HttpTransport#CONCURRENCY_LIMIT} fast-path - * dispatches whose HTTP futures we control, queues {@code extras} slow-path callers, cancels all - * the slow-path futures, and then completes the fast-path HTTP futures so {@code release()} - * fires. Once every dispatch has settled, every permit must be back in the pool. - */ @Test - void permitsAreReleasedWhenSlowPathFuturesAreCancelled() throws Exception { - ControllableHttpClient client = new ControllableHttpClient(); - HttpTransport transport = - new HttpTransport("http://localhost", "v1", "test/0.0", null, client, NO_RETRY); - - AsyncSemaphore permits = readSemaphore(transport); - int initial = permits.availablePermits(); - assertThat(initial).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); - - // Saturate the pool — these go through the fast path (acquire returns an already-completed - // future), dispatch is invoked, sendAsync is called → ControllableHttpClient returns a - // pending future we hold the handle to. - List> fastPath = new ArrayList<>(initial); - for (int i = 0; i < initial; i++) { - fastPath.add(transport.executeAsync(RequestSpec.get("ping").build(), Object.class)); - } - assertThat(permits.availablePermits()).isZero(); - assertThat(permits.queueLength()).isZero(); - assertThat(client.pendingCount()).isEqualTo(initial); - - // Slow path — these enqueue waiters in the semaphore. dispatch is NOT yet called for them. - int extras = 5; - List> slowPath = new ArrayList<>(extras); - for (int i = 0; i < extras; i++) { - slowPath.add(transport.executeAsync(RequestSpec.get("ping").build(), Object.class)); - } - assertThat(permits.queueLength()).isEqualTo(extras); + void leadingSlashInPathIsStripped() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - // 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); - } + transport.executeAsync(RequestSpec.get("/markets/status").build()).join(); - // 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")); + // Defensive strip — no double slash even when the resource accidentally prepends one. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/markets/status/"); + } - // 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); + @Test + void queryParamWithSpaceEncodesAsPercent20NotPlus() { + // URLEncoder defaults to form-encoding (spaces → "+"), which strict RFC-3986 servers treat + // as a literal "+" in the query string. The transport patches this to "%20" so endpoints + // taking arbitrary text (e.g. a multi-word symbol or description) round-trip correctly. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport + .executeAsync(RequestSpec.get("stocks/quotes").query("symbol", "BRK A").build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/stocks/quotes/?symbol=BRK%20A"); } - // ---------- asRuntime: covers the three branches in the executeSync catch ---------- + @Test + void queryParamWithReservedCharactersIsPercentEncoded() { + // Reserved characters like &, =, ?, # in a value must be percent-encoded so they aren't + // parsed as query-string delimiters. URLEncoder handles these correctly out of the box — + // this test just locks in that behavior so a future refactor of encodeQueryComponent + // doesn't accidentally regress it. + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("stocks/quotes").query("q", "a&b=c?d#e").build()).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/stocks/quotes/?q=a%26b%3Dc%3Fd%23e"); + } + + // ---------- response envelope ---------- + + @Test + void successReturnsEnvelopeWithBodyStatusAndRequestId() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("cf-ray", "abc-123")); + CapturingClient client = new CapturingClient(200, "payload".getBytes(), headers); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(new String(env.body())).isEqualTo("payload"); + assertThat(env.statusCode()).isEqualTo(200); + assertThat(env.requestId()).isEqualTo("abc-123"); + assertThat(env.url().toString()).isEqualTo("http://localhost/v1/markets/status/"); + } @Test - void 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()); + void status203AlsoReturnsEnvelope() { + CapturingClient client = + new CapturingClient(203, "cached".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - RuntimeException result = HttpTransport.asRuntime(mde); + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); - assertThat(result).isSameAs(mde); + assertThat(env.statusCode()).isEqualTo(203); + assertThat(new String(env.body())).isEqualTo("cached"); } @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"); + void status404AlsoReturnsEnvelope() { + // The API uses 404 for "no_data" responses; the body still carries a payload that resources + // need to inspect. + CapturingClient client = + new CapturingClient( + 404, "{\"s\":\"no_data\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(env.statusCode()).isEqualTo(404); + assertThat(new String(env.body())).isEqualTo("{\"s\":\"no_data\"}"); + } + + // ---------- status routing to typed exceptions ---------- + + @Test + void status401ThrowsAuthenticationError() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(AuthenticationError.class); + } - RuntimeException result = HttpTransport.asRuntime(re); + @Test + void status400ThrowsBadRequestError() { + CapturingClient client = + new CapturingClient(400, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - assertThat(result).isSameAs(re); + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(BadRequestError.class); } @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"); + void status429ThrowsRateLimitError() { + CapturingClient client = + new CapturingClient(429, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); + } - RuntimeException result = HttpTransport.asRuntime(error); + @Test + void status500ThrowsServerError() { + CapturingClient client = + new CapturingClient(500, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - assertThat(result).isInstanceOf(com.marketdata.sdk.exception.NetworkError.class); - assertThat(result.getCause()).isSameAs(error); - assertThat(result.getMessage()).contains("Unexpected failure invoking SDK"); + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); } @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); + void status418ThrowsNotFoundFallbackOrServerError() { + // Sanity: unmapped 4xx falls through to HttpStatusMapper's catch-all; we don't pin to + // a specific type here, only that it surfaces as SOME MarketDataException. + CapturingClient client = + new CapturingClient(418, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.MarketDataException.class); + } - assertThat(result).isInstanceOf(com.marketdata.sdk.exception.NetworkError.class); - assertThat(result.getCause()).isNull(); + @Test + void notFoundStatusIsNotThrownBecauseTheApiUsesItForNoData() { + // Sanity: 404 must NOT route to NotFoundError — it carries a no_data body. The status + // routing's "if 200/203/404 return envelope" branch covers this. + CapturingClient client = + new CapturingClient(404, "{}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(env.statusCode()).isEqualTo(404); + // Compiler-only: ensure NotFoundError exists so test wouldn't compile if removed. + @SuppressWarnings("unused") + Class noisy = NotFoundError.class; } - // ---------- unwrap: covers all 4 branches of `t instanceof CE && t.getCause() != null` - // ---------- + // ---------- rate-limit snapshot ---------- @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); + void rateLimitSnapshotUpdatesWhenHeadersPresent() { + HttpHeaders headers = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), headers); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + RateLimitSnapshot snap = transport.getLatestRateLimits(); + assertThat(snap).isNotNull(); + assertThat(snap.limit()).isEqualTo(1000); + assertThat(snap.remaining()).isEqualTo(987); + assertThat(snap.consumed()).isEqualTo(13); } @Test - void 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); + void rateLimitSnapshotNotClearedByResponseWithoutHeaders() { + // First call sets a snapshot; second call returns no headers; snapshot must remain + // populated (vs flickering to null). + // Real data has remaining > 0 — otherwise the §10.3 pre-flight would block the second call. + HttpHeaders withRl = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "100", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "400")); + HttpHeaders empty = HttpHeaders.of(Map.of(), (a, b) -> true); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), withRl); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + RateLimitSnapshot before = transport.getLatestRateLimits(); + assertThat(before).isNotNull(); + + client.nextHeaders = empty; + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(transport.getLatestRateLimits()).isSameAs(before); + } - assertThat(HttpTransport.unwrap(wrapped)).isSameAs(root); + @Test + void rateLimitSnapshotNotClobberedByPartialHeaders() { + // §8.2: the four x-api-ratelimit-* headers travel together. A response that only carries a + // subset is treated as "no rate-limit info" — we keep the last-known-good snapshot instead + // of stomping it with phantom zeros that would trip the §10.3 preflight. + HttpHeaders complete = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "100", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "400")); + HttpHeaders partial = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "500", + "x-api-ratelimit-remaining", "99")); // missing reset + consumed + CapturingClient client = new CapturingClient(200, "ok".getBytes(), complete); + HttpTransport transport = newTransport(client); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + RateLimitSnapshot before = transport.getLatestRateLimits(); + assertThat(before).isNotNull(); + + client.nextHeaders = partial; + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(transport.getLatestRateLimits()).isSameAs(before); } + // ---------- sync bridge ---------- + @Test - 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); + void executeSyncReturnsEnvelopeOnSuccess() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); + + HttpResponseEnvelope env = transport.executeSync(RequestSpec.get("markets/status").build()); - assertThat(HttpTransport.unwrap(causeless)).isSameAs(causeless); + assertThat(env.statusCode()).isEqualTo(200); } - // ---------- helpers ---------- + @Test + void executeSyncUnwrapsCompletionExceptionToCause() { + CapturingClient client = + new CapturingClient(500, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception { - Field f = HttpTransport.class.getDeclaredField("concurrencyPermits"); - f.setAccessible(true); - return (AsyncSemaphore) f.get(t); + assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("markets/status").build())) + .isInstanceOf(ServerError.class); // not CompletionException, not wrapped + } + + // ---------- §10.3 pre-flight rate-limit check ---------- + + private static HttpHeaders rateLimitHeaders(int remaining) { + // Reset always in the future relative to Clock.systemUTC() so the preflight's reset-window + // guard treats the snapshot as "still exhausted" rather than "stale — let it through". + long resetEpoch = Instant.now().plus(Duration.ofHours(1)).getEpochSecond(); + return TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + String.valueOf(remaining), + "x-api-ratelimit-reset", + String.valueOf(resetEpoch), + "x-api-ratelimit-consumed", + "1")); } /** - * 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. + * After a response that exhausts credits, the next call must fail fast with {@link + * RateLimitError} and never reach the HttpClient. Without §10.3 we'd waste a real request to + * discover the same answer the snapshot already gave us. */ - 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(); - } + @Test + void preflightRejectsWhenSnapshotShowsZeroRemaining() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), rateLimitHeaders(/* remaining */ 0)); + HttpTransport transport = newTransport(client); + + // First call populates the snapshot (remaining=0) and succeeds normally. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + // Second call should be vetoed by the pre-flight; HttpClient must not see it. + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.RateLimitError.class); - @Override - public Optional connectTimeout() { - return Optional.empty(); - } + assertThat(client.captured).hasSize(1); + } - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } + @Test + void preflightAllowsWhenSnapshotShowsCreditsRemaining() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), rateLimitHeaders(/* remaining */ 42)); + HttpTransport transport = newTransport(client); - @Override - public Optional proxy() { - return Optional.empty(); - } + // First call populates the snapshot. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + // Second call should proceed — credits still available. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } + assertThat(client.captured).hasSize(2); + } - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } + /** + * Before any rate-limit-bearing response has arrived, the snapshot is {@code null} — the first + * request must NOT be blocked despite there being "zero" remaining in the EMPTY sentinel. The + * pre-flight gate has to distinguish "no data yet" from "actually exhausted". + */ + @Test + void preflightAllowsTheFirstRequestWhenNoSnapshotExistsYet() { + CapturingClient client = + new CapturingClient(200, "ok".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - @Override - public Optional authenticator() { - return Optional.empty(); - } + // No prior response → no snapshot → request proceeds. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); - @Override - public Version version() { - return Version.HTTP_1_1; - } + assertThat(client.captured).hasSize(1); + assertThat(transport.getLatestRateLimits()).isNull(); + } - @Override - public Optional executor() { - return Optional.empty(); - } + /** + * The reset-window guard: once {@code reset} has elapsed the preflight must let the request + * through even though {@code remaining=0}. Without this guard a single response with {@code + * remaining=0} would lock the client out forever — no request reaches the wire, so the snapshot + * never refreshes from a fresh response. + */ + @Test + void preflightAllowsWhenResetWindowHasElapsed() { + // reset = 15:00:00 UTC; the test runs the second call at 15:00:10 — past the reset, so the + // preflight must allow the request even though remaining=0. + Instant resetTs = Instant.parse("2026-05-20T15:00:00Z"); + long resetEpoch = resetTs.getEpochSecond(); + Clock fixedAfterReset = Clock.fixed(resetTs.plus(Duration.ofSeconds(10)), ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetEpoch), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedAfterReset); + + // First call lands the exhausted snapshot. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + // Second call: remaining=0, but reset has already passed → preflight must allow. + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(2); + } - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } + @Test + void preflightAllowsAtTheExactResetInstant() { + // Boundary check: "now == reset" is treated as window-elapsed (the server has presumably + // refreshed by the instant the reset timestamp names). + Instant resetTs = Instant.parse("2026-05-20T15:00:00Z"); + Clock fixedAtReset = Clock.fixed(resetTs, ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetTs.getEpochSecond()), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedAtReset); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + + assertThat(client.captured).hasSize(2); + } - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } + /** + * §9.4 vs §10.3 conflict: a 5xx response can carry both rate-limit headers (which exhaust the + * snapshot) AND an explicit {@code Retry-After} (which schedules the retry). The retry must honor + * the server's directive — bypassing the local preflight gate — otherwise the SDK would sabotage + * the server-orchestrated backoff. Two attempts must reach the wire. + */ + @Test + void retryWithServerHintedRetryAfterBypassesPreflight() { + HttpHeaders exhaustedWithHint = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + "0", + "x-api-ratelimit-reset", + String.valueOf(Instant.now().plus(Duration.ofHours(1)).getEpochSecond()), + "x-api-ratelimit-consumed", + "1000", + "Retry-After", + "0")); + CapturingClient client = new CapturingClient(503, new byte[0], exhaustedWithHint); + RetryPolicy twoAttempts = new RetryPolicy(2, Duration.ofMillis(1), Duration.ofMillis(1)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(twoAttempts), + () -> null, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } + // Both attempts reached the wire: the snapshot would have BLOCKed the retry, but the + // server's Retry-After said "come back" and the SDK honored it. Without the bypass this + // would be 1 (and the final cause would be RateLimitError). + assertThat(client.captured).hasSize(2); } /** - * 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. + * Regression guard for the bypass scope: when the retry is NOT server-hinted (no {@code + * Retry-After}), the snapshot's exhaustion verdict still vetoes the retry. Otherwise we'd be + * leaking the bypass to every retry and defeating §10.3 entirely. */ - private static final class ControllableHttpClient extends HttpClient { - private final List>> pending = new ArrayList<>(); + @Test + void retryWithoutServerHintStillTriggersPreflightBlock() { + HttpHeaders exhaustedNoHint = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", + "1000", + "x-api-ratelimit-remaining", + "0", + "x-api-ratelimit-reset", + String.valueOf(Instant.now().plus(Duration.ofHours(1)).getEpochSecond()), + "x-api-ratelimit-consumed", + "1000")); + CapturingClient client = new CapturingClient(503, new byte[0], exhaustedNoHint); + RetryPolicy twoAttempts = new RetryPolicy(2, Duration.ofMillis(1), Duration.ofMillis(1)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(twoAttempts), + () -> null, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); - @SuppressWarnings("unchecked") - @Override - public CompletableFuture> sendAsync( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { - CompletableFuture> f = new CompletableFuture<>(); - pending.add((CompletableFuture>) (CompletableFuture) f); - return f; - } + // Only the first attempt reached the wire; the retry was vetoed by the preflight because + // the previous cause had no server-side Retry-After to authorize the bypass. + assertThat(client.captured).hasSize(1); + } - int pendingCount() { - return pending.size(); - } + @Test + void preflightStillBlocksWhenResetIsInTheFuture() { + // reset is in the future → the snapshot's "exhausted" verdict is still current; the + // preflight must veto the second call. + Instant resetTs = Instant.parse("2026-05-20T15:30:00Z"); + Clock fixedBeforeReset = Clock.fixed(resetTs.minus(Duration.ofMinutes(5)), ZoneOffset.UTC); + + HttpHeaders exhausted = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "0", + "x-api-ratelimit-reset", String.valueOf(resetTs.getEpochSecond()), + "x-api-ratelimit-consumed", "1000")); + CapturingClient client = new CapturingClient(200, "ok".getBytes(), exhausted); + HttpTransport transport = newTransport(client, fixedBeforeReset); + + transport.executeAsync(RequestSpec.get("markets/status").build()).join(); + assertThat(client.captured).hasSize(1); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class); - void failAll(Throwable t) { - for (CompletableFuture> f : pending) { - f.completeExceptionally(t); - } - } + assertThat(client.captured).hasSize(1); + } - @Override - public Optional cookieHandler() { - return Optional.empty(); - } + // ---------- §9.4 Retry-After header ---------- - @Override - public Optional connectTimeout() { - return Optional.empty(); - } + /** + * When the server attaches a {@code Retry-After} header to a 5xx response, the resulting {@link + * ServerError} must carry the parsed {@link Duration} so the retry policy can override its + * calculated backoff with the server's directive. + */ + @Test + void serverErrorCarriesParsedRetryAfterDuration() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("Retry-After", "7")); + CapturingClient client = new CapturingClient(503, new byte[0], headers); + HttpTransport transport = newTransport(client); - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class) + .satisfies( + t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).contains(Duration.ofSeconds(7)); + }); + } - @Override - public Optional proxy() { - return Optional.empty(); - } + @Test + void serverErrorRetryAfterIsEmptyWhenHeaderAbsent() { + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class) + .satisfies( + t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).isEmpty(); + }); + } - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } + /** + * RFC 6585 defines {@code Retry-After} for 429. The SDK does not retry 429 itself, but the + * consumer can read the directive to schedule its own backoff. The parsed duration must travel + * with the {@link RateLimitError}. + */ + @Test + void rateLimitErrorCarriesParsedRetryAfterDuration() { + HttpHeaders headers = TestHttpClients.headersOf(Map.of("Retry-After", "30")); + CapturingClient client = new CapturingClient(429, new byte[0], headers); + HttpTransport transport = newTransport(client); - @Override - public Optional authenticator() { - return Optional.empty(); - } + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class) + .satisfies( + t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).contains(Duration.ofSeconds(30)); + }); + } - @Override - public Version version() { - return Version.HTTP_1_1; - } + @Test + void rateLimitErrorRetryAfterIsEmptyWhenHeaderAbsent() { + CapturingClient client = + new CapturingClient(429, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = newTransport(client); - @Override - public Optional executor() { - return Optional.empty(); - } + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(RateLimitError.class) + .satisfies( + t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).isEmpty(); + }); + } - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } + // ---------- §9.5 status-cache gate ---------- - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); - } + /** + * Even with a 5xx that the policy would retry, an "offline" entry in the cache must veto the + * retry. The dispatcher should see exactly one call: the original attempt; no retries are + * scheduled. + */ + @Test + void cacheOfflineEntryVetoesA5xxRetry() throws Exception { + com.marketdata.sdk.utilities.ApiStatus offlineForService = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/status/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(offlineForService), + java.time.Clock.systemUTC()); + cache.triggerRefresh(); + // Wait for the snapshot to land — the fetcher returns a completed future, so the + // whenComplete fires synchronously on the same thread, but be defensive. + Thread.sleep(20); + + // Allow 4 retries so we'd retry on a 5xx — IF the cache didn't veto. + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); - @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); - } + // The cache vetoed: exactly one HTTP dispatch, no retries scheduled. + assertThat(client.captured).hasSize(1); } - /** 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"); - } + /** When the cache says online (or no entry matches), retries proceed normally. */ + @Test + void cacheOnlineEntryAllowsNormalRetryFlow() throws Exception { + com.marketdata.sdk.utilities.ApiStatus online = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/status/", + "online", + true, + 1.0, + 1.0, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(online), java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); - @Override - public Optional cookieHandler() { - return Optional.empty(); - } + // 4 attempts: initial + 3 retries (policy allows; cache doesn't veto). + assertThat(client.captured).hasSize(4); + } - @Override - public Optional connectTimeout() { - return Optional.empty(); - } + /** + * Self-referential bypass: even when the cache reports the /status/ service offline, retries of + * the /status/ fetch itself must proceed — otherwise the cache could never refresh out of an + * "offline" snapshot and would stay frozen in that state indefinitely. + */ + @Test + void cacheDoesNotBlockRetriesOnTheStatusEndpointEvenWhenSnapshotMarksItOffline() + throws Exception { + com.marketdata.sdk.utilities.ApiStatus statusItselfOffline = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/status/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(statusItselfOffline), + java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + // Confirm the snapshot really would BLOCK /status/ retries if the bypass didn't exist. + assertThat(cache.check(URI.create("http://localhost/status/"))) + .isEqualTo(StatusCache.Decision.BLOCK); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("status").unversioned().build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); - @Override - public Redirect followRedirects() { - return Redirect.NEVER; - } + // All 4 attempts run: the bypass kicked in for the /status/ path, so the cache did not + // veto. Without the bypass this would be 1. + assertThat(client.captured).hasSize(4); + } - @Override - public Optional proxy() { - return Optional.empty(); - } + @Test + void selfReferentialBypassDoesNotLeakToOtherEndpoints() throws Exception { + // Regression guard: the bypass for /status/ must NOT generalize. A different endpoint + // marked offline should still BLOCK retries as today. + com.marketdata.sdk.utilities.ApiStatus quotesOffline = + new com.marketdata.sdk.utilities.ApiStatus( + java.util.List.of( + new com.marketdata.sdk.utilities.ServiceStatus( + "/v1/markets/quotes/", + "offline", + false, + 0.5, + 0.5, + java.time.Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(quotesOffline), java.time.Clock.systemUTC()); + cache.triggerRefresh(); + Thread.sleep(20); + + RetryPolicy fourAttempts = new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(1)); + CapturingClient client = + new CapturingClient(503, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(fourAttempts), + () -> cache, + Clock.systemUTC()); + + assertThatThrownBy( + () -> transport.executeAsync(RequestSpec.get("markets/quotes").build()).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); - @Override - public SSLContext sslContext() { - throw new UnsupportedOperationException(); - } + // Cache vetoed: only 1 attempt, no retries. The bypass is /status/-specific. + assertThat(client.captured).hasSize(1); + } - @Override - public SSLParameters sslParameters() { - throw new UnsupportedOperationException(); - } + // ---------- §12 concurrency limit (50 in-flight) ---------- - @Override - public Optional authenticator() { - return Optional.empty(); - } + /** + * §12 mandates a 50-request concurrency cap on in-flight HTTP work. This test pins the value AND + * verifies the gating happens at the {@code transport.executeAsync} entry point — not just inside + * {@link HttpDispatcher} in isolation — so a future refactor that bypasses the dispatcher (or + * accidentally instantiates a parallel pool elsewhere) breaks the assertion. + * + *

Determinism: {@link TestHttpClients.Controllable} hands out fresh pending futures from + * {@code sendAsync}, so 50 dispatches fill the {@code pending} list and the 51st is forced onto + * the semaphore's slow path. Completing the in-flight set in two passes (the second covers the + * 51st request, which re-enters {@code sendAsync} as permits transfer through release()) drains + * the system back to a fully-available pool. + */ + @Test + void respectsGlobalConcurrencyLimitOfFifty() { + // Pin the constant — a silent edit that drops it (or hikes it past 50) would otherwise pass + // every existing test while quietly violating §12. + assertThat(HttpTransport.CONCURRENCY_LIMIT).isEqualTo(50); - @Override - public Version version() { - return Version.HTTP_1_1; - } + TestHttpClients.Controllable client = new TestHttpClients.Controllable(); + HttpDispatcher dispatcher = new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT); + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + dispatcher, + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + + List> calls = new ArrayList<>(); + for (int i = 0; i < 51; i++) { + calls.add(transport.executeAsync(RequestSpec.get("markets/status").build())); + } + + // 50 reached the wire; the 51st is parked in the semaphore queue. + assertThat(client.pendingCount()).isEqualTo(50); + assertThat(dispatcher.queueLength()).isOne(); + assertThat(dispatcher.availablePermits()).isZero(); + + HttpResponse ok = + TestHttpClients.response( + 200, + new byte[0], + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/v1/markets/status/")); + + // Pass 1: complete the original 50. Each release transfers to the queued 51st, which + // re-enters sendAsync and lands a fresh pending future. The 49 remaining releases bump the + // available counter. + client.completeAll(ok); + // Pass 2: complete the 51st's now-pending sendFuture so its dispatch chain settles too. + client.completeAll(ok); + + for (CompletableFuture c : calls) { + assertThat(c).isCompleted(); + } + assertThat(dispatcher.availablePermits()).isEqualTo(HttpTransport.CONCURRENCY_LIMIT); + assertThat(dispatcher.queueLength()).isZero(); + } - @Override - public Optional executor() { - return Optional.empty(); - } + // ---------- stub HttpClient ---------- - @Override - public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException, InterruptedException { - throw new UnsupportedOperationException(); - } + /** + * Captures every {@link HttpRequest} that flows through {@code sendAsync} and replies with a + * canned {@link HttpResponse}. Tests can mutate {@code nextHeaders}/{@code nextBody}/{@code + * nextStatus} between calls to drive different responses across requests. + */ + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + int nextStatus; + byte[] nextBody; + HttpHeaders nextHeaders; - @Override - public CompletableFuture> sendAsync( - HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler) { - throw new UnsupportedOperationException(); + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.nextStatus = status; + this.nextBody = body; + this.nextHeaders = headers; } + @SuppressWarnings({"unchecked", "rawtypes"}) @Override - public WebSocket.Builder newWebSocketBuilder() { - throw new UnsupportedOperationException(); + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response( + nextStatus, nextBody, nextHeaders, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); } } } diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java new file mode 100644 index 0000000..5e9aa20 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -0,0 +1,438 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; +import com.marketdata.sdk.utilities.User; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonResponseParserTest { + + /** + * Build a parser pre-loaded with the utilities resource's wire-format module. The parser itself + * is resource-agnostic (issue #9 fix); these tests exercise the deserializers shipped by {@link + * UtilitiesResource}, so the registration that the production constructor performs is replicated + * here. + */ + private static JsonResponseParser parserWithUtilitiesModule() { + JsonResponseParser p = new JsonResponseParser(); + p.registerModule(UtilitiesResource.wireFormatModule()); + return p; + } + + private static HttpResponseEnvelope env(String body) { + return new HttpResponseEnvelope( + body.getBytes(), + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/headers/")); + } + + @Test + void parsesRequestHeadersFromFlatJsonObject() { + JsonResponseParser parser = parserWithUtilitiesModule(); + + RequestHeaders rh = + parser.parse( + env("{\"accept\":\"*/*\",\"cf-ray\":\"abc-123\",\"user-agent\":\"java/0\"}"), + RequestHeaders.class); + + assertThat(rh.headers()) + .containsEntry("accept", "*/*") + .containsEntry("cf-ray", "abc-123") + .containsEntry("user-agent", "java/0"); + } + + @Test + void emptyJsonObjectProducesEmptyHeaders() { + JsonResponseParser parser = parserWithUtilitiesModule(); + RequestHeaders rh = parser.parse(env("{}"), RequestHeaders.class); + assertThat(rh.headers()).isEmpty(); + } + + @Test + void requestHeadersMapIsImmutable() { + JsonResponseParser parser = parserWithUtilitiesModule(); + RequestHeaders rh = parser.parse(env("{\"a\":\"1\"}"), RequestHeaders.class); + + assertThatThrownBy(() -> rh.headers().put("hacked", "value")) + .isInstanceOf(UnsupportedOperationException.class); + } + + // ---------- User: hyphenated wire keys → camelCase record ---------- + + @Test + void parsesUserMappingHyphenatedKeysToCamelCase() { + JsonResponseParser parser = parserWithUtilitiesModule(); + + User u = + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":5421," + + "\"x-ratelimit-requests-limit\":100000," + + "\"x-options-data-permissions\":\"OPRA data delayed 15 minutes\"}"), + User.class); + + assertThat(u.requestsRemaining()).isEqualTo(5421); + assertThat(u.requestsLimit()).isEqualTo(100000); + assertThat(u.optionsDataPermissions()).isEqualTo("OPRA data delayed 15 minutes"); + } + + @Test + void parsesUserWithEmptyOptionsPermissionsAsRealTimeMarker() { + // Empty string is the server's convention for "real-time access"; the SDK preserves it + // verbatim so consumers can detect realTime via `permissions.isEmpty()`. + JsonResponseParser parser = parserWithUtilitiesModule(); + + User u = + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":10," + + "\"x-ratelimit-requests-limit\":10," + + "\"x-options-data-permissions\":\"\"}"), + User.class); + + assertThat(u.optionsDataPermissions()).isEmpty(); + } + + @Test + void missingUserNumericFieldRaisesParseError() { + // Strict: a silent zero would mask backend regressions and surface later as a confusing + // "quota apparently exhausted". Same policy as ParallelArrays. + JsonResponseParser parser = parserWithUtilitiesModule(); + + assertThatThrownBy(() -> parser.parse(env("{\"x-ratelimit-requests-limit\":500}"), User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-integer") + .hasMessageContaining("x-ratelimit-requests-remaining"); + } + + @Test + void userNumericFieldOfWrongTypeRaisesParseError() { + // String "500" instead of integer 500 — strict rejection rather than Jackson's lax coercion. + JsonResponseParser parser = parserWithUtilitiesModule(); + + assertThatThrownBy( + () -> + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":\"5\"," + + "\"x-ratelimit-requests-limit\":10," + + "\"x-options-data-permissions\":\"\"}"), + User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-integer") + .hasMessageContaining("x-ratelimit-requests-remaining"); + } + + @Test + void userMissingOptionsPermsRaisesParseError() { + // The empty string is the legitimate "real-time access" marker — but the field must be + // present as a JSON string. Absence is treated as a backend regression, not a default. + JsonResponseParser parser = parserWithUtilitiesModule(); + + assertThatThrownBy( + () -> + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":1," + + "\"x-ratelimit-requests-limit\":2}"), + User.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-string") + .hasMessageContaining("x-options-data-permissions"); + } + + // ---------- ApiStatus: parallel-arrays wire format zipped into List ---------- + + @Test + void parsesApiStatusByZippingParallelArrays() { + // Canonical happy-path payload — six arrays of equal length plus the leading "s":"ok". + String body = + "{" + + "\"s\":\"ok\"," + + "\"service\":[\"/v1/stocks/quotes/\",\"/v1/options/chain/\"]," + + "\"status\":[\"online\",\"offline\"]," + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,0.9961]," + + "\"uptimePct90d\":[0.99828,0.95]," + + "\"updated\":[1734036832,1734036833]" + + "}"; + + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); + + assertThat(status.services()).hasSize(2); + ServiceStatus first = status.services().get(0); + assertThat(first.service()).isEqualTo("/v1/stocks/quotes/"); + assertThat(first.status()).isEqualTo("online"); + assertThat(first.online()).isTrue(); + assertThat(first.uptimePct30d()).isEqualTo(1.0); + assertThat(first.uptimePct90d()).isEqualTo(0.99828); + assertThat(first.updated()).isEqualTo(MarketDataDates.marketTimeFromEpochSecond(1734036832L)); + + ServiceStatus second = status.services().get(1); + assertThat(second.service()).isEqualTo("/v1/options/chain/"); + assertThat(second.online()).isFalse(); + assertThat(second.uptimePct30d()).isEqualTo(0.9961); + } + + @Test + void parsesApiStatusWithEmptyArrays() { + String body = + "{\"s\":\"ok\",\"service\":[],\"status\":[],\"online\":[]," + + "\"uptimePct30d\":[],\"uptimePct90d\":[],\"updated\":[]}"; + + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + + @Test + void apiStatusServicesListIsImmutable() { + String body = + "{\"s\":\"ok\",\"service\":[\"a\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[0]}"; + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); + + assertThatThrownBy(() -> status.services().add(null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void apiStatusNoDataEnvelopeProducesEmptyApiStatus() { + // The backend returns {"s":"no_data"} (with HTTP 404) when a query has no matches. The + // parser must not explode on the absent arrays — the response wrapper relies on the typed + // model being constructable so consumers can branch on isNoData() while still calling + // .data().services(). + String body = "{\"s\":\"no_data\"}"; + + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + + @Test + void apiStatusNoDataEnvelopeWithMetadataFieldsStillEmpty() { + // Some backend handlers attach hints to the no_data envelope (nextTime, prevTime, errmsg); + // those siblings must not perturb the empty result. + String body = + "{\"s\":\"no_data\"," + + "\"nextTime\":null,\"prevTime\":null," + + "\"errmsg\":\"Market closed on this date.\"}"; + + ApiStatus status = parserWithUtilitiesModule().parse(env(body), ApiStatus.class); + + assertThat(status.services()).isEmpty(); + } + + @Test + void apiStatusServerSideErrorBecomesParseError() { + // `s: "error"` is the server's soft-error path — the body is valid JSON but doesn't carry + // the usable arrays. Surface as ParseError so it doesn't masquerade as an empty success. + String body = "{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("database connection refused"); + } + + @Test + void apiStatusMismatchedArrayLengthsBecomeParseError() { + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\",\"b\"]," + + "\"status\":[\"online\"]," // 1 vs 2 + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,1.0]," + + "\"uptimePct90d\":[1.0,1.0]," + + "\"updated\":[0,0]}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("mismatched lengths"); + } + + @Test + void apiStatusNonArrayFieldBecomesParseError() { + // Field exists in the response but is not an array (e.g. a string). The "missing or + // non-array" guard treats this as malformed. + String body = + "{\"s\":\"ok\"," + + "\"service\":\"not-an-array\"," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("service"); + } + + @Test + void apiStatusMissingArrayBecomesParseError() { + // No `online` array — could happen if a backend refactor drops a field; better to fail + // loudly than silently default booleans to false for every row. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\"]," + + "\"status\":[\"online\"]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("online"); + } + + @Test + void apiStatusNullCellInOnlineArrayBecomesParseError() { + // Real-world regression scenario: the backend ships a build where `online` is sometimes + // null instead of a boolean. Before the strict-cell validation, this silently became + // online=false for every row → StatusCache marks services as offline → SDK blocks retries + // across the board. The strict accessor must surface the malformed cell as ParseError. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\",\"b\"]," + + "\"status\":[\"online\",\"online\"]," + + "\"online\":[true,null]," + + "\"uptimePct30d\":[1.0,1.0]," + + "\"uptimePct90d\":[1.0,1.0]," + + "\"updated\":[0,0]}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("null cell") + .hasMessageContaining("online"); + } + + @Test + void apiStatusWrongTypeInUptimeArrayBecomesParseError() { + // The backend swaps a number for a string (e.g. "1.0" instead of 1.0). Strict mode rejects + // it rather than relying on Jackson's lax string→number coercion. + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"a\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[\"1.0\"]," // string instead of number + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[0]}"; + + assertThatThrownBy(() -> parserWithUtilitiesModule().parse(env(body), ApiStatus.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("expected number") + .hasMessageContaining("uptimePct30d"); + } + + @Test + void malformedJsonRaisesParseErrorCarryingResponseContext() { + JsonResponseParser parser = parserWithUtilitiesModule(); + + assertThatThrownBy(() -> parser.parse(env("{not-json"), RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .satisfies( + t -> { + ParseError err = (ParseError) t; + assertThat(err.getRequestUrl()).isEqualTo("http://localhost/headers/"); + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + assertThat(err.getCause()).isNotNull(); + }); + } + + /** + * Issue #29: a zero-length body must surface as a precise, actionable {@link ParseError} — "empty + * response body" — rather than Jackson's generic "No content to map" wrap. Proxies that strip + * bodies (some misconfigured corporate setups) are the canonical cause; consumers shouldn't have + * to read Jackson's stack trace to figure that out. + */ + @Test + void emptyBodyRaisesParseErrorWithExplicitMessage() { + JsonResponseParser parser = parserWithUtilitiesModule(); + HttpResponseEnvelope empty = + new HttpResponseEnvelope( + new byte[0], + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/headers/")); + + assertThatThrownBy(() -> parser.parse(empty, RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Empty response body") + .hasMessageContaining("0 bytes") + .satisfies( + t -> { + ParseError err = (ParseError) t; + // Context is still populated — consumers can correlate via requestId, see the + // status code, etc. + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + // No Jackson cause when we short-circuit at the empty-body check. + assertThat(err.getCause()).isNull(); + }); + } + + /** + * Issue #16: {@code getMessage()} is consumer-accessible — anything embedded in it ends up in + * logs the moment a consumer does the obvious {@code log.error(ex.getMessage())}. Query strings + * carry tokens, account ids, and queried symbols; §16 requires that they not leak through this + * surface. The full URI remains on {@link ParseError#getRequestUrl()} for callers that need it. + */ + @Test + void parseErrorMessageRedactsQueryString() { + JsonResponseParser parser = parserWithUtilitiesModule(); + HttpResponseEnvelope envWithQuery = + new HttpResponseEnvelope( + "{not-json".getBytes(), + 200, + "test-request-id", + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://localhost/stocks/quotes/?token=secret-xyz&symbol=AAPL")); + + assertThatThrownBy(() -> parser.parse(envWithQuery, RequestHeaders.class)) + .isInstanceOf(ParseError.class) + .satisfies( + t -> { + ParseError err = (ParseError) t; + // Message redacts the query. + assertThat(err.getMessage()).doesNotContain("secret-xyz").doesNotContain("AAPL"); + assertThat(err.getMessage()).contains("?…"); + // getRequestUrl() also redacts (pre-existing policy on MarketDataException). + assertThat(err.getRequestUrl()).doesNotContain("secret-xyz"); + // The raw context retains the full URI for diagnostic use that won't be persisted. + assertThat(err.getContext().requestUrl()).contains("token=secret-xyz"); + }); + } + + // ---------- §9 / ADR-007: parser is resource-agnostic ---------- + + /** + * Regression guard: a bare {@link JsonResponseParser} (no modules registered) must NOT know how + * to deserialize {@link RequestHeaders} or any other resource type. If a future change + * reintroduces hardcoded deserializers in the parser's constructor, this test catches it. + */ + @Test + void bareParserDoesNotKnowResourceDeserializers() { + JsonResponseParser bare = new JsonResponseParser(); + + // RequestHeaders requires the custom deserializer; without it Jackson's default record + // mapping fails for the wire shape ({"accept":"*/*",...}) because the record has no + // matching property names. Surfaces as ParseError per the parser's contract. + assertThatThrownBy(() -> bare.parse(env("{\"accept\":\"*/*\"}"), RequestHeaders.class)) + .isInstanceOf(ParseError.class); + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index ca51fbd..32893a9 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -1,131 +1,237 @@ package com.marketdata.sdk; import static org.assertj.core.api.Assertions.assertThat; - -import java.util.ArrayList; -import java.util.List; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import com.marketdata.sdk.exception.MarketDataException; +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; class MarketDataClientTest { + private static final Function NO_ENV = key -> null; + + private static Function envOf(Map values) { + return values::get; + } + + private static Path noDotEnv(Path tmp) { + return tmp.resolve("missing.env"); + } + + /** Reserves a fresh port and immediately releases it so connects target a known-closed socket. */ + private static String reserveClosedLocalUrl() throws Exception { + int port; + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + return "http://127.0.0.1:" + port; + } + @Test - void buildsWithExplicitToken() { - try (var client = new MarketDataClient("test-key", null, null, true)) { - assertThat(client.isDemoMode()).isFalse(); - assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); - assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); + void no_arg_constructor_resolves_defaults_and_returns_null_rate_limits(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { + // Before any rate-limit-bearing response arrives, the snapshot is null — distinct from a + // server-reported (0, 0, EPOCH, 0) snapshot that a real "remaining=0" would produce. + assertThat(client.getRateLimits()).isNull(); } } @Test - void demoModeWhenNoTokenAvailable() { - // Demo mode iff the full cascade (env var → .env → null) yields nothing. Deriving the - // expectation from the same Configuration helper the constructor uses keeps the test - // valid both on CI (no token anywhere → demoMode) and locally (.env-supplied token → - // not demoMode); a plain `System.getenv` check would miss the .env source and break - // locally. - try (var client = new MarketDataClient()) { - boolean expectDemo = Configuration.loadFromProcess().resolve(null, EnvVars.TOKEN) == null; - assertThat(client.isDemoMode()).isEqualTo(expectDemo); + void four_arg_constructor_uses_explicit_values(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient( + "explicit-key", "https://explicit.example", "v9", false, NO_ENV, noDotEnv(tmp))) { + assertThat(client.toString()) + .contains("baseUrl=https://explicit.example") + .contains("apiVersion=v9") + .contains("demoMode=false") + .doesNotContain("explicit-key"); } } @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); + void to_string_redacts_token(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient("supersecret-token-YKT0", null, null, false, NO_ENV, noDotEnv(tmp))) { + String repr = client.toString(); + + assertThat(repr).doesNotContain("supersecret-token-YKT0"); + assertThat(repr).contains("***…***YKT0"); } + } - 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"); + @Test + void to_string_shows_demo_mode_when_no_api_key(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { + assertThat(client.toString()).contains("demoMode=true"); } } - /** Minimal {@link Handler} that buffers everything in memory for assertions. */ - private static final class CapturingHandler extends Handler { - final List records = new ArrayList<>(); + // ---------- validateOnStartup wiring (end-to-end, no Runnable seam) ---------- + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void validate_on_startup_true_attempts_validation(@TempDir Path tmp) throws Exception { + // With a non-demo token and an unreachable baseUrl, the ctor must attempt the /user/ call + // and surface the failure to the caller. If the validation hook ever gets disconnected from + // the ctor flow, this test fails because construction would succeed silently. + String unreachable = reserveClosedLocalUrl(); + + assertThatThrownBy( + () -> new MarketDataClient("any-token", unreachable, null, true, NO_ENV, noDotEnv(tmp))) + .isInstanceOf(MarketDataException.class); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void validate_on_startup_false_skips_validation_even_with_token(@TempDir Path tmp) + throws Exception { + // Symmetric case: a non-demo token + unreachable baseUrl + validateOnStartup=false must + // construct cleanly. Any latent path that fires validation despite the flag would surface + // here as a thrown ctor. + String unreachable = reserveClosedLocalUrl(); + + try (MarketDataClient client = + new MarketDataClient("any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp))) { + assertThat(client.toString()).contains("demoMode=false"); + } + } - @Override - public void publish(LogRecord record) { - records.add(record); + @Test + void resolves_token_from_env_when_not_provided_explicitly(@TempDir Path tmp) { + try (MarketDataClient client = + new MarketDataClient( + null, + null, + null, + false, + envOf(Map.of(EnvVars.TOKEN, "env-token-ABCD")), + noDotEnv(tmp))) { + assertThat(client.toString()).contains("***…***ABCD").contains("demoMode=false"); } + } - @Override - public void flush() {} + @Test + void close_is_idempotent(@TempDir Path tmp) { + MarketDataClient client = new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp)); - @Override - public void close() {} + client.close(); + client.close(); } @Test - void noArgConstructorAppliesProductionDefaults() { - // The no-arg constructor must be equivalent to `new MarketDataClient(null, null, null, - // true)` — production path with everything resolved from the cascade and startup - // validation enabled. validateOnStartup and the userAgent format are env-independent, - // so we assert them unconditionally; baseUrl/apiVersion fall back to the documented - // defaults only when the cascade has no override, so we gate those assertions on the - // env vars being unset (mirrors the demo-mode test above). - try (var client = new MarketDataClient()) { - assertThat(client.isValidateOnStartup()).isTrue(); - assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); - - if (System.getenv("MARKETDATA_BASE_URL") == null) { - assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); - } - if (System.getenv("MARKETDATA_API_VERSION") == null) { - assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); - } + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void run_startup_validation_fails_fast_when_api_unreachable(@TempDir Path tmp) throws Exception { + // §5 + retry policy: startup validation must use a single-attempt policy so a slow/down API + // doesn't burn the full retry budget (~6.75 min worst case with defaults) before the + // constructor returns. Drive a real connection-refused (closed local port) and assert the + // failure surfaces well below even one default-policy retry would. + String unreachable = reserveClosedLocalUrl(); + + try (MarketDataClient client = + new MarketDataClient("any-token", unreachable, null, false, NO_ENV, noDotEnv(tmp))) { + long start = System.nanoTime(); + assertThatThrownBy(client::runStartupValidation).isInstanceOf(MarketDataException.class); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + // With the default retry policy this would have taken ~7 s minimum (1 s + 2 s + 4 s + // backoffs between four attempts). A single-attempt run is bounded by connect-refused + // latency, well under 2 s on any reasonable runner. + assertThat(elapsedMs) + .as("startup validation should not burn the retry budget") + .isLessThan(2000); } } @Test - void overridesAreHonored() { - try (var client = new MarketDataClient("KEY", "https://example.test/", "v2", false)) { - assertThat(client.getBaseUrl()).isEqualTo("https://example.test"); // trailing slash trimmed - assertThat(client.getApiVersion()).isEqualTo("v2"); - assertThat(client.isValidateOnStartup()).isFalse(); + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void run_startup_validation_skips_in_demo_mode(@TempDir Path tmp) { + // §5: when apiKey is unresolvable (demo mode), runStartupValidation must not hit /user/ — + // the server would return 401, breaking construction for any consumer who tries to "kick + // the tires" without a token. The @Timeout guards against regression: if the skip ever + // breaks, the test fails in 5s instead of hanging on the full retry budget (~6.75 min). + try (MarketDataClient client = + new MarketDataClient(null, null, null, false, NO_ENV, noDotEnv(tmp))) { + assertThat(client.toString()).contains("demoMode=true"); + client.runStartupValidation(); // must return immediately, not make a network call } } @Test - void userAgentMatchesSpec() { - try (var client = new MarketDataClient("KEY", null, null, true)) { - assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); + void quick_start_usage_resolves_real_environment_and_never_leaks_token() { + // The no-arg public ctor now hits /user/ for startup validation (§5). Don't exercise + // that path here — this test asserts config resolution and token redaction, not the live + // call. Use the 4-arg variant with validateOnStartup=false to keep this a pure unit test. + try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { + assertThat(client.getRateLimits()).isNull(); + assertThat(client.toString()).startsWith("MarketDataClient[").endsWith("]"); + + String envToken = System.getenv(EnvVars.TOKEN); + if (envToken != null && !envToken.isBlank()) { + assertThat(client.toString()).doesNotContain(envToken); + } + + String envBaseUrl = System.getenv(EnvVars.BASE_URL); + if (envBaseUrl == null || envBaseUrl.isBlank()) { + assertThat(client.toString()).contains("baseUrl=" + Configuration.DEFAULT_BASE_URL); + } } } + // ---------- issue #25: .env warnings survive resolve failure ---------- + + /** + * If {@link Configuration#resolve} throws (e.g. invalid baseUrl), any {@code .env} warnings + * collected before the throw used to be dropped — the constructor's replay loop runs only on the + * happy path. The fix attaches each warning as a suppressed exception so the IAE stack trace + * carries the breadcrumb (an unreadable {@code .env} could be the very reason the cascade fell + * back to a misconfigured default). + */ @Test - void rateLimitsStartUnpopulated() { - try (var client = new MarketDataClient("KEY", null, null, true)) { - assertThat(client.getRateLimits()).isNull(); + void resolve_failure_attaches_pending_dotenv_warnings_as_suppressed(@TempDir Path tmp) + throws IOException { + // Build an unreadable .env so DotEnvLoader emits a "not readable" warning. + Path dotEnv = tmp.resolve(".env"); + Files.writeString(dotEnv, "MARKETDATA_TOKEN=irrelevant\n"); + boolean permsSupported = false; + try { + Files.setPosixFilePermissions(dotEnv, PosixFilePermissions.fromString("---------")); + permsSupported = true; + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem (rare on CI runners but possible on Windows) — skip the test cleanly + // by checking permission below. } + org.junit.jupiter.api.Assumptions.assumeTrue( + permsSupported && !Files.isReadable(dotEnv), + "Test requires a filesystem that supports making files unreadable to the current user."); + + // Explicit baseUrl is invalid — resolve will throw IAE — AFTER the .env loader has fired its + // warning. Without the #25 fix, the warning vanishes; with the fix it surfaces as a + // suppressed exception on the IAE. + Throwable thrown = + catchThrowable( + () -> new MarketDataClient("any-token", "not-a-url", null, false, NO_ENV, dotEnv)); + + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + assertThat(thrown.getSuppressed()) + .as("the .env unreadable warning must be attached as a suppressed exception") + .isNotEmpty(); + assertThat(thrown.getSuppressed()[0]) + .hasMessageContaining(".env") + .hasMessageContaining("not readable"); } } diff --git a/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java new file mode 100644 index 0000000..e8fcef5 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketDataLoggingTest.java @@ -0,0 +1,199 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MarketDataLoggingTest { + + private static Logger sdkLogger() { + return Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME); + } + + @BeforeEach + void reset() { + MarketDataLogging.resetForTests(); + } + + @AfterEach + void resetAfter() { + MarketDataLogging.resetForTests(); + } + + // ---------- parseLevel ---------- + + @Test + void parseLevelMapsSpecVocabularyToJulLevels() { + assertThat(MarketDataLogging.parseLevel("DEBUG")).isEqualTo(Level.FINE); + assertThat(MarketDataLogging.parseLevel("INFO")).isEqualTo(Level.INFO); + assertThat(MarketDataLogging.parseLevel("WARNING")).isEqualTo(Level.WARNING); + assertThat(MarketDataLogging.parseLevel("ERROR")).isEqualTo(Level.SEVERE); + } + + @Test + void parseLevelIsCaseAndWhitespaceInsensitive() { + assertThat(MarketDataLogging.parseLevel(" debug ")).isEqualTo(Level.FINE); + assertThat(MarketDataLogging.parseLevel("Info")).isEqualTo(Level.INFO); + } + + @Test + void parseLevelFallsBackToDefaultWhenNullOrUnknown() { + assertThat(MarketDataLogging.parseLevel(null)).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + assertThat(MarketDataLogging.parseLevel("VERBOSE")).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + assertThat(MarketDataLogging.parseLevel("")).isEqualTo(MarketDataLogging.DEFAULT_LEVEL); + } + + // ---------- configure ---------- + + @Test + void configureInstallsExactlyOneHandlerWithTheCanonicalFormatter() { + MarketDataLogging.configure("INFO"); + + Handler[] handlers = sdkLogger().getHandlers(); + assertThat(handlers).hasSize(1); + assertThat(handlers[0].getFormatter()).isInstanceOf(CanonicalLogFormatter.class); + } + + @Test + void configureSetsLevelOnSdkLogger() { + MarketDataLogging.configure("DEBUG"); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + } + + @Test + void configureDisablesParentHandlersToAvoidDuplicateEmission() { + MarketDataLogging.configure(null); + assertThat(sdkLogger().getUseParentHandlers()).isFalse(); + } + + @Test + void configureIsIdempotentAcrossCalls() { + // Multiple MarketDataClient instances must not pile up handlers; the first call wins. + MarketDataLogging.configure("DEBUG"); + MarketDataLogging.configure("ERROR"); + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getHandlers()).hasSize(1); + // First call's level stands (DEBUG → FINE), not the subsequent ones. + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + } + + @Test + void defaultLevelWhenSpecIsNullIsInfo() { + MarketDataLogging.configure(null); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); + } + + // ---------- consumer-config detection ---------- + + @Test + void configureSkipsWhenConsumerAlreadyAttachedAHandler() { + // Consumer pre-attached their own handler (e.g. SLF4J bridge, Logback appender). The SDK + // must not add its ConsoleHandler on top — that would emit each log line twice. + Handler consumerHandler = new TestHandler(); + sdkLogger().addHandler(consumerHandler); + + MarketDataLogging.configure("DEBUG"); + + assertThat(sdkLogger().getHandlers()).containsExactly(consumerHandler); + // useParentHandlers must remain at its default (true) — flipping it would break the + // consumer's parent-handler routing. + assertThat(sdkLogger().getUseParentHandlers()).isTrue(); + // Level was not set by the SDK; remains null (inherits from parent). + assertThat(sdkLogger().getLevel()).isNull(); + } + + @Test + void configureSkipsWhenConsumerAlreadySetALevel() { + // Consumer explicitly chose a level (e.g. FINE for local debugging). The SDK's default + // INFO must not silently override it. + sdkLogger().setLevel(Level.FINE); + + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + // No handler added either — having any opinion at all on the logger counts as "consumer + // has taken control". + assertThat(sdkLogger().getHandlers()).isEmpty(); + } + + @Test + void configureRetriesInstallAfterConsumerReleasesControl() { + // Bug from review #8: previously `configured` latched on the consumer-pre-config skip path, + // so the SDK was frozen out for the lifetime of the process even after the consumer + // cleared their handler/level. The fix latches `configured` only on actual install. This + // test pins the new behavior — no resetForTests() called between attempts. + sdkLogger().setLevel(Level.FINE); // simulate consumer state + MarketDataLogging.configure("INFO"); + // First call backed off: no handler, consumer's level intact. + assertThat(sdkLogger().getHandlers()).isEmpty(); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.FINE); + + // Consumer releases control (e.g. their bootstrap completed and they cleared the override). + sdkLogger().setLevel(null); + + MarketDataLogging.configure("WARNING"); + + // SDK now installs because consumer-pre-config disappeared — the prior skip did not latch + // the flag. + assertThat(sdkLogger().getHandlers()).hasSize(1); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.WARNING); + } + + @Test + void configureRunsAgainAfterResetClearsConsumerState() { + // Defensive: resetForTests() wipes both the idempotency flag and the logger state, so a + // subsequent configure() must see a fresh slate and install the SDK defaults. + sdkLogger().setLevel(Level.FINE); // simulate consumer state + MarketDataLogging.configure("INFO"); + assertThat(sdkLogger().getHandlers()).isEmpty(); // skipped + + MarketDataLogging.resetForTests(); + MarketDataLogging.configure("INFO"); + + assertThat(sdkLogger().getHandlers()).hasSize(1); + assertThat(sdkLogger().getLevel()).isEqualTo(Level.INFO); + } + + // ---------- §7 consolidation: every SDK class emits via the single root logger ---------- + + /** + * Regression guard for the consolidation: if anyone re-introduces a per-class logger via {@code + * Logger.getLogger(SomeClass.class.getName())}, the configure() consumer-pre-config detection and + * {@code useParentHandlers=false} guard would no longer cover the new sub-logger — records could + * double-emit through the root JUL handler or escape the SDK's level control. + */ + @Test + void all_sdk_classes_emit_via_the_consolidated_root_logger() throws Exception { + for (Class clazz : + java.util.List.of( + MarketDataClient.class, HttpTransport.class, HttpDispatcher.class, StatusCache.class)) { + java.lang.reflect.Field loggerField = clazz.getDeclaredField("LOGGER"); + loggerField.setAccessible(true); + Logger logger = (Logger) loggerField.get(null); + assertThat(logger.getName()) + .as( + "Class %s must use Logger.getLogger(MarketDataLogging.SDK_LOGGER_NAME) so its records" + + " stay under the single configured root (§7).", + clazz.getSimpleName()) + .isEqualTo(MarketDataLogging.SDK_LOGGER_NAME); + } + } + + /** Minimal Handler stub used to simulate a consumer-attached handler. */ + private static final class TestHandler extends Handler { + @Override + public void publish(java.util.logging.LogRecord record) {} + + @Override + public void flush() {} + + @Override + public void close() {} + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java deleted file mode 100644 index cdb6c79..0000000 --- a/src/test/java/com/marketdata/sdk/MarketStatusDeserializerTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.marketdata.sdk.markets.MarketStatus; -import java.io.IOException; -import java.time.LocalDate; -import org.junit.jupiter.api.Test; - -class MarketStatusDeserializerTest { - - private final ObjectMapper mapper = newMapper(); - - private static ObjectMapper newMapper() { - // Per ADR-007 response records carry no @JsonDeserialize annotation — the deserializer - // is registered programmatically (HttpTransport does this in production; the test mirrors - // the same wiring so it exercises the real deserializer). - ObjectMapper m = new ObjectMapper(); - SimpleModule module = new SimpleModule("marketdata-wire-test"); - module.addDeserializer(MarketStatus.class, new MarketStatusDeserializer()); - m.registerModule(module); - return m; - } - - @Test - void parsesOkResponseIntoChronologicalDays() throws IOException { - // 1706745600 = 2024-02-01 00:00:00 UTC = 2024-01-31 19:00 US/Eastern → date 2024-01-31 - // The API normalizes "trading day midnight Eastern" to a unix timestamp; we expect the - // deserializer to recover the local Eastern date. - String json = - """ - { "s": "ok", - "date": [1706673600, 1706760000, 1706846400], - "status": ["open", "open", "closed"] } - """; - - MarketStatus status = mapper.readValue(json, MarketStatus.class); - - assertThat(status.days()).hasSize(3); - assertThat(status.days().get(0).open()).isTrue(); - assertThat(status.days().get(1).open()).isTrue(); - assertThat(status.days().get(2).open()).isFalse(); - assertThat(status.days().get(0).date()).isInstanceOf(LocalDate.class); - assertThat(status.isEmpty()).isFalse(); - } - - @Test - void noDataResponseProducesEmptyResult() throws IOException { - MarketStatus status = mapper.readValue("{\"s\":\"no_data\"}", MarketStatus.class); - - assertThat(status.days()).isEmpty(); - assertThat(status.isEmpty()).isTrue(); - } - - @Test - void rejectsUnknownStatusField() { - assertThatThrownBy(() -> mapper.readValue("{\"s\":\"weird\"}", MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("'weird'"); - } - - @Test - void rejectsMismatchedArraySizes() { - String json = - """ - { "s": "ok", - "date": [1706673600, 1706760000], - "status": ["open"] } - """; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("different sizes"); - } - - @Test - void rejectsResponseMissingArrays() { - String json = "{\"s\":\"ok\"}"; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("expected 'date' and 'status' arrays"); - } - - @Test - void rejectsResponseWhereDateIsArrayButStatusIsMissing() { - // Covers the right-hand branch of the `||` in `!dates.isArray() || !statuses.isArray()`: - // dates is a valid array, but statuses is absent. Without this test, the short-circuit - // means the second condition is only ever evaluated when the first is false and matches. - String json = - """ - { "s": "ok", - "date": [1706673600] } - """; - - assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) - .isInstanceOf(JsonMappingException.class) - .hasMessageContaining("expected 'date' and 'status' arrays"); - } -} diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java deleted file mode 100644 index 9db68cf..0000000 --- a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java +++ /dev/null @@ -1,461 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import com.marketdata.sdk.markets.MarketStatus; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -/** - * Exercises the full resource → transport → HTTP path against an in-process {@link HttpServer} (JDK - * built-in — no extra mock dep). Verifies URL construction, query-param encoding, response - * decoding, error mapping, and rate-limit header parsing. - */ -class MarketsResourceTest { - - private HttpServer server; - private final AtomicReference lastRequest = new AtomicReference<>(); - private RouteHandler handler; - - @BeforeEach - void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - handler = new RouteHandler(); - server.createContext("/", handler); - server.start(); - } - - @AfterEach - void stopServer() { - server.stop(0); - } - - private MarketDataClient newClient() { - int port = server.getAddress().getPort(); - return new MarketDataClient("test-key", "http://127.0.0.1:" + port, null, false); - } - - // ---------- success paths ---------- - - /** - * The 5 paths exercised below are the load-bearing scenarios — each runs once for {@link - * CallMode#SYNC} and once for {@link CallMode#ASYNC} so we satisfy SDK requirements §13's "tests - * must cover both sync and async variants for every endpoint" without duplicating every single - * mechanical case. - */ - @ParameterizedTest - @EnumSource(CallMode.class) - void statusNoArgsHitsCanonicalUrlAndDecodesPayload(CallMode mode) { - handler.setResponse( - 200, - """ - { "s":"ok", "date":[1706673600,1706760000], "status":["open","closed"] } - """, - List.of( - rateLimitHeader("limit", "50000"), - rateLimitHeader("remaining", "49500"), - rateLimitHeader("reset", "1735689600"), - rateLimitHeader("consumed", "1"))); - - try (var client = newClient()) { - MarketStatus result = mode.statusNoArgs(client.markets()); - - assertThat(result.days()).hasSize(2); - assertThat(result.days().get(0).open()).isTrue(); - assertThat(result.days().get(1).open()).isFalse(); - - RecordedRequest req = lastRequest.get(); - assertThat(req.path).isEqualTo("/v1/markets/status/"); - assertThat(req.query).isNull(); - assertThat(req.headers.firstValue("Authorization")).hasValue("Bearer test-key"); - assertThat(req.headers.firstValue("User-Agent")) - .get() - .asString() - .startsWith("marketdata-sdk-java/"); - assertThat(req.headers.firstValue("Accept")).hasValue("application/json"); - - RateLimits rl = client.getRateLimits(); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(50000L); - assertThat(rl.remaining()).isEqualTo(49500L); - assertThat(rl.consumed()).isEqualTo(1L); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void statusForDateBuildsDateQueryParam(CallMode mode) { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - MarketStatus result = mode.statusForDate(client.markets(), LocalDate.of(2024, 2, 1)); - - assertThat(result.days()).hasSize(1); - assertThat(lastRequest.get().path).isEqualTo("/v1/markets/status/"); - assertThat(lastRequest.get().query).isEqualTo("date=2024-02-01"); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void statusForRangeBuildsFromAndToQueryParams(CallMode mode) { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - mode.statusForRange(client.markets(), LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 5)); - - assertThat(lastRequest.get().query).isEqualTo("from=2024-01-31&to=2024-02-05"); - } - } - - @Test - void rangeWithSwappedBoundsThrowsIllegalArgument() { - try (var client = newClient()) { - assertThatThrownBy( - () -> client.markets().status(LocalDate.of(2024, 2, 5), LocalDate.of(2024, 1, 31))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must not be after"); - } - } - - // ---------- async-specific smoke ---------- - - /** - * Verifies that {@code statusAsync()} returns a real {@link - * java.util.concurrent.CompletableFuture} usable with the standard {@code .get()} contract - * (checked exception path). The {@code @ParameterizedTest}s above cover .join() semantics; this - * one covers .get(). - */ - @Test - void statusAsyncReturnsRealCompletableFuture() throws Exception { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); - - try (var client = newClient()) { - MarketStatus async = client.markets().statusAsync().get(); - assertThat(async.days()).hasSize(1); - assertThat(async.days().get(0).open()).isFalse(); - } - } - - // ---------- no-data and error paths ---------- - - @ParameterizedTest - @EnumSource(CallMode.class) - void notFoundWithNoDataBodyDecodesAsEmpty(CallMode mode) { - handler.setResponse(404, "{\"s\":\"no_data\"}", List.of()); - - try (var client = newClient()) { - MarketStatus result = mode.statusNoArgs(client.markets()); - assertThat(result.isEmpty()).isTrue(); - } - } - - @ParameterizedTest - @EnumSource(CallMode.class) - void http401ThrowsAuthenticationError(CallMode mode) { - handler.setResponse(401, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(AuthenticationError.class) - .satisfies( - t -> { - AuthenticationError ae = (AuthenticationError) t; - assertThat(ae.getStatusCode()).isEqualTo(401); - assertThat(ae.getRequestUrl()).contains("/v1/markets/status/"); - }); - } - } - - @Test - void http429ThrowsRateLimitError() { - handler.setResponse(429, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(RateLimitError.class); - } - } - - @Test - void http500ThrowsServerError() { - handler.setResponse(500, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ServerError.class); - } - } - - // ---------- malformed responses ---------- - - @ParameterizedTest - @EnumSource(CallMode.class) - void garbageBodyOnSuccessProducesParseError(CallMode mode) { - handler.setResponse(200, "this is plainly not json", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(ParseError.class) - .hasMessageContaining("Failed to decode"); - } - } - - @Test - void emptyBodyOnSuccessProducesParseError() { - handler.setResponse(200, "", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ParseError.class); - } - } - - @Test - void unknownStatusFieldProducesParseError() { - handler.setResponse(200, "{\"s\":\"weird\"}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("weird"); - } - } - - @Test - void responseMissingArraysProducesParseError() { - handler.setResponse(200, "{\"s\":\"ok\"}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("date"); - } - } - - @Test - void mismatchedArraySizesProduceParseError() { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600,1706760000],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(ParseError.class) - .hasMessageContaining("different sizes"); - } - } - - // ---------- weird headers ---------- - - @Test - void successWithoutAnyRateLimitHeadersLeavesSnapshotNull() { - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); - - try (var client = newClient()) { - client.markets().status(); - assertThat(client.getRateLimits()).isNull(); - } - } - - @Test - void partialRateLimitHeadersStillProduceSnapshot() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "100000"}, - new String[] {"x-api-ratelimit-remaining", "99999"})); - - try (var client = newClient()) { - client.markets().status(); - - RateLimits rl = client.getRateLimits(); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(100_000L); - assertThat(rl.remaining()).isEqualTo(99_999L); - assertThat(rl.consumed()).isEqualTo(0L); // missing → defaulted - } - } - - @Test - void allUnparseableRateLimitHeadersAreIgnoredAsAbsent() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "not-a-number"}, - new String[] {"x-api-ratelimit-remaining", "still-not"}, - new String[] {"x-api-ratelimit-reset", "??"}, - new String[] {"x-api-ratelimit-consumed", "wat"})); - - try (var client = newClient()) { - client.markets().status(); - assertThat(client.getRateLimits()).isNull(); - } - } - - /** - * Regression for Issue #4: a successful request that arrives without rate-limit headers must not - * clobber the previously-cached snapshot. The API's rate-limit middleware can silently swallow - * its own errors and serve the response without headers; if we overwrote the snapshot with {@code - * null} on each such response, the user-visible {@code getRateLimits()} would flicker between - * populated and {@code null} across consecutive successful calls. Spec §8 mandates " update - * client-level snapshot" — implicitly only when there's something to update. - */ - @Test - void successWithoutHeadersDoesNotClobberPreviousSnapshot() { - handler.setResponse( - 200, - "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", - List.of( - new String[] {"x-api-ratelimit-limit", "50000"}, - new String[] {"x-api-ratelimit-remaining", "49000"}, - new String[] {"x-api-ratelimit-reset", "1735689600"}, - new String[] {"x-api-ratelimit-consumed", "1000"})); - - try (var client = newClient()) { - client.markets().status(); - RateLimits before = client.getRateLimits(); - assertThat(before).isNotNull(); - assertThat(before.remaining()).isEqualTo(49000L); - - // Same client, second successful call — but the server didn't include rate-limit headers - // this time (e.g. middleware glitch on the API side). - handler.setResponse( - 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); - client.markets().status(); - - RateLimits after = client.getRateLimits(); - assertThat(after) - .as("snapshot must retain the last known rate-limit data, not reset to null") - .isNotNull(); - assertThat(after.remaining()).isEqualTo(49000L); - } - } - - @Test - void errorResponseWithoutCfRayProducesNullRequestId() { - handler.setResponse(401, "{}", List.of()); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(AuthenticationError.class) - .satisfies(t -> assertThat(((AuthenticationError) t).getRequestId()).isNull()); - } - } - - @Test - void errorResponseWithCfRayPropagatesRequestId() { - handler.setResponse(401, "{}", List.of(new String[] {"cf-ray", "abc123-XYZ"})); - - try (var client = newClient()) { - assertThatThrownBy(() -> client.markets().status()) - .isInstanceOf(AuthenticationError.class) - .satisfies( - t -> assertThat(((AuthenticationError) t).getRequestId()).isEqualTo("abc123-XYZ")); - } - } - - // ---------- network failure (connect refused — fast-failing proxy for timeout class) ---------- - - /** - * The 99-second per-request timeout is fixed by SDK requirements §10. Forcing a real timeout in a - * test would block for ~99 s, which we don't want. Instead we exercise the {@link NetworkError} - * path by pointing the client at a port nothing is listening on (TCP RST → fast failure). This - * proves the transport surfaces transport-level failures as a typed exception rather than letting - * raw {@code IOException}s leak. - */ - @ParameterizedTest - @EnumSource(CallMode.class) - void connectionRefusedProducesNetworkError(CallMode mode) throws IOException { - // Bind to an ephemeral port and immediately close — the OS guarantees that connecting to a - // recently-closed local port produces a fast RST (Linux/macOS) or ConnectException (Windows) - // rather than the long timeouts some hardened sandboxes serve on the historically-privileged - // port 1. The narrow window where another process could grab the port before our connect - // attempt is theoretical on CI. - int closedPort; - try (java.net.ServerSocket probe = - new java.net.ServerSocket(0, 0, java.net.InetAddress.getByName("127.0.0.1"))) { - closedPort = probe.getLocalPort(); - } - - try (var client = - new MarketDataClient("test-key", "http://127.0.0.1:" + closedPort, null, false)) { - - assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) - .isInstanceOf(NetworkError.class) - .satisfies( - t -> { - NetworkError ne = (NetworkError) t; - assertThat(ne.getCause()).isNotNull(); - assertThat(ne.getRequestUrl()).contains("127.0.0.1:" + closedPort); - }); - } - } - - // ---------- helpers ---------- - - // CallMode (sync vs async dispatcher) lives in its own file so the integration-test source set - // can reuse it. See CallMode.java in this same package. - - private static String[] rateLimitHeader(String suffix, String value) { - return new String[] {"x-api-ratelimit-" + suffix, value}; - } - - private record RecordedRequest(String path, String query, java.net.http.HttpHeaders headers) {} - - private final class RouteHandler implements HttpHandler { - private int statusCode = 200; - private String body = "{}"; - private List extraHeaders = List.of(); - - void setResponse(int code, String body, List extraHeaders) { - this.statusCode = code; - this.body = body; - this.extraHeaders = extraHeaders; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - // Snapshot request shape for assertions. - URI uri = exchange.getRequestURI(); - var headerMap = new java.util.HashMap>(); - exchange.getRequestHeaders().forEach((k, v) -> headerMap.put(k, new ArrayList<>(v))); - lastRequest.set( - new RecordedRequest( - uri.getPath(), - uri.getRawQuery(), - java.net.http.HttpHeaders.of(headerMap, (a, b) -> true))); - - for (String[] h : extraHeaders) { - exchange.getResponseHeaders().add(h[0], h[1]); - } - byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(statusCode, bodyBytes.length); - exchange.getResponseBody().write(bodyBytes); - exchange.getResponseBody().close(); - } - } -} diff --git a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java new file mode 100644 index 0000000..75b23af --- /dev/null +++ b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java @@ -0,0 +1,385 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ParallelArraysTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static JsonNode parse(String json) throws IOException { + return MAPPER.readTree(json); + } + + // ---------- happy path: zip + typed accessors ---------- + + @Test + void zipsParallelArraysIntoRowsViaTypedAccessors() throws IOException { + JsonNode root = + parse( + "{\"s\":\"ok\"," + + "\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"price\":[150.0,400.0]," + + "\"active\":[true,false]," + + "\"updated\":[1700000000,1700000001]}"); + + List rows = + ParallelArrays.zip( + null, + root, + List.of("symbol", "price", "active", "updated"), + row -> + new Record( + row.text("symbol"), row.dbl("price"), row.bool("active"), row.lng("updated"))); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0)).isEqualTo(new Record("AAPL", 150.0, true, 1700000000L)); + assertThat(rows.get(1)).isEqualTo(new Record("MSFT", 400.0, false, 1700000001L)); + } + + @Test + void emptyArraysProduceEmptyListWithoutInvokingBuilder() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"a\":[],\"b\":[]}"); + + List rows = + ParallelArrays.zip( + null, + root, + List.of("a", "b"), + row -> { + throw new AssertionError("builder must not be invoked when arrays are empty"); + }); + + assertThat(rows).isEmpty(); + } + + // ---------- envelope-error short-circuit ---------- + + @Test + void serverSideErrorEnvelopeShortCircuitsBeforeFieldValidation() { + // s=error means the body intentionally omits the data arrays. The helper must surface the + // errmsg instead of complaining about missing fields downstream. + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"), + List.of("symbol"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("database connection refused"); + } + + @Test + void errorEnvelopeWithoutErrmsgYieldsPlaceholderText() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, parse("{\"s\":\"error\"}"), List.of("symbol"), row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("no errmsg field"); + } + + // ---------- no_data envelope (paired with HTTP 404) ---------- + + @Test + void noDataEnvelopeShortCircuitsToEmptyListWithoutFieldValidation() throws IOException { + // The backend returns {"s":"no_data"} (HTTP 404) when a query has no results — the data + // arrays are deliberately absent. The helper must return an empty list so the deserializer + // wraps it in its container type instead of complaining about "missing field". + List rows = + ParallelArrays.zip( + null, + parse("{\"s\":\"no_data\"}"), + List.of("symbol", "price"), + row -> { + throw new AssertionError("builder must not be invoked for no_data envelope"); + }); + + assertThat(rows).isEmpty(); + } + + @Test + void noDataEnvelopeIgnoresAdjacentMetadataFields() throws IOException { + // Some backend handlers attach metadata to the no_data envelope (e.g. nextTime, prevTime, + // errmsg). Those fields are not the parallel-array columns and must not affect the result. + List rows = + ParallelArrays.zip( + null, + parse( + "{\"s\":\"no_data\"," + + "\"nextTime\":null," + + "\"prevTime\":null," + + "\"errmsg\":\"Market closed on this date.\"}"), + List.of("symbol", "price"), + row -> { + throw new AssertionError("builder must not be invoked for no_data envelope"); + }); + + assertThat(rows).isEmpty(); + } + + // ---------- presence and length validation ---------- + + @Test + void missingFieldFailsWithFieldName() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\"]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("price"); + } + + @Test + void nonArrayFieldFailsWithFieldName() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":\"AAPL\",\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("missing or non-array") + .hasMessageContaining("symbol"); + } + + @Test + void mismatchedLengthsFailWithDetail() { + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + parse( + "{\"s\":\"ok\"," + + "\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("mismatched lengths") + .hasMessageContaining("price=1") + .hasMessageContaining("expected=2"); + } + + // ---------- Row.node() for custom conversions ---------- + + @Test + void rowNodeExposesRawJsonNodeForCustomConversion() throws IOException { + // node() lets the builder do conversions the typed helpers don't cover — here we parse + // an array element directly so the test exercises that escape hatch. + JsonNode root = parse("{\"s\":\"ok\",\"nested\":[{\"k\":\"v1\"},{\"k\":\"v2\"}]}"); + + List rows = + ParallelArrays.zip( + null, root, List.of("nested"), row -> row.node("nested").get("k").asText()); + + assertThat(rows).containsExactly("v1", "v2"); + } + + // ---------- Row programming errors ---------- + + @Test + void rowAccessorRejectsUndeclaredField() throws IOException { + // Asking for a field that wasn't in the declared `fields` list is a programming bug in the + // builder lambda — surface it loudly rather than NPEing on a null array. + JsonNode root = parse("{\"s\":\"ok\",\"a\":[\"x\"]}"); + + assertThatThrownBy( + () -> + ParallelArrays.zip( + null, + root, + List.of("a"), + row -> row.text("nonexistent"))) // builder asks for an undeclared column + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nonexistent") + .hasMessageContaining("[a]"); + } + + // ---------- strict cell validation ---------- + + @Test + void textFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\",null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("symbol"), row -> row.text("symbol"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("symbol") + .hasMessageContaining("row 1"); + } + + @Test + void textFailsWhenCellIsNotAString() throws IOException { + // The server suddenly sending a number where a symbol is expected is the kind of regression + // we want to surface immediately, not silently coerce to "123" via Jackson's lax asText. + JsonNode root = parse("{\"s\":\"ok\",\"symbol\":[123]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("symbol"), row -> row.text("symbol"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected string") + .hasMessageContaining("symbol"); + } + + @Test + void boolFailsWhenCellIsJsonNull() throws IOException { + // The exact regression flagged in the review: a missing `online` cell silently becoming + // `false` would mass-block retries via StatusCache. Strict mode rejects it. + JsonNode root = parse("{\"s\":\"ok\",\"online\":[true,null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("online"), row -> row.bool("online"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("online"); + } + + @Test + void boolFailsWhenCellIsNotABoolean() throws IOException { + // "true" as a string is not the same as boolean true — Jackson's lax asBoolean would coerce. + JsonNode root = parse("{\"s\":\"ok\",\"online\":[\"true\"]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("online"), row -> row.bool("online"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected boolean") + .hasMessageContaining("online"); + } + + @Test + void dblFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"price\":[null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("price"), row -> row.dbl("price"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("price"); + } + + @Test + void dblFailsWhenCellIsNotANumber() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"price\":[\"150.0\"]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("price"), row -> row.dbl("price"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected number") + .hasMessageContaining("price"); + } + + @Test + void lngFailsWhenCellIsJsonNull() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"updated\":[null]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("updated"), row -> row.lng("updated"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("null cell") + .hasMessageContaining("updated"); + } + + @Test + void lngFailsWhenCellIsNotANumber() throws IOException { + JsonNode root = parse("{\"s\":\"ok\",\"updated\":[true]}"); + + assertThatThrownBy( + () -> ParallelArrays.zip(null, root, List.of("updated"), row -> row.lng("updated"))) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected number") + .hasMessageContaining("updated"); + } + + @Test + void nodeAccessorReturnsNullJsonNodeVerbatimForCustomHandling() throws IOException { + // Strict accessors fail on null; the raw `node()` escape hatch must NOT — callers that opt + // into raw access are responsible for handling null themselves (e.g. nested object fields). + JsonNode root = parse("{\"s\":\"ok\",\"raw\":[null]}"); + + List rows = + ParallelArrays.zip(null, root, List.of("raw"), row -> row.node("raw").isNull()); + + assertThat(rows).containsExactly(true); + } + + // ---------- listDeserializer factory (issue #10) ---------- + + @Test + void listDeserializerProducesJacksonDeserializerWiringTheZipPipeline() throws IOException { + // The factory replaces hand-written JsonDeserializer subclasses for parallel-arrays endpoints + // (issue #10). Each new endpoint declares only its fields, row builder, and wrapper — the + // zip + tree-read + wrap plumbing is shared. + com.fasterxml.jackson.databind.JsonDeserializer deser = + ParallelArrays.listDeserializer( + List.of("symbol", "price"), + row -> new Record(row.text("symbol"), row.dbl("price"), false, 0), + Container::new); + + // Register on a fresh ObjectMapper and round-trip a wire-shaped payload. + com.fasterxml.jackson.databind.ObjectMapper m = + new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.module.SimpleModule module = + new com.fasterxml.jackson.databind.module.SimpleModule("test"); + module.addDeserializer(Container.class, deser); + m.registerModule(module); + + Container c = + m.readValue( + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"],\"price\":[150.0,400.0]}", + Container.class); + + assertThat(c.rows()).hasSize(2); + assertThat(c.rows().get(0).symbol()).isEqualTo("AAPL"); + assertThat(c.rows().get(1).price()).isEqualTo(400.0); + } + + @Test + void listDeserializerHonorsEnvelopeShortCircuits() throws IOException { + // The factory delegates structural validation to zip(): envelope errors and no_data + // short-circuit consistently regardless of which factory call instantiated the deserializer. + com.fasterxml.jackson.databind.JsonDeserializer deser = + ParallelArrays.listDeserializer( + List.of("symbol"), row -> new Record(row.text("symbol"), 0, false, 0), Container::new); + + com.fasterxml.jackson.databind.ObjectMapper m = + new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.module.SimpleModule module = + new com.fasterxml.jackson.databind.module.SimpleModule("test"); + module.addDeserializer(Container.class, deser); + m.registerModule(module); + + // no_data → empty list, wrapped in Container. + Container empty = m.readValue("{\"s\":\"no_data\"}", Container.class); + assertThat(empty.rows()).isEmpty(); + + // error envelope → JsonMappingException bubbles up through Jackson. + assertThatThrownBy(() -> m.readValue("{\"s\":\"error\",\"errmsg\":\"boom\"}", Container.class)) + .isInstanceOf(com.fasterxml.jackson.databind.JsonMappingException.class) + .hasMessageContaining("boom"); + } + + // ---------- helper record ---------- + + private record Record(String symbol, double price, boolean active, long updated) {} + + /** + * Container wrapper for the {@link + * #listDeserializerProducesJacksonDeserializerWiringTheZipPipeline} test. + */ + private record Container(List rows) {} +} diff --git a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java index b3074f5..3144580 100644 --- a/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java +++ b/src/test/java/com/marketdata/sdk/RateLimitHeadersTest.java @@ -2,7 +2,6 @@ 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; @@ -12,8 +11,6 @@ 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 @@ -37,128 +34,138 @@ void parsesAllFourHeaders() { "x-api-ratelimit-reset", "1714867200", "x-api-ratelimit-consumed", "13")); - RateLimits rl = RateLimitHeaders.parse(headers); + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(1000L); - assertThat(rl.remaining()).isEqualTo(987L); + assertThat(rl.limit()).isEqualTo(1000); + assertThat(rl.remaining()).isEqualTo(987); assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1714867200L)); - assertThat(rl.consumed()).isEqualTo(13L); + assertThat(rl.consumed()).isEqualTo(13); } // ---------- 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(); + assertThat(RateLimitHeaders.parse(headers)).isNull(); } - // ---------- partial headers (one present, others missing) ---------- + // ---------- partial headers ---------- @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); + void anyMissingHeaderReturnsNull() { + // §8.2: the four x-api-ratelimit-* headers travel together on every successful response. A + // partial response is a server-side bug — surfacing it as a snapshot with phantom zeros + // would flip the preflight gate into a false "exhausted" state and feed consumers a + // snapshot indistinguishable from a real one. Returning null instead lets the caller keep + // the last-known-good snapshot. + HttpHeaders missingRemaining = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingLimit = + headersOf( + Map.of( + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingReset = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-consumed", "13")); + HttpHeaders missingConsumed = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200")); - assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(500L); - assertThat(rl.remaining()).isZero(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(0L)); - assertThat(rl.consumed()).isZero(); + assertThat(RateLimitHeaders.parse(missingRemaining)).isNull(); + assertThat(RateLimitHeaders.parse(missingLimit)).isNull(); + assertThat(RateLimitHeaders.parse(missingReset)).isNull(); + assertThat(RateLimitHeaders.parse(missingConsumed)).isNull(); } @Test - void onlyConsumedPresentZerosTheOthers() { - // 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); + void onlyOneHeaderPresentReturnsNull() { + // The complementary check — a single header doesn't carry enough information to be useful, + // so the all-or-nothing rule returns null whether 0 or 1 (or 2 or 3) headers are present. + HttpHeaders onlyLimit = headersOf(Map.of("x-api-ratelimit-limit", "500")); - assertThat(rl).isNotNull(); - assertThat(rl.consumed()).isEqualTo(42L); - assertThat(rl.limit()).isZero(); + assertThat(RateLimitHeaders.parse(onlyLimit)).isNull(); } - @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(); - } + // ---------- malformed values ---------- @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); + void anyMalformedValueReturnsNull() { + // A malformed value is treated as absent by readLong; with the all-or-nothing rule that + // makes the whole snapshot unreliable — same outcome as the header being missing entirely. + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", "1000", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "not-a-number", + "x-api-ratelimit-consumed", "13")); - assertThat(rl).isNotNull(); - assertThat(rl.reset()).isEqualTo(Instant.ofEpochSecond(1735689600L)); - assertThat(rl.limit()).isZero(); - assertThat(rl.remaining()).isZero(); - assertThat(rl.consumed()).isZero(); + assertThat(RateLimitHeaders.parse(headers)).isNull(); } - // ---------- malformed values ---------- - @Test - void malformedNumberIsTreatedAsAbsent() { - // readLong's catch(NumberFormatException) returns null; the header is then treated as - // missing. With every header malformed the result must be null, same as none-present. + void allMalformedValuesReturnNull() { HttpHeaders headers = headersOf( Map.of( "x-api-ratelimit-limit", "not-a-number", - "x-api-ratelimit-remaining", "also-broken")); + "x-api-ratelimit-remaining", "also-broken", + "x-api-ratelimit-reset", "still-broken", + "x-api-ratelimit-consumed", "broken-too")); - RateLimits rl = RateLimitHeaders.parse(headers); - - assertThat(rl).isNull(); + assertThat(RateLimitHeaders.parse(headers)).isNull(); } @Test void valuesAreTrimmedBeforeParsing() { - HttpHeaders headers = headersOf(Map.of("x-api-ratelimit-limit", " 1000 ")); + // The complete-headers happy path; the trim guard applies to every value, exercised through + // the limit header here. + HttpHeaders headers = + headersOf( + Map.of( + "x-api-ratelimit-limit", " 1000 ", + "x-api-ratelimit-remaining", "987", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "13")); - RateLimits rl = RateLimitHeaders.parse(headers); + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(1000L); + assertThat(rl.limit()).isEqualTo(1000); } - // ---------- sanity: parse() doesn't depend on URI/method ---------- - @Test void parseIgnoresNonRateLimitHeaders() { - URI dummy = URI.create("https://example/"); + // The four required headers are still present alongside unrelated ones — unrelated headers + // must not affect parsing. HttpHeaders headers = headersOf( Map.of( "cf-ray", "abc", "content-type", "application/json", - "x-api-ratelimit-limit", "100")); + "x-api-ratelimit-limit", "100", + "x-api-ratelimit-remaining", "99", + "x-api-ratelimit-reset", "1714867200", + "x-api-ratelimit-consumed", "1")); - RateLimits rl = RateLimitHeaders.parse(headers); + RateLimitSnapshot rl = RateLimitHeaders.parse(headers); assertThat(rl).isNotNull(); - assertThat(rl.limit()).isEqualTo(100L); - assertThat(dummy).isNotNull(); // silence unused + assertThat(rl.limit()).isEqualTo(100); } } diff --git a/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java new file mode 100644 index 0000000..9525271 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RateLimitSnapshotTest.java @@ -0,0 +1,32 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RateLimitSnapshotTest { + + @Test + void exposes_all_fields() { + Instant reset = Instant.parse("2026-05-15T12:00:00Z"); + + RateLimitSnapshot snapshot = new RateLimitSnapshot(1000, 750, reset, 250); + + assertThat(snapshot.limit()).isEqualTo(1000); + assertThat(snapshot.remaining()).isEqualTo(750); + assertThat(snapshot.reset()).isEqualTo(reset); + assertThat(snapshot.consumed()).isEqualTo(250); + } + + @Test + void records_with_same_values_are_equal() { + Instant reset = Instant.parse("2026-05-15T12:00:00Z"); + + RateLimitSnapshot a = new RateLimitSnapshot(100, 50, reset, 50); + RateLimitSnapshot b = new RateLimitSnapshot(100, 50, reset, 50); + + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } +} diff --git a/src/test/java/com/marketdata/sdk/RateLimitsTest.java b/src/test/java/com/marketdata/sdk/RateLimitsTest.java deleted file mode 100644 index 84a7b41..0000000 --- a/src/test/java/com/marketdata/sdk/RateLimitsTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Instant; -import org.junit.jupiter.api.Test; - -class RateLimitsTest { - - @Test - void recordExposesAllFields() { - Instant reset = Instant.parse("2026-05-04T12:00:00Z"); - var rl = new RateLimits(50_000L, 49_500L, reset, 1L); - - assertThat(rl.limit()).isEqualTo(50_000L); - assertThat(rl.remaining()).isEqualTo(49_500L); - assertThat(rl.reset()).isEqualTo(reset); - assertThat(rl.consumed()).isEqualTo(1L); - } -} diff --git a/src/test/java/com/marketdata/sdk/RequestHeadersTest.java b/src/test/java/com/marketdata/sdk/RequestHeadersTest.java new file mode 100644 index 0000000..89173d4 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RequestHeadersTest.java @@ -0,0 +1,60 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.utilities.RequestHeaders; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Focused tests for the {@link RequestHeaders} record's canonical constructor. The wire-level path + * (a server returning a JSON-{@code null} body) is exercised end-to-end in {@link + * UtilitiesResourceTest}; this class documents the public-API contract that consumers see when + * constructing the record directly. + */ +class RequestHeadersTest { + + @Test + void constructorRejectsNullMapWithNamedFieldMessage() { + // The package is @NullMarked, so `null` violates the public contract. Pre-checking with + // requireNonNull yields a clear "headers" message; without it, Map.copyOf(null) throws a bare + // NPE that leaves the consumer hunting for which constructor argument was null. + assertThatThrownBy(() -> new RequestHeaders(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("headers"); + } + + @Test + void constructorAcceptsEmptyMap() { + // An empty map is a legitimate (if unusual) value — Map.copyOf preserves emptiness and the + // result is still immutable. + RequestHeaders rh = new RequestHeaders(Map.of()); + + assertThat(rh.headers()).isEmpty(); + } + + @Test + void constructorDefensivelyCopiesTheInputMap() { + // Map.copyOf snapshots the input. A consumer mutating the original after construction must + // not be able to mutate the record's view — this is the defensive-copy guarantee the Javadoc + // promises. + Map mutable = new HashMap<>(); + mutable.put("accept", "*/*"); + RequestHeaders rh = new RequestHeaders(mutable); + + mutable.put("authorization", "Bearer leaked"); + + assertThat(rh.headers()).containsOnlyKeys("accept"); + } + + @Test + void headersAccessorReturnsImmutableView() { + // The Map.copyOf result is unmodifiable; consumers attempting to mutate get UOE rather than + // silently corrupting the record's invariant. + RequestHeaders rh = new RequestHeaders(Map.of("accept", "*/*")); + + assertThat(rh.headers()).isUnmodifiable(); + } +} diff --git a/src/test/java/com/marketdata/sdk/RequestSpecTest.java b/src/test/java/com/marketdata/sdk/RequestSpecTest.java index c192322..ec20f9b 100644 --- a/src/test/java/com/marketdata/sdk/RequestSpecTest.java +++ b/src/test/java/com/marketdata/sdk/RequestSpecTest.java @@ -2,14 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.List; import org.junit.jupiter.api.Test; class RequestSpecTest { @Test void buildPreservesPathAndOmitsNullQueryParams() { - // Covers both branches of `if (value != null)` in Builder.query: the null branch is - // exercised by .query("ignored", null), the non-null branch by .query("date", "2024-05-01"). + // Covers both branches of `if (value != null)` in Builder.query. RequestSpec spec = RequestSpec.get("markets/status") .query("date", "2024-05-01") @@ -43,8 +43,6 @@ void queryParamsAreImmutable() { @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) @@ -53,4 +51,95 @@ void queryConvertsNonStringValuesViaToString() { assertThat(spec.queryParams()).containsEntry("countback", "5").containsEntry("limit", "100"); } + + // ---------- universal params ---------- + + @Test + void defaultFormatIsJsonAndNotWrittenToQuery() { + // No-op default: an explicit `?format=json` is redundant and adds query noise. + RequestSpec spec = RequestSpec.get("markets/status").build(); + assertThat(spec.format()).isEqualTo(Format.JSON); + assertThat(spec.queryParams()).doesNotContainKey("format"); + } + + @Test + void formatSetterWritesQueryParamAndUpdatesField() { + RequestSpec spec = RequestSpec.get("stocks/candles").format(Format.CSV).build(); + assertThat(spec.format()).isEqualTo(Format.CSV); + assertThat(spec.queryParams()).containsEntry("format", "csv"); + } + + @Test + void dateformatWritesQueryParam() { + RequestSpec spec = RequestSpec.get("stocks/candles").dateformat(DateFormat.SPREADSHEET).build(); + assertThat(spec.queryParams()).containsEntry("dateformat", "spreadsheet"); + } + + @Test + void modeWritesQueryParam() { + RequestSpec spec = RequestSpec.get("stocks/quotes").mode(Mode.DELAYED).build(); + assertThat(spec.queryParams()).containsEntry("mode", "delayed"); + } + + @Test + void headersAndHumanWriteBooleansAsStrings() { + RequestSpec spec = RequestSpec.get("stocks/candles").headers(false).human(true).build(); + assertThat(spec.queryParams()).containsEntry("headers", "false").containsEntry("human", "true"); + } + + @Test + void columnsCommaJoinsTheList() { + RequestSpec spec = + RequestSpec.get("stocks/quotes").columns(List.of("symbol", "last", "volume")).build(); + assertThat(spec.queryParams()).containsEntry("columns", "symbol,last,volume"); + } + + @Test + void columnsEmptyListIsNoOp() { + // Sending `?columns=` (empty value) would risk the server interpreting it as "no columns" + // rather than "all columns". Easier to omit. + RequestSpec spec = RequestSpec.get("stocks/quotes").columns(List.of()).build(); + assertThat(spec.queryParams()).doesNotContainKey("columns"); + } + + @Test + void limitAndOffsetWriteInts() { + RequestSpec spec = RequestSpec.get("stocks/news").limit(50).offset(100).build(); + assertThat(spec.queryParams()).containsEntry("limit", "50").containsEntry("offset", "100"); + } + + // ---------- versioned / unversioned ---------- + + @Test + void specsAreVersionedByDefault() { + // Every business endpoint lives under /v1/, so the default has to be the common case. + RequestSpec spec = RequestSpec.get("markets/status").build(); + assertThat(spec.versioned()).isTrue(); + } + + @Test + void unversionedFlipsThePrefixOff() { + // /status/ and /headers/ live at the API root, not under /v1/. + RequestSpec spec = RequestSpec.get("status").unversioned().build(); + assertThat(spec.versioned()).isFalse(); + assertThat(spec.path()).isEqualTo("status"); + } + + @Test + void universalParamsAccumulateAlongsideArbitraryQueryParams() { + // The universal-setter API does not replace `.query(...)` — both coexist, ordered by + // insertion. + RequestSpec spec = + RequestSpec.get("stocks/candles") + .query("symbol", "AAPL") + .format(Format.CSV) + .dateformat(DateFormat.UNIX) + .build(); + + assertThat(spec.queryParams()) + .containsExactly( + java.util.Map.entry("symbol", "AAPL"), + java.util.Map.entry("format", "csv"), + java.util.Map.entry("dateformat", "unix")); + } } diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java new file mode 100644 index 0000000..8f46b87 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/ResponseTest.java @@ -0,0 +1,188 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ResponseTest { + + private static HttpResponseEnvelope env(byte[] body, int status, String url) { + return new HttpResponseEnvelope( + body, status, "req-id-123", HttpHeaders.of(Map.of(), (a, b) -> true), URI.create(url)); + } + + // ---------- typed accessors ---------- + + @Test + void exposesDataStatusAndUrl() { + Response r = + Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); + + assertThat(r.data()).isEqualTo("payload"); + assertThat(r.statusCode()).isEqualTo(200); + assertThat(r.requestUrl()).isEqualTo(URI.create("http://x/y")); + assertThat(r.requestId()).isEqualTo("req-id-123"); + } + + @Test + void requestIdNullWhenServerOmitsIt() { + HttpResponseEnvelope e = + new HttpResponseEnvelope( + "x".getBytes(), + 200, + null, + HttpHeaders.of(Map.of(), (a, b) -> true), + URI.create("http://x")); + Response r = Response.wrap("data", e, Format.JSON); + + assertThat(r.requestId()).isNull(); + } + + // ---------- format detection ---------- + + @Test + void formatDetectionExposesBooleansOnly() { + // The Format enum itself is package-private; consumers only see the booleans. + Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); + Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); + Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); + + assertThat(json.isJson()).isTrue(); + assertThat(json.isCsv()).isFalse(); + assertThat(json.isHtml()).isFalse(); + + assertThat(csv.isCsv()).isTrue(); + assertThat(csv.isJson()).isFalse(); + assertThat(csv.isHtml()).isFalse(); + + // §13.5: HTML detection — typically a misrouted request that landed on the web-server tier. + assertThat(html.isHtml()).isTrue(); + assertThat(html.isJson()).isFalse(); + assertThat(html.isCsv()).isFalse(); + } + + // ---------- no-data ---------- + + @Test + void isNoDataReflects404Convention() { + Response ok = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); + Response noData = Response.wrap("d", env("d".getBytes(), 404, "http://x"), Format.JSON); + + assertThat(ok.isNoData()).isFalse(); + assertThat(noData.isNoData()).isTrue(); + } + + // ---------- raw body immutability ---------- + + @Test + void rawBodyReturnsDefensiveCopy() { + byte[] source = "hello".getBytes(); + Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); + + byte[] firstCopy = r.rawBody(); + firstCopy[0] = 'X'; // mutate the returned array + byte[] secondCopy = r.rawBody(); + + assertThat(secondCopy[0]) + .as("internal state must not be affected by mutation") + .isEqualTo((byte) 'h'); + } + + @Test + void constructorCopiesIncomingRawBody() { + // Symmetric: the constructor must clone the input so mutations to the source after + // construction don't bleed into the Response. + byte[] source = "hello".getBytes(); + Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); + source[0] = 'X'; + + assertThat(r.rawBody()[0]).isEqualTo((byte) 'h'); + } + + // ---------- saveToFile ---------- + + @Test + void saveToFileWritesRawBodyVerbatim(@TempDir Path tmp) throws IOException { + byte[] body = "alpha,beta\n1,2\n".getBytes(); + Response r = Response.wrap("ignored", env(body, 200, "http://x"), Format.CSV); + + Path target = tmp.resolve("out.csv"); + r.saveToFile(target); + + assertThat(Files.readAllBytes(target)).isEqualTo(body); + } + + @Test + void saveToFileWrapsIoFailuresInUncheckedIoException(@TempDir Path tmp) { + Response r = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); + + // A non-existent parent directory triggers NoSuchFileException — the wrapper turns it into + // UncheckedIOException so the call fits in a fluent chain without checked-exception noise. + Path inaccessible = tmp.resolve("does-not-exist").resolve("out.txt"); + + assertThatThrownBy(() -> r.saveToFile(inaccessible)) + .isInstanceOf(UncheckedIOException.class) + .hasMessageContaining(inaccessible.toString()); + } + + // ---------- toString ---------- + + @Test + void toStringIncludesStatusFormatBytesAndUrl() { + Response r = + Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); + + String repr = r.toString(); + + // safeUri emits the path only (its contract): host/scheme are uninteresting in a log line and + // omitting them mirrors what HttpDispatcher already does for ambient request logs. + assertThat(repr) + .contains("status=200") + .contains("format=json") + .contains("bytes=4") + .contains("url=/y"); + } + + /** + * Issue #38: {@code toString} is a routine logging surface. The typed payload may carry sensitive + * content (e.g. a {@code RequestHeaders} map with {@code authorization} or client IPs), so it + * must not be embedded. Consumers that want the payload have {@link Response#data()}. + */ + @Test + void toStringDoesNotIncludeDataPayload() { + Response r = + Response.wrap( + "sensitive-payload-do-not-leak", + env("body".getBytes(), 200, "http://x/y"), + Format.JSON); + + assertThat(r.toString()).doesNotContain("sensitive-payload-do-not-leak"); + } + + /** + * Issue #38 + §16: query strings (tokens, account_ids, symbols) must not survive through {@code + * toString}. The full URI is still available via {@link Response#requestUrl()}. + */ + @Test + void toStringRedactsQueryStringInUrl() { + Response r = + Response.wrap( + "data", + env("body".getBytes(), 200, "http://x/quotes/?token=secret&symbol=AAPL"), + Format.JSON); + + String repr = r.toString(); + + assertThat(repr).doesNotContain("secret").doesNotContain("AAPL"); + assertThat(repr).contains("?…"); + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java b/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java new file mode 100644 index 0000000..1b964f9 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RetryAfterHeaderTest.java @@ -0,0 +1,67 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RetryAfterHeaderTest { + + private static final Instant NOW = Instant.parse("2026-05-18T12:00:00Z"); + + // ---------- delta-seconds form ---------- + + @Test + void parsesPositiveSeconds() { + assertThat(RetryAfterHeader.parse("120", NOW)).contains(Duration.ofSeconds(120)); + } + + @Test + void parsesZeroSeconds() { + assertThat(RetryAfterHeader.parse("0", NOW)).contains(Duration.ZERO); + } + + @Test + void negativeSecondsClampToZero() { + // Spec doesn't allow negatives but clients have spotted them in the wild — treat as + // "retry now" rather than blow up parsing. + assertThat(RetryAfterHeader.parse("-5", NOW)).contains(Duration.ZERO); + } + + @Test + void valueIsTrimmedBeforeParsing() { + assertThat(RetryAfterHeader.parse(" 30 ", NOW)).contains(Duration.ofSeconds(30)); + } + + // ---------- HTTP-date form (RFC 1123) ---------- + + @Test + void parsesHttpDateInTheFuture() { + // 5 minutes after NOW. + String header = "Mon, 18 May 2026 12:05:00 GMT"; + assertThat(RetryAfterHeader.parse(header, NOW)).contains(Duration.ofMinutes(5)); + } + + @Test + void httpDateInThePastClampsToZero() { + // 10 seconds before NOW. + String header = "Mon, 18 May 2026 11:59:50 GMT"; + assertThat(RetryAfterHeader.parse(header, NOW)).contains(Duration.ZERO); + } + + // ---------- malformed ---------- + + @Test + void emptyHeaderProducesEmpty() { + assertThat(RetryAfterHeader.parse("", NOW)).isEmpty(); + assertThat(RetryAfterHeader.parse(" ", NOW)).isEmpty(); + } + + @Test + void garbageHeaderProducesEmpty() { + // Neither a valid integer nor a parseable HTTP-date. Caller falls back to its own backoff. + assertThat(RetryAfterHeader.parse("not-a-thing", NOW)).isEmpty(); + assertThat(RetryAfterHeader.parse("2026-05-18", NOW)).isEmpty(); // wrong date format + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java new file mode 100644 index 0000000..2e0bff0 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -0,0 +1,286 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.ServerError; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RetryExecutorTest { + + // Sub-millisecond backoffs so tests don't wait on real wall-clock. + private static final RetryPolicy FAST_RETRY = + new RetryPolicy(4, Duration.ofMillis(1), Duration.ofMillis(2)); + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + + private static ErrorContext ctx() { + return ErrorContext.forNoResponse("https://example/u", Instant.EPOCH); + } + + private static NetworkError retriableNet() { + return new NetworkError("net", ctx(), new IOException("transport down")); + } + + private static ServerError retriable5xx() { + return new ServerError( + "503", ErrorContext.forResponse("https://example/u", 503, null, Instant.EPOCH)); + } + + private static ServerError nonRetriable500() { + return new ServerError( + "500", ErrorContext.forResponse("https://example/u", 500, null, Instant.EPOCH)); + } + + // ---------- success on first attempt ---------- + + @Test + void firstAttemptSucceedsNoRetry() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + String result = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.completedFuture("ok"); + }) + .join(); + + assertThat(result).isEqualTo("ok"); + assertThat(calls).hasValue(1); + } + + // ---------- retries until success ---------- + + @Test + void retriesUntilSuccess() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + String result = + exec.execute( + () -> { + int n = calls.incrementAndGet(); + if (n < 3) { + return CompletableFuture.failedFuture(retriableNet()); + } + return CompletableFuture.completedFuture("ok"); + }) + .join(); + + assertThat(result).isEqualTo("ok"); + assertThat(calls).hasValue(3); + } + + // ---------- exhausts attempts ---------- + + @Test + void exhaustsAttemptsAndSurfacesLastCause() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriable5xx()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + assertThat(calls).hasValue(4); // 1 initial + 3 retries + } + + // ---------- noRetry policy: exactly one attempt regardless of cause ---------- + + @Test + void noRetryPolicyInvokesSupplierExactlyOnceOnFailure() { + // RetryPolicy.noRetry() is the policy MarketDataClient#runStartupValidation uses to ensure + // a slow/down API can't burn the full retry budget before the constructor returns. Verify + // the supplier is invoked exactly once even for the most retriable failure shape. + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(RetryPolicy.noRetry()); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(NetworkError.class); + + assertThat(calls).hasValue(1); + } + + // ---------- non-retriable surfaces immediately ---------- + + @Test + void nonRetriableCauseStopsImmediately() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(nonRetriable500()); + }); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ServerError.class); + + assertThat(calls).hasValue(1); + } + + // ---------- cancellation propagation ---------- + + @Test + void cancelOfResultCancelsInFlightAttempt() { + AtomicReference> handle = new AtomicReference<>(); + RetryExecutor exec = new RetryExecutor(NO_RETRY); + + CompletableFuture result = + exec.execute( + () -> { + CompletableFuture f = new CompletableFuture<>(); + handle.set(f); + return f; + }); + + assertThat(handle.get()).isNotNull(); + result.cancel(false); + + assertThat(handle.get()).isCancelled(); + } + + @Test + void cancelDuringBackoffPreventsNextAttempt() throws Exception { + AtomicInteger calls = new AtomicInteger(); + // Slow enough backoff that we can cancel between attempts; the wall-clock cost is bounded + // by the cancel firing before 50 ms elapses. + RetryPolicy slow = new RetryPolicy(4, Duration.ofMillis(50), Duration.ofMillis(50)); + RetryExecutor exec = new RetryExecutor(slow); + + CompletableFuture result = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); + + // Wait until the first attempt has fired and we're presumably in the backoff window. + Thread.sleep(10); + result.cancel(false); + + // Give the (cancelled) scheduler a window to NOT fire a second attempt. + Thread.sleep(150); + + assertThat(result).isCancelled(); + assertThat(calls.get()).as("should not have started a second attempt").isOne(); + } + + // ---------- result shape ---------- + + // ---------- custom retry predicate overload ---------- + + /** + * The overload that accepts a custom {@code BiPredicate} is the seam HttpTransport uses to AND + * the policy with a status-cache veto (§9.5). Verify that when the predicate returns false even + * though the policy would have said true, no retry happens. + */ + @Test + void customPredicateCanVetoARetryThePolicyWouldHaveAllowed() { + AtomicInteger calls = new AtomicInteger(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + CompletableFuture f = + exec.execute( + () -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }, + /* shouldRetry */ (cause, attempt) -> false); + + assertThatThrownBy(f::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(com.marketdata.sdk.exception.NetworkError.class); + assertThat(calls).hasValue(1); // policy would have allowed; predicate vetoed + } + + @Test + void customPredicateReceivesUnwrappedCauseAndAttemptIndex() { + java.util.List seenAttempts = new java.util.ArrayList<>(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + + exec.execute( + () -> CompletableFuture.failedFuture(retriableNet()), + (cause, attempt) -> { + seenAttempts.add(attempt); + // Allow first two retries, then veto. + return attempt < 2; + }) + .exceptionally(e -> null) + .join(); + + assertThat(seenAttempts).containsExactly(0, 1, 2); + } + + // ---------- context-aware supplier threading previousCause ---------- + + @Test + void attemptSupplierReceivesAttemptIndexAndPreviousCause() { + // The AttemptSupplier variant exposes the previous attempt's (unwrapped) cause so callers + // can branch — e.g. HttpTransport bypasses preflight when previousCause carries an explicit + // server-side Retry-After. This test pins that the threading is correct across attempts. + java.util.List seenAttempts = new java.util.ArrayList<>(); + java.util.List seenCauses = new java.util.ArrayList<>(); + RetryExecutor exec = new RetryExecutor(FAST_RETRY); + NetworkError netError = retriableNet(); + + exec.execute( + (attemptIdx, previousCause) -> { + seenAttempts.add(attemptIdx); + seenCauses.add(previousCause); + return CompletableFuture.failedFuture(netError); + }, + (cause, attempt) -> attempt < 2) + .exceptionally(e -> null) + .join(); + + assertThat(seenAttempts).containsExactly(0, 1, 2); + // First attempt has no previous cause; subsequent attempts see the unwrapped NetworkError. + assertThat(seenCauses.get(0)).isNull(); + assertThat(seenCauses.get(1)).isSameAs(netError); + assertThat(seenCauses.get(2)).isSameAs(netError); + } + + @Test + void resultFutureCarriesCancellationException() { + RetryExecutor exec = new RetryExecutor(NO_RETRY); + + CompletableFuture result = + exec.execute(CompletableFuture::new); // never-completing supplier + + result.cancel(false); + + assertThatThrownBy(result::join).isInstanceOf(CancellationException.class); + } +} diff --git a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java index dae21b5..fc7af18 100644 --- a/src/test/java/com/marketdata/sdk/RetryPolicyTest.java +++ b/src/test/java/com/marketdata/sdk/RetryPolicyTest.java @@ -10,13 +10,23 @@ import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.exception.RateLimitError; import com.marketdata.sdk.exception.ServerError; +import java.io.IOException; import java.time.Duration; +import java.time.Instant; import org.junit.jupiter.api.Test; class RetryPolicyTest { private static final RetryPolicy DEFAULTS = RetryPolicy.defaults(); + private static ErrorContext ctxNoResponse() { + return ErrorContext.forNoResponse("https://example/u", Instant.EPOCH); + } + + private static ErrorContext ctxWithStatus(int status) { + return ErrorContext.forResponse("https://example/u", status, null, Instant.EPOCH); + } + // ---------- shouldRetry: which errors are retriable ---------- @Test @@ -25,7 +35,7 @@ void networkErrorsWithIoCauseAreRetriable() { // subtype like ConnectException / HttpTimeoutException). NetworkError err = new NetworkError( - "connect refused", ErrorContext.empty(), new java.io.IOException("connect refused")); + "connect refused", ctxNoResponse(), new java.io.IOException("connect refused")); assertThat(DEFAULTS.shouldRetry(err, 0)).isTrue(); } @@ -33,7 +43,7 @@ void networkErrorsWithIoCauseAreRetriable() { void networkErrorsWithoutCauseAreNotRetriable() { // A NetworkError with no cause has no signal that it was an actual network failure. // Better to surface immediately than burn 3 attempts on a possibly-deterministic bug. - assertThat(DEFAULTS.shouldRetry(new NetworkError("boom", ErrorContext.empty()), 0)).isFalse(); + assertThat(DEFAULTS.shouldRetry(new NetworkError("boom", ctxNoResponse()), 0)).isFalse(); } @Test @@ -44,52 +54,103 @@ void networkErrorsWrappingNonIoCauseAreNotRetriable() { NetworkError syncThrow = new NetworkError( "Request failed before dispatch", - ErrorContext.empty(), + ctxNoResponse(), new IllegalArgumentException("malformed URI")); assertThat(DEFAULTS.shouldRetry(syncThrow, 0)).isFalse(); } + /** + * Issue #15: under HTTP/2 multiplexing (and certain JDK builds) a real IOException can be + * delivered nested two levels deep — typically {@code ExecutionException → IOException} — because + * HttpDispatcher's unwrap peels only one CompletionException layer. The retry decision must walk + * the chain or this resilience gap surfaces only in production under load. + */ + @Test + void networkErrorsWithIoExceptionNestedInCauseChainAreRetriable() { + NetworkError nested = + new NetworkError( + "wrapped failure", + ctxNoResponse(), + new java.util.concurrent.ExecutionException( + new java.io.IOException("connect reset by peer"))); + assertThat(DEFAULTS.shouldRetry(nested, 0)).isTrue(); + } + + @Test + void networkErrorsWithDeeplyNestedIoExceptionAreRetriable() { + // Three wrapper layers — the walk must still find the IOException at the bottom. + NetworkError deep = + new NetworkError( + "thrice wrapped", + ctxNoResponse(), + new java.util.concurrent.CompletionException( + new java.util.concurrent.ExecutionException( + new RuntimeException(new java.io.IOException("socket closed"))))); + assertThat(DEFAULTS.shouldRetry(deep, 0)).isTrue(); + } + + @Test + void networkErrorsWithDeeplyNestedNonIoCauseAreNotRetriable() { + // Regression guard: the walk must not classify any nested cause as IO — it has to find an + // IOException specifically. A chain of non-IO causes still surfaces as non-retriable. + NetworkError nonIo = + new NetworkError( + "thrice wrapped non-IO", + ctxNoResponse(), + new java.util.concurrent.ExecutionException( + new RuntimeException(new IllegalArgumentException("bug")))); + assertThat(DEFAULTS.shouldRetry(nonIo, 0)).isFalse(); + } + @Test void status500IsNotRetriable() { - ServerError err = new ServerError("500", new ErrorContext(null, "u", 500)); + ServerError err = new ServerError("500", ctxWithStatus(500)); assertThat(DEFAULTS.shouldRetry(err, 0)).isFalse(); } @Test void status501Through599AreRetriable() { for (int code : new int[] {501, 502, 503, 504, 599}) { - ServerError err = new ServerError("err", new ErrorContext(null, "u", code)); + ServerError err = new ServerError("err", ctxWithStatus(code)); assertThat(DEFAULTS.shouldRetry(err, 0)).as("status %d should be retriable", code).isTrue(); } } + @Test + void serverErrorWithoutHttpStatusIsNotRetriable() { + // ErrorContext.forNoResponse sets statusCode = 0 — the synthetic-path sentinel for a + // ServerError that wasn't backed by a real HTTP response. Falls outside 501–599, so it's + // not retried. + ServerError synthetic = new ServerError("no response", ctxNoResponse()); + assertThat(DEFAULTS.shouldRetry(synthetic, 0)).isFalse(); + } + @Test void authenticationErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new AuthenticationError("a", ErrorContext.empty()), 0)) - .isFalse(); + assertThat(DEFAULTS.shouldRetry(new AuthenticationError("a", ctxWithStatus(401)), 0)).isFalse(); } @Test void badRequestErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new BadRequestError("b", ErrorContext.empty()), 0)).isFalse(); + assertThat(DEFAULTS.shouldRetry(new BadRequestError("b", ctxWithStatus(400)), 0)).isFalse(); } @Test void rateLimitErrorIsNotRetriable() { // Spec §9: "Never retry 4xx or rate limit errors." Even though 429 carries Retry-After in // some protocols, the SDK contract is to surface RateLimitError to the caller immediately. - assertThat(DEFAULTS.shouldRetry(new RateLimitError("r", ErrorContext.empty()), 0)).isFalse(); + assertThat(DEFAULTS.shouldRetry(new RateLimitError("r", ctxWithStatus(429)), 0)).isFalse(); } @Test void notFoundErrorIsNotRetriable() { - assertThat(DEFAULTS.shouldRetry(new NotFoundError("n", ErrorContext.empty()), 0)).isFalse(); + assertThat(DEFAULTS.shouldRetry(new NotFoundError("n", ctxWithStatus(404)), 0)).isFalse(); } @Test void parseErrorIsNotRetriable() { // A bad-shape body is deterministic — retrying produces the same broken decode. - assertThat(DEFAULTS.shouldRetry(new ParseError("p", ErrorContext.empty()), 0)).isFalse(); + assertThat(DEFAULTS.shouldRetry(new ParseError("p", ctxNoResponse()), 0)).isFalse(); } @Test @@ -104,7 +165,7 @@ void unknownThrowableIsNotRetriable() { @Test void retriesStopAfterMaxAttempts() { NetworkError retriable = - new NetworkError("net", ErrorContext.empty(), new java.io.IOException("transport down")); + new NetworkError("net", ctxNoResponse(), new java.io.IOException("transport down")); // Defaults: maxAttempts = 4 → attempts 0, 1, 2 are eligible to be followed by a retry // (attempt 3 was the fourth try; no fifth attempt allowed). assertThat(DEFAULTS.shouldRetry(retriable, 0)).isTrue(); @@ -149,11 +210,103 @@ void customConstructorWiresValuesThrough() { new RetryPolicy(/* maxAttempts */ 5, Duration.ofMillis(1), Duration.ofMillis(10)); NetworkError net = - new NetworkError("n", ErrorContext.empty(), new java.io.IOException("transport down")); + new NetworkError("n", ctxNoResponse(), new java.io.IOException("transport down")); assertThat(tiny.shouldRetry(net, 3)).isTrue(); assertThat(tiny.shouldRetry(net, 4)).isFalse(); assertThat(tiny.backoffDelay(0)).isEqualTo(Duration.ofMillis(1)); assertThat(tiny.backoffDelay(1)).isEqualTo(Duration.ofMillis(2)); assertThat(tiny.backoffDelay(20)).isEqualTo(Duration.ofMillis(10)); } + + // ---------- backoffDelay(cause, attempt) honors Retry-After ---------- + + @Test + void backoffWithCauseFallsBackToExponentialWhenCauseHasNoRetryAfter() { + // ServerError without Retry-After → exponential as before. + ServerError noRetryAfter = new ServerError("503", ctxWithStatus(503)); + assertThat(DEFAULTS.backoffDelay(noRetryAfter, 0)).isEqualTo(Duration.ofSeconds(1)); + assertThat(DEFAULTS.backoffDelay(noRetryAfter, 3)).isEqualTo(Duration.ofSeconds(8)); + } + + @Test + void backoffWithCauseHonorsRetryAfterOnServerError() { + // The server's Retry-After completely replaces the calculated exponential — even when the + // exponential would have been smaller (server knows better). + ServerError withRetryAfter = + new ServerError( + "503", ctxWithStatus(503), /* cause */ null, /* retryAfter */ Duration.ofSeconds(45)); + + // Attempt 0 would normally be 1s; Retry-After overrides to 45s. + assertThat(DEFAULTS.backoffDelay(withRetryAfter, 0)).isEqualTo(Duration.ofSeconds(45)); + // Attempt 5 would normally cap at 30s; Retry-After still wins with 45s. + assertThat(DEFAULTS.backoffDelay(withRetryAfter, 5)).isEqualTo(Duration.ofSeconds(45)); + } + + @Test + void backoffWithCauseIgnoresRetryAfterOnNonServerErrorCauses() { + // NetworkError doesn't carry Retry-After at all → exponential math. + NetworkError net = new NetworkError("n", ctxNoResponse(), new IOException("down")); + assertThat(DEFAULTS.backoffDelay(net, 1)).isEqualTo(Duration.ofSeconds(2)); + } + + /** + * Issue #21: a server emitting an unbounded {@code Retry-After} (compromised, buggy, or a + * malicious upstream proxy) must not freeze the SDK's automatic retry for an unreasonable + * duration. Above {@link RetryPolicy#MAX_RETRY_AFTER} the SDK falls back to its calculated + * exponential backoff. The server's hint itself is still preserved on {@code ServerError} so + * consumers can decide what to do with it. + */ + @Test + void backoffIgnoresRetryAfterAboveCapAndFallsBackToExponential() { + ServerError pathological = + new ServerError( + "503", + ctxWithStatus(503), + null, + // Cap is 10 min; this would block the SDK for 1 day if honored verbatim. + Duration.ofDays(1)); + + // attempt 0 would be 1s exponential; that's what we expect since the 1d Retry-After is + // above the cap and ignored. + assertThat(DEFAULTS.backoffDelay(pathological, 0)).isEqualTo(Duration.ofSeconds(1)); + // attempt 3 would be 8s exponential. + assertThat(DEFAULTS.backoffDelay(pathological, 3)).isEqualTo(Duration.ofSeconds(8)); + // The original ServerError still carries the raw value — consumers see what the server said. + assertThat(pathological.getRetryAfter()).contains(Duration.ofDays(1)); + } + + @Test + void backoffHonorsRetryAfterRightAtTheCap() { + // Boundary: exactly MAX_RETRY_AFTER (10 min) is honored verbatim — only values strictly above + // the cap fall back to exponential. + ServerError atCap = + new ServerError("503", ctxWithStatus(503), null, RetryPolicy.MAX_RETRY_AFTER); + + assertThat(DEFAULTS.backoffDelay(atCap, 0)).isEqualTo(RetryPolicy.MAX_RETRY_AFTER); + } + + @Test + void rejectsNonPositiveMaxAttempts() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new RetryPolicy(0, Duration.ofMillis(1), Duration.ofMillis(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAttempts"); + } + + // ---------- noRetry factory ---------- + + @Test + void noRetryFactoryNeverAllowsRetry() { + // The factory caters to callers (startup validation today) that need a single-attempt call. + // shouldRetry must return false for any retriable cause from attempt 0 onward — otherwise + // the caller's deadline assumption is wrong. + RetryPolicy single = RetryPolicy.noRetry(); + + NetworkError retriable = + new NetworkError("net", ctxNoResponse(), new java.io.IOException("transport down")); + ServerError retriable5xx = new ServerError("503", ctxWithStatus(503)); + + assertThat(single.shouldRetry(retriable, 0)).isFalse(); + assertThat(single.shouldRetry(retriable5xx, 0)).isFalse(); + } } diff --git a/src/test/java/com/marketdata/sdk/StatusCacheTest.java b/src/test/java/com/marketdata/sdk/StatusCacheTest.java new file mode 100644 index 0000000..4047764 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/StatusCacheTest.java @@ -0,0 +1,349 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class StatusCacheTest { + + /** Test clock whose instant can be advanced step-by-step. */ + private static final class FixedClock extends Clock { + private Instant now; + + FixedClock(Instant start) { + this.now = start; + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + + void advance(java.time.Duration by) { + now = now.plus(by); + } + } + + private static ApiStatus snapshot(String service, String status) { + return new ApiStatus( + List.of( + new ServiceStatus( + service, + status, + "online".equals(status), + 1.0, + 1.0, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)))); + } + + // ---------- empty cache ---------- + + @Test + void emptyCacheAllowsAndTriggersRefresh() { + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + calls.incrementAndGet(); + return CompletableFuture.completedFuture(snapshot("/v1/x/", "online")); + }, + new FixedClock(Instant.now())); + + assertThat(cache.check(URI.create("http://api/v1/x/AAPL/"))) + .isEqualTo(StatusCache.Decision.ALLOW); + assertThat(calls).hasValue(1); + } + + // ---------- fresh cache ---------- + + @Test + void freshCacheReturnsOfflineBlock() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + Supplier> fetcher = + () -> CompletableFuture.completedFuture(snapshot("/v1/stocks/quotes/", "offline")); + StatusCache cache = new StatusCache(fetcher, clock); + cache.triggerRefresh(); + + clock.advance(java.time.Duration.ofSeconds(10)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void freshCacheReturnsOnlineAllow() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/markets/status/", "online")), + clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/markets/status/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } + + // ---------- aging cache: serve + refresh ---------- + + @Test + void agingCacheServesAndKicksAsyncRefresh() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger refreshCalls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + refreshCalls.incrementAndGet(); + return CompletableFuture.completedFuture(snapshot("/v1/x/", "online")); + }, + clock); + cache.triggerRefresh(); // initial fill + assertThat(refreshCalls).hasValue(1); + + // Move time to 280s — past refresh threshold, before expiry. + clock.advance(java.time.Duration.ofSeconds(280)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); // served from cache + assertThat(refreshCalls).hasValue(2); // refresh fired + } + + // ---------- expired cache ---------- + + @Test + void expiredCacheReturnsAllowAndRefreshes() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger refreshCalls = new AtomicInteger(); + // First call returns synchronously to populate the cache; subsequent calls return a future + // that never completes — so the refresh is genuinely in-flight when we measure the decision, + // matching real-world async behavior. With the #19 fix, a synchronous-completing refresh + // would otherwise immediately overwrite the stale snapshot and the "stale → unknown → ALLOW" + // invariant would be untestable. + StatusCache cache = + new StatusCache( + () -> { + int n = refreshCalls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + return new CompletableFuture<>(); // never completes + }, + clock); + cache.triggerRefresh(); + assertThat(refreshCalls).hasValue(1); + + // 310s — past expiry. Cache is stale → treat as unknown → ALLOW even though the cached + // entry says offline. The async refresh runs simultaneously. + clock.advance(java.time.Duration.ofSeconds(310)); + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + assertThat(refreshCalls).hasValue(2); + } + + // ---------- in-flight guard ---------- + + @Test + void overlappingRefreshesAreDeduplicatedByInFlightGuard() { + // The fetcher returns a future that never completes; the in-flight guard must prevent + // the second/third/fourth check from kicking additional refreshes while the first is open. + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger fetcherInvocations = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + fetcherInvocations.incrementAndGet(); + return new CompletableFuture<>(); // never completes + }, + clock); + + for (int i = 0; i < 5; i++) { + cache.check(URI.create("http://api/v1/x/")); + } + + assertThat(fetcherInvocations).hasValue(1); + } + + // ---------- failure handling ---------- + + @Test + void failedRefreshLeavesPreviousSnapshotIntact() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + int n = calls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + // Second call fails — simulate /status/ briefly down. + return CompletableFuture.failedFuture(new RuntimeException("status endpoint down")); + }, + clock); + cache.triggerRefresh(); + // Cache now has /v1/x/ -> offline. + + // Trigger refresh: it fails. + clock.advance(java.time.Duration.ofSeconds(280)); + cache.check(URI.create("http://api/v1/x/")); + + // Cache still serves the previous snapshot. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void fetcherThrowingSyncAlsoLeavesCacheIntact() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + AtomicInteger calls = new AtomicInteger(); + StatusCache cache = + new StatusCache( + () -> { + int n = calls.incrementAndGet(); + if (n == 1) { + return CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")); + } + throw new IllegalStateException("synchronous failure"); + }, + clock); + cache.triggerRefresh(); + + clock.advance(java.time.Duration.ofSeconds(280)); + cache.check(URI.create("http://api/v1/x/")); // triggers refresh; sync throws + // The in-flight guard must reset so subsequent refresh attempts can proceed. + + clock.advance(java.time.Duration.ofSeconds(50)); + cache.check(URI.create("http://api/v1/x/")); // age > 300, triggers again + assertThat(calls).hasValue(3); // initial fill + 2 failed refreshes + } + + // ---------- URI → service matching ---------- + + @Test + void uriMatchesLongestServicePrefix() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> + CompletableFuture.completedFuture( + new ApiStatus( + List.of( + new ServiceStatus( + "/v1/", + "online", + true, + 1.0, + 1.0, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE)), + new ServiceStatus( + "/v1/stocks/quotes/", + "offline", + false, + 0.5, + 0.6, + Instant.EPOCH.atZone(MarketDataDates.MARKET_ZONE))))), + clock); + cache.triggerRefresh(); + + // /v1/stocks/quotes/AAPL/ matches both /v1/ and /v1/stocks/quotes/ — longest wins. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + @Test + void uriWithNoMatchingServiceTreatsAsUnknown() { + // The /status/ endpoint itself has no matching service entry — its own call must not + // recurse on offline lookups. Test that the URI of /status/ falls through to ALLOW. + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")), clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/status/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } + + /** + * Issue #18: a truncated or malformed service entry like {@code /v1/stock} must NOT match the + * unrelated {@code /v1/stocks/...} endpoint. Without path-boundary normalization, one bad + * snapshot entry could block retries across an entire family of services. + */ + @Test + void shorterServiceKeyDoesNotFalselyMatchLongerPath() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + // Note: `/v1/stock` (no trailing slash, no `s`) — a truncated entry. With the trailing-slash + // normalization the cache key becomes `/v1/stock/`, which is NOT a path prefix of + // `/v1/stocks/quotes/AAPL/`. + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/stock", "offline")), clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.ALLOW); + } + + /** + * Issue #18 positive case: a key that IS a real path-prefix still matches even when the server + * emits it without the trailing slash. The normalization is two-way (key + lookup path). + */ + @Test + void serverKeyWithoutTrailingSlashStillMatchesGenuinePrefix() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/stocks/quotes", "offline")), + clock); + cache.triggerRefresh(); + + StatusCache.Decision d = cache.check(URI.create("http://api/v1/stocks/quotes/AAPL/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } + + /** + * Issue #19: with a synchronous fetcher, the snapshot is populated before {@code check} returns, + * so a cold start against a known-offline service must already see the BLOCK decision on the + * first call. Pre-fix the local {@code snap} reference was captured before {@code triggerRefresh} + * and never re-read, so the first call always answered ALLOW — burning the retry budget against a + * service the API had just reported offline. + */ + @Test + void coldStartWithSyncFetcherUsesFreshSnapshotOnFirstCall() { + FixedClock clock = new FixedClock(Instant.parse("2026-01-01T00:00:00Z")); + StatusCache cache = + new StatusCache( + () -> CompletableFuture.completedFuture(snapshot("/v1/x/", "offline")), clock); + + // No pre-warm — this is the very first check. The fetcher completes synchronously inside + // triggerRefresh and must populate the snapshot in time for the same call to use it. + StatusCache.Decision d = cache.check(URI.create("http://api/v1/x/")); + + assertThat(d).isEqualTo(StatusCache.Decision.BLOCK); + } +} diff --git a/src/test/java/com/marketdata/sdk/TestHttpClients.java b/src/test/java/com/marketdata/sdk/TestHttpClients.java new file mode 100644 index 0000000..5f8002c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/TestHttpClients.java @@ -0,0 +1,205 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +/** + * Shared {@link HttpClient} stubs used across transport-layer tests. The JDK only ships abstract + * implementations of {@code HttpClient}; subclassing it forces stubs for ~12 methods we don't use, + * so we centralize the noise here. + */ +final class TestHttpClients { + + private TestHttpClients() {} + + /** {@code HttpHeaders} from a flat key→value map. */ + static HttpHeaders headersOf(Map entries) { + Map> multi = new TreeMap<>(); + entries.forEach((k, v) -> multi.put(k, List.of(v))); + return HttpHeaders.of(multi, (a, b) -> true); + } + + /** A canned successful {@link HttpResponse} with the given status, body, and headers. */ + static HttpResponse response(int status, byte[] body, HttpHeaders headers, URI uri) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return HttpRequest.newBuilder(uri).build(); + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public byte[] body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return uri; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_2; + } + }; + } + + /** Bare-bones {@link HttpClient} with the abstract surface stubbed. */ + abstract static class StubHttpClient extends HttpClient { + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + throw new UnsupportedOperationException(); + } + + @Override + public SSLParameters sslParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler bh) + throws IOException, InterruptedException { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler bh, + HttpResponse.PushPromiseHandler ph) { + throw new UnsupportedOperationException(); + } + + @Override + public WebSocket.Builder newWebSocketBuilder() { + throw new UnsupportedOperationException(); + } + } + + /** Always throws {@link IllegalArgumentException} synchronously from {@code sendAsync}. */ + static final class SyncThrowing extends StubHttpClient { + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + throw new IllegalArgumentException("simulated synchronous throw from sendAsync"); + } + } + + /** Throws an {@link Error} (e.g. simulated OOM) synchronously. */ + static final class ErrorThrowing extends StubHttpClient { + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + throw new OutOfMemoryError("simulated synchronous Error from sendAsync"); + } + } + + /** Returns fresh pending futures from {@code sendAsync}; the test controls completion. */ + static final class Controllable extends StubHttpClient { + private final List>> pending = new ArrayList<>(); + + @SuppressWarnings("unchecked") + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + CompletableFuture> f = new CompletableFuture<>(); + pending.add((CompletableFuture>) (CompletableFuture) f); + return f; + } + + int pendingCount() { + return pending.size(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + void completeAll(HttpResponse response) { + // Completing a future can trigger downstream callbacks that re-enter sendAsync (e.g. a + // queued waiter receiving its permit and dispatching), which would CME on `pending`. + // Snapshot first; any sends that happen as a side effect get scheduled into the next call. + List>> snapshot = new ArrayList<>(pending); + for (CompletableFuture f : snapshot) { + f.complete(response); + } + } + + void failAll(Throwable t) { + List>> snapshot = new ArrayList<>(pending); + for (CompletableFuture> f : snapshot) { + f.completeExceptionally(t); + } + } + } +} diff --git a/src/test/java/com/marketdata/sdk/TokensTest.java b/src/test/java/com/marketdata/sdk/TokensTest.java index 4be730c..2ba628a 100644 --- a/src/test/java/com/marketdata/sdk/TokensTest.java +++ b/src/test/java/com/marketdata/sdk/TokensTest.java @@ -6,31 +6,74 @@ class TokensTest { + private static final String REDACTED = "***…***"; + + @Test + void redact_returns_marker_for_null() { + assertThat(Tokens.redact(null)).isEqualTo(REDACTED); + } + @Test - void redactsLongTokenKeepingLastFourChars() { - String redacted = Tokens.redact("0123456789abcdefghijklmnopqrstuvwxyzYKT0"); - assertThat(redacted).endsWith("YKT0"); - assertThat(redacted).matches("\\*+YKT0"); - assertThat(redacted).hasSize(40); + void redact_returns_marker_for_empty_string() { + assertThat(Tokens.redact("")).isEqualTo(REDACTED); + } + + @Test + void redact_returns_marker_for_tokens_shorter_than_four_chars() { + assertThat(Tokens.redact("a")).isEqualTo(REDACTED); + assertThat(Tokens.redact("ab")).isEqualTo(REDACTED); + assertThat(Tokens.redact("abc")).isEqualTo(REDACTED); + } + + /** + * Issue #24: tokens of length ≤ 8 are fully redacted — emitting the last 4 chars would expose + * 50%–100% of the value. Sandbox/demo keys are exactly this short, and the SDK promises (§16) + * never to log a token verbatim. Above 8 chars the trailing 4 give consumers enough material to + * disambiguate which key is loaded without revealing it. + */ + @Test + void redact_returns_marker_only_for_tokens_eight_or_shorter() { + assertThat(Tokens.redact("abcd")).isEqualTo(REDACTED); // len=4: would have been 100% leak + assertThat(Tokens.redact("abcde")).isEqualTo(REDACTED); // 80% leak + assertThat(Tokens.redact("abcdef")).isEqualTo(REDACTED); // 67% + assertThat(Tokens.redact("abcdefg")).isEqualTo(REDACTED); // 57% + assertThat(Tokens.redact("abcdefgh")).isEqualTo(REDACTED); // 50% + } + + @Test + void redact_appends_last_four_only_above_length_eight() { + // Boundary: length 9 is the first that gets the trailing-4 form. + assertThat(Tokens.redact("abcdefghi")).isEqualTo(REDACTED + "fghi"); + } + + @Test + void redact_appends_last_four_chars_for_normal_token() { + assertThat(Tokens.redact("MARKETDATA_TOKEN_VALUE_YKT0")).isEqualTo(REDACTED + "YKT0"); + } + + @Test + void redact_never_contains_token_prefix_for_normal_token() { + String token = "supersecrettoken1234567890"; + + String redacted = Tokens.redact(token); + + assertThat(redacted).doesNotContain("supersecret"); + assertThat(redacted).doesNotContain("token12345"); + assertThat(redacted).endsWith("7890"); } @Test - void padsShortTokensToMinimumMaskLength() { - // 10-char token: 4 visible, 6 hidden — but mask floor is 32. - String redacted = Tokens.redact("ABCDEF1234"); - assertThat(redacted).endsWith("1234"); - assertThat(redacted).hasSize(36); // 32 asterisks + 4 visible + void redact_handles_tokens_with_special_characters() { + assertThat(Tokens.redact("abc.def-ghi/jklMNOP")).isEqualTo(REDACTED + "MNOP"); } @Test - void tokenShorterThanFourCharsIsFullyMasked() { - assertThat(Tokens.redact("abc")).isEqualTo("***"); + void redact_handles_unicode_token() { + assertThat(Tokens.redact("token-ñöùéABCD")).isEqualTo(REDACTED + "ABCD"); } @Test - void blankOrNullTokenRendersAsNone() { - assertThat(Tokens.redact(null)).isEqualTo("(none)"); - assertThat(Tokens.redact("")).isEqualTo("(none)"); - assertThat(Tokens.redact(" ")).isEqualTo("(none)"); + void redact_returns_marker_unchanged_for_blank_strings_shorter_than_four() { + assertThat(Tokens.redact(" ")).isEqualTo(REDACTED); } } diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java new file mode 100644 index 0000000..6a9601c --- /dev/null +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -0,0 +1,320 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.utilities.ApiStatus; +import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.User; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class UtilitiesResourceTest { + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + + /** Mints a fresh transport + resource pair against the given canned HTTP client. */ + private static UtilitiesResource resourceWith(HttpClient client) { + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + return new UtilitiesResource(transport, new JsonResponseParser()); + } + + // ---------- URL & verb ---------- + + @Test + void headersHitsTheUnversionedRootEndpoint() { + CapturingClient client = + new CapturingClient(200, "{}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.headersAsync().join(); + + HttpRequest sent = client.captured.get(0); + // No /v1/ prefix — /headers/ is documented at the API root. + assertThat(sent.uri().toString()).isEqualTo("http://localhost/headers/"); + assertThat(sent.method()).isEqualTo("GET"); + } + + // ---------- response decoding ---------- + + @Test + void headersAsyncReturnsDecodedRecord() { + String body = + "{\"accept\":\"*/*\",\"authorization\":\"Bearer ***REDACTED***\"," + + "\"cf-ray\":\"abc-123-xyz\"}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + RequestHeaders rh = utilities.headersAsync().join().data(); + + assertThat(rh.headers()) + .containsEntry("accept", "*/*") + .containsEntry("authorization", "Bearer ***REDACTED***") + .containsEntry("cf-ray", "abc-123-xyz"); + } + + @Test + void headersSyncMirrorsHeadersAsync() { + CapturingClient client = + new CapturingClient( + 200, "{\"x\":\"1\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + RequestHeaders rh = utilities.headers().data(); + + assertThat(rh.headers()).containsEntry("x", "1"); + } + + // ---------- /user/ endpoint ---------- + + @Test + void userHitsUnversionedEndpoint() { + // The backend mounts the user router at the API root (no /v1/ prefix), same as /status/ + // and /headers/. Hitting /v1/user/ falls through to the global 404 handler — the SDK + // must request /user/ directly. + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":2," + + "\"x-options-data-permissions\":\"\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.userAsync().join(); + + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/user/"); + } + + @Test + void userAsyncReturnsDecodedRecord() { + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":42,\"x-ratelimit-requests-limit\":100," + + "\"x-options-data-permissions\":\"OPRA data delayed 15 minutes\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + User u = utilities.userAsync().join().data(); + + assertThat(u.requestsRemaining()).isEqualTo(42); + assertThat(u.requestsLimit()).isEqualTo(100); + assertThat(u.optionsDataPermissions()).isEqualTo("OPRA data delayed 15 minutes"); + } + + @Test + void userSyncMirrorsAsync() { + CapturingClient client = + new CapturingClient( + 200, + ("{\"x-ratelimit-requests-remaining\":7,\"x-ratelimit-requests-limit\":7," + + "\"x-options-data-permissions\":\"\"}") + .getBytes(), + HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + User u = utilities.user().data(); + + assertThat(u.requestsRemaining()).isEqualTo(7); + } + + /** + * The {@code /user/} endpoint's typical failure mode is "no billing plan" — surfaces as 401. The + * sync method must unwrap it to {@link AuthenticationError} directly so {@code validateOnStartup} + * (when wired) can catch it without digging through {@code CompletionException}. + */ + @Test + void user401SurfacesAuthenticationErrorDirectly() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::user).isInstanceOf(AuthenticationError.class); + } + + // ---------- /status/ endpoint ---------- + + @Test + void statusHitsTheUnversionedRootEndpoint() { + String body = + "{\"s\":\"ok\",\"service\":[\"/v1/x/\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1700000000]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + utilities.statusAsync().join(); + + // /status/ is at the API root, not under /v1/. + assertThat(client.captured.get(0).uri().toString()).isEqualTo("http://localhost/status/"); + } + + @Test + void statusAsyncReturnsZippedServiceList() { + String body = + "{\"s\":\"ok\"," + + "\"service\":[\"/v1/a/\",\"/v1/b/\"]," + + "\"status\":[\"online\",\"offline\"]," + + "\"online\":[true,false]," + + "\"uptimePct30d\":[1.0,0.9]," + + "\"uptimePct90d\":[1.0,0.95]," + + "\"updated\":[1700000000,1700000001]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + ApiStatus status = utilities.statusAsync().join().data(); + + assertThat(status.services()).hasSize(2); + assertThat(status.services().get(0).service()).isEqualTo("/v1/a/"); + assertThat(status.services().get(0).online()).isTrue(); + assertThat(status.services().get(1).service()).isEqualTo("/v1/b/"); + assertThat(status.services().get(1).online()).isFalse(); + } + + @Test + void statusSyncMirrorsAsync() { + String body = + "{\"s\":\"ok\",\"service\":[\"/v1/x/\"],\"status\":[\"online\"],\"online\":[true]," + + "\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1700000000]}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + ApiStatus status = utilities.status().data(); + + assertThat(status.services()).hasSize(1); + } + + // ---------- Response wrapper composition ---------- + + /** + * The resource layer is responsible for composing typed model + raw body + format into a {@link + * Response}. This verifies the wiring end-to-end: the bytes from the wire reach {@code rawBody}, + * the request URL is preserved for support, and the format from the spec is reflected in the + * format accessors. + */ + @Test + void resourceWrapsTypedDataWithRawBodyAndMetadata() { + String body = "{\"x\":\"1\"}"; + CapturingClient client = + new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + Response r = utilities.headers(); + + assertThat(r.data().headers()).containsEntry("x", "1"); + assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.statusCode()).isEqualTo(200); + assertThat(r.isJson()).isTrue(); + assertThat(r.isNoData()).isFalse(); + assertThat(r.requestUrl().toString()).isEqualTo("http://localhost/headers/"); + } + + // ---------- §13.5 no_data convention (HTTP 404 + {"s":"no_data"}) ---------- + + /** + * When the backend returns the no_data envelope with HTTP 404, the consumer must reach {@link + * Response#isNoData()} and {@link Response#data()} normally — no {@link + * com.marketdata.sdk.exception.ParseError} from the parallel-array field validation. The data + * payload is the typed model with an empty collection. + */ + @Test + void statusEndpointSurfaces404NoDataAsEmptyResponseInsteadOfParseError() { + String body = "{\"s\":\"no_data\"}"; + CapturingClient client = + new CapturingClient(404, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + Response r = utilities.status(); + + assertThat(r.statusCode()).isEqualTo(404); + assertThat(r.isNoData()).isTrue(); + assertThat(r.data().services()).isEmpty(); + assertThat(new String(r.rawBody())).isEqualTo(body); + } + + // ---------- error surfacing through sync ---------- + + /** + * Per ADR-006: sync wrappers must unwrap {@code CompletionException} so consumers catch the + * underlying {@link com.marketdata.sdk.exception.MarketDataException} subtype directly. A 401 + * from the server must reach the caller as {@link AuthenticationError}, not wrapped. + */ + @Test + void headersSyncUnwrapsAuthenticationFailureFromCompletionException() { + CapturingClient client = + new CapturingClient(401, new byte[0], HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::headers).isInstanceOf(AuthenticationError.class); + } + + /** + * A literal JSON {@code null} body for {@code /headers/} must surface as a {@link + * com.marketdata.sdk.exception.ParseError} carrying the request URL, status, and id — never as a + * raw {@code NullPointerException} from the {@link RequestHeaders} canonical constructor. The + * {@link RequestHeadersDeserializer} pre-check converts the null token into a {@code + * JsonMappingException}, which {@link JsonResponseParser} wraps with the support context. + */ + @Test + void headersJsonNullBodySurfacesParseErrorNotNpe() { + CapturingClient client = + new CapturingClient(200, "null".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); + UtilitiesResource utilities = resourceWith(client); + + assertThatThrownBy(utilities::headers) + .isInstanceOf(com.marketdata.sdk.exception.ParseError.class) + .hasMessageContaining("/headers/") + .hasMessageContaining("JSON null"); + } + + // ---------- stub HttpClient ---------- + + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final int status; + final byte[] body; + final HttpHeaders headers; + + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.status = status; + this.body = body; + this.headers = headers; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response(status, body, headers, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/VersionTest.java b/src/test/java/com/marketdata/sdk/VersionTest.java index 8ab9742..ad4f2b5 100644 --- a/src/test/java/com/marketdata/sdk/VersionTest.java +++ b/src/test/java/com/marketdata/sdk/VersionTest.java @@ -6,38 +6,43 @@ class VersionTest { - // ---------- resolve: covers all 4 branch outcomes of the `!= null && !isBlank()` chain - // ---------- + @Test + void resolve_returns_fallback_for_null() { + assertThat(Version.resolve(null)).isEqualTo(Version.FALLBACK); + } @Test - void resolveReturnsDetectedVersionWhenPresent() { - assertThat(Version.resolve("1.2.3")).isEqualTo("1.2.3"); + void resolve_returns_fallback_for_empty_string() { + assertThat(Version.resolve("")).isEqualTo(Version.FALLBACK); } @Test - void resolveFallsBackWhenDetectedIsNull() { - assertThat(Version.resolve(null)).isEqualTo(Version.FALLBACK); + void resolve_returns_fallback_for_blank_string() { + assertThat(Version.resolve(" ")).isEqualTo(Version.FALLBACK); } @Test - void resolveFallsBackWhenDetectedIsEmpty() { - assertThat(Version.resolve("")).isEqualTo(Version.FALLBACK); + void resolve_returns_value_when_present() { + assertThat(Version.resolve("1.2.3")).isEqualTo("1.2.3"); } @Test - void resolveFallsBackWhenDetectedIsBlank() { - // Exercises the second condition independently (`!isBlank()` evaluated `false` on whitespace). - assertThat(Version.resolve(" ")).isEqualTo(Version.FALLBACK); + void resolve_returns_snapshot_value_unchanged() { + assertThat(Version.resolve("0.1.0-SNAPSHOT")).isEqualTo("0.1.0-SNAPSHOT"); } - // ---------- current: lives at the package boundary; only asserts the contract ---------- + @Test + void sdk_version_returns_fallback_when_loaded_from_classpath_not_jar() { + String version = Version.sdkVersion(); + + assertThat(version).isEqualTo(Version.FALLBACK); + } @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(); + void sdk_version_never_returns_null_or_blank() { + String version = Version.sdkVersion(); + + assertThat(version).isNotNull(); + assertThat(version.isBlank()).isFalse(); } } diff --git a/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java b/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java new file mode 100644 index 0000000..f3fd991 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/exception/ErrorContextTest.java @@ -0,0 +1,43 @@ +package com.marketdata.sdk.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ErrorContextTest { + + @Test + void for_response_carries_all_fields() { + Instant ts = Instant.parse("2026-05-15T12:00:00Z"); + + ErrorContext ctx = + ErrorContext.forResponse("https://api.example/v1/markets/status", 401, "req-1", ts); + + assertThat(ctx.requestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(ctx.statusCode()).isEqualTo(401); + assertThat(ctx.requestId()).isEqualTo("req-1"); + assertThat(ctx.timestamp()).isEqualTo(ts); + } + + @Test + void for_response_allows_null_request_id() { + ErrorContext ctx = + ErrorContext.forResponse( + "https://api.example", 500, null, Instant.parse("2026-05-15T12:00:00Z")); + + assertThat(ctx.requestId()).isNull(); + } + + @Test + void for_no_response_uses_zero_status_and_null_request_id() { + Instant ts = Instant.parse("2026-05-15T12:00:00Z"); + + ErrorContext ctx = ErrorContext.forNoResponse("https://api.example/v1/markets/status", ts); + + assertThat(ctx.statusCode()).isZero(); + assertThat(ctx.requestId()).isNull(); + assertThat(ctx.requestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(ctx.timestamp()).isEqualTo(ts); + } +} diff --git a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java index 16c7d39..92c02db 100644 --- a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java +++ b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java @@ -1,129 +1,161 @@ package com.marketdata.sdk.exception; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.List; +import java.io.IOException; +import java.time.Instant; import org.junit.jupiter.api.Test; class MarketDataExceptionTest { + private static final Instant TS = Instant.parse("2026-05-15T12:00:00Z"); + + private static ErrorContext sampleContext() { + return ErrorContext.forResponse("https://api.example/v1/markets/status", 401, "req-abc", TS); + } + @Test - void emptyContextLeavesFieldsNull() { - var error = new BadRequestError("symbol must not be blank", ErrorContext.empty()); - - assertThat(error.getRequestId()).isNull(); - assertThat(error.getRequestUrl()).isNull(); - assertThat(error.getStatusCode()).isNull(); - assertThat(error.getTimestamp()).isNotNull(); - assertThat(error.getExceptionType()).isEqualTo("BadRequestError"); + void exposes_all_context_fields_via_getters() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + assertThat(error.getMessage()).isEqualTo("Token invalid"); + assertThat(error.getStatusCode()).isEqualTo(401); + assertThat(error.getRequestUrl()).isEqualTo("https://api.example/v1/markets/status"); + assertThat(error.getRequestId()).isEqualTo("req-abc"); + assertThat(error.getTimestamp()).isEqualTo(TS); + assertThat(error.getExceptionType()).isEqualTo("AuthenticationError"); + assertThat(error.getContext()).isEqualTo(sampleContext()); } @Test - void carriesContextFields() { - var ctx = - new ErrorContext( - "8a1b2c3d4e5f6g7h-SJC", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); + void preserves_cause_when_provided() { + IOException cause = new IOException("connection refused"); - var error = new RateLimitError("Rate limit exceeded", ctx); + NetworkError error = new NetworkError("Network failure", sampleContext(), cause); - assertThat(error.getRequestId()).isEqualTo("8a1b2c3d4e5f6g7h-SJC"); - assertThat(error.getStatusCode()).isEqualTo(429); - assertThat(error.getExceptionType()).isEqualTo("RateLimitError"); + assertThat(error.getCause()).isSameAs(cause); } @Test - void supportInfoIncludesAllRequiredFields() { - var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); - var error = new RateLimitError("Rate limit exceeded", ctx); - - String supportInfo = error.getSupportInfo(); - - assertThat(supportInfo) - .contains("RateLimitError") - .contains("Rate limit exceeded") - .contains("429") - .contains("RAY-1") - .contains("https://api.marketdata.app/v1/stocks/quotes/AAPL/") - .contains("US/Eastern"); + void support_info_matches_spec_format() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + assertThat(info) + .contains("--- MARKET DATA SUPPORT INFO ---") + .contains("--------------------------------") + .contains("request_id:") + .contains("request_url:") + .contains("status_code:") + .contains("timestamp:") + .contains("message:") + .contains("exception_type:") + .contains("AuthenticationError") + .contains("Token invalid") + .contains("401") + .contains("https://api.example/v1/markets/status") + .contains("req-abc"); } @Test - void allSubtypesCarryContextAndCause() { - var ctx = new ErrorContext("RAY-X", "https://api.marketdata.app/v1/test/", 500); - var cause = new RuntimeException("root cause"); - - // The four subtypes not exercised by the other tests in this file. - var net = new NetworkError("network down", ctx, cause); - var nf = new NotFoundError("not found", ctx); - var pe = new ParseError("bad json", ctx, cause); - var se = new ServerError("internal", ctx); - - assertThat(net.getExceptionType()).isEqualTo("NetworkError"); - assertThat(net.getCause()).isSameAs(cause); - assertThat(nf.getExceptionType()).isEqualTo("NotFoundError"); - assertThat(nf.getCause()).isNull(); - assertThat(pe.getExceptionType()).isEqualTo("ParseError"); - assertThat(pe.getCause()).isSameAs(cause); - assertThat(se.getExceptionType()).isEqualTo("ServerError"); - assertThat(se.getCause()).isNull(); - - for (MarketDataException ex : List.of(net, nf, pe, se)) { - assertThat(ex.getStatusCode()).isEqualTo(500); - assertThat(ex.getRequestId()).isEqualTo("RAY-X"); - assertThat(ex.getRequestUrl()).isEqualTo("https://api.marketdata.app/v1/test/"); - assertThat(ex.getTimestamp()).isNotNull(); - } + void support_info_renders_timestamp_in_us_eastern() { + // 2026-05-15T12:00:00Z is during EDT (UTC-4): expected 2026-05-15 08:00:00 + AuthenticationError summer = new AuthenticationError("x", sampleContext()); + assertThat(summer.getSupportInfo()).contains("2026-05-15 08:00:00"); + + // 2026-01-15T12:00:00Z is during EST (UTC-5): expected 2026-01-15 07:00:00 + ErrorContext winterCtx = + ErrorContext.forResponse( + "https://api.example", 500, "r", Instant.parse("2026-01-15T12:00:00Z")); + ServerError winter = new ServerError("x", winterCtx); + assertThat(winter.getSupportInfo()).contains("2026-01-15 07:00:00"); } @Test - void everySubtypeExposesBothConstructors() { - var ctx = ErrorContext.empty(); - var cause = new RuntimeException("cause"); - - // Each subtype has two constructors: (msg, ctx) and (msg, ctx, cause). - // Exercise the one that the other tests in this file don't already hit. - List exhaustive = - List.of( - new AuthenticationError("a", ctx, cause), - new BadRequestError("b", ctx, cause), - new NotFoundError("n", ctx, cause), - new RateLimitError("r", ctx, cause), - new ServerError("s", ctx, cause), - new NetworkError("net", ctx), // cause-less variant - new ParseError("p", ctx)); // cause-less variant - - for (MarketDataException ex : exhaustive) { - assertThat(ex.getMessage()).isNotBlank(); - assertThat(ex.getTimestamp()).isNotNull(); - } + void support_info_preserves_field_order_per_spec() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + assertThat(info.indexOf("request_id:")).isLessThan(info.indexOf("request_url:")); + assertThat(info.indexOf("request_url:")).isLessThan(info.indexOf("status_code:")); + assertThat(info.indexOf("status_code:")).isLessThan(info.indexOf("timestamp:")); + assertThat(info.indexOf("timestamp:")).isLessThan(info.indexOf("message:")); + assertThat(info.indexOf("message:")).isLessThan(info.indexOf("exception_type:")); } @Test - void 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"); + void support_info_handles_missing_request_id() { + ErrorContext ctx = ErrorContext.forResponse("https://api.example", 500, null, TS); + ServerError error = new ServerError("Server boom", ctx); + + String info = error.getSupportInfo(); + + assertThat(info).contains("request_id: (not provided)"); + assertThat(info).doesNotContain("request_id: null"); } @Test - void supportInfoNeverContainsSensitiveData() { - // The exception itself never receives the token; we just - // double-check that the canonical message+URL form doesn't leak. - var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/user/", 401); - var error = new AuthenticationError("Invalid token", ctx); + void support_info_handles_no_response_context() { + ErrorContext ctx = ErrorContext.forNoResponse("https://api.example", TS); + NetworkError error = new NetworkError("Connection refused", ctx); + + String info = error.getSupportInfo(); + + assertThat(info) + .contains("exception_type: NetworkError") + .contains("status_code: 0") + .contains("request_id: (not provided)"); + } + + @Test + void support_info_uses_sixteen_char_column_padding() { + AuthenticationError error = new AuthenticationError("Token invalid", sampleContext()); + + String info = error.getSupportInfo(); + + // exception_type: is 15 chars + 1 space = 16; value starts at column 16 + assertThat(info).contains("exception_type: AuthenticationError"); + // request_id: is 11 chars + 5 spaces = 16 + assertThat(info).contains("request_id: req-abc"); + } + + @Test + void can_be_thrown_and_caught_as_market_data_exception() { + assertThatThrownBy( + () -> { + throw new RateLimitError("Quota exceeded", sampleContext()); + }) + .isInstanceOf(MarketDataException.class) + .isInstanceOf(RateLimitError.class) + .hasMessage("Quota exceeded"); + } + + @Test + void supports_instanceof_dispatch_over_sealed_hierarchy() { + MarketDataException exception = new RateLimitError("rate limited", sampleContext()); + + String label; + if (exception instanceof AuthenticationError) { + label = "auth"; + } else if (exception instanceof BadRequestError) { + label = "bad"; + } else if (exception instanceof NotFoundError) { + label = "notfound"; + } else if (exception instanceof RateLimitError) { + label = "rate"; + } else if (exception instanceof ServerError) { + label = "server"; + } else if (exception instanceof NetworkError) { + label = "network"; + } else if (exception instanceof ParseError) { + label = "parse"; + } else { + label = "unknown"; + } - assertThat(error.getSupportInfo()).doesNotContain("token=").doesNotContain("Bearer "); + assertThat(label).isEqualTo("rate"); } } diff --git a/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java new file mode 100644 index 0000000..8301607 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/exception/SealedHierarchyTest.java @@ -0,0 +1,40 @@ +package com.marketdata.sdk.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SealedHierarchyTest { + + @Test + void permits_exactly_the_seven_canonical_subtypes() { + // ADR-002 fixes the canonical list at exactly these 7 permits. Expanding requires an ADR + // amendment — adding a permit silently would break consumers compiling against the documented + // shape on JDK 21+ (pattern matching for switch). This snapshot is the regression guard. + Class[] permitted = MarketDataException.class.getPermittedSubclasses(); + + assertThat(permitted) + .containsExactlyInAnyOrder( + AuthenticationError.class, + BadRequestError.class, + NotFoundError.class, + RateLimitError.class, + ServerError.class, + NetworkError.class, + ParseError.class); + } + + @Test + void base_class_is_sealed() { + assertThat(MarketDataException.class.isSealed()).isTrue(); + } + + @Test + void all_subtypes_are_final() { + for (Class subtype : MarketDataException.class.getPermittedSubclasses()) { + assertThat(java.lang.reflect.Modifier.isFinal(subtype.getModifiers())) + .as("Subtype %s must be final", subtype.getSimpleName()) + .isTrue(); + } + } +}