Skip to content

Commit c30dfd1

Browse files
committed
fix: route JDK transport async dispatch failures through the future
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
1 parent 80dfbf1 commit c30dfd1

4 files changed

Lines changed: 79 additions & 18 deletions

File tree

sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,21 +138,26 @@ public class JdkHttpTransport private constructor(
138138
* completions to release the body's connection back to the pool.
139139
*/
140140
override fun executeAsync(request: Request): CompletableFuture<Response> {
141-
val jdkRequest =
142-
try {
143-
requestAdapter.adapt(request, responseTimeout)
144-
} catch (e: Exception) {
145-
// The async contract is that errors arrive through the returned future. Request
146-
// adaptation runs on the caller's thread and can throw (e.g. a CONNECT request the
147-
// JDK client rejects), so route the failure into a failed future instead of
148-
// throwing synchronously where a future-composing caller's .exceptionally/.handle
149-
// would never observe it. Errors (OOM and other JVM-fatal conditions) are left to
150-
// propagate up the caller's stack rather than be packaged into a future that may
151-
// never be awaited.
152-
return CompletableFuture.failedFuture<Response>(e)
153-
}
154-
val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream())
155-
return bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) }
141+
return try {
142+
val jdkRequest = requestAdapter.adapt(request, responseTimeout)
143+
// `sendAsync` is inside the guard too: it does not promise that every failure arrives
144+
// through its returned future. On a closed `java.net.http.HttpClient` (JDK 21+, where
145+
// the client is `AutoCloseable`) it throws synchronously on the caller's thread, which
146+
// would bypass the future and break the error-delivery contract documented above.
147+
val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream())
148+
bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) }
149+
} catch (e: Exception) {
150+
// The async contract is that errors arrive through the returned future. The dispatch
151+
// path above runs on the caller's thread and can throw — request adaptation rejecting a
152+
// CONNECT request, `sendAsync` on a closed client, or an unexpected adapter bug such as
153+
// an NPE — so route any of these into a failed future instead of throwing synchronously
154+
// where a future-composing caller's .exceptionally/.handle would never observe it. The
155+
// breadth is intentional: catching `Exception` (not `RuntimeException`) keeps a future
156+
// adapter step's checked exception on the future too. Only `Error` (OOM and other
157+
// JVM-fatal conditions) is left to propagate up the caller's stack rather than be
158+
// packaged into a future that may never be awaited.
159+
CompletableFuture.failedFuture<Response>(e)
160+
}
156161
}
157162

