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つです。
- 待ち時間の関数を自作している。
Retry-Afterがあればその秒数、なければwait_random_exponentialに任せます。tenacityのwaitには、retry_stateを受け取る関数を渡せます。 - 上限超えは待たない。
Retry-After: 3600に従って1時間ブロックするより、例外で呼び出し側に返すほうが扱いやすいためです。上限はmax_waitの1か所で決まります。 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を呼ぶ側の話です。隣接する話題は別の層にあります。
- Notion APIのように、特定サービスの制限値に合わせた移行スクリプトは、Notion APIのレート制限を守る移行スクリプトが扱っています。サービス固有の制限と、ここで作った汎用の再試行を組み合わせる関係です。
- Claude Code自身が行うAPIの再試行は、api_retryをstream-jsonで拾う記事で監視できます。
- 529 Overloadedが続くときの待ち方は、Claude Codeの529 Overloadedの記事にあります。
まとめ
再試行の品質は、書いたコードの見た目ではなく、障害の並びを再現するテストで決まります。Retry-After は秒数と日付の両形式を読み、上限を超えたら待たない。ジッタ入りの待ちは乱数を固定して上限を確かめる。この2点を完了条件にすれば、Claude Codeが書いた再試行ロジックを、実サーバーなしで検証できます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。