Java HTTP Client API のタイムアウト設定

## 概要

- Java 11 から正式導入された HTTP Client API (java.net.http パッケージ) のタイムアウト設定について接続時の動作を検証する
- HTTP Client API では設定できるタイムアウト値が2種類ある
  - 接続タイムアウト: HttpClient.Builder.connectTimeout
  - リクエストタイムアウト: HttpRequest.Builder.timeout

## 検証結果からの考察

- 接続タイムアウト HttpClient.Builder.connectTimeout には接続タイムアウトの許容時間を指定する
- リクエストタイムアウト HttpRequest.Builder.timeout にはリクエスト全体 (接続タイムアウトや読み取りタイムアウトを含めたもの) の許容時間を指定する

## 公式ドキュメントによる情報

### HttpClient.Builder.connectTimeout

[HttpClient\.Builder \(Java SE 14 & JDK 14\)](https://docs.oracle.com/javase/jp/14/docs/api/java.net.http/java/net/http/HttpClient.Builder.html#connectTimeout(java.time.Duration))

> HttpClient.Builder connectTimeout​(Duration duration)
> 
> このクライアントの接続タイムアウト期間を設定します。
> 新しい接続を確立する必要がある場合に、指定された duration内に接続を確立できないと、HttpClient::sendはHttpConnectTimeoutExceptionをスローし、HttpClient::sendAsyncはHttpConnectTimeoutExceptionを使用して例外的に完了します。 たとえば、接続を前のリクエストから再利用できる場合など、新しい接続を確立する必要がない場合、このタイムアウト期間は影響しません。
> 
> パラメータ:
> duration - 基礎となる接続の確立を許可する期間
> 
> 戻り値:
> このビルダー
> 
> 例外:
> IllegalArgumentException - 期間が非正の場合

[HttpClient\.Builder \(Java SE 14 & JDK 14\)](https://docs.oracle.com/en/java/javase/14/docs/api/java.net.http/java/net/http/HttpClient.Builder.html#connectTimeout(java.time.Duration))

> HttpClient.Builder connectTimeout​(Duration duration)
> 
> Sets the connect timeout duration for this client.
> 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, or HttpClient::sendAsync completes exceptionally with 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.
> 
> Parameters:
> duration - the duration to allow the underlying connection to be established
> 
> Returns:
> this builder
> 
> Throws:
> IllegalArgumentException - if the duration is non-positive

### HttpRequest.Builder.timeout

[HttpRequest\.Builder \(Java SE 14 & JDK 14\)](https://docs.oracle.com/javase/jp/14/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration))

> HttpRequest.Builder timeout​(Duration duration)
> 
> このリクエストのタイムアウトを設定します。 指定されたタイムアウト内にレスポンスを受信しなかった場合、HttpClient::sendまたはHttpClient::sendAsyncからHttpTimeoutExceptionが例外的にHttpTimeoutExceptionを使ってスローされます。 タイムアウトを設定しない場合の影響は、無限期間(永続的ブロック)の設定と同じです。
> 
> パラメータ:
> duration - タイムアウト期間
> 
> 戻り値:
> このビルダー
> 
> 例外:
> IllegalArgumentException - 期間が非正の場合

[HttpRequest\.Builder \(Java SE 14 & JDK 14\)](https://docs.oracle.com/en/java/javase/14/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html#timeout(java.time.Duration))

> HttpRequest.Builder timeout​(Duration 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.
> 
> Parameters:
> duration - the timeout duration
> 
> Returns:
> this builder
> 
> Throws:
> IllegalArgumentException - if the duration is non-positive

## 挙動を検証するソースコード

HttpClientTimeoutTest.java というファイル名で以下の内容を保存する。

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;

public class HttpClientTimeoutTest {

  public static void main(String[] args) throws Exception {

    // 接続タイムアウトのテスト (存在しないサーバ、接続タイムアウト3秒)
    testTimeout("http://127.0.0.2:8000/", Duration.ofSeconds(3), null);
    Thread.sleep(1000L);

    // 読み取りタイムアウトのテスト (レスポンスが遅いサーバ、リクエストタイムアウト3秒)
    testTimeout("http://127.0.0.1:8000/", null, Duration.ofSeconds(3));
    Thread.sleep(1000L);

    // リクエストタイムアウトの影響範囲のテスト (存在しないサーバ、接続タイムアウト5秒、リクエストタイムアウト3秒)
    testTimeout("http://127.0.0.2:8000/", Duration.ofSeconds(5), Duration.ofSeconds(3));
    Thread.sleep(1000L);
  }

  /**
   * タイムアウトのテスト。
   * @param url URL
   * @param connectTimeout HttpClient.Builder.connectTimeout にセットする値
   * @param requestTimeout HttpRequest.Builder.timeout にセットする値
   */
  private static void testTimeout(String url, Duration connectTimeout, Duration requestTimeout) {

    try {
      // パラメータを確認
      System.out.println("URL: " + url);
      System.out.println("ConnectTimeout: " + connectTimeout);
      System.out.println("RequestTimeout: " + requestTimeout);

      // HttpClient オブジェクトを構築
      HttpClient.Builder hcb = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1);
      if (connectTimeout != null) {
        hcb.connectTimeout(connectTimeout); // 接続タイムアウトを設定
      }
      HttpClient client = hcb.build();

      // HttpRequest オブジェクトを構築
      HttpRequest.Builder hrb = HttpRequest.newBuilder(new URI(url)).GET();
      if (requestTimeout != null) {
        hrb.timeout(requestTimeout); // リクエストタイムアウトを設定
      }
      HttpRequest request = hrb.build();

      // タイムアウト設定を確認
      System.out.println("HttpClient.connectTimeout: " + client.connectTimeout());
      System.out.println("HttpRequest.timeout: " + request.timeout());

      // HTTP リクエストを投げる
      System.out.println("HttpClient.send begins: " + LocalDateTime.now());
      HttpResponse<String> res = client.send(request, HttpResponse.BodyHandlers.ofString());

    } catch (Exception e) {
      System.out.println("HttpClient.send failed: " + LocalDateTime.now());
      e.printStackTrace();
    }
  }
}
```

## レスポンスが遅い HTTP サーバを用意

リクエストが来てから10秒後にレスポンスを返すような HTTP サーバを Ruby で書く。

server.rb というファイル名で以下の内容を保存する。

```ruby
require 'webrick'

server = WEBrick::HTTPServer.new({
  :Port => 8000,
  :HTTPVersion => WEBrick::HTTPVersion.new('1.1')
})

server.mount_proc('/') do |req, res|
  sleep(10) # 10秒スリープ
  res.status = 200
  res.body = ''
end

Signal.trap('INT'){server.shutdown}
server.start
```

ruby コマンドで HTTP サーバのプログラムを実行する。

```
$ ruby server.rb
[2020-06-28 11:03:15] INFO  WEBrick 1.6.0
[2020-06-28 11:03:15] INFO  ruby 2.7.1 (2020-03-31) [x86_64-darwin19]
[2020-06-28 11:03:15] INFO  WEBrick::HTTPServer#start: pid=47723 port=8000
```

## 実行結果

java コマンドで HttpClientTimeoutTest.java を実行する。

Java 11 移行であれば javac によるコンパイル無しで実行できる。
参考: [JEP 330: Launch Single\-File Source\-Code Programs](https://openjdk.java.net/jeps/330)

今回の検証環境: macOS Catalina + Java 14 (AdoptOpenJDK 14.0.1+7)

```
$ java HttpClientTimeoutTest.java
URL: http://127.0.0.2:8000/
ConnectTimeout: PT3S
RequestTimeout: null
HttpClient.connectTimeout: Optional[PT3S]
HttpRequest.timeout: Optional.empty
HttpClient.send begins: 2020-06-28T11:07:13.619689
HttpClient.send failed: 2020-06-28T11:07:16.703287
java.net.http.HttpConnectTimeoutException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:557)
	at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:119)
	at HttpClientTimeoutTest.testTimeout(HttpClientTimeoutTest.java:59)
	at HttpClientTimeoutTest.main(HttpClientTimeoutTest.java:13)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:564)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:415)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:192)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:132)
