Claude Media
Claude Codeで429のRetry-Afterを守るHTTPクライアントを書く — バックオフをテストで固める

Claude Codeで429のRetry-Afterを守るHTTPクライアントを書く — バックオフをテストで固める

httpxとtenacityで、Retry-Afterを優先し、待ち時間に上限を付け、5xxとタイムアウトも再試行するクライアントをClaude Codeに書かせる手順です。モック転送でジッタまで固定します。

外部APIが429を返したとき、固定の sleep(1) で再試行するクライアントは、相手の指示を無視しています。サーバーが Retry-After: 120 と言っているのに1秒後に叩けば、また429です。

この記事では、Claude Codeに「Retry-Afterを優先し、待ち時間に上限を付け、5xxとタイムアウトも再試行するHTTPクライアント」を書かせます。仕様の根拠はRFC、実装はhttpxとtenacity、検証はモック転送です。待ち時間はテストの中で数値として固定し、ネットワークには一度も出ません。

429とRetry-Afterの約束事は何か

429は「一定時間内のリクエストが多すぎる」ことを示すステータスです。RFC 6585は、応答に詳細を含めることをSHOULD、Retry-After ヘッダーを含めることをMAYとしています。つまり、付いているとは限りません。

Retry-After の値はRFC 9110で2形式です。

Retry-After: 120
Retry-After: Fri, 31 Dec 1999 23:59:59 GMT

前者は待つ秒数(非負の整数)、後者は再試行してよい日時です。RFC 9110は503と3xxにも同じヘッダーを使うと書いています。

クライアントの方針は、この2つの事実から決まります。

状況待ち方理由
429 / 503で Retry-After あり待ち方その値を使う理由サーバー自身の指示だから
Retry-After が上限より長い待ち方待たずに失敗させる理由呼び出し側がハングしないため
ヘッダーなしの429・5xx待ち方指数バックオフ + ジッタ理由複数クライアントの再試行を散らすため
接続・読み取りのタイムアウト待ち方同じくバックオフ理由一時的な障害の可能性が高い
404などの4xx待ち方再試行しない理由待っても結果が変わらない

Claude Codeにどう頼むか

「リトライを付けて」だけでは、固定間隔や無制限の再試行が返ってきがちです。守ってほしい条件を、検証できる形で先に渡します。プロジェクトの CLAUDE.md には、次のような断片を置いておきます。

## 外部APIクライアントの規約
- 再試行は httpx + tenacity。自前の while ループと time.sleep は書かない
- Retry-After(秒数・HTTP日付の両形式)があれば最優先で使う
- 1回の待ちには上限(既定 30 秒)を付け、Retry-After が上限を超えたら待たず例外にする
- ヘッダーなしの待ちは指数バックオフ + フルジッタ。試行回数にも上限を付ける
- sleep は引数で差し替え可能にし、テストは実時間を待たない
- 完了条件: モック転送で 429 連続・5xx・タイムアウトを再現する pytest が全件通ること

そのうえでの依頼文は短くて足ります。

retry_client.py に、httpx.Client を包む RetryingClient を書いて。
CLAUDE.md の規約に従い、test_retry_client.py も同時に作って、pytest が通るまで直して。

「pytestが通るまで直して」と完了条件を渡すのが肝です。通った出力を見てから終わらせる流れになるので、実装だけ書いて終わる事態を避けられます。

tenacityで書いたクライアント

次が、手元で実際に動かした実装です(Python 3.12、httpx 0.28.1、tenacity 9.2.1)。

from __future__ import annotations
 
import time
from datetime import timezone
from email.utils import parsedate_to_datetime
 
import httpx
from tenacity import (
    Retrying,
    retry_if_exception,
    stop_after_attempt,
    wait_random_exponential,
)
 
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
 
 
class RetryableStatus(Exception):
    def __init__(self, response, retry_after):
        super().__init__(f"HTTP {response.status_code}")
        self.response = response
        self.retry_after = retry_after
 
 
