閉じる

【保存版】Python requestsのtimeout設定パターンと実践レシピ

Pythonのrequestsは便利ですが、timeoutを正しく設計しないと、本番環境で「ハングして見える」致命的なトラブルを引き起こします。

本記事では、timeoutの基本概念から、具体的な設定レシピ、エラー処理やチューニングの実践テクニックまでを体系立てて解説します。

サンプルコードはすべてコピペで試せる形で掲載しています。

requestsのtimeout基礎

timeoutとは何かと必要性

HTTP通信では、ネットワークの遅延や外部サービスの障害などにより、応答が返ってこない状況が起こり得ます。

timeoutとは「この時間を過ぎても応答がなければ通信を中断する」という上限時間のことです。

Pythonのrequestsは、timeoutを指定しない場合、OS側のソケットの設定に依存し、実質「かなり長い」時間待ち続けることがあります。

これは次のような問題を引き起こします。

  • バッチ処理が終わらず、後続ジョブが詰まる
  • Webアプリのワーカーが塞がり、サービス全体が重くなる
  • 外部API障害時に、アプリ全体が巻き込まれてダウンする

そのため、外部サービスにアクセスするコードには、基本的に必ずtimeoutを指定することが重要です。

接続timeoutと読み取りtimeoutの違い

timeoutには大きく2種類あります。

1つ目は接続timeout(connect timeout)です。

これは、クライアントからサーバへのTCP接続を確立するまでに待つ最大時間です。

サーバがダウンしていたり、DNS解決ができないような場合は、接続timeoutが発生します。

2つ目は読み取りtimeout(read timeout)です。

これは、接続はできたものの、サーバからレスポンスデータが返ってくるまでに待つ最大時間です。

サーバ内部の処理が遅い場合や、高負荷で応答が返せない場合には、読み取りtimeoutの問題になります。

connect timeoutは短め(数秒)、read timeoutはある程度長め(要求に応じて数秒〜数十秒)にするのが典型的な設計です。

理由として、接続できない状況は待っても改善が少ない一方で、正常な重い処理(レポート生成など)では、レスポンス時間自体が長くなることがあるためです。

デフォルト動作と注意点

Pythonのrequestsでは、timeoutを指定しないと、「無制限」ではありませんが、実務上ほぼ無制限に近い長さで待ち続ける挙動となります。

公式ドキュメントでも、timeoutの指定を推奨しています。

以下のようなコードは、実運用では避けた方が安全です。

Python
import requests

# timeoutを指定していないNG例
resp = requests.get("https://api.example.com/data")
print(resp.status_code)

環境によっては、外部APIが一時的にハングしただけで、この行で数分〜十数分止まり続ける可能性があります。

本番コードでは、原則としてすべてのHTTP通信にtimeoutを明示指定すると考えておくと安全です。

timeoutの指定パターンと書き方

単一値で指定するtimeoutの基本

もっともシンプルな指定方法は、timeout引数に単一の数値(秒)を渡す方法です。

この場合、connectとreadの両方に同じ値が適用されます。

Python
import requests

# 両方に5秒のtimeoutを設定する例
response = requests.get(
    "https://httpbin.org/delay/3",  # 3秒後に応答を返すテストAPI
    timeout=5                       # connect + read に5秒を設定
)
print(response.status_code, response.elapsed.total_seconds())
実行結果
200 3.0

このとき、接続確立とレスポンス読み取りのいずれかが、合計で5秒を超えてしまうとrequests.exceptions.Timeoutが送出されます。

簡易的なスクリプトや検証コードでは、timeout=5のような指定でも問題ありません。

ただし、本番システムや長期運用を前提としたアプリでは、connectとreadを分けて設計する方が望ましいです。

タプル(connect, read)で指定するtimeout

requestsでは、timeout=(connect, read)という形でタプルを渡すと、接続timeoutと読み取りtimeoutを個別に指定できます。

Python
import requests

# (connect_timeout, read_timeout) のタプルで指定
url = "https://httpbin.org/delay/3"  # 3秒遅延

response = requests.get(
    url,
    timeout=(2.0, 5.0)  # 接続は2秒まで、レスポンス読み取りは5秒まで
)

print("status:", response.status_code)
print("elapsed:", response.elapsed.total_seconds())
実行結果
status: 200
elapsed: 3.0

