Skip to content

Commit 2b6be9c

Browse files
committed
test: deterministically exercise the JDK transport's async dispatch guard
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.
1 parent 18fa894 commit 2b6be9c

2 files changed

Lines changed: 90 additions & 29 deletions

File tree

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,26 @@ public class JdkHttpTransport private constructor(
140140
override fun executeAsync(request: Request): CompletableFuture<Response> {
141141
return try {
142142
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.
143+
// `sendAsync` is inside the guard too. Its contract does not promise that every failure
144+
// is delivered through the returned future: the JDK's own Javadoc permits a synchronous
145+
// `IllegalArgumentException` for a request it rejects, and a custom or future
146+
// `HttpClient` is free to throw on the caller's thread instead of returning a failed
147+
// future. Guarding the dispatch keeps the error-delivery contract documented above
148+
// intact whichever way a failure surfaces. (Today's stock JDK client packages such
149+
// failures into an already-failed future — e.g. on a closed client — which the bridge
150+
// propagates and so never reaches this catch; the guard is for the throwing case.)
147151
val inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream())
148152
bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) }
149153
} catch (e: Exception) {
150154
// The async contract is that errors arrive through the returned future. The dispatch
151155
// 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.
156+
// CONNECT request, a synchronous `sendAsync` rejection, or an unexpected adapter bug
157+
// such as an NPE — so route any of these into a failed future instead of throwing
158+
// synchronously where a future-composing caller's .exceptionally/.handle would never
159+
// observe it. The breadth is intentional: catching `Exception` (not `RuntimeException`)
160+
// keeps a future adapter step's checked exception on the future too. Only `Error` (OOM
161+
// and other JVM-fatal conditions) is left to propagate up the caller's stack rather
162+
// than be packaged into a future that may never be awaited.
159163
CompletableFuture.failedFuture<Response>(e)
160164
}
161165
}

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

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,31 @@ import org.dexpace.sdk.io.OkioIoProvider
2424
import org.dexpace.sdk.transport.jdkhttp.internal.BodyPublishers
2525
import java.io.ByteArrayOutputStream
2626
import java.io.IOException
27+
import java.net.Authenticator
28+
import java.net.CookieHandler
2729
import java.net.InetSocketAddress
30+
import java.net.ProxySelector
2831
import java.net.ServerSocket
2932
import java.net.URL
3033
import java.net.http.HttpClient
3134
import java.net.http.HttpRequest
35+
import java.net.http.HttpResponse
3236
import java.nio.ByteBuffer
3337
import java.security.MessageDigest
3438
import java.time.Duration
39+
import java.util.Optional
3540
import java.util.concurrent.CancellationException
41+
import java.util.concurrent.CompletableFuture
3642
import java.util.concurrent.CompletionException
3743
import java.util.concurrent.CountDownLatch
3844
import java.util.concurrent.ExecutionException
45+
import java.util.concurrent.Executor
3946
import java.util.concurrent.Flow
4047
import java.util.concurrent.TimeUnit
4148
import java.util.concurrent.atomic.AtomicBoolean
4249
import java.util.concurrent.atomic.AtomicReference
50+
import javax.net.ssl.SSLContext
51+
import javax.net.ssl.SSLParameters
4352
import kotlin.test.BeforeTest
4453
import kotlin.test.Test
4554
import kotlin.test.assertContentEquals
@@ -183,28 +192,32 @@ class JdkHttpTransportTest {
183192
}
184193

185194
@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")
195+
fun `executeAsyncRoutesSynchronousDispatchThrowThroughFuture`() {
196+
// `sendAsync`'s contract does not promise that every failure is delivered through the
197+
// returned future — a client may throw synchronously on the caller's thread. The dispatch
198+
// path runs after a successful adaptation, so this injects a client whose `sendAsync`
199+
// throws to exercise the post-adapt guard deterministically on every JDK. (The stock JDK
200+
// client instead returns an already-failed future for, e.g., a closed client; the bridge
201+
// propagates that and it never reaches the guard, so a real closed client cannot prove
202+
// this path. The test double does.) The failure must arrive through the returned future,
203+
// never escape executeAsync on the caller's thread.
204+
val boom = IllegalStateException("synchronous dispatch failure")
205+
val throwingTransport = JdkHttpTransport.create(SyncThrowingDispatchClient(boom))
206+
199207
// Must return a future rather than throwing on the caller's thread.
200-
val future = closedTransport.executeAsync(request)
208+
val future = throwingTransport.executeAsync(simpleGet("/async-dispatch-throw"))
209+
// Completion is synchronous, not merely eventual: the future is already completed
210+
// exceptionally on return, before anything is awaited.
201211
assertTrue(
202212
future.isCompletedExceptionally,
203-
"a synchronous sendAsync throw must complete the future exceptionally synchronously",
213+
"a synchronous dispatch throw must complete the future exceptionally synchronously on return",
214+
)
215+
val ex = assertFailsWith<ExecutionException> { future.get(5, TimeUnit.SECONDS) }
216+
assertEquals(
217+
boom,
218+
ex.cause,
219+
"the dispatch throw must surface verbatim as the future's cause, was: ${ex.cause}",
204220
)
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) }
208221
}
209222

210223
// -------- headers round-trip --------
@@ -870,4 +883,48 @@ class JdkHttpTransportTest {
870883

871884
override fun canHandle(challenges: List<org.dexpace.sdk.core.http.auth.AuthenticateChallenge>): Boolean = false
872885
}
886+
887+
/**
888+
* A minimal [HttpClient] whose async dispatch throws synchronously on the caller's thread,
889+
* modelling a client (or a future JDK) that does not package every `sendAsync` failure into the
890+
* returned future. Only [sendAsync] is exercised by [JdkHttpTransport.executeAsync]; the rest
891+
* are inert stubs that are never invoked on the dispatch path under test.
892+
*/
893+
private class SyncThrowingDispatchClient(
894+
private val failure: RuntimeException,
895+
) : HttpClient() {
896+
override fun cookieHandler(): Optional<CookieHandler> = Optional.empty()
897+
898+
override fun connectTimeout(): Optional<Duration> = Optional.empty()
899+
900+
override fun followRedirects(): HttpClient.Redirect = HttpClient.Redirect.NEVER
901+
902+
override fun proxy(): Optional<ProxySelector> = Optional.empty()
903+
904+
override fun sslContext(): SSLContext = SSLContext.getDefault()
905+
906+
override fun sslParameters(): SSLParameters = SSLParameters()
907+
908+
override fun authenticator(): Optional<Authenticator> = Optional.empty()
909+
910+
override fun version(): HttpClient.Version = HttpClient.Version.HTTP_1_1
911+
912+
override fun executor(): Optional<Executor> = Optional.empty()
913+
914+
override fun <T> send(
915+
request: HttpRequest,
916+
responseBodyHandler: HttpResponse.BodyHandler<T>,
917+
): HttpResponse<T> = throw UnsupportedOperationException("synchronous send is not used by this test double")
918+
919+
override fun <T> sendAsync(
920+
request: HttpRequest,
921+
responseBodyHandler: HttpResponse.BodyHandler<T>,
922+
): CompletableFuture<HttpResponse<T>> = throw failure
923+
924+
override fun <T> sendAsync(
925+
request: HttpRequest,
926+
responseBodyHandler: HttpResponse.BodyHandler<T>,
927+
pushPromiseHandler: HttpResponse.PushPromiseHandler<T>?,
928+
): CompletableFuture<HttpResponse<T>> = throw failure
929+
}
873930
}

0 commit comments

Comments
 (0)