O código parece bem protegido. O HttpClient do JDK foi criado com connectTimeout(Duration.ofSeconds(2)), alguém aprovou o PR e todo mundo passou a acreditar que nenhuma chamada fica parada mais de dois segundos. Aí o serviço do outro lado aceita a conexão TCP e o handler trava antes de mandar os headers. A chamada não termina em 2s. Ela espera o que o servidor quiser, e sem um timeout na request isso pode ser para sempre.
Isso não é bug do JDK. É o escopo que o javadoc documenta: o connectTimeout cobre o estabelecimento da conexão. Esperar a resposta depois do accept é responsabilidade de outro timeout, configurado em outro builder.
Este post reproduz os dois casos com um HttpServer local e um teste JUnit. A saída é um padrão mínimo para qualquer chamada a outro processo: os dois timeouts configurados e a classe exata da exceção verificada no teste.
Ambiente
- Temurin 25.0.4 (
25.0.4+7-LTS), Maven 3.9.12,maven.compiler.release=25, sem--enable-preview. junit-jupiter6.1.3,maven-surefire-plugin3.6.0,maven-compiler-plugin3.14.0.- Só
java.net.http.*,com.sun.net.httpserver.*e JUnit. Sem Spring, sem container, sem WireMock.
O POM tem uma única dependência:
<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>
O HttpServer fica no módulo jdk.httpserver, que já vem no JDK, então não aparece no POM.
O post de 03/10, RestClient no Boot 4.1: o 4xx que some com o body, tratava do 4xx do RestClient que vira exceção e de como ler o JSON que vem nela. Este aqui é só o HttpClient do JDK, com connectTimeout e request timeout diante de uma resposta que trava depois do accept. Os dois usam o mesmo HttpServer por coincidência.
O que o javadoc promete, e o que não promete
O javadoc do HttpClient.Builder no Java 25) define o connectTimeout(Duration) assim:
> "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."
O parâmetro é descrito como "the duration to allow the underlying connection to be established". Em nenhum ponto o texto fala de esperar headers ou body. Quando a conexão já existe, seja nova ou reaproveitada, o connectTimeout não faz mais nada.
O timeout da resposta fica na request. O javadoc do HttpRequest.Builder) diz:
> "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."
Duas coisas nesse trecho importam. A primeira é que, sem timeout, a espera é infinita. Não existe um padrão escondido de 30 segundos. O getter HttpRequest.timeout() devolve um Optional vazio, e um Optional vazio não quer dizer "30 segundos", quer dizer vazio mesmo. Com o connectTimeout é igual: sem configuração, HttpClient.connectTimeout() devolve vazio, e o javadoc não documenta nenhum padrão. A segunda é que o javadoc não diz quando o relógio começa a contar, se no send, depois da conexão ou depois dos headers. Diz só "if the response is not received within the specified timeout". Então não dimensione o valor supondo um ponto de partida que a documentação não garante.
As exceções seguem a mesma divisão:
- HttpTimeoutException: "Thrown when a response is not received within a specified time period." Estende
IOException. - HttpConnectTimeoutException: "Thrown when a connection [...] is not successfully established within a specified time period." Estende
HttpTimeoutException.
A exceção de conexão é subclasse da exceção de timeout da resposta. Isso tem consequência direta na hora de testar, e o fixture trata disso mais abaixo.
O fixture: um servidor que aceita a conexão e trava
O HttpServer do JDK 25 escuta em 127.0.0.1 numa porta efêmera (porta 0). GET /hang dorme 5s antes de sendResponseHeaders e depois responde 200 com ok. GET /fast responde na hora. O socket não é recusado nem fechado no accept: a conexão TCP fica pronta e quem atrasa é o 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);
}
}
Os três clientes usam .version(HttpClient.Version.HTTP_1_1). Sem isso, o builder prefere HTTP/2 e cai para HTTP/1.1 se o upgrade falhar. Fixar a versão evita que o teste dependa dessa negociação. O HttpClient implementa AutoCloseable desde o Java 21, por isso cada teste usa try-with-resources.
Cliente A: só connectTimeout
@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);
}
}
O cliente estabelece a conexão bem antes dos 2s e o connectTimeout já cumpriu o papel dele. A partir daí, o send espera com toda a educação os cinco segundos de sono do handler e recebe 200 ok. O teste confirma que passaram pelo menos 4s. O "timeout de 2s" esperou mais que o dobro e ninguém reclamou.
Cliente 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);
}
}
Com o timeout na request, o send lança HttpTimeoutException antes de o handler acordar. A segunda asserção, assertEquals(HttpTimeoutException.class, ex.getClass()), é a que importa: ela garante que a exceção é a classe exata e não a subclasse de conexão.
Cliente C: os dois timeouts no caminho rápido
@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());
}
}
}
Esse é o controle. A configuração que corta o /hang não atrapalha um servidor saudável.
Resultado neste host
mvn -q test gerou o relatório do Surefire TEST-academy.devdojo.httpclient.ConnectTimeoutDoesNotCutHungResponseTest.xml com tests=3 failures=0 errors=0 skipped=0 e time="10.42".
| cliente | connectTimeout | request timeout | path | resultado neste host |
|---|---|---|---|---|
| A | 2s | (ausente = infinito) | /hang (sleep 5s) | 200, elapsed ≥ 4s |
| B | 2s | 2s | /hang | HttpTimeoutException (classe exata), elapsed < 4.5s |
| C | 2s | 2s | /fast | 200 ok |
É um teste funcional, com uma única execução, em loopback, com um sleep de 5s. Ele não mede latência nem throughput e não diz quanto tempo o seu serviço deveria esperar. O único caso exercitado foi o de conexão aceita com resposta atrasada. O cenário em que a conexão nem chega a se estabelecer (firewall que descarta pacotes, host que não responde ao handshake) é o território do connectTimeout e do HttpConnectTimeoutException, e ficou de fora deste fixture.
Armadilhas
Configurar só o connectTimeout. É o Cliente A. Ele protege contra não conseguir conectar e não protege contra conectar e não receber resposta. Se o builder do cliente tem só connectTimeout e o handler do outro lado pode travar depois do accept, a espera não tem limite.
assertThrows(HttpTimeoutException.class) sozinho. Como HttpConnectTimeoutException estende HttpTimeoutException, esse assertThrows também passa quando quem falhou foi a conexão. Um teste que deveria provar "o request timeout cortou a resposta" fica verde mesmo quando a conexão não foi estabelecida. Compare ex.getClass() com a classe exata, como no Cliente B.
Esperar um wrapper. O send declara throws IOException, InterruptedException, e os timeouts são essa IOException, sem nenhum tipo por cima. Um catch (IOException e) genérico pega os dois timeouts junto com qualquer outro erro de I/O. Se a decisão depende de saber se a conexão falhou ou se a resposta não chegou, capture HttpConnectTimeoutException antes de HttpTimeoutException, e as duas antes de IOException.
Procurar setSoTimeout. Socket.setSoTimeout) pertence a java.net.Socket, outra API. Não é por ele que se configura o java.net.http.HttpClient.
Generalizar para outros clientes. Este teste vale só para o HttpClient do JDK. RestClient, Apache HttpClient, OkHttp e Netty têm configuração e exceções próprias. E spring.mvc.async.request-timeout, apesar do nome, não configura o HttpClient que você criou com HttpClient.newBuilder().
Para a produção, a mesma distinção serve de observabilidade: registre e conte HttpConnectTimeoutException separado de HttpTimeoutException. A primeira indica que a conexão não foi estabelecida, que é rede ou disponibilidade. A segunda indica que a conexão foi aceita e a resposta não chegou a tempo, ou seja, o processo do outro lado está lento ou travado. Somar as duas num contador "timeout" joga fora a única informação que separa um problema do outro.
Recomendação
A DevDojo colocaria em produção o HttpClient do JDK com os dois timeouts sempre que a chamada fala com outro processo. O connectTimeout no builder do cliente cobre a conexão que não se estabelece. O timeout em cada HttpRequest cobre o handler que aceitou e travou. Um não substitui o outro.
O sinal para rever o código é o builder ter só o connectTimeout enquanto o serviço do outro lado pode dormir depois do accept. Request sem timeout não ganha 30 segundos de brinde. Ela espera indefinidamente.
Próximo passo
No próximo HttpClient que fizer chamada de saída, acrescente .timeout(...) no HttpRequest.Builder ao lado do connectTimeout que já existe. Copie o fixture: um HttpServer em porta efêmera com um /hang que dorme mais que o seu timeout, mais uma asserção com ex.getClass() == HttpTimeoutException.class. Roda em cerca de dez segundos, sem Docker, e mostra se o seu timeout de fato encerra a chamada.