@@ -24,22 +24,31 @@ import org.dexpace.sdk.io.OkioIoProvider
2424import org.dexpace.sdk.transport.jdkhttp.internal.BodyPublishers
2525import java.io.ByteArrayOutputStream
2626import java.io.IOException
27+ import java.net.Authenticator
28+ import java.net.CookieHandler
2729import java.net.InetSocketAddress
30+ import java.net.ProxySelector
2831import java.net.ServerSocket
2932import java.net.URL
3033import java.net.http.HttpClient
3134import java.net.http.HttpRequest
35+ import java.net.http.HttpResponse
3236import java.nio.ByteBuffer
3337import java.security.MessageDigest
3438import java.time.Duration
39+ import java.util.Optional
3540import java.util.concurrent.CancellationException
41+ import java.util.concurrent.CompletableFuture
3642import java.util.concurrent.CompletionException
3743import java.util.concurrent.CountDownLatch
3844import java.util.concurrent.ExecutionException
45+ import java.util.concurrent.Executor
3946import java.util.concurrent.Flow
4047import java.util.concurrent.TimeUnit
4148import java.util.concurrent.atomic.AtomicBoolean
4249import java.util.concurrent.atomic.AtomicReference
50+ import javax.net.ssl.SSLContext
51+ import javax.net.ssl.SSLParameters
4352import kotlin.test.BeforeTest
4453import kotlin.test.Test
4554import 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