Skip to content

fix: route JDK transport async dispatch failures through the returned future#134

Merged
OmarAlJarrah merged 4 commits into
mainfrom
fix/async-adaptation-failure-delivery
Jun 21, 2026
Merged

fix: route JDK transport async dispatch failures through the returned future#134
OmarAlJarrah merged 4 commits into
mainfrom
fix/async-adaptation-failure-delivery

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

JdkHttpTransport.executeAsync is documented to deliver every error through the returned CompletableFuture, never by throwing on the caller's thread. Today the try/catch wraps only the requestAdapter.adapt(...) call. The dispatch kickoff that runs immediately after — client.sendAsync(...) plus the bridgeAsyncResponse(...) wiring — runs unguarded on the caller's thread before the future is handed back.

sendAsync does not guarantee that every failure arrives through its returned future. Its Javadoc permits a synchronous IllegalArgumentException for a request it rejects, and a custom or future HttpClient implementation is free to throw on the caller's thread rather than completing the future exceptionally. Any such synchronous throw escapes executeAsync, so a future-composing caller's .exceptionally/.handle would never observe it, breaking the method's contract. (The stock JDK client today packages dispatch failures — e.g. on a closed client — into an already-failed future, which bridgeAsyncResponse already propagates; the guard exists for the throwing case.)

The OkHttp transport is already correct here: newCall(...) does no throwing work, and a dispatch failure (including a RejectedExecutionException from a shut-down dispatcher) is delivered through Callback.onFailure, so it already reaches the future.

Change

  • JDK transport: widen the executeAsync try/catch to enclose the whole post-adaptation dispatch path (sendAsync + bridgeAsyncResponse), so any synchronous throw becomes a failed future rather than escaping on the caller's thread.
  • OkHttp transport: no behavioral change; add a comment recording why its post-adapt path is already future-safe.
  • Both transports: keep the catch at Exception (not RuntimeException). The intent is that any non-fatal failure, including an unexpected adapter bug such as an NPE, surfaces via the future; only Error / JVM-fatal conditions propagate. The inline comments state that breadth is deliberate.
  • Tests: both transports' async adaptation-failure tests assert future.isCompletedExceptionally() is already true on return (completion is synchronous, not merely eventual) and assert a substring of the adapter's message so an unrelated IllegalArgumentException cannot satisfy the test. The JDK transport additionally injects a stub HttpClient whose sendAsync throws synchronously and asserts the throw is routed verbatim through the returned future — exercising the post-adaptation guard deterministically on every JDK (the module's tests run on the Java 11 toolchain, where the real client is not AutoCloseable). Narrowing the guard back to wrap only request adaptation makes this test fail, confirming it pins the behaviour.

No public-API change.

Gated build commands run

./gradlew :sdk-transport-jdkhttp:test :sdk-transport-jdkhttp:ktlintCheck :sdk-transport-jdkhttp:apiCheck \
          :sdk-transport-okhttp:test :sdk-transport-okhttp:ktlintCheck :sdk-transport-okhttp:detekt :sdk-transport-okhttp:apiCheck

Result: BUILD SUCCESSFUL. (detekt is skipped on sdk-transport-jdkhttp per the module's toolchain exclusion.)

Closes #120
Closes #121
Closes #122

JdkHttpTransport.executeAsync guarded only the request-adaptation call.
The dispatch kickoff that follows on the caller's thread —
HttpClient.sendAsync plus the bridgeAsyncResponse wiring — ran
unguarded. sendAsync does not guarantee that every failure reaches its
returned future: on a closed java.net.http.HttpClient (JDK 21+, where
the client is AutoCloseable) it throws synchronously, so the exception
escaped on the caller's thread and bypassed the future. That breaks the
method's documented contract that all errors arrive through the returned
CompletableFuture, and a future-composing caller's .exceptionally/.handle
would never observe such a throw.

Widen the try/catch to enclose the whole post-adaptation dispatch path so
any synchronous throw becomes a failed future. The OkHttp transport is
already correct here — newCall does no throwing work and dispatch
failures arrive via Callback.onFailure — so it is left unchanged apart
from a comment recording why its post-adapt path is future-safe.

The catch stays at Exception (not RuntimeException) in both transports:
the intent is that any non-fatal failure, including an unexpected adapter
bug such as an NPE, surfaces via the future; only Error / JVM-fatal
conditions propagate. The inline comments now state that breadth is
deliberate.

Both transports' async adaptation-failure tests now assert
future.isCompletedExceptionally() is already true on return (completion
is synchronous, not merely eventual) and assert a substring of the
adapter's message so an unrelated IllegalArgumentException cannot satisfy
the test. A new JDK test closes an AutoCloseable client and asserts the
synchronous sendAsync throw is delivered through the future.

Closes #120
Closes #121
Closes #122
@OmarAlJarrah OmarAlJarrah changed the title Route JDK transport async dispatch failures through the returned future fix: route JDK transport async dispatch failures through the returned future Jun 17, 2026
…uard

The async dispatch-failure test closed a java.net.http.HttpClient to provoke a
synchronous sendAsync throw, but two things undercut it: the JDK client returns
an already-failed future for a closed client rather than throwing (so the bridge,
not executeAsync's guard, handled it), and on this module's JDK 11 test runtime
HttpClient is not AutoCloseable, so the test took its early-return skip path and
asserted nothing on every run.

Replace it with an injected HttpClient whose sendAsync throws on the caller's
thread. That drives the post-adaptation guard directly and deterministically on
every JDK; narrowing the guard back to wrap only request adaptation makes the
test fail, confirming it pins the behaviour.

Also correct the executeAsync comments, which stated as fact that sendAsync
throws synchronously on a closed client. The guard is defence-in-depth for a
client that throws on dispatch — not a description of the stock JDK client,
which delivers such failures through the returned future.
JdkHttpTransport.executeAsync guards the whole post-adaptation dispatch
path so a synchronous throw becomes a failed future. But if sendAsync had
already returned a live in-flight exchange when a later step threw, that
exchange kept running on a future nothing would await, leaking its
connection.

Hoist the in-flight handle out of the try and cancel it from the catch so
the exchange is aborted on the failure path; the cancel is a no-op when
the throw happened at or before dispatch (handle still null).

Also document on the test's HttpClient double why its sslContext() and
send() stubs are inert: executeAsync only ever calls sendAsync, so neither
is reached on the path under test.
@OmarAlJarrah OmarAlJarrah merged commit 7f54ccb into main Jun 21, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the fix/async-adaptation-failure-delivery branch June 21, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant