【Python】APIをむやみに再試行しない:回数制限・待ち時間・冪等性

PythonのTopに戻る

結論:再試行する失敗とメソッドを限定し、回数を数える

一時的なHTTP障害へ再試行を加える場合は、SessionへHTTPAdapterを設定し、urllib3のRetryで対象と上限を決める。全ての失敗を同じように再送せず、どのステータス、どのメソッド、何回までを許すかを明示する。例では状態コードに対する再試行だけを最大2回に限定し、接続・読込の失敗は自動再試行しない設定にする。

最初の1回に再試行2回を加えるので、同じ要求の試行数は最大3回となる。timeoutは一回の通信に対する設定であり、再試行の待機を含む全体時間の上限ではない。回数が増えるほど相手の負荷も実行時間も増える。何度も送る前に、再試行で回復する種類の問題なのかを判断しよう。

そのまま動かせる例

ローカルのHTTPサーバーを使い、2回503を返した後に成功するGET、ずっと503のGET、再試行しないPOST、対象外の401を比較する。Requestsとurllib3が必要である。POST先も検証用のサーバーで、応答回数を数える以外の更新処理は行わない。外部APIへの送信、実際の購入や更新、認証情報の使用は一切ない。

コード全体を保存して実行すると、サーバーが受け取った試行回数を表示する。HTTPAdapterはこのサーバーのURL接頭辞だけに取り付け、リダイレクトも無効にしている。Retry-Afterは検証用に0秒を返すので長い待機は発生しない。終了時にはSessionとサーバーを閉じる。実サービスの制限や認証との統合はこの例では試していない。

from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
import urllib3

counts = Counter()
class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass
    def answer(self):
        key = (self.command, self.path)
        counts[key] += 1
        ok = self.path == "/eventual" and counts[key] >= 3
        status = 200 if ok else (401 if self.path == "/auth" else 503)
        body = b'{"ok": true}' if ok else b'{"error": "temporary"}'
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        if status == 503:
            self.send_header("Retry-After", "0")
        self.end_headers()
        self.wfile.write(body)
    do_GET = answer
    do_POST = answer

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}/"
retry = Retry(total=2, connect=0, read=0, other=0, status=2, redirect=0,
              allowed_methods=frozenset({"GET", "HEAD"}),
              status_forcelist=[429, 500, 502, 503, 504],
              backoff_factor=0.01, backoff_max=0.1,
              respect_retry_after_header=True)
try:
    with requests.Session() as session:
        session.trust_env = False
        session.mount(base, HTTPAdapter(max_retries=retry))
        with session.get(base + "eventual", timeout=(1, 1), allow_redirects=False) as r:
            r.raise_for_status()
            assert r.json() == {"ok": True}
        try:
            session.get(base + "never", timeout=(1, 1), allow_redirects=False)
        except requests.exceptions.RetryError:
            print("retry exhausted: RetryError")
        else:
            raise AssertionError("expected retry limit")
        with session.post(base + "write", timeout=(1, 1), allow_redirects=False) as r:
            assert r.status_code == 503
        with session.get(base + "auth", timeout=(1, 1), allow_redirects=False) as r:
            assert r.status_code == 401
finally:
    server.shutdown()
    server.server_close()
    thread.join(timeout=2)
assert not thread.is_alive()
assert counts == {("GET", "/eventual"): 3, ("GET", "/never"): 3,
                  ("POST", "/write"): 1, ("GET", "/auth"): 1}
for key, count in sorted(counts.items()):
    print(*key, "attempts:", count)
print("urllib3:", urllib3.__version__)

実行結果

retry exhausted: RetryError
GET /auth attempts: 1
GET /eventual attempts: 3
GET /never attempts: 3
POST /write attempts: 1
urllib3: 2.8.0

対象の絞り方を読む

allowed_methodsをGETとHEADへ限定し、status_forcelistに一時障害として扱う候補を並べている。POSTの503は一回で返り、401も今回の対象には含めない。これは「POSTはどんな場合も絶対に再送されない」という一般的な説明ではなく、この設定と失敗種別での確認である。接続前のエラーなどは別の回数設定にも関わるため、connect・read・otherも明示している。

ずっと503のエンドポイントでは、上限に達してRequests側のRetryErrorになる。一方、再試行対象外の応答はResponseとして返るので、実処理ではraise_for_statusや期待するステータスの確認が引き続き必要である。再試行機能を取り付けただけで、全ての失敗が自動的に成功か例外へ分類されると考えないこと。

待ち時間とRetry-After

backoff_factorは続けて失敗したときの待機を調整する。直ちに同じ要求を集中させるより、間隔を空ける方が相手の回復を妨げにくい。多くの利用者が同時に再試行する場面ではジッターも検討できるが、利用中のurllib3版で対応を確認する。今回の小さな例は実際の待機秒数を性能値として比較するものではない。

respect_retry_after_header=Trueは対象の応答にあるRetry-Afterを尊重する指定である。backoff_maxは指数バックオフの上限であり、サーバーが指定したRetry-Afterの待機を同じ値へ制限する保証ではない。長すぎる指示や全体の締切を扱う必要があるなら、再試行を見送って後へ回すなど、サービスの契約と実行予算に応じた別の制御を設計しよう。

冪等性と二重実行を確認する

通信が失敗しても、相手が何もしていないとは限らない。更新を受け付けて処理した後、応答だけが届かなかった可能性がある。同じ更新を再送すれば二重登録などにつながるため、POSTを安易に対象へ追加しない。冪等性キーを使えるAPIでも、同じキーの有効期間や同一要求の判定規則はサービスの公式仕様に従う必要がある。

GETでも実装や業務上の契約を確認し、無制限のアクセスや禁止された再試行を行わない。認証不正や入力ミスは設定を直す必要があり、回数を増やすだけでは解決しない場合が多い。試行数、最終ステータス、対象IDを記録し、原因が分かる情報を残そう。失敗後に別の操作を勝手に実行するのではなく、定めた範囲で再試行を止められる構成が重要である。

確認環境と参考資料

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

関連項目:ThreadPoolExecutorで結果と失敗を取りこぼさず回収する / Requestsで通信の失敗を扱う:timeout・HTTPエラー・JSON解析

PythonのTopに戻る