158163
/**

sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,49 @@ class JdkHttpTransportTest {
162162
.build()
163163
// Must return a future rather than throwing on the caller's thread.
164164
val future = transport.executeAsync(request)
165+
// Completion is synchronous, not merely eventual: the future is already completed
166+
// exceptionally on return, before anything is awaited.
167+
assertTrue(
168+
future.isCompletedExceptionally,
169+
"adaptation failure must complete the future exceptionally synchronously on return",
170+
)
165171
val ex = assertFailsWith<ExecutionException> { future.get(5, TimeUnit.SECONDS) }
166172
assertTrue(
167173
ex.cause is IllegalArgumentException,
168174
"adaptation failure must surface as the future's cause, was: ${ex.cause?.let { it::class }}",
169175
)
176+
// Assert the message so an unrelated IllegalArgumentException cannot satisfy the test.
177+
// RequestAdapter rejects CONNECT with "java.net.http.HttpClient does not support
178+
// user-issued CONNECT requests."
179+
assertTrue(
180+
ex.cause?.message?.contains("does not support user-issued CONNECT requests") == true,
181+
"expected the JDK CONNECT-rejection message, was: ${ex.cause?.message}",
182+
)
183+
}
184+
185+
@Test
186+
fun `executeAsyncDeliversDispatchFailureThroughFuture`() {
187+
// `sendAsync` does not promise that every failure arrives through its returned future:
188+
// on a closed `java.net.http.HttpClient` it throws synchronously on the caller's thread.
189+
// The dispatch path runs after a successful adaptation, so this exercises the post-adapt
190+
// guard specifically — the failure must still arrive through the returned future, not be
191+
// thrown on the caller's thread. `HttpClient` became `AutoCloseable` in JDK 21 (JEP 461);
192+
// on older runtimes there is no close hook to trigger this, so the case is skipped.
193+
val client = HttpClient.newHttpClient()
194+
val closeable = client as? AutoCloseable ?: return // JDK 21+ only; no close hook earlier.
195+
closeable.close()
196+
val closedTransport = JdkHttpTransport.create(client)
197+
198+
val request = simpleGet("/async-dispatch-fail")
199+
// Must return a future rather than throwing on the caller's thread.
200+
val future = closedTransport.executeAsync(request)
201+
assertTrue(
202+
future.isCompletedExceptionally,
203+
"a synchronous sendAsync throw must complete the future exceptionally synchronously",
204+
)
205+
// The closed-client throw is an unchecked exception; surface it as the future's cause
206+
// rather than letting it escape executeAsync on the caller's thread.
207+
assertFailsWith<ExecutionException> { future.get(5, TimeUnit.SECONDS) }
170208
}
171209

172210
// -------- headers round-trip --------

sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,16 @@ public class OkHttpTransport private constructor(
116116
// adaptation runs on the caller's thread and can throw (e.g. a method/body
117117
// mismatch OkHttp rejects), so route the failure into a completed-exceptionally
118118
// future instead of throwing synchronously where a future-composing caller's
119-
// .exceptionally/.handle would never observe it. Errors (OOM and other JVM-fatal
120-
// conditions) are left to propagate up the caller's stack rather than be packaged
121-
// into a future that may never be awaited.
119+
// .exceptionally/.handle would never observe it. The breadth is intentional:
120+
// catching `Exception` (not `RuntimeException`) also funnels an unexpected adapter
121+
// bug such as an NPE through the future so the caller can observe it. Only `Error`
122+
// (OOM and other JVM-fatal conditions) is left to propagate up the caller's stack
123+
// rather than be packaged into a future that may never be awaited.
122124
return failedFuture(e)
123125
}
126+
// The post-adaptation dispatch needs no guard: `newCall(...)` does no throwing work, and a
127+
// dispatch failure (including a `RejectedExecutionException` from a shut-down dispatcher)
128+
// is delivered through `Callback.onFailure` below, so it already reaches the future.
124129
val call = client.newCall(okRequest)
125130
val future = CompletableFuture<Response>()
126131
call.enqueue(

sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,24 @@ class OkHttpTransportTest {
207207
.build()
208208
// Must return a future rather than throwing on the caller's thread.
209209
val future = transport.executeAsync(request)
210+
// Completion is synchronous, not merely eventual: the future is already completed
211+
// exceptionally on return, before anything is awaited.
212+
assertTrue(
213+
future.isCompletedExceptionally,
214+
"adaptation failure must complete the future exceptionally synchronously on return",
215+
)
210216
val ex = assertFailsWith<ExecutionException> { future.get(5, TimeUnit.SECONDS) }
211217
assertTrue(
212218
ex.cause is IllegalArgumentException,
213219
"adaptation failure must surface as the future's cause, was: ${ex.cause?.let { it::class }}",
214220
)
221+
// Assert the message so an unrelated IllegalArgumentException cannot satisfy the test.
222+
// OkHttp's Request.Builder.method rejects a body on GET with "<method> must not have a
223+
// request body."
224+
assertTrue(
225+
ex.cause?.message?.contains("must not have a request body") == true,
226+
"expected OkHttp's body-on-GET rejection message, was: ${ex.cause?.message}",
227+
)
215228
}
216229

217230
// -------- headers round-trip --------

0 commit comments

Comments
 (0)