CLOVER🍀

That was when it all began.

Java標準のHTTPクライアント(HttpClient)のタイムアウト(HttpRequest.Builder#timeout)の挙動を確認する

これは、なにをしたくて書いたもの?

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の呼び出しで待てる時間
    • SocketSO_TIMEOUTのこと

注意点としては、HTTPリクエストを送信してHTTPレスポンスを受信しきるまでのタイムアウトの設定はありません。

socket timeout(read timeout)は、あくまでSocketの1回のInputStream#readの呼び出しに対するタイムアウトなので、
読み取りができれば次の読み取り操作の時にはリセットされます。

HTTPレスポンスを受信しきるまでの時間自体のタイムアウトをコントールしたい場合は、Non Blocking IOかスレッドを使うことに
なります。

JavaのHttpClientのタイムアウト設定

ここで、JavaHttpClientタイムアウト設定を見てみます。

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を使ってスローされます。 タイムアウトを設定しない場合の影響は、無限期間(永続的ブロック)の設定と同じです。

https://docs.oracle.com/javase/jp/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration)

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.

https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration)

これは、最初にレスポンスを受信するまでのタイムアウトを表すようです。

つまり少しでもレスポンスを受信すると、その後はタイムアウトは作用しません。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.HttpURLConnectionjava.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#timeoutJavadocを見返すと、「指定されたタイムアウト内にレスポンスを受信しなかった場合」と
あるので、HTTPレスポンスでHTTPヘッダーの部分を返してしまっているのがよくない?と思いまして。

このリクエストのタイムアウトを設定します。 指定されたタイムアウト内にレスポンスを受信しなかった場合、HttpClient::sendまたはHttpClient::sendAsyncからHttpTimeoutExceptionが例外的にHttpTimeoutExceptionを使ってスローされます。 タイムアウトを設定しない場合の影響は、無限期間(永続的ブロック)の設定と同じです。

https://docs.oracle.com/javase/jp/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration)

というわけで、もうひとつ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)の挙動を確認してみました。

予想外すぎて、ちょっとビックリする動きですね。

今までとタイムアウトの考え方がだいぶ違うので、ちょっと戸惑うというか、少しでもレスポンスを受信するとその後は
タイムアウトが効かないようなのでそれはどうなんでしょうね…。