class RetryAfterTooLong(Exception):
    """Retry-After が上限を超えるので待たずに諦める。"""
 
 
def parse_retry_after(value, now=None):
    if not value:
        return None
    value = value.strip()
    if value.isdigit():
        return float(value)
    try:
        when = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    now = time.time() if now is None else now
    return max(0.0, when.timestamp() - now)
 
 
class RetryingClient:
    def __init__(self, client, *, max_attempts=5, base=0.5,
                 max_wait=30.0, sleep=time.sleep):
        self.client, self.max_attempts = client, max_attempts
        self.max_wait, self.sleep = max_wait, sleep
        self._backoff = wait_random_exponential(
            multiplier=base, max=max_wait)
 
    def _wait(self, state):
        exc = state.outcome.exception()
        if isinstance(exc, RetryableStatus) and exc.retry_after is not None:
            return exc.retry_after
        return self._backoff(state)
 
    def _retryable(self, exc):
        if isinstance(exc, RetryableStatus):
            return exc.retry_after is None or exc.retry_after <= self.max_wait
        return isinstance(exc, httpx.TransportError)
 
    def request(self, method, url, **kw):
        def once():
            resp = self.client.request(method, url, **kw)
            if resp.status_code in RETRYABLE_STATUS:
                ra = parse_retry_after(resp.headers.get("Retry-After"))
                if ra is not None and ra > self.max_wait:
                    raise RetryAfterTooLong(
                        f"Retry-After {ra:.0f}s > {self.max_wait}s")
                raise RetryableStatus(resp, ra)
            return resp
 
        return Retrying(
            retry=retry_if_exception(self._retryable),
            wait=self._wait,
            stop=stop_after_attempt(self.max_attempts),
            sleep=self.sleep,
            reraise=True,
        )(once)

設計の要点は3つです。

  1. 待ち時間の関数を自作している。 Retry-After があればその秒数、なければ wait_random_exponential に任せます。tenacityの wait には、retry_state を受け取る関数を渡せます。
  2. 上限超えは待たない。 Retry-After: 3600 に従って1時間ブロックするより、例外で呼び出し側に返すほうが扱いやすいためです。上限は max_wait の1か所で決まります。
  3. reraise=True を付けている。試行回数を使い切ったとき、tenacity独自の RetryError ではなく、最後の例外がそのまま上がります。

wait_random_exponential は、AWSのブログが紹介する「フルジッタ」に当たる方式で、試行n回目の待ちは0から base × 2^(n-1) の範囲の乱数になります。範囲の上限は max で頭打ちです。

モック転送で429の連続・5xx・タイムアウトを再現する

実サーバーを立てなくても、httpxの MockTransport に応答の列を返させれば、障害の並びを自由に作れます。sleep を差し替えれば、待ち時間は実行されず、記録されるだけです。

def seq(*responses):
    it = iter(responses)
    calls = []
    def handler(req):
        calls.append(req)
        r = next(it)
        if isinstance(r, Exception):
            raise r
        return r
    return handler, calls

テストは7観点です。抜粋します。

def test_retry_after_seconds_is_used_as_is():
    h, calls = seq(httpx.Response(429, headers={"Retry-After": "3"}),
                   httpx.Response(200))
    c, sleeps = make(h)
    assert c.request("GET", "http://x/").status_code == 200
    assert sleeps == [3.0] and len(calls) == 2
 
 
def test_retry_after_over_cap_gives_up_without_sleeping():
    h, calls = seq(httpx.Response(429, headers={"Retry-After": "3600"}))
    c, sleeps = make(h, max_wait=30)
    with pytest.raises(RetryAfterTooLong):
        c.request("GET", "http://x/")
    assert sleeps == [] and len(calls) == 1
 
 
def test_timeout_is_retried_then_succeeds():
    h, calls = seq(httpx.ReadTimeout("slow"), httpx.Response(200))
    c, sleeps = make(h)
    assert c.request("GET", "http://x/").status_code == 200
    assert len(sleeps) == 1

