コンテンツへスキップ

HTTPタイムアウトの3種類 — 接続・読み取り・全体の違いと設計の考え方

外部のサーバーへ HTTP リクエストを送るコードを書くとき、「タイムアウトを設定しておく」ことは多くの人が意識しています。ただ、タイムアウトには性質の違う3種類があり、1つの数字で全部を守れるわけではありません。

この記事では、接続タイムアウト・読み取りタイムアウト・全体タイムアウトの違いを、Python の requests ライブラリ(2.34.0、Python 3.12.8)で実際に動かした結果を交えて整理します。ライブラリ固有の話に見える部分もありますが、考え方は他の言語・HTTP クライアントにもそのまま当てはまります。

補足: ここでの「タイムアウト」は、相手の応答を待つ時間に上限を付けて、超えたら諦める仕組みのことです。上限がないと、相手が応答しない場合に処理が無期限に止まります。

3種類のタイムアウトは、待っている対象が違う

HTTP リクエストは大まかに「①相手につなぐ → ②リクエストを送る → ③応答を受け取る」と進みます。タイムアウトは、どの段階を待っているかで分かれます。

種類 待っている対象 典型的な原因
接続タイムアウト ①TCP 接続が確立するまで サーバーが落ちている、経路の途中で遮断されている
読み取りタイムアウト ③相手から次のデータが届くまで サーバーが処理中で固まっている、重いクエリ待ち
全体タイムアウト ①〜③のすべてを合わせた総時間 上の2つでは捉えられない「遅いが止まってはいない」状態

requests では timeout=(接続, 読み取り) のようにタプルで2つの値を渡せます。全体タイムアウトに相当する引数は用意されていません(この点は後で扱います)。

実験1: 応答が来ない相手は、読み取りタイムアウトで切れる

接続は受け付けるものの、リクエストを受け取ったあと4秒間なにも返さないサーバーをローカルに立てて、timeout=(3, 1)(接続3秒・読み取り1秒)でアクセスしました。

import requests
requests.get("http://127.0.0.1:PORT/", timeout=(3, 1))

結果は ReadTimeout が約 1.0 秒後に発生しました。接続自体は一瞬で成功しているので、接続側の3秒は使われず、読み取り側の1秒で切れています。なお、数字を1つだけ渡す timeout=1 でも、同じく約1.0秒で ReadTimeout になりました。スカラー値を渡すと、接続と読み取りの両方に同じ値が適用されるためです。

実験2: 接続できない相手は、接続タイムアウトで切れる

次に、どこにも届かない宛先(10.255.255.1、応答のないプライベートアドレス)へ timeout=(1, 5) でアクセスしました。結果は ConnectTimeout が約 1.0 秒後でした。読み取りの5秒は一切使われていません。

一方、ポートが閉じているだけのサーバー(接続拒否が即座に返る)に対しては、タイムアウトは関係なく、約0.0秒で ConnectionError になりました。「届かない」と「断られる」では、待ち時間も例外の種類も別物です。

  • 経路上でパケットが捨てられる → 返事が来ないので、接続タイムアウトまで待つ
  • 相手が明示的に拒否する → すぐ失敗するので、タイムアウトに頼らない

エラー処理を書くときは、この2つ(ConnectTimeout と ConnectionError)を同じ扱いにするか分けるかを、意図して決めておくのがよいでしょう。

実験3: 読み取りタイムアウトは「全体の上限」ではない

ここが一番見落とされやすい点です。サーバーが 12バイトの本文を、0.5秒ごとに1バイトずつ送ってくる状況を作り、timeout=(3, 1) でアクセスしました。

結果は、タイムアウトせず 200 OK を受け取り、所要時間は約 5.5 秒でした。読み取り上限は1秒なのに、5秒以上かかっています。

理由は、読み取りタイムアウトが「次のデータが届くまでの待ち時間」を測っているからです。0.5秒ごとにデータが届き続ける限り、1秒の上限には一度も達しません。つまり、

  • 読み取りタイムアウト=「無音状態がどれだけ続いたか」の上限
  • 「リクエスト全体に何秒かかったか」の上限ではない

という違いがあります。低速でだらだら応答を返す相手に対しては、読み取りタイムアウトだけでは処理がいつまでも終わらないことがあります。

全体タイムアウトが必要なときの考え方

「このリクエストは何があっても◯秒で終わらせたい」という要件には、requests の timeout 引数だけでは足りません。一般的な対処は次のとおりです。

  1. 経過時間を自分で数える。 本文をチャンクごとに読み(stream=True)、開始時刻からの経過が上限を超えたら読み取りを打ち切る。
  2. 呼び出し側で上限を持つ。 スレッドやプロセス、あるいはライブラリが提供する別の仕組みで、処理全体に期限を付ける。
  3. 別の HTTP クライアントを選ぶ。 全体タイムアウトを標準でサポートしているクライアントもある(言語・ライブラリによる)。

どの方法でも、「全体」の期限は自分の設計として明示的に持つものだ、という点は共通しています。

数字の決め方

値の決め方に唯一の正解はありませんが、判断の軸は整理できます。

  • 接続タイムアウトは短めに。 接続の確立は通常ごく短時間で終わるため、数秒でも十分なことが多い。長くしても成功率はあまり上がらず、失敗の検知が遅れるだけになりやすい。
  • 読み取りタイムアウトは、相手の処理時間に合わせる。 重いバックアップ処理を呼ぶ API と、状態を返すだけの API を同じ値にしない。
  • 全体の期限は、利用者が待てる時間から逆算する。 画面に結果を出すまでの待ち時間なのか、夜間バッチなのかで変わる。
  • どれも「通常の何倍か」で見積もる。 正常時の所要時間を計測しておき、その数倍を目安にすると、正常な処理を切らずに異常だけ拾いやすい。

リトライとの関係

タイムアウトで切れた後に再試行する場合は、1回あたりの上限 × 試行回数が、実質的な最悪待ち時間になります。たとえば読み取り10秒・3回試行なら、待ち時間だけで最大30秒を見込む必要があります。再試行の間隔の取り方は、別記事の「指数バックオフとリトライ設計」で扱っています。

また、書き込みを伴うリクエストは、タイムアウトで切れた時点で相手側では処理が完了している可能性があります。「切れたから失敗」とは限らないため、再試行する場合は同じ処理を二重に実行しても結果が変わらない作りにしておくことが前提になります。

外部コマンドを呼ぶ場合のタイムアウトについては、「Python から外部コマンドを呼ぶときの落とし穴」も参考になります。

まとめ

  • タイムアウトは待つ対象で3種類(接続・読み取り・全体)に分かれる。
  • requests の timeout は接続と読み取りの2つで、全体の上限ではない(少しずつデータを返す相手には効かない)。
  • 「届かない」(接続タイムアウト)と「断られる」(接続エラー)は別の失敗として扱う。
  • 全体の期限が必要なら、自分の設計として持つ。
  • リトライするなら、上限 × 試行回数が最悪待ち時間になる。

「タイムアウトを設定した」ことと「どの状況で何秒後に切れるかを説明できる」ことは別です。一度、自分のコードの timeout= の値が3種類のどれを守っているのかを確認してみると、意外な抜けが見つかるかもしれません。