Article

⏱️ Java HttpClientのタイムアウト設計:接続待ちと応答待ちを分ける

java, http, backend, timeout

外部APIを呼び出すJavaの処理で、相手の応答が遅いとスレッドが長時間待ち続け、アプリ全体が遅くなることがあります。そんなとき最初に考えるのがタイムアウト(一定時間で待機を打ち切る設定)です。

JDK標準のjava.net.http.HttpClientには、似た名前の設定が2つあります。

  • HttpClient.Builder.connectTimeout(...):新しく接続を確立するまでの待ち時間
  • HttpRequest.Builder.timeout(...):個々のHTTPリクエストで応答を待つ時間

両方を設定するのが基本ですが、役割は異なります。この記事はJava 21で動くコードを使い、設定場所、例外処理、実務上の注意点を整理します。APIの説明はJava SE 21/25の公式ドキュメントと照合しました。

まず通信の段階を分ける

外部APIへのアクセスには、概念的に次の段階があります。

  1. 接続先の名前を解決する(DNS)
  2. サーバーと接続する(TCP、HTTPSならTLSによる暗号化の確立も関係する)
  3. HTTPリクエストを送る
  4. HTTPレスポンスを受け取る
  5. 受け取ったJSONを解析して、DBへの保存など後続処理を行う

connectTimeoutは主に接続の確立を扱い、HttpRequest.timeoutはそのリクエストの応答待ちを扱います。いずれも5のJSON解析やDB処理を含めた、業務処理全体の制限時間ではありません。

項目 connectTimeout HttpRequest.timeout
設定先 HttpClient HttpRequest
主な対象 新しい接続の確立 リクエストの応答待ち
典型的な設定例 3秒 5秒
代表的な例外 HttpConnectTimeoutException HttpTimeoutException
接続を再利用した場合 新しい接続が不要なら効かない リクエストごとに指定できる

表の秒数は説明用です。適切な値はAPIのSLO(どれくらいの速さで応答すべきかという目標)、ネットワーク環境、呼び出し側の残り時間によって決まります。

動くサンプル:例外を分けて扱う

以下をTimeoutDemo.javaとして保存します。JDK 11以降で標準搭載のAPIのみを使います。

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

public class TimeoutDemo {
    // 一度作ったクライアントを複数のリクエストで使い回す
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(3))
            .followRedirects(HttpClient.Redirect.NEVER)
            .build();

    public static void main(String[] args) {
        // 実行時引数でテスト用URLを指定できる
        String url = args.length > 0 ? args[0] : "https://example.com";
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();

        try {
            HttpResponse<String> response = CLIENT.send(
                    request, HttpResponse.BodyHandlers.ofString());
            int status = response.statusCode();
            System.out.println("HTTP status: " + status);
            if (status >= 500) {
                System.out.println("サーバー側エラー。再試行の可否を別途判断する");
            } else if (status >= 400) {
                System.out.println("リクエスト内容や認証状態を確認する");
            } else {
                System.out.println("応答受信成功");
            }
        } catch (HttpConnectTimeoutException e) {
            // この例外はHttpTimeoutExceptionのサブクラスなので先にcatchする
            System.err.println("接続タイムアウト: " + e.getMessage());
        } catch (HttpTimeoutException e) {
            System.err.println("応答タイムアウト: " + e.getMessage());
        } catch (IOException e) {
            // 接続拒否や名前解決失敗などは別のIOExceptionになり得る
            System.err.println("通信エラー: " + e.getClass().getSimpleName());
        } catch (InterruptedException e) {
            // 呼び出し元が中断した事実を失わないようにする
            Thread.currentThread().interrupt();
            System.err.println("待機処理が中断されました");
        }
    }
}

実行方法は次のとおりです。

javac TimeoutDemo.java
java TimeoutDemo http://127.0.0.1:8000/

javacはJavaソースをコンパイル(実行可能なクラスファイルへ変換)します。javaは生成したクラスを実行します。ここではローカルの8000番ポートにHTTPサーバーを起動している前提なので、起動していなければタイムアウトではなく接続拒否になることがあります。手軽な確認なら別ターミナルでpython3 -m http.server 8000を実行すると、作業ディレクトリを配信する簡単なサーバーが起動します。python3 -mはPythonのモジュールをコマンドとして起動する指定です。