残りは、HTTP日付形式の Retry-After を秒に直すテスト、5xxが続いたときの試行回数と待ち時間の上限、404を再試行しないテストです。実行結果は次のとおりです。

python -m pytest -q
........                                                                 [100%]
8 passed in 0.10s

ジッタは「確率的に通る」テストにしない

ジッタ入りの待ち時間は、毎回値が変わります。「4秒を超えないはず」というテストは、上限の実装を消しても、乱数がたまたま小さければ通ります。

実際に、バックオフの上限(max=max_wait)を外して試すと、乱数の出方次第で通るテストが混じりました。そこで、tenacityが使う random.uniform を「範囲の上限を返す関数」に差し替えるテストを足しています。

def test_envelope_is_exact_when_jitter_takes_the_upper_bound(monkeypatch):
    import tenacity.wait as tw
    monkeypatch.setattr(tw.random, "uniform", lambda lo, hi: hi)
    h, _calls = seq(*[httpx.Response(503)] * 5)
    c, sleeps = make(h, max_attempts=5, base=1.0, max_wait=4.0)
    with pytest.raises(RetryableStatus):
        c.request("GET", "http://x/")
    assert sleeps == [1.0, 2.0, 4.0, 4.0]

上限を外した実装にこのテストを当てると、待ちが [1, 2, 4, 8] になって失敗します。Claude Codeに書かせたテストは、実装を壊して落ちるかを確かめて初めて信用できます。「上限を外して」と頼み、テストが赤くなるところまで見るのが確実です。

Retry-Afterの変な値をどう扱うか

実サーバーは、仕様どおりの値だけを返してくれるとは限りません。parse_retry_after に、境界の入力を実際に渡した結果は次のとおりです。

入力戻り値その後の動き
0戻り値0.0その後の動きすぐ再試行
7(前後に空白)戻り値7.0その後の動き7秒待つ
1.5戻り値なしその後の動きバックオフに切り替え
-5戻り値なしその後の動きバックオフに切り替え
過去の日付戻り値0.0その後の動きすぐ再試行
soon戻り値なしその後の動きバックオフに切り替え

RFC 9110が定める秒数は非負の整数なので、小数や負数は仕様外です。読めない値は無視してバックオフに回すと決めておけば、不正なヘッダー1つでクライアントが止まることはありません。

日付形式には別の注意があります。待ち時間は「サーバーが書いた日時」と「自分の時計」の差で決まるため、手元の時計がずれていると、待ちが長すぎたり0秒になったりします。0秒になっても、次の再試行で429が返れば、その応答の指示とバックオフが働いて間隔は開いていきます。差が上限を超えるほど大きければ、前節の規則どおり待たずに失敗します。

待ちをログに残す

再試行は、成功すると痕跡が消えます。あとから「その日は何回待ったのか」を知りたいなら、tenacityの before_sleep にログ関数を渡します。

import logging
from tenacity import before_sleep_log
 
logger = logging.getLogger("retry")
 
Retrying(
    ...,
    before_sleep=before_sleep_log(logger, logging.WARNING),
)

待ちに入る直前に1行ログが出るので、Retry-After に従った待ちとバックオフの待ちを、あとから数えられます。Claude Codeに原因調査を頼むときも、このログがあれば「どのAPIで何回詰まったか」を推測でなく記録から読ませられます。

なぜジッタが要るのか

同じ時刻に失敗した100台のクライアントが、全員「2秒後」に再試行すると、サーバーには2秒後に100件が同時に届きます。待ち時間を乱数で散らすのがジッタの役目です。tenacityのドキュメントも、固定の待ちとジッタの組み合わせを、群れの殺到(thundering herd)を避ける手段として紹介しています。ヘッダーなしの5xxやタイムアウトでは、サーバーが時刻をずらしてくれないので、クライアント側の乱数が頼りです。

最悪でどれだけ待つかを数字で決める