Caused by: java.net.http.HttpConnectTimeoutException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.MultiExchange.toTimeoutException(MultiExchange.java:508)
	at java.net.http/jdk.internal.net.http.MultiExchange.getExceptionalCF(MultiExchange.java:455)
	at java.net.http/jdk.internal.net.http.MultiExchange.lambda$responseAsyncImpl$7(MultiExchange.java:382)
	at java.base/java.util.concurrent.CompletableFuture.uniHandle(CompletableFuture.java:930)
	at java.base/java.util.concurrent.CompletableFuture$UniHandle.tryFire(CompletableFuture.java:907)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:506)
	at java.base/java.util.concurrent.CompletableFuture.completeExceptionally(CompletableFuture.java:2152)
	at java.net.http/jdk.internal.net.http.Http1Exchange.lambda$cancelImpl$9(Http1Exchange.java:482)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
	at java.base/java.lang.Thread.run(Thread.java:832)
Caused by: java.net.ConnectException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.MultiExchange.toTimeoutException(MultiExchange.java:509)
	... 10 more
URL: http://127.0.0.1:8000/
ConnectTimeout: null
RequestTimeout: PT3S
HttpClient.connectTimeout: Optional.empty
HttpRequest.timeout: Optional[PT3S]
HttpClient.send begins: 2020-06-28T11:07:17.713737
HttpClient.send failed: 2020-06-28T11:07:20.720473
java.net.http.HttpTimeoutException: request timed out
	at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:561)
	at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:119)
	at HttpClientTimeoutTest.testTimeout(HttpClientTimeoutTest.java:59)
	at HttpClientTimeoutTest.main(HttpClientTimeoutTest.java:17)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:564)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:415)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:192)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:132)
