【Python】Requestsで通信の失敗を扱う:timeout・HTTPエラー・JSON解析

PythonのTopに戻る

結論:通信・HTTP・JSON・内容を順に確認する

Requestsでデータを取得するときは、timeoutを付けて通信し、HTTPステータスを確認し、JSONを解析し、最後に必要なキーと型を検証する。この四つは別の失敗である。JSONが読めたから成功とは限らず、エラー応答の説明自体がJSONになっている場合もある。逆にHTTP 200でもHTMLや空の本文が返れば、期待したJSONとしては読めない。

raise_for_status()は4xx・5xxをHTTPErrorとして扱う。目的のAPIが200だけを成功とするなら、その条件も別に確認する。リダイレクトを受け付けるか、本文がない204をどう扱うかなどは、APIの契約に合わせて決める必要がある。「例外が出なければ何でも正常な測定値」として先へ進めないことが、取得処理の基本となる。

そのまま動かせる例

以下はRequestsと標準ライブラリだけで動く。自分のPC内の127.0.0.1に一時的なHTTPサーバーを立て、正常応答、503、壊れたJSON、遅い応答、型の違うJSON、応答前の切断を再現する。外部API、認証情報、実サービスへの書き込みは使わない。HTTPサーバー部分は動作確認用であり、公開用サーバーとして利用するものではない。

例全体を一つのファイルへ保存して実行する。通信先のポートはOSに選ばせ、終了時にサーバーとSessionを閉じる。session.trust_env=Falseはこのローカル実験が環境のプロキシ設定に左右されないための指定で、普段のネットワーク設定を一律に無効化する推奨ではない。実サービスの認証、TLS、プロキシや接続制限との統合はこの例では試していない。

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import time
import requests

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass
    def do_GET(self):
        if self.path == "/disconnect":
            self.close_connection = True
            return
        if self.path == "/slow":
            time.sleep(0.2)
        status = 503 if self.path == "/status" else 200
        body = b'not json' if self.path == "/json" else b'{"value": 3}'
        if self.path == "/schema":
            body = b'{"value": "wrong"}'
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError):
            pass  # The timeout client has already disconnected.

def fetch_value(session, url):
    with session.get(url, timeout=(1, 0.05), allow_redirects=False) as response:
        response.raise_for_status()
        if response.status_code != 200:
            raise ValueError("expected status 200")
        data = response.json()
    if not isinstance(data, dict) or type(data.get("value")) is not int:
        raise ValueError("value must be an integer")
    return data["value"]

server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.daemon_threads = False  # server_close waits for these short fixture handlers.
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
thread.start()
base = f"http://127.0.0.1:{server.server_port}"
try:
    with requests.Session() as session:
        session.trust_env = False  # Keep this fixture independent of proxy settings.
        assert fetch_value(session, base + "/ok") == 3
        print("ok: 3")
        expected = {"status": requests.HTTPError,
                    "json": requests.exceptions.JSONDecodeError,
                    "slow": requests.ReadTimeout,
                    "schema": ValueError,
                    "disconnect": requests.ConnectionError}
        for name, error_type in expected.items():
            try:
                fetch_value(session, base + "/" + name)
            except error_type as exc:
                print(name + ":", type(exc).__name__)
            else:
                raise AssertionError(name)
finally:
    server.shutdown()
    server.server_close()
    thread.join(timeout=2)
assert not thread.is_alive()
print("loopback server stopped: True")

実行結果

ok: 3
status: HTTPError
json: JSONDecodeError
slow: ReadTimeout
schema: ValueError
disconnect: ConnectionError
loopback server stopped: True

timeoutは通信全体の締切ではない

タプルのtimeoutは接続と読込の時間を分ける。例の1秒と0.05秒は人工的なローカル試験用で、外部APIに適した値を示すものではない。読込timeoutは、データを受け取れない時間が続いた場合の制限であり、ダウンロード全体がその秒数以内に完了する保証ではない。少しずつデータが届く場合や複数回の接続試行などで総時間は長くなり得る。

遅いエンドポイントはヘッダーを返す前に待つため、ここではReadTimeoutを再現できる。本文をストリーミングしている途中のタイムアウトなどでは、見える例外の包まれ方が異なる場合もある。timeoutを付けた事実だけで止まり方を決めつけず、自分の取得方法に近い失敗を再現して確認しよう。全体の締切が必要なら別に管理する必要がある。

JSONの型まで確認する

正常なJSONでも、valueが文字列なら数値計算に使う契約とは違う。この例はdictとintを明示的に確認し、Trueを整数として受け入れないようにしている。数値文字列を変換して使う設計も可能だが、その変換規則を明確にしたい。空の配列、欠けたキー、非有限値、単位の違いなど、取得先の仕様に合わせた検証を追加する。

JSONDecodeErrorとHTTPErrorは別の原因なので、何でもValueErrorへまとめてしまわない方が調査しやすい。今回は各ケースで期待する例外を個別に捕まえ、正常値と混ざらないことを確認する。実際の呼び出し側も、入力IDや応答ステータスを添えて失敗を返すなど、再試行や人の確認へつなげられる状態を残すとよい。

再試行とログを慎重に設計する

接続の問題は一時的かもしれないが、認証失敗や入力形式の誤りを同じ条件で何度送っても解決しない場合が多い。特に更新要求は、応答を失っただけでサーバー側では処理済みの可能性がある。エラーを捕まえたら即座に無制限で再送するのではなく、回数・待ち時間・対象メソッドを決める必要がある。再試行は別の設計として扱おう。

例外の文字列にはURLなどが含まれることがある。実APIのログではトークン、認証ヘッダー、個人情報をそのまま残さず、必要な診断情報を選ぶ。HTTPSの証明書確認を無効にして通信エラーを消すことも解決策にはしない。今回確かめたのはローカルでの失敗分類と後始末であり、実サービスへ接続する際は公式仕様に沿って設定と権限を確認してほしい。

確認環境と参考資料

例はLinux・CPython 3.12.14・Requests 2.34.2で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:asyncioの時間切れとキャンセルで後始末を漏らさない / printからloggingへ:重要度と例外の記録を使い分ける / APIをむやみに再試行しない:回数制限・待ち時間・冪等性 / JSONで保存できない値をどう扱う?日時・数値・日本語の受け渡し

PythonのTopに戻る