この例では、接続確立が2秒以内に終わり、その後レスポンスの読み取りに最大5秒まで待つ設計になっています。

典型的な組み合わせとしては、次のようなパターンがよく使われます。

  • 外部APIコール: timeout=(3.0, 5.0)
  • やや重めの処理(レポート生成など): timeout=(3.0, 20.0)
  • バッチ処理の長時間待ち: timeout=(5.0, 60.0)

このように、接続timeoutは短く、read timeoutはユースケースに応じて調整するのが定石です。

セッション(Session)とtimeoutの組み合わせ

requests.Sessionを使うと、コネクションの再利用や共通ヘッダの設定ができ、パフォーマンスや可読性が向上します。

timeout自体はSessionオブジェクトに「デフォルト値」として直接保持されないため、各リクエストごとにtimeout引数を指定する必要があります。

Python
import requests

session = requests.Session()
session.headers.update({"User-Agent": "my-app/1.0"})

def fetch_with_default_timeout(url, timeout=(3.0, 5.0)):
    # 共通のtimeoutを関数でラップして使いやすくする
    return session.get(url, timeout=timeout)

resp = fetch_with_default_timeout("https://httpbin.org/get")
print(resp.status_code)
実行結果
200

このように、Sessionと小さなヘルパー関数を組み合わせて「共通のtimeoutポリシー」を適用するのが実務でよく使われるパターンです。

代表的なtimeout設定レシピ

外部API向けの安全なtimeout設定例

外部APIは、自社システムからは制御できないため、「落ちる」「遅い」ことを前提に防御的に設計する必要があります。

代表的な例として、次のようなtimeoutポリシーがあります。

  • 接続timeout: 1〜3秒
  • 読み取りtimeout: 3〜10秒程度(サービスのSLOや要件に応じて)

実装例を示します。

Python
import requests
from requests.exceptions import Timeout, RequestException

API_TIMEOUT = (2.0, 6.0)  # (connect, read)

def call_external_api():
    url = "https://api.example.com/v1/resource"
    try:
        resp = requests.get(url, timeout=API_TIMEOUT)
        resp.raise_for_status()
        return resp.json()
    except Timeout:
        # 外部APIの遅延・障害
        # ログ出力やフォールバック処理などを行う
        return {"status": "timeout", "data": None}
    except RequestException as e:
        # その他の通信エラー
        return {"status": "error", "detail": str(e)}

result = call_external_api()
print(result)
実行結果
{'status': 'timeout', 'data': None}

(※実際の結果は接続先や環境に依存します)

このように、外部APIには明示的なtimeoutと、タイムアウト時のフォールバック戦略をセットで設計することが重要です。

バッチ処理での長めtimeout設定パターン

夜間バッチなど、一括処理で時間に多少の余裕がある場面では、通常のオンライン処理より長めのtimeoutを設定することがあります。

ただし、無制限に伸ばすのではなく、「上限は必ず決める」ことがポイントです。

Python
import requests
from requests.exceptions import Timeout

BATCH_TIMEOUT = (5.0, 60.0)  # バッチ専用の長めtimeout

def download_large_report(job_id: str):
    url = f"https://report.example.com/jobs/{job_id}/result"
    try:
        resp = requests.get(url, timeout=BATCH_TIMEOUT)
        resp.raise_for_status()
        return resp.content  # バイナリ(例: PDF)
    except Timeout:
        # バッチジョブなら、リトライやステータス更新などを行う
        raise RuntimeError(f"Job {job_id} timed out while downloading report")

content = download_large_report("job-123")
print(len(content))
実行結果
102400

(サイズは仮の例です)

このように、バッチ処理用・オンライン処理用でtimeoutを分けて設計すると、ユーザー体験と処理の安定性のバランスを取りやすくなります。

Webアプリ(Django/Flask)でのtimeout設計

Webアプリケーションでは、1リクエストあたりの処理時間が長くなると、ワーカー数が枯渇してサービス全体がダウンしやすくなります。

そのため、外部API呼び出しに対するtimeoutは特に重要です。

典型的な目安としては、次のようなガイドラインが考えられます。

  • ユーザー操作に対するレスポンス目標: 0.5〜2秒
  • 外部APIを複数呼ぶ場合、その合計をこの中に収める
  • 1つのAPI呼び出しに許せるtimeoutは、数百ミリ秒〜1秒台が望ましい

Flaskを例にして、requestsのtimeoutとユーザーへの応答をセットで設計する例を示します。

Python
from flask import Flask, jsonify
import requests
from requests.exceptions import Timeout

app = Flask(__name__)

API_TIMEOUT = (1.0, 2.0)  # Webアプリ用に短めのtimeout

@app.route("/profile/<user_id>")
def profile(user_id):
    url = f"https://api.example.com/users/{user_id}"
    try:
        resp = requests.get(url, timeout=API_TIMEOUT)
        resp.raise_for_status()
        data = resp.json()
        return jsonify({"ok": True, "profile": data})
    except Timeout:
        # 一定時間待っても応答がない場合は、簡易情報のみ返すなどのフォールバックも検討
        return jsonify({
            "ok": False,
            "error": "timeout",
            "message": "現在プロフィール情報を取得できません。しばらくしてから再度お試しください。"
        }), 504

if __name__ == "__main__":
    app.run(debug=True)
実行結果
 * Running on http://127.0.0.1:5000

ここでのポイントは、timeout発生時に「ユーザー向けのエラー応答」を明確に設計している点です。

詳細は後述のエラー処理の章で解説します。

再試行(retry)とtimeoutを組み合わせる

一時的なネットワーク障害であれば、短いtimeoutと複数回のretryを組み合わせることで成功率を高められます。

ただし、retry回数を増やしすぎると全体の待ち時間が伸びるため、「1回あたりのtimeout × リトライ回数」が許容範囲内に収まるよう設計します。

requests単体には高機能なretry機能はありませんが、urllib3.util.retry.RetryHTTPAdapterを組み合わせて実現できます。

Python
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def create_session_with_retry(
    total_retries=3,
    backoff_factor=0.5,
    status_forcelist=(500, 502, 503, 504),
    timeout=(1.0, 2.0),
):
    session = requests.Session()

    retry = Retry(
        total=total_retries,
        read=total_retries,
        connect=total_retries,
        backoff_factor=backoff_factor,  # 再試行間隔(指数的に増加)
        status_forcelist=status_forcelist,
        allowed_methods=["GET", "POST"],
        raise_on_status=False,
    )

    adapter = HTTPAdapter(max_retries=retry)
    session.mount("http://", adapter)
    session.mount("https://", adapter)

    # timeoutはアダプタには設定できないので、呼び出し側で指定する
    session.request_timeout = timeout  # 自前プロパティとして保持しておく

    return session

session = create_session_with_retry()

try:
    resp = session.get(
        "https://httpbin.org/status/500",
        timeout=session.request_timeout
    )
    print(resp.status_code)
except requests.exceptions.RequestException as e:
    print("error:", e)
実行結果
500

この例では、サーバが一時的に500を返した場合に自動的に再試行します。

timeoutとretryの掛け合わせにより、最悪ケースでの待ち時間(タイムライン)を事前に計算しておくことが重要です。

エラー処理と例外ハンドリング

timeout例外(RequestException, Timeout)の扱い方

requestsのtimeoutに関連する代表的な例外は、次の2つです。

  • requests.exceptions.Timeout
  • requests.exceptions.RequestException (通信全般の基底クラス)

TimeoutはRequestExceptionのサブクラスなので、優先的にTimeoutを捕捉し、それ以外の通信エラーはRequestExceptionでまとめて扱うパターンが多いです。

Python
import requests
from requests.exceptions import Timeout, RequestException

def fetch(url, timeout=(2.0, 5.0)):
    try:
        resp = requests.get(url, timeout=timeout)
        resp.raise_for_status()
    except Timeout as e:
        # timeoutは明確に区別して扱う
        raise RuntimeError(f"Request to {url} timed out") from e
    except RequestException as e:
        # DNSエラー、接続拒否、HTTPエラーなど
        raise RuntimeError(f"Request to {url} failed: {e}") from e
    else:
        return resp.text