URL: http://127.0.0.2:8000/
ConnectTimeout: PT5S
RequestTimeout: PT3S
HttpClient.connectTimeout: Optional[PT5S]
HttpRequest.timeout: Optional[PT3S]
HttpClient.send begins: 2020-06-28T11:07:21.729119
HttpClient.send failed: 2020-06-28T11:07:24.735766
java.net.http.HttpConnectTimeoutException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:557)
	at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:119)
	at HttpClientTimeoutTest.testTimeout(HttpClientTimeoutTest.java:59)
	at HttpClientTimeoutTest.main(HttpClientTimeoutTest.java:21)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:564)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:415)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:192)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:132)
Caused by: java.net.http.HttpConnectTimeoutException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.ResponseTimerEvent.handle(ResponseTimerEvent.java:68)
	at java.net.http/jdk.internal.net.http.HttpClientImpl.purgeTimeoutsAndReturnNextDeadline(HttpClientImpl.java:1259)
	at java.net.http/jdk.internal.net.http.HttpClientImpl$SelectorManager.run(HttpClientImpl.java:888)
Caused by: java.net.ConnectException: HTTP connect timed out
	at java.net.http/jdk.internal.net.http.ResponseTimerEvent.handle(ResponseTimerEvent.java:69)
	... 2 more
```

## 実行結果の検証

### 接続タイムアウトのテスト (存在しないサーバ、接続タイムアウト3秒)

- 接続タイムアウト: HttpClient.Builder.connectTimeout に3秒を指定
- リクエストタイムアウト: HttpRequest.Builder.timeout は無指定
- 3秒で接続タイムアウトが発生
- 例外 java.net.http.HttpConnectTimeoutException が発生

- 例外チェーン
  - java.net.http.HttpConnectTimeoutException: HTTP connect timed out
  - Caused by: java.net.http.HttpConnectTimeoutException: HTTP connect timed out
  - Caused by: java.net.ConnectException: HTTP connect timed out

### 読み取りタイムアウトのテスト (レスポンスが遅いサーバ、リクエストタイムアウト3秒)

- 接続タイムアウト: HttpClient.Builder.connectTimeout は無指定
- リクエストタイムアウト: HttpRequest.Builder.timeout に3秒を指定
- 3秒でリクエストタイムアウトが発生
- 例外 java.net.http.HttpTimeoutException が発生

- 例外チェーン (原因例外は無し)
  - java.net.http.HttpTimeoutException: request timed out

### リクエストタイムアウトの影響範囲のテスト (存在しないサーバ、接続タイムアウト5秒、リクエストタイムアウト3秒)

- 接続タイムアウト: HttpClient.Builder.connectTimeout に5秒を指定
- リクエストタイムアウト: HttpRequest.Builder.timeout に3秒を指定
- 3秒で接続タイムアウトが発生
  - 接続タイムアウトに5秒を指定しているのにもかかわらず3秒で接続タイムアウトが発生している
  - リクエストタイムアウトに設定した3秒で接続タイムアウトが発生していると思われる
  - リクエストタイムアウトは接続タイムアウトの時間込みの値だと考えられる
- 例外 java.net.http.HttpTimeoutException が発生

- 例外チェーン
  - java.net.http.HttpConnectTimeoutException: HTTP connect timed out
  - Caused by: java.net.http.HttpConnectTimeoutException: HTTP connect timed out
  - Caused by: java.net.ConnectException: HTTP connect timed out

## 参考資料

- [java\.net\.http \(Java SE 14 & JDK 14\)](https://docs.oracle.com/javase/jp/14/docs/api/java.net.http/java/net/http/package-summary.html)
- [URLConnection \(Java SE 14 & JDK 14\)](https://docs.oracle.com/javase/jp/14/docs/api/java.base/java/net/URLConnection.html)