試行5回、max_wait=30 の設定では、待ちは最大4回で、1回ごとに30秒が上限です。バックオフの待ちは合計で最長120秒、Retry-After が30秒以内なら同じ範囲に収まります。

ただし、これは待ち時間だけの話です。各リクエストのタイムアウトは別に足されます。httpxは既定で5秒のタイムアウトを全面に適用し、接続・読み取り・書き込み・コネクションプール取得の4種類を別々に設定できます。httpx.Timeout(10.0, connect=60.0) のように、全体の値と個別の値を併記できる形です。

呼び出し元が「最長でX秒で結果を返す」契約を持つなら、試行回数、max_wait、タイムアウトの3つをまとめて見積もります。

落とし穴

POSTを黙って二重送信してしまう

サンプルは渡されたメソッドをすべて再試行します。GETなら問題ありませんが、決済や注文のPOSTで読み取りタイムアウトを再試行すると、サーバー側で処理済みの操作を再送する恐れがあります。冪等でないメソッドは対象から外すか、Idempotency-Key のような仕組みがあるAPIに限って再試行させます。Claude Codeには「POSTは再試行しない」を規約に足し、テストも1本書かせます。

呼び出し側から見た使い方と例外

組み立ては、タイムアウトを決めた httpx.Client を渡すだけです。

import httpx
from retry_client import RetryAfterTooLong, RetryableStatus, RetryingClient
 
http = httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0))
client = RetryingClient(http, max_attempts=5, max_wait=30.0)
 
try:
    resp = client.request("GET", "https://api.example.com/items")
except RetryAfterTooLong:
    ...  # 上限を超える待ちを要求された。ジョブを後回しにする
except RetryableStatus as e:
    ...  # 試行を使い切った。e.response に最後の応答が残っている

試行を使い切ったとき、サンプルは最後の429や5xxを戻り値として返さず、例外にして上げます。「成功した応答」と「諦めた応答」が型で区別できるので、呼び出し側が429のボディを成功扱いで読んでしまう事故を避けられます。最後の応答の本文やヘッダーが必要なら、e.response から読めます。

Claude Codeに書かせたコードを読むときは、この境界を見ます。再試行を使い切った後の挙動が、テストにも実装にも書かれていない場合は、追加で1本テストを頼みます。

Stop hookでpytestを完了条件にする

「pytestが通るまで直して」は、プロンプトの約束にすぎません。確実にしたいなら、Stop hookで機械的に確かめます。Stopは「Claude Codeが応答を終えたとき」に走るイベントで、hookが終了コード2を返すと、Claude Codeは止まらずに会話を続けます。stderrの内容が理由として伝わります。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/pytest-gate.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
input=$(cat)
# 続行中の再発火では二重に止めない
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
out=$(python -m pytest -q 2>&1) || { echo "$out" >&2; exit 2; }

Stop hookの入力には stop_hook_active が入っていて、hookによる続行の最中は true になります。条件が解消しない場合に無限に止め続けないための目印です。この設定を .claude/settings.json に置けば、リポジトリにコミットしてチームで共有できます。PostToolUseとの使い分けは、hooksでテストを自動実行する記事にまとめています。

関連するリトライとの切り分け

この記事のクライアントは、自分のコードが外部APIを呼ぶ側の話です。隣接する話題は別の層にあります。

まとめ

再試行の品質は、書いたコードの見た目ではなく、障害の並びを再現するテストで決まります。Retry-After は秒数と日付の両形式を読み、上限を超えたら待たない。ジッタ入りの待ちは乱数を固定して上限を確かめる。この2点を完了条件にすれば、Claude Codeが書いた再試行ロジックを、実サーバーなしで検証できます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. rfc-editor.org/rfc/rfc9110.html
  2. rfc-editor.org/rfc/rfc6585.html
  3. tenacity.readthedocs.io/en/latest/index.html
  4. python-httpx.org/advanced/timeouts
  5. code.claude.com/docs/en/hooks
この記事を共有:XはてブLinkedIn