Skip to content

Commit 89d700b

Browse files
adds retry policies
1 parent 63fc0a6 commit 89d700b

6 files changed

Lines changed: 670 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,16 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
6565
- §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.
6666
- §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`).
6767
- §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`.
68-
- §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 a constant ready to be applied to `HttpRequest.Builder#timeout` when the request layer lands.
69-
- §12 concurrency: `Semaphore(50)` field on `MarketDataClient` (wiring of acquire/release lands with the request layer).
68+
- §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`.
69+
- §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).
70+
- §9 retry/backoff: `RetryPolicy` (3 attempts, exponential 1s→30s) 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.
7071
- §15 packaging: SemVer, MIT `LICENSE`, `CHANGELOG.md` in Keep a Changelog format, version auto-detected via JAR manifest (`Implementation-Version`).
7172
- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option).
7273
- ADR-002 CI: split into four workflows.
7374
- `.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.
7475
- `.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).
7576
- `.github/workflows/pr-matrix-on-demand.yml` — manually triggered on a PR by commenting `/run-all-jdks`, `/jdk-matrix`, or `/test-all`. Runs the **unit-test** matrix on JDK 21 and 25 (17 already ran via `pull-request.yml`). Gated to write/maintain/admin commenters. Reacts 👀 to the trigger comment and posts a result summary.
76-
- `.github/workflows/pr-integration-on-demand.yml` — manually triggered on a PR by commenting `integrationtest` (JDK 17 only) or `integrationtestfull` (matrix `{17, 21, 25}`). Runs the **integration-test** suite against the live API. Same write+ permission gate as the matrix-on-demand workflow. Aggregates the matrix outcome into a single required check named **"Integration tests pass"** so branch protection can require it uniformly regardless of which command was used. Branch-protection rules on `main` should list this check as required for merge.
77+
- `.github/workflows/pr-integration-on-demand.yml` — manually triggered on a PR by commenting `/integrationtest` (JDK 17 only) or `/integrationtestfull` (matrix `{17, 21, 25}`) on the **first line** of the comment body. Runs the **integration-test** suite against the live API. Same write+ permission gate as the matrix-on-demand workflow. The first-line + exact-match constraint prevents accidental triggers from quoted replies (`> /integrationtest`) or prose that mentions the command. Aggregates the matrix outcome into a single required check named **"Integration tests pass"** so branch protection can require it uniformly regardless of which command was used. Branch-protection rules on `main` should list this check as required for merge.
7778
- All four `issue_comment`-driven workflows execute from the default branch's copy of their YAML, not the PR's. Feature-branch edits to these workflows take effect only after merge to main.
7879
- `-PtestJdk=N` is wired to **all** `Test` tasks (`test` and `integrationTest`) via `tasks.withType<Test>().configureEach { javaLauncher.set(...) }` in `build.gradle.kts`, so the matrix flag works uniformly across unit and integration tests.
7980
- 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`.
@@ -84,19 +85,14 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
8485
- §5 actual `/user/` startup validation call (the `validateOnStartup` flag is the seam; the call itself comes with the request layer).
8586
- §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.
8687
- §8 rate-limit header parsing, pre-flight check, request-scoped attachment.
87-
- §9 retry/backoff policy and `/status/` cache workflow.
88-
- §12 acquire/release of the concurrency semaphore around dispatched requests.
88+
- §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).
8989
- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold.
9090

9191
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.
9292

93-
**Known latent gaps to revisit when retry/timeout lands:**
94-
- `HttpTransport.executeSync` only catches `CompletionException` from `.join()`, not `CancellationException`. Today the latter is unreachable — the user can't cancel a future they never see (the future is local to `executeSync`), no internal code cancels it, and `dispatch`'s `handle((response, error) -> ...)` translates every upstream error (including a hypothetical `CancellationException` from `sendAsync`) into `CompletionException(NetworkError)`. The gap becomes real once we add:
95-
- `dispatched.orTimeout(99s)` / `completeOnTimeout` to enforce the §10 timeout strictly (these produce `CancellationException` on the downstream future).
96-
- A retry coordinator (§9) that cancels in-flight futures when aborting a retry chain.
97-
- A bump to JDK 21+ where `HttpClient.close()` cancels in-flight futures.
98-
When any of those land, extend the catch in `executeSync` (or fold it into `asRuntime`) so cancellations don't escape as raw `RuntimeException` to sync callers. Tracked as Issue #2 of the 2026-05-11 review (`REVIEW-2026-05-11-markets-status.md`).
93+
**Known latent gaps:**
9994
- `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.
95+
- `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.
10096

10197
## Acceptance checklist
10298

src/main/java/com/marketdata/sdk/HttpTransport.java

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.concurrent.CancellationException;
2121
import java.util.concurrent.CompletableFuture;
2222
import java.util.concurrent.CompletionException;
23+
import java.util.concurrent.TimeUnit;
2324
import java.util.concurrent.atomic.AtomicReference;
2425
import org.jspecify.annotations.Nullable;
2526

