The code looks well protected. The JDK HttpClient is built with connectTimeout(Duration.ofSeconds(2)). Someone approved the PR, and since then everyone has assumed no call can hang for more than two seconds. Then the service on the other end accepts the TCP connection, and its handler stalls before it sends the headers. The call does not end at 2s. It waits as long as the server likes, and with no timeout on the request, that can be forever.
This is not a JDK bug. The javadoc documents this scope: connectTimeout covers establishing the connection. Waiting for the response after the accept is the job of a different timeout, which you set on a different builder.
This post reproduces both cases with a local HttpServer and a JUnit test. What you get out of it is a minimal pattern for any call to another process: both timeouts set, and the test checking the exact exception class.
Environment
- Temurin 25.0.4 (
25.0.4+7-LTS), Maven 3.9.12,maven.compiler.release=25, no--enable-preview. junit-jupiter6.1.3,maven-surefire-plugin3.6.0,maven-compiler-plugin3.14.0.- Only
java.net.http.*,com.sun.net.httpserver.*and JUnit. No Spring, no container, no WireMock.
The POM has a single dependency:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
HttpServer lives in the jdk.httpserver module, which already ships with the JDK, so it does not show up in the POM.
The October 3 post, RestClient in Spring Boot 4.1: the 4xx that becomes an exception and hides the body, covered how RestClient turns a 4xx into an exception and how to read the JSON it carries. This one is only about the JDK HttpClient: connectTimeout and the request timeout against a response that stalls after the accept. Both posts use the same HttpServer, but that is a coincidence.
What the javadoc promises, and what it doesn't
The Java 25 javadoc for HttpClient.Builder) defines connectTimeout(Duration) like this:
> "In the case where a new connection needs to be established, if the connection cannot be established within the given duration, then HttpClient::send throws an HttpConnectTimeoutException [...]. If a new connection does not need to be established, for example if a connection can be reused from a previous request, then this timeout duration has no effect."
The parameter is described as "the duration to allow the underlying connection to be established". The text never mentions waiting for headers or a body. Once a connection exists, new or reused, connectTimeout has nothing left to do.
The response timeout lives on the request. The HttpRequest.Builder javadoc) says:
> "If the response is not received within the specified timeout then an HttpTimeoutException is thrown from HttpClient::send [...]. The effect of not setting a timeout is the same as setting an infinite Duration, i.e. block forever."
Two things in that passage matter. First, with no timeout the wait is infinite. There is no hidden 30-second default. The HttpRequest.timeout() getter returns an empty Optional, and an empty Optional does not mean "30 seconds". It means empty. The same is true of connectTimeout: if you never set it, HttpClient.connectTimeout() returns empty, and the javadoc documents no default. Second, the javadoc does not say when the clock starts: at send, after the connection, or after the headers. All it says is "if the response is not received within the specified timeout". So don't size the value around a start point the documentation doesn't promise.
The exceptions split the same way:
- HttpTimeoutException: "Thrown when a response is not received within a specified time period." Extends
IOException. - HttpConnectTimeoutException: "Thrown when a connection [...] is not successfully established within a specified time period." Extends
HttpTimeoutException.
So the connect exception is a subclass of the response-timeout exception. That affects how you write the test, and the fixture below deals with it.
The fixture: a server that accepts the connection and stalls
The JDK 25 HttpServer listens on 127.0.0.1 on an ephemeral port (port 0). GET /hang sleeps 5s before sendResponseHeaders, then answers 200 with ok. GET /fast answers right away. The socket is neither refused nor closed at accept time: the TCP connection is up, and the delay comes from the handler.
package academy.devdojo.httpclient;
// imports: java.net.http.*, com.sun.net.httpserver.HttpServer, JUnit Jupiter
class ConnectTimeoutDoesNotCutHungResponseTest {
private HttpServer server;
private String base;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/hang", exchange -> {
try {
Thread.sleep(Duration.ofSeconds(5).toMillis());
byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
});
server.createContext("/fast", exchange -> {
try {
byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
} finally {
exchange.close();
}
});
server.start();
base = "http://127.0.0.1:" + server.getAddress().getPort();
}
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
All three clients use .version(HttpClient.Version.HTTP_1_1). Without it, the builder prefers HTTP/2 and falls back to HTTP/1.1 if the upgrade fails. Pinning the version keeps the test independent of that negotiation. HttpClient has implemented AutoCloseable since Java 21, so each test uses try-with-resources.
Client A: connectTimeout only
@Test
void connectTimeoutAloneDoesNotCutHungResponse() throws Exception {
try (HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build()) {
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/hang")).GET().build();
long t0 = System.nanoTime();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0);
assertEquals(200, res.statusCode());
assertEquals("ok", res.body());
assertTrue(elapsedMs >= 4_000, "connectTimeout must not cut hung response; elapsedMs=" + elapsedMs);
}
}
The client connects well within 2s, and at that point connectTimeout has done its job. From there, send politely sits through the handler's five-second nap and gets back 200 ok. The test checks that at least 4s went by. The "2-second timeout" waited more than twice that long, and nobody complained.
Client B: connectTimeout + request timeout
@Test
void requestTimeoutThrowsHttpTimeoutExceptionOnHungResponse() throws Exception {
try (HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build()) {
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/hang"))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
long t0 = System.nanoTime();
HttpTimeoutException ex = assertThrows(HttpTimeoutException.class,
() -> client.send(req, HttpResponse.BodyHandlers.ofString()));
assertEquals(HttpTimeoutException.class, ex.getClass());
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0);
assertTrue(elapsedMs < 4_500, "request timeout should fire before the 5s hang; elapsedMs=" + elapsedMs);
}
}
With timeout set on the request, send throws HttpTimeoutException before the handler wakes up. The second assertion, assertEquals(HttpTimeoutException.class, ex.getClass()), is the important one: it makes sure the exception is that exact class and not the connect subclass.
Client C: both timeouts on the fast path
@Test
void bothTimeoutsStillSucceedOnFastPath() throws Exception {
try (HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2))
.build()) {
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/fast"))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
assertEquals(200, res.statusCode());
assertEquals("ok", res.body());
}
}
}
This is the control case. The configuration that cuts off /hang does not get in the way of a healthy server.
Result on this host
mvn -q test produced the Surefire report TEST-academy.devdojo.httpclient.ConnectTimeoutDoesNotCutHungResponseTest.xml with tests=3 failures=0 errors=0 skipped=0 and time="10.42".
| client | connectTimeout | request timeout | path | result on this host |
|---|---|---|---|---|
| A | 2s | (unset = infinite) | /hang (sleep 5s) | 200, elapsed ≥ 4s |
| B | 2s | 2s | /hang | HttpTimeoutException (exact class), elapsed < 4.5s |
| C | 2s | 2s | /fast | 200 ok |
This is a functional test: one run, on loopback, with a 5s sleep. It does not measure latency or throughput, and it says nothing about how long your service should wait. The only case it exercises is a connection that gets accepted and a response that comes late. The case where the connection is never established at all (a firewall dropping packets, a host that never answers the handshake) is connectTimeout and HttpConnectTimeoutException territory, and this fixture leaves it out.
Pitfalls
Setting only connectTimeout. That's Client A. It protects you from failing to connect. It does not protect you from connecting and then never getting a response. If the client builder has only connectTimeout and the handler on the other side can stall after the accept, there is no limit on the wait.
assertThrows(HttpTimeoutException.class) on its own. Because HttpConnectTimeoutException extends HttpTimeoutException, that assertThrows also passes when the connection is what failed. A test meant to prove "the request timeout cut off the response" stays green even when no connection was ever made. Compare ex.getClass() against the exact class, as Client B does.
Expecting a wrapper. send declares throws IOException, InterruptedException, and the timeouts are that IOException, with nothing wrapped around them. A generic catch (IOException e) catches both timeouts along with every other I/O error. If what you do next depends on whether the connection failed or the response never came, catch HttpConnectTimeoutException before HttpTimeoutException, and both of them before IOException.
Looking for setSoTimeout. Socket.setSoTimeout) belongs to java.net.Socket, which is a different API. It is not how you configure java.net.http.HttpClient.
Generalizing to other clients. This test applies only to the JDK HttpClient. RestClient, Apache HttpClient, OkHttp and Netty each have their own configuration and exceptions. And spring.mvc.async.request-timeout, despite the name, does not configure the HttpClient you built with HttpClient.newBuilder().
In production, the same split works as observability: log and count HttpConnectTimeoutException separately from HttpTimeoutException. The first means the connection was never established, so you're looking at the network or availability. The second means the connection was accepted and the response didn't arrive in time, so the process on the other side is slow or stuck. Adding both to a single "timeout" counter throws away the one piece of information that tells the two problems apart.
Recommendation
DevDojo would ship the JDK HttpClient with both timeouts whenever the call goes to another process. connectTimeout on the client builder covers a connection that never gets established. timeout on each HttpRequest covers a handler that accepted the connection and then stalled. Neither one replaces the other.
The sign that the code needs another look is a builder with only connectTimeout while the service on the other end can fall asleep after the accept. A request with no timeout doesn't get a free 30 seconds. It waits indefinitely.
Next step
In the next HttpClient that makes an outbound call, add .timeout(...) on the HttpRequest.Builder next to the connectTimeout that is already there. Copy the fixture: an HttpServer on an ephemeral port with a /hang that sleeps longer than your timeout, plus an assertion that ex.getClass() == HttpTimeoutException.class. It runs in about ten seconds with no Docker, and it shows whether your timeout actually ends the call.