TL;DR
HTTPクライアントはサーバーへの接続に成功しましたが、サーバーが時間内にデータを返しませんでした。デフォルトでは、HttpURLConnectionの読み取りタイムアウトは0に設定されており、永遠に待ち続けます。明示的なタイムアウトを設定してください:接続タイムアウトは5〜10秒、読み取りタイムアウトは10〜30秒。不安定なネットワークにはリトライロジックも追加しましょう。
エラーの見た目
java.net.SocketTimeoutException: Read timed out
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at sun.security.ssl.InputRecord.readFully(InputRecord.java:465)
...
TCP接続自体は成功しています。つまり、DNSとファイアウォールは問題ありません。しかし、タイムアウト時間内にレスポンスが届きませんでした。主な原因としては、バックエンドのクエリが遅い、大きなペイロード(50MBのCSVエクスポートなど)、ネットワークの輻輳、またはサーバーが転送途中でサイレントに接続を切断したことが考えられます。
HTTPクライアント別の修正方法
HttpURLConnection(標準ライブラリ)
デフォルトでは読み取りタイムアウトが設定されていないため、永遠にブロックされます。両方を必ず明示的に設定してください:
URL url = new URL("https://api.example.com/data");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5_000); // TCP接続確立までの5秒
conn.setReadTimeout(15_000); // 接続後のデータ受信までの15秒
conn.setRequestMethod("GET");
try (InputStream in = conn.getInputStream()) {
// レスポンスを読み取る
}
Apache HttpClient 5.x
RequestConfig config = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(5))
.setConnectTimeout(Timeout.ofSeconds(5))
.setResponseTimeout(Timeout.ofSeconds(15)) // これが読み取りタイムアウト
.build();
try (CloseableHttpClient client = HttpClients.custom()
.setDefaultRequestConfig(config)
.build()) {
HttpGet request = new HttpGet("https://api.example.com/data");
try (CloseableHttpResponse response = client.execute(request)) {
// レスポンスを処理する
}
}
Apache HttpClient 4.xの場合、setResponseTimeout()をsetSocketTimeout()に置き換えてください。メジャーバージョン間でパラメータ名が変更されています。
OkHttp
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build();
Request request = new Request.Builder()
.url("https://api.example.com/data")
.build();
try (Response response = client.newCall(request).execute()) {
// レスポンスを処理する
}
Spring RestTemplate
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5_000);
factory.setReadTimeout(15_000);
RestTemplate restTemplate = new RestTemplate(factory);
String result = restTemplate.getForObject("https://api.example.com/data", String.class);
本番環境でApache HttpClientバックエンドを使いたい場合は、Beanとして設定します:
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5_000);
factory.setConnectionRequestTimeout(5_000);
factory.setReadTimeout(15_000);
return new RestTemplate(factory);
}
Spring WebClient(リアクティブ)
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5_000)
.responseTimeout(Duration.ofSeconds(15));
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
一時的な障害に対するリトライロジックの追加
タイムアウトが1回発生しただけでサーバーがダウンしているとは限りません。ネットワークは不安定なものです。一時的な障害がユーザーへのエラーとして表面化しないよう、リトライロジックでAPIコールをラップしましょう:
// Spring Retryを使用
@Retryable(
value = { SocketTimeoutException.class, ResourceAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String callApi() {
return restTemplate.getForObject(url, String.class);
}
Spring Retryを使いたくない場合は、指数バックオフを使って手動で実装できます:
int maxRetries = 3;
int delayMs = 1000;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
return callExternalApi();
} catch (SocketTimeoutException e) {
if (attempt == maxRetries) throw new RuntimeException("All retries failed", e);
Thread.sleep(delayMs * attempt);
}
}
適切なタイムアウト値の選び方
- **接続タイムアウト**:3〜10秒。正常なTCPハンドシェイクはローカルネットワークではミリ秒単位、インターネット経由でも1秒未満で完了します。
- **読み取りタイムアウト**:エンドポイントによって異なります。高速なREST APIは10〜15秒、低速なデータエクスポートやファイルダウンロードは60〜300秒が目安です。
- **本番環境では読み取りタイムアウトを0に設定しない**でください。ハングしたサーバーがスレッドを無期限に占有し続けます。
簡単な目安として、そのエンドポイントの想定される最悪ケースのレスポンス時間の2倍に読み取りタイムアウトを設定してください。レポートエンドポイントが通常8秒で応答するなら、16〜20秒に設定しましょう。
根本原因のデバッグ
タイムアウトを増やすだけでは応急処置に過ぎません。まず、サーバーがなぜ遅いのかを調査してください:
- タイムアウト発生の正確な時刻前後のサーバーサイドログを確認する
- Java以外で再現する:`curl --max-time 15 https://api.example.com/data`
- 散発的なタイムアウトはネットワークの問題を示し、一貫したタイムアウトはサーバーのパフォーマンス問題を示す
- どのステップが遅いかを特定するためにログにタイムスタンプを追加する
企業プロキシやVPNを経由している場合、ルーティングの問題がサーバーの遅延と見分けのつかないサイレントな読み取りストールを引き起こすことがあります。toolcraft.appのSubnet Calculatorを使えば、サービス間の到達性をデバッグする際にCIDRレンジとルーティングパスの確認に役立ちます。
修正の確認
- タイムアウトをシミュレートする:低速なエンドポイントに向けるか、テストサーバーハンドラに`Thread.sleep(20_000)`を追加する
- 設定したタイムアウト時間(30秒ではなく正確に15秒)後に例外が発生することを確認する
- 通常のリクエストを実行し、閾値を十分下回る時間で完了することを確認する
- ログを確認する:`SocketTimeoutException`がランダムな遅延後ではなく、予測可能な時間に発生していること
コネクションプールの枯渇(関連する問題)
HTTPクライアントは再利用してください。リクエストごとに新しいCloseableHttpClientやOkHttpClientを作成してはいけません。タイムアウトが処理されないまま積み重なると、コネクションプールが枯渇し、サービス全体で連鎖的な障害が発生します。プールの制限を明示的に設定しましょう:
// Apache HttpClient 5.x
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(100);
cm.setDefaultMaxPerRoute(20);
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(config)
.evictExpiredConnections()
.build();