@@ -55,6 +56,7 @@ final class HttpTransport implements AutoCloseable {
5556
private final HttpClient httpClient;
5657
private final ObjectMapper jsonMapper;
5758
private final AsyncSemaphore concurrencyPermits;
59+
private final RetryPolicy retryPolicy;
5860
private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>();
5961

6062
private final String baseUrl;
@@ -63,7 +65,7 @@ final class HttpTransport implements AutoCloseable {
6365
private final @Nullable String token;
6466

6567
HttpTransport(String baseUrl, String apiVersion, String userAgent, @Nullable String token) {
66-
this(baseUrl, apiVersion, userAgent, token, defaultHttpClient());
68+
this(baseUrl, apiVersion, userAgent, token, defaultHttpClient(), RetryPolicy.defaults());
6769
}
6870

6971
// Package-private constructor used by tests to inject a stubbed HttpClient
@@ -74,13 +76,25 @@ final class HttpTransport implements AutoCloseable {
7476
String userAgent,
7577
@Nullable String token,
7678
HttpClient httpClient) {
79+
this(baseUrl, apiVersion, userAgent, token, httpClient, RetryPolicy.defaults());
80+
}
81+
82+
// Package-private constructor used by retry tests to drive sub-millisecond backoffs.
83+
HttpTransport(
84+
String baseUrl,
85+
String apiVersion,
86+
String userAgent,
87+
@Nullable String token,
88+
HttpClient httpClient,
89+
RetryPolicy retryPolicy) {
7790
this.baseUrl = baseUrl;
7891
this.apiVersion = apiVersion;
7992
this.userAgent = userAgent;
8093
this.token = token;
8194
this.concurrencyPermits = new AsyncSemaphore(CONCURRENCY_LIMIT);
8295
this.jsonMapper = buildJsonMapper();
8396
this.httpClient = httpClient;
97+
this.retryPolicy = retryPolicy;
8498
}
8599

86100
private static HttpClient defaultHttpClient() {
@@ -102,14 +116,63 @@ private static HttpClient defaultHttpClient() {
102116
}
103117

104118
/**
105-
* Async-first request execution.
106-
*
107-
* <p>Acquires a concurrency permit, fires the request, parses rate-limit headers, decodes the
108-
* body when the status is 200/203/404 (the API returns 404 with {@code {"s":"no_data"}} as a
109-
* sentinel — see SDK requirements §9.1), and translates other status codes to the appropriate
110-
* {@link MarketDataException} subtype.
119+
* Async-first request execution with retry. Orchestrates one or more attempts according to {@link
120+
* RetryPolicy}: retries 501–599 and {@link NetworkError} with exponential backoff, surfaces every
121+
* other failure immediately. Cancellation of the returned future bails out of any pending backoff
122+
* and skips further attempts.
111123
*/
112124
<T> CompletableFuture<T> executeAsync(RequestSpec spec, Class<T> responseType) {
125+
CompletableFuture<T> result = new CompletableFuture<>();
126+
attempt(spec, responseType, 0, result);
127+
return result;
128+
}
129+
130+
private <T> void attempt(
131+
RequestSpec spec, Class<T> responseType, int attemptIdx, CompletableFuture<T> result) {
132+
if (result.isDone()) {
133+
// Caller cancelled (or completed exceptionally from a previous attempt's whenComplete).
134+
// Don't burn another HTTP request.
135+
return;
136+
}
137+
CompletableFuture<T> dispatched = executeOnce(spec, responseType);
138+
139+
// Cascade cancel from `result` (returned to caller) to `dispatched` (single-attempt internal
140+
// future). Without this, a caller cancelling `result` would leave a slow-path waiter alive
141+
// in the semaphore queue: a later release() would transfer the permit but the dispatch
142+
// function would never run, leaking the permit. The cascade re-enters executeOnce's own
143+
// CancellationException handler, which cancels the waiter so release() skips it.
144+
result.whenComplete(
145+
(r, t) -> {
146+
if (t instanceof CancellationException && !dispatched.isDone()) {
147+
dispatched.cancel(false);
148+
}
149+
});
150+
151+
dispatched.whenComplete(
152+
(value, error) -> {
153+
if (result.isDone()) {
154+
return;
155+
}
156+
if (error == null) {
157+
result.complete(value);
158+
return;
159+
}
160+
Throwable cause = unwrap(error);
161+
if (retryPolicy.shouldRetry(cause, attemptIdx)) {
162+
long delayMs = retryPolicy.backoffDelay(attemptIdx).toMillis();
163+
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
164+
.execute(() -> attempt(spec, responseType, attemptIdx + 1, result));
165+
} else {
166+
result.completeExceptionally(cause);
167+
}
168+
});
169+
}
170+
171+
/**
172+
* Single-shot dispatch — one HTTP request, one permit lease, one response decode. Public retry
173+
* orchestration lives in {@link #executeAsync}.
174+
*/
175+
private <T> CompletableFuture<T> executeOnce(RequestSpec spec, Class<T> responseType) {
113176
URI uri = buildUri(spec);
114177
HttpRequest request = buildRequest(uri);
115178

@@ -186,12 +249,19 @@ private <T> CompletableFuture<T> dispatch(URI uri, HttpRequest request, Class<T>
186249
/**
187250
* Sync wrapper around {@link #executeAsync}. Per ADR-006, calls {@code .join()} and unwraps
188251
* {@link CompletionException} so callers see the underlying {@link MarketDataException} directly.
252+
*
253+
* <p>{@link CancellationException} can in principle escape {@code .join()} as a sibling of {@link
254+
* CompletionException} (not nested), so it's caught explicitly. Today no internal code cancels
255+
* the future {@code executeSync} owns, but covering it keeps the contract honest if a future
256+
* change (timeout watchdog, retry coordinator) starts cancelling internally.
189257
*/
190258
<T> T executeSync(RequestSpec spec, Class<T> responseType) {
191259
try {
192260
return executeAsync(spec, responseType).join();
193261
} catch (CompletionException e) {
194262
throw asRuntime(e.getCause());
263+
} catch (CancellationException e) {
264+
throw asRuntime(e);
195265
}
196266
}
197267

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.marketdata.sdk;
2+
3+
import com.marketdata.sdk.exception.MarketDataException;
4+
import com.marketdata.sdk.exception.NetworkError;
5+
import com.marketdata.sdk.exception.ServerError;
6+
import java.time.Duration;
7+
8+
/**
9+
* Decides which failures get retried and how long to wait between attempts. SDK requirements §9
10+
* fixes the parameters: max 3 attempts total (one initial + two retries), exponential backoff
11+
* starting at 1s, capped at 30s. Network errors and HTTP 501–599 are retriable; 500 specifically is
12+
* not. Everything 4xx (including 401/429) surfaces immediately.
13+
*
14+
* <p>The constructor accepts custom values so tests can drive retries with sub-millisecond delays
15+
* without waiting on real wall-clock backoffs.
16+
*/
17+
final class RetryPolicy {
18+
19+
private final int maxAttempts;
20+
private final Duration initialBackoff;
21+
private final Duration maxBackoff;
22+
23+
RetryPolicy(int maxAttempts, Duration initialBackoff, Duration maxBackoff) {
24+
if (maxAttempts < 1) {
25+
throw new IllegalArgumentException("maxAttempts must be >= 1, was " + maxAttempts);
26+
}
27+
this.maxAttempts = maxAttempts;
28+
this.initialBackoff = initialBackoff;
29+
this.maxBackoff = maxBackoff;
30+
}
31+
32+
/** Spec defaults: 3 attempts, 1s → 30s exponential. */
33+
static RetryPolicy defaults() {
34+
return new RetryPolicy(3, Duration.ofSeconds(1), Duration.ofSeconds(30));
35+
}
36+
37+
/**
38+
* Whether the SDK should retry after {@code cause}, given that {@code attempt} attempts have
39+
* already been spent (zero-indexed: {@code attempt == 0} means the original call just failed and
40+
* we're considering the first retry).
41+
*/
42+
boolean shouldRetry(Throwable cause, int attempt) {
43+
if (attempt + 1 >= maxAttempts) {
44+
return false;
45+
}
46+
return isRetriable(cause);
47+
}
48+
49+
/**
50+
* Backoff before the next attempt. {@code attempt == 0} means "before the first retry", i.e. the
51+
* delay applied right after the original call failed.
52+
*/
53+
Duration backoffDelay(int attempt) {
54+
long base = initialBackoff.toMillis();
55+
long max = maxBackoff.toMillis();
56+
// attempt 0 → base * 1, attempt 1 → base * 2, attempt N → base * 2^N. Long arithmetic with
57+
// an explicit cap because shifting >= 63 bits is undefined in Java; also avoids overflow if
58+
// a misconfigured policy used a huge attempt count.
59+
long delay;
60+
if (attempt >= 62) {
61+
delay = max;
62+
} else {
63+
long multiplier = 1L << attempt;
64+
delay = (base > max / multiplier) ? max : base * multiplier;
65+
}
66+
return Duration.ofMillis(Math.min(delay, max));
67+
}
68+
69+
private static boolean isRetriable(Throwable cause) {
70+
if (!(cause instanceof MarketDataException)) {
71+
// Conservative: unknown failure types don't get retried. The caller sees the original
72+
// exception rather than an amplified series of identical hits.
73+
return false;
74+
}
75+
if (cause instanceof NetworkError) {
76+
return true;
77+
}
78+
if (cause instanceof ServerError server) {
79+
Integer status = server.getStatusCode();
80+
// Spec §9: 500 is not retriable; 501–599 are. A null status means "we threw a ServerError
81+
// without a real HTTP code" — that's only the synthetic-path of HttpStatusMapper today, so
82+
// don't retry it.
83+
return status != null && status >= 501 && status <= 599;
84+
}
85+
// AuthenticationError, BadRequestError, RateLimitError, NotFoundError, ParseError: §9 says
86+
// never retry 4xx, and ParseError is deterministic.
87+
return false;
88+
}
89+
}

0 commit comments

Comments
 (0)