これは、なにをしたくて書いたもの?
JJUG CCC 2025 Springで、こういう発表があったみたいです。
障害を回避するHttpClient再入門 / Avoiding Failures HttpClient Reintroduction - Speaker Deck
この中のJava標準のHttpClient
のタイムアウトを説明を見て、そういえばタイムアウトの挙動は確認していなかったなと思って
試してみました。結果としては、思っていたのとだいぶ違うことになりましたが…。
タイムアウト?
この手のHTTPクライアントに関するタイムアウトは以下があると思います。
※資料内に出てくるコネクションプールの話はいったん置いておきます
- connect timeout
- 対象のサーバーへの接続が確立するまでの時間
Socket#connect
の呼び出しで待てる時間
- socket timeout(read timeout)
- データを読み取るまでの時間
Socket
に対する1回のInputStream#read
の呼び出しで待てる時間Socket
のSO_TIMEOUT
のこと
注意点としては、HTTPリクエストを送信してHTTPレスポンスを受信しきるまでのタイムアウトの設定はありません。
socket timeout(read timeout)は、あくまでSocket
の1回のInputStream#read
の呼び出しに対するタイムアウトなので、
読み取りができれば次の読み取り操作の時にはリセットされます。
HTTPレスポンスを受信しきるまでの時間自体のタイムアウトをコントールしたい場合は、Non Blocking IOかスレッドを使うことに
なります。
JavaのHttpClientのタイムアウト設定
ここで、JavaのHttpClient
のタイムアウト設定を見てみます。
connect timeoutは、HttpClient.Builder#connectTimeout
が相当します。
HttpClient.Builder (Java SE 21 & JDK 21)
そしてもうひとつ、HttpRequest.Builder#timeout
というものがあります。
HttpRequest.Builder (Java SE 21 & JDK 21)
HttpRequest.Builder#timeout
はread timeout(socket timeout)ではなく、HTTPレスポンスを受信しきるまでの時間を表すような
ことが資料に書かれてあったので、「新しいHttpClient
はそういう動きなんだ、ちょっと試してみよう」と思ったことが
このエントリーを書いた動機になりますね。
結論としては、ここまでに紹介してきたタイムアウトのいずれとも異なる動きをします。
Javadocの説明は、以下のようになっていました。
このリクエストのタイムアウトを設定します。 指定されたタイムアウト内にレスポンスを受信しなかった場合、HttpClient::sendまたはHttpClient::sendAsyncからHttpTimeoutExceptionが例外的にHttpTimeoutExceptionを使ってスローされます。 タイムアウトを設定しない場合の影響は、無限期間(永続的ブロック)の設定と同じです。
Sets a timeout for this request. If the response is not received within the specified timeout then an HttpTimeoutException is thrown from HttpClient::send or HttpClient::sendAsync completes exceptionally with an HttpTimeoutException. The effect of not setting a timeout is the same as setting an infinite Duration, i.e. block forever.
これは、最初にレスポンスを受信するまでのタイムアウトを表すようです。
つまり少しでもレスポンスを受信すると、その後はタイムアウトは作用しません。read timeout(socket timeout)の場合は
次回のInputStream#read
の呼び出しにもタイムアウト設定の効果が適用されますが、これとは違う動きをします。
ちょっとビックリしました。
というわけで、実際に確認してみましょう。
環境
今回の環境はこちらです。
$ java --version openjdk 21.0.7 2025-04-15 OpenJDK Runtime Environment (build 21.0.7+6-Ubuntu-0ubuntu124.04) OpenJDK 64-Bit Server VM (build 21.0.7+6-Ubuntu-0ubuntu124.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.7, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "6.8.0-60-generic", arch: "amd64", family: "unix"
準備
Maven依存関係などはこちら。
<properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.13.1</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.27.3</version> <scope>test</scope> </dependency> </dependencies>
テスト用のライブラリーのみですね。
お題
今回は、以下のお題でやってみます。
- HTTPクライアントは
java.net.HttpURLConnection
とjava.net.http.HttpClient
の2つを使い、挙動の違いを確認する - HTTPサーバーは2種類用意する
- メッセージを10個送信するが、1回の送信ごとに指定時間スリープするHTTPサーバー
- 指定時間スリープするが、スリープのタイミングをHTTPレスポンスを返し始める前か、HTTPヘッダーとHTTPボディの送信の間で選択できる
2つ目のHTTPサーバーは、HttpClient
用なんですけどね。
確認してみる
それでは、テストコードを書いて確認してみましょう。
テストコードの雛形はこちら。
src/test/java/org/littlewings/httpclient/HttpClientTimeoutTest.java
package org.littlewings.httpclient; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UncheckedIOException; import java.net.HttpURLConnection; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class HttpClientTimeoutTest { // ここに、テストを書く! }
メッセージを10個送信するHTTPサーバーを用意。1回の送信ごとに指定時間スリープします。
src/test/java/org/littlewings/httpclient/SlowHttpServer.java
package org.littlewings.httpclient; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.OutputStream; import java.io.UncheckedIOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; public class SlowHttpServer implements AutoCloseable { private HttpServer server; private SlowHttpServer(HttpServer server) { this.server = server; } public static SlowHttpServer newServer(int port, Duration sleepDuration) { try { HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); server.createContext("/", exchange -> { List<String> messages = IntStream.rangeClosed(1, 10).mapToObj(i -> "message-" + i) .toList(); int contentLength = (String.join("\r\n", messages) + "\r\n").getBytes(StandardCharsets.UTF_8).length; exchange.sendResponseHeaders(200, contentLength); try (OutputStream os = exchange.getResponseBody()) { messages.forEach(message -> { try { os.write(message.getBytes(StandardCharsets.UTF_8)); os.write("\r\n".getBytes(StandardCharsets.UTF_8)); os.flush(); TimeUnit.MILLISECONDS.sleep(sleepDuration.toMillis()); } catch (IOException e) { throw new UncheckedIOException(e); } catch (InterruptedException e) { // ignore } }); } }); server.start(); return new SlowHttpServer(server); } catch (IOException e) { throw new UncheckedIOException(e); } } @Override public void close() { server.stop(0); } }
まずはHttpURLConnection
で確認。
@Test void httpUrlConnection() throws IOException { SlowHttpServer server = SlowHttpServer.newServer(8080, Duration.ofSeconds(1L)); // 1秒ごとにメッセージを送信 try { HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080") .toURL() .openConnection(); connection.setReadTimeout(5000); // Read Timeoutを5秒に設定 List<String> receivedMessages = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { reader.lines().forEach(receivedMessages::add); } assertThat(receivedMessages).hasSize(10); assertThat(receivedMessages).containsExactly( "message-1", "message-2", "message-3", "message-4", "message-5", "message-6", "message-7", "message-8", "message-9", "message-10" ); } finally { server.close(); } }
メッセージは1秒ごとにスリープしながら送りますが、read timeoutは5秒にしているのでメッセージは全部受け取れます。
実行時間としては10秒ほどかかることになりますね。
HttpClient
で確認。設定はHttpURLConnection
と同じですね。
@Test void httpClient() throws IOException, InterruptedException { SlowHttpServer server = SlowHttpServer.newServer(8080, Duration.ofSeconds(1L)); // 1秒ごとにメッセージを送信 try (HttpClient httpClient = HttpClient .newBuilder() .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build()) { HttpRequest request = HttpRequest .newBuilder(URI.create("http://localhost:8080")) .timeout(Duration.ofSeconds(5)) // Timeoutを5秒に設定 .GET() .build(); HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); List<String> receivedMessages = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { reader.lines().forEach(receivedMessages::add); } assertThat(receivedMessages).hasSize(10); assertThat(receivedMessages).containsExactly( "message-1", "message-2", "message-3", "message-4", "message-5", "message-6", "message-7", "message-8", "message-9", "message-10" ); } finally { server.close(); } }
こちらもメッセージをすべて受け取れます。
次はタイムアウトの値を少し調整します。
HttpURLConnection
を使った場合で、メッセージの送信を10秒おきにします。read timeoutは5秒のままです。
@Test void httpUrlConnectionTimeout() throws IOException { SlowHttpServer server = SlowHttpServer.newServer(8080, Duration.ofSeconds(10L)); // 10秒ごとにメッセージを送信 try { HttpURLConnection connection = (HttpURLConnection) URI.create("http://localhost:8080") .toURL() .openConnection(); connection.setReadTimeout(5000); // Read Timeoutを5秒に設定 List<String> receivedMessages = new ArrayList<>(); assertThatThrownBy(() -> { try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { reader.lines().forEach(receivedMessages::add); } }) .isInstanceOf(UncheckedIOException.class) .hasMessage("java.net.SocketTimeoutException: Read timed out"); assertThat(receivedMessages).hasSize(1); assertThat(receivedMessages).containsExactly("message-1"); } finally { server.close(); } }
こうなるとメッセージの受信間隔が5秒を超えるのでSocketTimeoutException
がスローされます。HTTPサーバー側はメッセージを
送信してからスリープするので、最初の1件だけ受け取っていますね。
次はHttpClient
。メッセージを10秒おきに送信し、HttpRequest.Builder#timeout
は5秒のままです。
@Test void httpClientSlow() throws IOException, InterruptedException { SlowHttpServer server = SlowHttpServer.newServer(8080, Duration.ofSeconds(10L)); // 10秒ごとにメッセージを送信 try (HttpClient httpClient = HttpClient .newBuilder() .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build()) { HttpRequest request = HttpRequest .newBuilder(URI.create("http://localhost:8080")) .timeout(Duration.ofSeconds(5)) // Timeoutを5秒に設定 .GET() .build(); HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); List<String> receivedMessages = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { reader.lines().forEach(receivedMessages::add); } assertThat(receivedMessages).hasSize(10); assertThat(receivedMessages).containsExactly( "message-1", "message-2", "message-3", "message-4", "message-5", "message-6", "message-7", "message-8", "message-9", "message-10" ); } finally { server.close(); } }
なんとこちらはレスポンスを受信しきってしまいます。実行には100秒ほどかかります。
この結果がよくわからずに、ちょっと困りました。HttpClient
がまったくタイムアウトしません。HTTPサーバー側のスリープの
時間を調整したりしてもうまくいきません。
ここでHttpRequest.Builder#timeout
のJavadocを見返すと、「指定されたタイムアウト内にレスポンスを受信しなかった場合」と
あるので、HTTPレスポンスでHTTPヘッダーの部分を返してしまっているのがよくない?と思いまして。
このリクエストのタイムアウトを設定します。 指定されたタイムアウト内にレスポンスを受信しなかった場合、HttpClient::sendまたはHttpClient::sendAsyncからHttpTimeoutExceptionが例外的にHttpTimeoutExceptionを使ってスローされます。 タイムアウトを設定しない場合の影響は、無限期間(永続的ブロック)の設定と同じです。
というわけで、もうひとつHTTPサーバーを用意。
こちらが指定時間スリープしますが、スリープのタイミングをHTTPレスポンスを返し始める前か、HTTPヘッダーと
HTTPボディの送信の間で選択できるHTTPサーバーですね。
src/test/java/org/littlewings/httpclient/PlainSlowHttpServer.java
package org.littlewings.httpclient; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class PlainSlowHttpServer implements AutoCloseable { public enum Mode { WAIT_BEFORE, WAIT_MIDDLE } private ServerSocket serverSocket; private ExecutorService executorService; private PlainSlowHttpServer(ServerSocket serverSocket) { this.serverSocket = serverSocket; executorService = Executors.newCachedThreadPool(); } public static PlainSlowHttpServer newServer(int port, Duration sleepDuration, Mode mode) { try { ServerSocket serverSocket = new ServerSocket(port); PlainSlowHttpServer server = new PlainSlowHttpServer(serverSocket); server.start(sleepDuration, mode); return server; } catch (IOException e) { throw new UncheckedIOException(e); } } private void start(Duration sleepDuration, Mode mode) { executorService.submit(() -> { try { while (true) { try (Socket socket = serverSocket.accept(); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) { if (mode == Mode.WAIT_BEFORE) { TimeUnit.MILLISECONDS.sleep(sleepDuration.toMillis()); } writer.write("HTTP/1.1 200 OK\r\n"); writer.write("Content-Type: text/plain; charset=UTF-8\r\n"); writer.write("Content-Length: 13\r\n"); writer.write("Connection: close\r\n"); writer.write("\r\n"); writer.flush(); // 1度クライアントに送信 if (mode == Mode.WAIT_MIDDLE) { TimeUnit.MILLISECONDS.sleep(sleepDuration.toMillis()); } writer.write("Hello World\r\n"); } } } catch (IOException e) { throw new UncheckedIOException(e); } catch (InterruptedException e) { // ignore } }); } @Override public void close() throws IOException { executorService.shutdownNow(); serverSocket.close(); } }
HTTPヘッダーを返す前にスリープするパターン。タイムアウトは5秒で、スリープは10秒です。
@Test void httpClientSleepBefore() throws IOException, InterruptedException { PlainSlowHttpServer server = PlainSlowHttpServer.newServer(8080, Duration.ofSeconds(10L), PlainSlowHttpServer.Mode.WAIT_BEFORE); try (HttpClient httpClient = HttpClient .newBuilder() .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build()) { HttpRequest request = HttpRequest .newBuilder(URI.create("http://localhost:8080")) .timeout(Duration.ofSeconds(5)) // Timeoutを5秒に設定 .GET() .build(); assertThatThrownBy(() -> httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()) ) .isExactlyInstanceOf(HttpTimeoutException.class) .hasMessage("request timed out"); } finally { server.close(); } }
こちらはタイムアウトします。
つまり先ほどのHTTPサーバーでも、HttpExchange#sendResponseHeaders
を呼び出す前にスリープさせておけば実は
タイムアウトさせられたわけです(実際、タイムアウトします)。
server.createContext("/", exchange -> { List<String> messages = IntStream.rangeClosed(1, 10).mapToObj(i -> "message-" + i) .toList(); int contentLength = (String.join("\r\n", messages) + "\r\n").getBytes(StandardCharsets.UTF_8).length; // ここでスリープさせる exchange.sendResponseHeaders(200, contentLength); try (OutputStream os = exchange.getResponseBody()) {
スタックトレースとしてはこうなります。
java.net.http.HttpTimeoutException: request timed out at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:950) at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:133)
次にHTTPヘッダーを返した後にスリープするパターン。タイムアウトは5秒で、スリープする時間は10秒です。
@Test void httpClientSleepMiddle() throws IOException, InterruptedException { PlainSlowHttpServer server = PlainSlowHttpServer.newServer(8080, Duration.ofSeconds(10L), PlainSlowHttpServer.Mode.WAIT_MIDDLE); try (HttpClient httpClient = HttpClient .newBuilder() .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build()) { HttpRequest request = HttpRequest .newBuilder(URI.create("http://localhost:8080")) .timeout(Duration.ofSeconds(5)) // Timeoutを5秒に設定 .GET() .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.body()).isEqualTo("Hello World\r\n"); } finally { server.close(); } }
こちらはタイムアウトしません。
つまり、HttpRequest.Builder#timeout
は最初のレスポンスを受信するまでのタイムアウトであることがわかりました。
おわりに
Java標準のHTTPクライアント(HttpClient
)のタイムアウト(HttpRequest.Builder#timeout
)の挙動を確認してみました。
予想外すぎて、ちょっとビックリする動きですね。
今までとタイムアウトの考え方がだいぶ違うので、ちょっと戸惑うというか、少しでもレスポンスを受信するとその後は
タイムアウトが効かないようなのでそれはどうなんでしょうね…。