@@ -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