Skip to content

Commit d922085

Browse files
fix test
1 parent 89d700b commit d922085

4 files changed

Lines changed: 286 additions & 36 deletions

File tree

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

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,36 +117,52 @@ private static HttpClient defaultHttpClient() {
117117

118118
/**
119119
* 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.
120+
* RetryPolicy}: retries 501–599 and IOException-shaped {@link NetworkError}s with exponential
121+
* backoff, surfaces every other failure immediately. Cancellation of the returned future bails
122+
* out of any pending backoff and propagates to the current in-flight attempt.
123123
*/
124124
<T> CompletableFuture<T> executeAsync(RequestSpec spec, Class<T> responseType) {
125125
CompletableFuture<T> result = new CompletableFuture<>();
126-
attempt(spec, responseType, 0, result);
126+
// One cascade-cancel handler installed once: whichever attempt is currently in flight is
127+
// tracked in `currentDispatched`; cancelling `result` cancels that. Previous attempts in
128+
// the chain are already done by the time the next one updates the reference, so this
129+
// avoids accumulating a handler per attempt.
130+
AtomicReference<@Nullable CompletableFuture<T>> currentDispatched = new AtomicReference<>();
131+
result.whenComplete(
132+
(r, t) -> {
133+
if (t instanceof CancellationException) {
134+
CompletableFuture<T> inFlight = currentDispatched.get();
135+
if (inFlight != null && !inFlight.isDone()) {
136+
inFlight.cancel(false);
137+
}
138+
}
139+
});
140+
attempt(spec, responseType, 0, result, currentDispatched);
127141
return result;
128142
}
129143

130144
private <T> void attempt(
131-
RequestSpec spec, Class<T> responseType, int attemptIdx, CompletableFuture<T> result) {
145+
RequestSpec spec,
146+
Class<T> responseType,
147+
int attemptIdx,
148+
CompletableFuture<T> result,
149+
AtomicReference<@Nullable CompletableFuture<T>> currentDispatched) {
132150
if (result.isDone()) {
133151
// Caller cancelled (or completed exceptionally from a previous attempt's whenComplete).
134152
// Don't burn another HTTP request.
135153
return;
136154
}
137155
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-
});
156+
currentDispatched.set(dispatched);
157+
158+
// If the caller cancelled `result` between attempts (during a backoff window), the handler
159+
// installed in executeAsync has fired but `currentDispatched` was either null or pointing
160+
// to the previous (already-done) attempt — so the new one was never cancelled. Check here
161+
// and propagate immediately.
162+
if (result.isCancelled() && !dispatched.isDone()) {
163+
dispatched.cancel(false);
164+
return;
165+
}
150166

