Skip to content

Commit 2c07214

Browse files
committed
8368249: HttpClient: Translate exceptions thrown by sendAsync
Reviewed-by: jpai
1 parent 0f34b02 commit 2c07214

File tree

2 files changed

+298
-0
lines changed

2 files changed

+298
-0
lines changed

src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import java.util.Optional;
6161
import java.util.Set;
6262
import java.util.TreeSet;
63+
import java.util.concurrent.CancellationException;
6364
import java.util.concurrent.CompletableFuture;
6465
import java.util.concurrent.ConcurrentSkipListSet;
6566
import java.util.concurrent.ExecutionException;
@@ -75,6 +76,7 @@
7576
import java.util.concurrent.locks.ReentrantLock;
7677
import java.util.function.BiConsumer;
7778
import java.util.function.BooleanSupplier;
79+
import java.util.function.Function;
7880
import java.util.stream.Stream;
7981
import java.net.http.HttpClient;
8082
import java.net.http.HttpRequest;
@@ -974,6 +976,12 @@ private void debugCompleted(String tag, long startNanos, HttpRequest req) {
974976
}
975977
throw ie;
976978
} catch (ExecutionException e) {
979+
// Exceptions are often thrown from asynchronous code, and the
980+
// stacktrace may not always contain the application classes. That
981+
// makes it difficult to trace back to the application code which
982+
// invoked the `HttpClient`. Here we instantiate/recreate the
983+
// exceptions to capture the application's calling code in the
984+
// stacktrace of the thrown exception.
977985
final Throwable throwable = e.getCause();
978986
final String msg = throwable.getMessage();
979987

@@ -1104,6 +1112,8 @@ private void debugCompleted(String tag, long startNanos, HttpRequest req) {
11041112
res = registerPending(pending, res);
11051113

11061114
if (exchangeExecutor != null) {
1115+
// We're called by `sendAsync()` - make sure we translate exceptions
1116+
res = translateSendAsyncExecFailure(res);
11071117
// makes sure that any dependent actions happen in the CF default
11081118
// executor. This is only needed for sendAsync(...), when
11091119
// exchangeExecutor is non-null.
@@ -1121,6 +1131,31 @@ private void debugCompleted(String tag, long startNanos, HttpRequest req) {
11211131
}
11221132
}
11231133

1134+
/**
1135+
* {@return a new {@code CompletableFuture} wrapping the
1136+
* {@link #sendAsync(HttpRequest, BodyHandler, PushPromiseHandler, Executor) sendAsync()}
1137+
* execution failures with, as per specification, {@link IOException}, if necessary}
1138+
*/
1139+
private static <T> CompletableFuture<HttpResponse<T>> translateSendAsyncExecFailure(
1140+
CompletableFuture<HttpResponse<T>> responseFuture) {
1141+
return responseFuture
1142+
.handle((response, exception) -> {
1143+
if (exception == null) {
1144+
return MinimalFuture.completedFuture(response);
1145+
}
1146+
var unwrappedException = Utils.getCompletionCause(exception);
1147+
// Except `Error` and `CancellationException`, wrap failures inside an `IOException`.
1148+
// This is required to comply with the specification of `HttpClient::sendAsync`.
1149+
var translatedException = unwrappedException instanceof Error
1150+
|| unwrappedException instanceof CancellationException
1151+
|| unwrappedException instanceof IOException
1152+
? unwrappedException
1153+
: new IOException(unwrappedException);
1154+
return MinimalFuture.<HttpResponse<T>>failedFuture(translatedException);
1155+
})
1156+
.thenCompose(Function.identity());
1157+
}
1158+
11241159
// Main loop for this client's selector
11251160
private static final class SelectorManager extends Thread {
11261161

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*
2+
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
*
5+
* This code is free software; you can redistribute it and/or modify it
6+
* under the terms of the GNU General Public License version 2 only, as
7+
* published by the Free Software Foundation.
8+
*
9+
* This code is distributed in the hope that it will be useful, but WITHOUT
10+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12+
* version 2 for more details (a copy is included in the LICENSE file that
13+
* accompanied this code).
14+
*
15+
* You should have received a copy of the GNU General Public License version
16+
* 2 along with this work; if not, write to the Free Software Foundation,
17+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
18+
*
19+
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
20+
* or visit www.oracle.com if you need additional information or have any
21+
* questions.
22+
*/
23+
24+
import jdk.httpclient.test.lib.common.HttpServerAdapters;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.TestInfo;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
import javax.net.ssl.SSLParameters;
31+
import java.io.IOException;
32+
import java.io.UncheckedIOException;
33+
import java.lang.reflect.Method;
34+
import java.net.URI;
35+
import java.net.http.HttpClient;
36+
import java.net.http.HttpHeaders;
37+
import java.net.http.HttpOption;
38+
import java.net.http.HttpRequest;
39+
import java.net.http.HttpResponse;
40+
import java.net.http.UnsupportedProtocolVersionException;
41+
import java.time.Duration;
42+
import java.util.ArrayList;
43+
import java.util.Collections;
44+
import java.util.List;
45+
import java.util.Optional;
46+
import java.util.concurrent.CancellationException;
47+
import java.util.concurrent.ExecutionException;
48+
import java.util.function.Consumer;
49+
50+
import static java.net.http.HttpClient.Builder.NO_PROXY;
51+
import static org.junit.jupiter.api.Assertions.assertEquals;
52+
import static org.junit.jupiter.api.Assertions.assertThrows;
53+
import static org.junit.jupiter.api.Assertions.assertTrue;
54+
55+
/*
56+
* @test
57+
* @bug 8368249
58+
* @summary Verifies exceptions thrown by `HttpClient::sendAsync`
59+
* @library /test/jdk/java/net/httpclient/lib /test/lib
60+
* @run junit HttpClientSendAsyncExceptionTest
61+
*/
62+
63+
class HttpClientSendAsyncExceptionTest {
64+
65+
@Test
66+
void testClosedClient() {
67+
var client = HttpClient.newHttpClient();
68+
client.close();
69+
var request = HttpRequest.newBuilder(URI.create("https://example.com")).GET().build();
70+
var responseBodyHandler = HttpResponse.BodyHandlers.discarding();
71+
var responseFuture = client.sendAsync(request, responseBodyHandler);
72+
var exception = assertThrows(ExecutionException.class, responseFuture::get);
73+
var cause = assertThrowableInstanceOf(IOException.class, exception.getCause());
74+
assertContains(cause.getMessage(), "closed");
75+
}
76+
77+
@Test
78+
void testH3IncompatClient() {
79+
SSLParameters h3IncompatSslParameters = new SSLParameters(new String[0], new String[]{"foo"});
80+
try (var h3IncompatClient = HttpClient.newBuilder()
81+
// Provide `SSLParameters` incompatible with QUIC's TLS requirements to disarm the HTTP/3 support
82+
.sslParameters(h3IncompatSslParameters)
83+
.build()) {
84+
var h3Request = HttpRequest.newBuilder(URI.create("https://example.com"))
85+
.GET()
86+
.version(HttpClient.Version.HTTP_3)
87+
.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY)
88+
.build();
89+
var responseBodyHandler = HttpResponse.BodyHandlers.discarding();
90+
var responseFuture = h3IncompatClient.sendAsync(h3Request, responseBodyHandler);
91+
var exception = assertThrows(ExecutionException.class, responseFuture::get);
92+
var cause = assertThrowableInstanceOf(UnsupportedProtocolVersionException.class, exception.getCause());
93+
assertEquals("HTTP3 is not supported", cause.getMessage());
94+
}
95+
}
96+
97+
@Test
98+
void testConnectMethod() {
99+
try (var client = HttpClient.newHttpClient()) {
100+
// The default `HttpRequest` builder does not allow `CONNECT`.
101+
// Hence, we create our custom `HttpRequest` instance:
102+
var connectRequest = new HttpRequest() {
103+
104+
@Override
105+
public Optional<BodyPublisher> bodyPublisher() {
106+
return Optional.empty();
107+
}
108+
109+
@Override
110+
public String method() {
111+
return "CONNECT";
112+
}
113+
114+
@Override
115+
public Optional<Duration> timeout() {
116+
return Optional.empty();
117+
}
118+
119+
@Override
120+
public boolean expectContinue() {
121+
return false;
122+
}
123+
124+
@Override
125+
public URI uri() {
126+
return URI.create("https://example.com");
127+
}
128+
129+
@Override
130+
public Optional<HttpClient.Version> version() {
131+
return Optional.empty();
132+
}
133+
134+
@Override
135+
public HttpHeaders headers() {
136+
return HttpHeaders.of(Collections.emptyMap(), (_, _) -> true);
137+
}
138+
139+
};
140+
var responseBodyHandler = HttpResponse.BodyHandlers.discarding();
141+
var exception = assertThrows(
142+
IllegalArgumentException.class,
143+
() -> client.sendAsync(connectRequest, responseBodyHandler));
144+
assertContains(exception.getMessage(), "Unsupported method CONNECT");
145+
}
146+
}
147+
148+
static List<ExceptionTestCase> exceptionTestCases() {
149+
150+
// `RuntimeException`
151+
List<ExceptionTestCase> testCases = new ArrayList<>();
152+
var runtimeException = new RuntimeException();
153+
testCases.add(new ExceptionTestCase(
154+
"RuntimeException",
155+
_ -> { throw runtimeException; },
156+
exception -> {
157+
assertThrowableInstanceOf(IOException.class, exception);
158+
assertThrowableSame(runtimeException, exception.getCause());
159+
}));
160+
161+
// `Error`
162+
var error = new Error();
163+
testCases.add(new ExceptionTestCase(
164+
"Error",
165+
_ -> { throw error; },
166+
exception -> assertThrowableSame(error, exception)));
167+
168+
// `CancellationException`
169+
var cancellationException = new CancellationException();
170+
testCases.add(new ExceptionTestCase(
171+
"CancellationException",
172+
_ -> { throw cancellationException; },
173+
exception -> assertThrowableSame(cancellationException, exception)));
174+
175+
// `IOException` (needs sneaky throw)
176+
var ioException = new IOException();
177+
testCases.add(new ExceptionTestCase(
178+
"IOException",
179+
_ -> { sneakyThrow(ioException); throw new AssertionError(); },
180+
exception -> assertThrowableSame(ioException, exception)));
181+
182+
// `UncheckedIOException`
183+
var uncheckedIOException = new UncheckedIOException(ioException);
184+
testCases.add(new ExceptionTestCase(
185+
"UncheckedIOException(IOException)",
186+
_ -> { throw uncheckedIOException; },
187+
exception -> assertThrowableSame(uncheckedIOException, exception.getCause())));
188+
189+
return testCases;
190+
191+
}
192+
193+
private static <T extends Throwable> T assertThrowableInstanceOf(Class<T> expectedClass, Throwable actual) {
194+
if (!expectedClass.isInstance(actual)) {
195+
var message = "Was expecting `%s`".formatted(expectedClass.getCanonicalName());
196+
throw new AssertionError(message, actual);
197+
}
198+
return expectedClass.cast(actual);
199+
}
200+
201+
private static void assertThrowableSame(Throwable expected, Throwable actual) {
202+
if (expected != actual) {
203+
var message = "Was expecting `%s`".formatted(expected.getClass().getCanonicalName());
204+
throw new AssertionError(message, actual);
205+
}
206+
}
207+
208+
private record ExceptionTestCase(
209+
String description,
210+
HttpResponse.BodyHandler<Void> throwingResponseBodyHandler,
211+
Consumer<Throwable> exceptionVerifier) {
212+
213+
@Override
214+
public String toString() {
215+
return description;
216+
}
217+
218+
}
219+
220+
@SuppressWarnings("unchecked")
221+
private static <T extends Throwable> void sneakyThrow(Throwable throwable) throws T {
222+
throw (T) throwable;
223+
}
224+
225+
@ParameterizedTest
226+
@MethodSource("exceptionTestCases")
227+
void testIOExceptionWrap(ExceptionTestCase testCase, TestInfo testInfo) throws Exception {
228+
var version = HttpClient.Version.HTTP_1_1;
229+
try (var server = HttpServerAdapters.HttpTestServer.create(version);
230+
var client = HttpServerAdapters.createClientBuilderFor(version).proxy(NO_PROXY).build()) {
231+
232+
// Configure the server to respond with 200 containing a single byte
233+
var serverHandlerPath = "/%s/%s/".formatted(
234+
testInfo.getTestClass().map(Class::getSimpleName).orElse("unknown-class"),
235+
testInfo.getTestMethod().map(Method::getName).orElse("unknown-method"));
236+
HttpServerAdapters.HttpTestHandler serverHandler = exchange -> {
237+
try (exchange) {
238+
exchange.sendResponseHeaders(200, 1);
239+
exchange.getResponseBody().write(new byte[]{0});
240+
}
241+
};
242+
server.addHandler(serverHandler, serverHandlerPath);
243+
server.start();
244+
245+
// Verify the execution failure
246+
var requestUri = URI.create("http://" + server.serverAuthority() + serverHandlerPath);
247+
var request = HttpRequest.newBuilder(requestUri).version(version).build();
248+
// We need to make `sendAsync()` execution fail.
249+
// There are several ways to achieve this.
250+
// We choose to use a throwing response handler.
251+
var responseFuture = client.sendAsync(request, testCase.throwingResponseBodyHandler);
252+
var exception = assertThrows(ExecutionException.class, responseFuture::get);
253+
testCase.exceptionVerifier.accept(exception.getCause());
254+
255+
}
256+
257+
}
258+
259+
private static void assertContains(String target, String expected) {
260+
assertTrue(target.contains(expected), "does not contain `" + expected + "`: " + target);
261+
}
262+
263+
}

0 commit comments

Comments
 (0)