print(fetch("https://httpbin.org/get")[:80])
実行結果
{
  "args": {},
  "headers": {
    "Accept": "*/*",

このように、timeoutを特別扱いすることで、障害時の挙動(リトライやフォールバック)を細かく制御できます。

ログ設計とリトライ戦略の考え方

timeoutや通信エラーが発生した場合、適切なログを残して原因分析や監視に活用することが重要です。

最低限、次の情報をログに含めると役に立ちます。

  • 呼び出したURL
  • timeout設定値(connect, read)
  • リトライ回数と経過時間
  • ステータスコード(応答があった場合)
  • エラー種別(Timeout、ConnectionErrorなど)

簡易的なログ実装例を示します。

Python
import logging
import time
import requests
from requests.exceptions import Timeout, RequestException

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def fetch_with_retry(url, timeout=(1.0, 2.0), retries=2):
    for attempt in range(1, retries + 2):  # 初回 + リトライ回数
        start = time.time()
        try:
            resp = requests.get(url, timeout=timeout)
            resp.raise_for_status()
            elapsed = time.time() - start
            logger.info(
                "success url=%s status=%s elapsed=%.2f attempt=%s",
                url, resp.status_code, elapsed, attempt
            )
            return resp
        except Timeout as e:
            elapsed = time.time() - start
            logger.warning(
                "timeout url=%s timeout=%s elapsed=%.2f attempt=%s error=%s",
                url, timeout, elapsed, attempt, e
            )
        except RequestException as e:
            elapsed = time.time() - start
            logger.error(
                "request_error url=%s elapsed=%.2f attempt=%s error=%s",
                url, elapsed, attempt, e
            )
            break  # リトライしても無駄そうな場合は中断
    raise RuntimeError(f"Failed to fetch {url} after {retries + 1} attempts")

fetch_with_retry("https://httpbin.org/delay/3", timeout=(1.0, 1.0), retries=1)
実行結果
WARNING:__main__:timeout url=https://httpbin.org/delay/3 timeout=(1.0, 1.0) elapsed=1.00 attempt=1 error=...
ERROR:__main__:request_error url=https://httpbin.org/delay/3 elapsed=1.00 attempt=2 error=...
Traceback (most recent call last):
  ...
RuntimeError: Failed to fetch https://httpbin.org/delay/3 after 2 attempts

このように、ログの粒度とリトライ戦略をセットで設計しておくと、障害解析や監視設定が行いやすくなります。

ユーザー向けエラーメッセージ設計

timeoutエラーが発生したとき、ユーザーにスタックトレースや内部URLをそのまま見せるのは望ましくありません。

内部ログとユーザー向けメッセージを分離して設計します。

例えばWebアプリなら、次のような方針が考えられます。

  • ログには詳細なエラー内容(URL、timeout値、スタックトレース)を出力
  • ユーザーには「現在サービスが混み合っています」「しばらくしてから再度お試しください」のように、簡潔で安心感のあるメッセージを表示
  • APIの場合は、「504 Gateway Timeout」「一時的なエラーコード」など、クライアントがリトライ可能か判断できるステータスとエラーコードを返す

擬似コード的な例を示します。

Python
from flask import Flask, jsonify
import logging
import requests
from requests.exceptions import Timeout

app = Flask(__name__)
logger = logging.getLogger(__name__)

@app.route("/search")
def search():
    query = "example"
    url = f"https://api.example.com/search?q={query}"
    timeout = (1.0, 2.0)

    try:
        resp = requests.get(url, timeout=timeout)
        resp.raise_for_status()
        return jsonify(resp.json())
    except Timeout as e:
        # 内部ログ(詳細)
        logger.warning("search timeout url=%s timeout=%s error=%s", url, timeout, e)

        # ユーザー向けレスポンス(簡潔)
        return jsonify({
            "ok": False,
            "error": "timeout",
            "message": "検索結果の取得に時間がかかっています。時間をおいて、もう一度お試しください。"
        }), 504

if __name__ == "__main__":
    app.run()
実行結果
 * Running on http://127.0.0.1:5000

このように、内部では詳細に、外部には簡潔にという住み分けを意識することが大切です。

実践テクニックとベストプラクティス

グローバル設定と共通関数でtimeoutを一元管理

プロジェクトが大きくなると、あちこちのコードでバラバラにtimeoutを指定してしまい、全体のポリシーが見えなくなることがあります。

これを防ぐために、共通モジュールでtimeoutを一元管理するのがおすすめです。

Python
# http_client.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

DEFAULT_TIMEOUT = (2.0, 5.0)

class TimeoutHTTPAdapter(HTTPAdapter):
    """デフォルトtimeoutを持つHTTPAdapter"""

    def __init__(self, *args, timeout=DEFAULT_TIMEOUT, **kwargs):
        self.timeout = timeout
        super().__init__(*args, **kwargs)

    def send(self, request, **kwargs):
        timeout = kwargs.get("timeout")
        if timeout is None:
            kwargs["timeout"] = self.timeout
        return super().send(request, **kwargs)

def create_http_session(timeout=DEFAULT_TIMEOUT, retries=2):
    session = requests.Session()
    retry = Retry(
        total=retries,
        backoff_factor=0.3,
        status_forcelist=[500, 502, 503, 504],
        allowed_methods=["GET", "POST"],
    )
    adapter = TimeoutHTTPAdapter(max_retries=retry, timeout=timeout)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session
Python
# 使用側のコード
from http_client import create_http_session

session = create_http_session()

# timeoutを明示しなくてもDEFAULT_TIMEOUTが適用される
resp = session.get("https://httpbin.org/get")
print(resp.status_code)
実行結果
200

このように、デフォルトtimeoutを持つHTTPクライアント層を用意しておけば、アプリケーションコード側ではtimeoutを意識する場面を減らしつつ、必要な箇所だけ上書きできます。

requests.adaptersで接続プールとtimeoutを最適化

requests.adapters.HTTPAdapterを使うと、接続プールのサイズや再試行戦略を細かく制御できます。

大量のリクエストを送るサービスでは、この調整が性能と安定性に直結します。

代表的なパラメータは次の通りです。

  • pool_connections: ホストごとのコネクションプール数
  • pool_maxsize: 各プールの最大接続数
  • max_retries: 再試行戦略(Retryオブジェクト)

サンプルコードを示します。

Python
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def create_optimized_session():
    session = requests.Session()

    retry = Retry(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.2,
        status_forcelist=[500, 502, 503, 504],
        allowed_methods=["GET", "POST"],
    )

    adapter = HTTPAdapter(
        max_retries=retry,
        pool_connections=20,  # 接続プール数
        pool_maxsize=50,      # 各プールの最大接続数
    )

    session.mount("http://", adapter)
    session.mount("https://", adapter)

    return session

session = create_optimized_session()
response = session.get("https://httpbin.org/get", timeout=(2.0, 5.0))
print(response.status_code)
実行結果
200

このように、timeoutの設計だけでなく、接続プールの設定も合わせてチューニングすることで、より高トラフィックな環境でも安定した動作が期待できます。

timeout設定のチューニング手順とチェックポイント

timeoutは勘に頼って決めるのではなく、実測データに基づいてチューニングするのが理想です。

典型的な手順は次のようになります。

  1. まず、外部APIや自社サービスの現状のレスポンス時間分布(p50/p90/p95/p99など)を計測する
  2. 許容できるユーザー待ち時間やバッチ処理時間から、理想的なtimeoutの上限を逆算する
  3. ステージング環境で、決めたtimeout値で負荷テストを行い、タイムアウト頻度や再試行の影響を確認する
  4. 本番導入後も、timeoutエラー率や全体の遅延をモニタリングし、必要に応じて調整する

チェックすべきポイントとしては、次のような観点があります。

  • timeoutが短すぎて、正常なリクエストまで頻繁に切っていないか
  • timeoutが長すぎて、ワーカーが占有されリクエスト待ちが増えていないか
  • retry回数とtimeoutの掛け合わせで、「最悪ケースの処理時間」がどれくらいになるかを把握しているか
  • 外部API側のレートリミットやSLAに対して、こちらのretry戦略が過度な負荷になっていないか

このように、timeoutは一度決めたら終わりではなく、運用しながら継続的に見直すべきパラメータと言えます。

まとめ

本記事では、Pythonのrequestsにおけるtimeout設定の基礎から実戦的なレシピまでを解説しました。

connectとreadの違いを理解し、単一値とタプルを使い分け、SessionやHTTPAdapterで共通ポリシーを実装することで、外部API障害時にも安定して動作する堅牢なクライアントを構築できます。

さらに、timeoutとretry、接続プール、ログ設計を総合的に設計し、実測値に基づいてチューニングしていくことで、パフォーマンスと信頼性の両立が可能になります。

運用中のサービスでまだtimeoutを明示していない箇所があれば、まずはそこから着手してみてください。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!