151167
dispatched.whenComplete(
152168
(value, error) -> {
@@ -161,7 +177,8 @@ private <T> void attempt(
161177
if (retryPolicy.shouldRetry(cause, attemptIdx)) {
162178
long delayMs = retryPolicy.backoffDelay(attemptIdx).toMillis();
163179
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
164-
.execute(() -> attempt(spec, responseType, attemptIdx + 1, result));
180+
.execute(
181+
() -> attempt(spec, responseType, attemptIdx + 1, result, currentDispatched));
165182
} else {
166183
result.completeExceptionally(cause);
167184
}

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

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33
import com.marketdata.sdk.exception.MarketDataException;
44
import com.marketdata.sdk.exception.NetworkError;
55
import com.marketdata.sdk.exception.ServerError;
6+
import java.io.IOException;
67
import java.time.Duration;
78

89
/**
910
* Decides which failures get retried and how long to wait between attempts. SDK requirements §9
1011
* 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.
12+
* starting at 1s, capped at 30s. Network errors (only when wrapping an {@link IOException}-shaped
13+
* cause — see {@link #shouldRetry}) and HTTP 501–599 are retriable; 500 specifically is not, and
14+
* 4xx (including 401/429) surfaces immediately.
15+
*
16+
* <p><strong>Worst-case wall-clock per {@code executeAsync} call (defaults):</strong> 3 attempts ×
17+
* 99s per-request timeout + 1s + 2s backoff ≈ 5 minutes. SDK requirements §10 only mandates the
18+
* per-request timeout, not an overall deadline, so this is compliant — but callers in
19+
* latency-sensitive contexts may want to wrap calls with their own {@code orTimeout} cap.
1320
*
1421
* <p>The constructor accepts custom values so tests can drive retries with sub-millisecond delays
1522
* without waiting on real wall-clock backoffs.
@@ -53,17 +60,18 @@ boolean shouldRetry(Throwable cause, int attempt) {
5360
Duration backoffDelay(int attempt) {
5461
long base = initialBackoff.toMillis();
5562
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;
63+
// Two saturation points: (1) for large attempt indices, the shift `1L << N` would silently
64+
// wrap once N >= 63 (Java masks the shift count to its low 6 bits), and (2) for moderate
65+
// indices, `base * 2^attempt` can overflow Long before we get a chance to cap. (1) is
66+
// handled by the early return; (2) by the rearranged inequality
67+
// `base > max / multiplier ⇔ base * multiplier > max`, which detects overflow without
68+
// actually overflowing.
6069
if (attempt >= 62) {
61-
delay = max;
62-
} else {
63-
long multiplier = 1L << attempt;
64-
delay = (base > max / multiplier) ? max : base * multiplier;
70+
return Duration.ofMillis(max);
6571
}
66-
return Duration.ofMillis(Math.min(delay, max));
72+
long multiplier = 1L << Math.max(attempt, 0);
73+
long delay = (base > max / multiplier) ? max : base * multiplier;
74+
return Duration.ofMillis(delay);
6775
}
6876

6977
private static boolean isRetriable(Throwable cause) {
@@ -72,8 +80,12 @@ private static boolean isRetriable(Throwable cause) {
7280
// exception rather than an amplified series of identical hits.
7381
return false;
7482
}
75-
if (cause instanceof NetworkError) {
76-
return true;
83+
if (cause instanceof NetworkError net) {
84+
// NetworkError wraps two shapes: actual transport failures (IOException + subtypes:
85+
// ConnectException, HttpTimeoutException, ...) and sync-throws from httpClient.sendAsync
86+
// (NPE, IllegalArgumentException — bugs, not network). Retry only the former; the latter
87+
// is deterministic and just burns the backoff for the same crash.
88+
return net.getCause() instanceof IOException;
7789
}
7890
if (cause instanceof ServerError server) {
7991
Integer status = server.getStatusCode();

src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,101 @@ void exhaustedRetriesOnNetworkErrorsPropagatesLastError() {
178178
assertThat(client.callCount()).isEqualTo(3);
179179
}
180180

181+
// ---------- sync-throw bugs do NOT retry ----------
182+
183+
/**
184+
* If {@code httpClient.sendAsync} throws synchronously (malformed request, internal NPE, {@code
185+
* IllegalArgumentException}), the failure is wrapped as {@code NetworkError} but its cause is not
186+
* an {@link IOException}. {@link RetryPolicy} treats that as non-retriable: a deterministic bug
187+
* doesn't get better with 1s+2s of backoff.
188+
*/
189+
@Test
190+
void synchronousThrowDoesNotRetry() {
191+
SyncThrowingHttpClient client = new SyncThrowingHttpClient();
192+
HttpTransport transport =
193+
new HttpTransport("http://stub.local", "v1", "test/0.0", null, client, fastPolicy(3));
194+
195+
assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class))
196+
.isInstanceOf(NetworkError.class)
197+
.hasMessageContaining("before dispatch")
198+
.hasCauseInstanceOf(IllegalArgumentException.class);
199+
200+
assertThat(client.callCount())
201+
.as("a sync-throw is deterministic — retrying just burns backoff for the same crash")
202+
.isEqualTo(1);
203+
}
204+
205+
// ---------- rate-limit snapshot consistency under retry ----------
206+
207+
/**
208+
* If attempt 1 returns 503 with rate-limit headers and attempt 2 returns 200 without them, the
209+
* snapshot must reflect attempt 1's values (Issue #4 conservation rule applies cross-attempt, not
210+
* just cross-request).
211+
*/
212+
@Test
213+
void rateLimitSnapshotPreservedAcrossRetryAttempts() {
214+
MultiResponseHttpClient client =
215+
new MultiResponseHttpClient(
216+
response(
217+
503,
218+
"{}",
219+
Map.of(
220+
"x-api-ratelimit-limit", "50000",
221+
"x-api-ratelimit-remaining", "12345",
222+
"x-api-ratelimit-reset", "1735689600",
223+
"x-api-ratelimit-consumed", "37655")),
224+
response(200, "{\"value\":\"ok\"}", Map.of()));
225+
226+
HttpTransport transport = newTransport(client, fastPolicy(3));
227+
transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
228+
229+
RateLimits snapshot = transport.getLatestRateLimits();
230+
assertThat(snapshot).isNotNull();
231+
assertThat(snapshot.remaining())
232+
.as("the snapshot must keep the headers from the 503 attempt, not be cleared by the 200")
233+
.isEqualTo(12345L);
234+
}
235+
236+
// ---------- mid-backoff cancellation ----------
237+
238+
/**
239+
* Cancelling the returned future while a backoff is pending must (a) skip the next attempt and
240+
* (b) leave the permit pool intact. The cascade-cancel chain is the trickiest piece of {@link
241+
* HttpTransport}; this test is the explicit regression for it.
242+
*/
243+
@Test
244+
void cancellationMidBackoffSkipsRemainingAttempts() throws Exception {
245+
// Use a slow policy so we have a real backoff window to cancel into. 200 ms is short enough
246+
// to keep the test fast but long enough to reliably interleave the cancel.
247+
RetryPolicy slowPolicy = new RetryPolicy(3, Duration.ofMillis(200), Duration.ofSeconds(1));
248+
MultiResponseHttpClient client =
249+
new MultiResponseHttpClient(
250+
response(503, "{}"), response(503, "{}"), response(200, "{\"value\":\"ok\"}"));
251+
HttpTransport transport = newTransport(client, slowPolicy);
252+
253+
java.util.concurrent.CompletableFuture<Echo> future =
254+
transport.executeAsync(RequestSpec.get("ping").build(), Echo.class);
255+
256+
// Let attempt 1 run and fail (503 → schedule retry with 200 ms backoff). Then cancel before
257+
// the delayedExecutor fires the second attempt.
258+
Thread.sleep(50);
259+
boolean cancelled = future.cancel(false);
260+
assertThat(cancelled).isTrue();
261+
262+
// Give the would-be next attempt plenty of time to fire if cancellation didn't stop it.
263+
Thread.sleep(400);
264+
265+
assertThat(client.callCount())
266+
.as("after mid-backoff cancellation, no further attempts may run")
267+
.isEqualTo(1);
268+
269+
AsyncSemaphore permits = readSemaphore(transport);
270+
assertThat(permits.availablePermits())
271+
.as("permit lent to attempt 1 must have come back to the pool")
272+
.isEqualTo(HttpTransport.CONCURRENCY_LIMIT);
273+
assertThat(permits.queueLength()).isZero();
274+
}
275+
181276
// ---------- permits are still conserved across retries ----------
182277

183278
@Test
@@ -204,7 +299,12 @@ private static AsyncSemaphore readSemaphore(HttpTransport t) throws Exception {
204299
}
205300

206301
private static Supplier<CompletableFuture<HttpResponse<byte[]>>> response(int code, String body) {
207-
return () -> CompletableFuture.completedFuture(new StubHttpResponse(code, body, Map.of()));
302+
return response(code, body, Map.of());
303+
}
304+
305+
private static Supplier<CompletableFuture<HttpResponse<byte[]>>> response(
306+
int code, String body, Map<String, String> headers) {
307+
return () -> CompletableFuture.completedFuture(new StubHttpResponse(code, body, headers));
208308
}
209309

210310
private static Supplier<CompletableFuture<HttpResponse<byte[]>>> failedResponse(Throwable t) {
@@ -307,6 +407,90 @@ public WebSocket.Builder newWebSocketBuilder() {
307407
}
308408
}
309409

410+
/**
411+
* Stub {@link HttpClient} whose {@code sendAsync} throws {@link IllegalArgumentException}
412+
* synchronously. Used by {@link #synchronousThrowDoesNotRetry()} to drive the pre-dispatch-fault
413+
* path.
414+
*/
415+
private static final class SyncThrowingHttpClient extends HttpClient {
416+
private int callCount = 0;
417+
418+
int callCount() {
419+
return callCount;
420+
}
421+
422+
@Override
423+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
424+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
425+
callCount++;
426+
throw new IllegalArgumentException("simulated synchronous throw from sendAsync");
427+
}
428+
429+
@Override
430+
public Optional<CookieHandler> cookieHandler() {
431+
return Optional.empty();
432+
}
433+
434+
@Override
435+
public Optional<Duration> connectTimeout() {
436+
return Optional.empty();
437+
}
438+
439+
@Override
440+
public Redirect followRedirects() {
441+
return Redirect.NEVER;
442+
}
443+
444+
@Override
445+
public Optional<ProxySelector> proxy() {
446+
return Optional.empty();
447+
}
448+
449+
@Override
450+
public SSLContext sslContext() {
451+
throw new UnsupportedOperationException();
452+
}
453+
454+
@Override
455+
public SSLParameters sslParameters() {
456+
throw new UnsupportedOperationException();
457+
}
458+
459+
@Override
460+
public Optional<Authenticator> authenticator() {
461+
return Optional.empty();
462+
}
463+
464+
@Override
465+
public Version version() {
466+
return Version.HTTP_1_1;
467+
}
468+
469+
@Override
470+
public Optional<Executor> executor() {
471+
return Optional.empty();
472+
}
473+
474+
@Override
475+
public <T> HttpResponse<T> send(
476+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
477+
throw new UnsupportedOperationException();
478+
}
479+
480+
@Override
481+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
482+
HttpRequest request,
483+
HttpResponse.BodyHandler<T> responseBodyHandler,
484+
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
485+
throw new UnsupportedOperationException();
486+
}
487+
488+
@Override
489+
public WebSocket.Builder newWebSocketBuilder() {
490+
throw new UnsupportedOperationException();
491+
}
492+
}
493+
310494
/** Minimal {@link HttpResponse} stub — just the bits {@code HttpTransport} reads. */
311495
private static final class StubHttpResponse implements HttpResponse<byte[]> {
312496
private final int status;

0 commit comments

Comments
 (0)