HttpConnectTimeoutExceptionを先にcatchする理由

HttpConnectTimeoutExceptionはHttpTimeoutExceptionを継承しています。HttpTimeoutExceptionを先にcatchすると接続タイムアウトを区別できず、後ろのcatchは到達不能になります。

また、タイムアウト以外の障害は必ずタイムアウト例外になるわけではありません。接続拒否、DNSの問題、証明書エラーなども想定し、IOExceptionをまとめて扱うだけでなくログやメトリクスでは原因を分類するのが実務的です。

ハマりどころ1:接続を再利用したとき

HttpClientは接続をプール(既に作った接続を使い回す仕組み)で管理できます。接続済みのコネクションが使われれば、新しい接続を作る必要はありません。そのため、connectTimeoutだけでは2回目以降のリクエストの応答待ちを制限できません。

一方、呼び出しごとのHttpRequest.timeoutは個々のリクエストに設定できます。クライアント側の接続設定とリクエスト側の応答設定を混同しないようにします。

ハマりどころ2:HTTP 504とタイムアウト例外は別物

HttpResponse.statusCode()が504だった場合、それは上流のゲートウェイなどからHTTP応答が返った状態です。Javaのタイムアウト例外が発生したことを意味しません。

反対に、Java側でHttpTimeoutExceptionが発生したなら、HTTPステータスを受け取れなかった可能性があります。sendの戻り値で判定するケースと、catchで処理するケースを分けてください。

ハマりどころ3:タイムアウトしたら無条件に再送しない

send()でタイムアウトが発生しても、サーバー側の処理が終わっていないとは限りません。例えば注文登録のPOSTで「登録は成功したが応答だけが遅れた」場合、無条件のリトライで注文を二重登録するおそれがあります。

  • GETなど安全に再実行できる操作でも、相手サービスの負荷と再試行回数を制限する。
  • 注文作成などのPOSTは、冪等性キー(同じ要求を識別し、複数回来ても結果を二重適用しないためのID)など、サーバー側の重複防止を設計する。
  • 再試行する場合も、固定の短い間隔で連打せず、バックオフ(間隔を徐々に延ばす方法)とジッター(待ち時間に揺らぎを加える方法)を検討する。

再試行はネットワークの設定だけで完結する話ではなく、APIの副作用(呼び出すことでDB更新や課金などが起きること)とセットで判断します。

ハマりどころ4:sendAsyncは例外の受け取り方が違う

send()は現在のスレッドを待機させる同期APIです。sendAsync()はCompletableFuture(将来得られる結果を表すオブジェクト)を返す非同期APIです。

CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString())
        .whenComplete((response, error) -> {
            if (error != null) {
                // 完了時の例外がCompletionException等に包まれることもある
                System.err.println("通信失敗: " + error);
                return;
            }
            System.out.println(response.statusCode());
        });

このコードは上のTimeoutDemoのmainに置き換えて使う例です。実際には、非同期処理が終了するまでアプリを動かす仕組みも必要です。sendAsync()を呼んですぐメイン処理を終了すると、結果を表示する前にプロセスが終わる場合があります。障害分類では例外のcause(原因例外)も確認します。

実務での使い分け

connectTimeout=3秒、request.timeout=5秒のように2種類を区別するところから始めます。ただし、5秒以内に業務全体が必ず終わるという意味ではありません。JSONの変換、DBアクセス、リトライ待ち、複数API呼び出しには別途時間がかかります。呼び出しチェーン全体の**期限(deadline)**を設計し、残り時間に合わせて個別タイムアウトを調整してください。

最初から秒数を決め打ちするより、成功率・p95/p99応答時間(遅い側5%/1%の境界に当たる統計)・タイムアウト発生率を測り、相手の許容負荷を考慮して調整する方が堅実です。

まとめ

connectTimeoutは接続の確立、HttpRequest.timeoutは個別リクエストの応答待ちの設定です。両方を使い、接続タイムアウトと応答タイムアウトを別々に記録すると、障害対応の切り分けがしやすくなります。HTTPステータス、通信例外、業務処理の期限、リトライの冪等性をそれぞれ別の設計要素として扱うことが重要です。

参考資料(公式)