「リトライを入れたのに、なぜか 429 が増えた気がする」— Claude API を本番投入した方から、これを一番よく相談されます。私自身も、初めて運用していた API クライアントに「3 秒待って 3 回まで再送」というシンプルな実装を入れたとき、ピーク時間帯にレート制限ヒットがかえって増えた経験があります。
直感的には、リトライは「優しい挙動」であるはずです。失敗したら少し待って再送するだけなのに、なぜ事態が悪くなるのでしょうか。理由は単純で、Retry-After ヘッダーを読まずに固定間隔で再送するクライアントが複数走ると、サーバーから見れば「同期した波」になるからです。Anthropic のドキュメントでは「指数バックオフを推奨」とだけ書かれていますが、実際に効くのは「ジッター付き指数バックオフ + Retry-After 優先」の組み合わせです。
ここでは私が個人開発で複数のアプリに Claude API を組み込む過程で何度も踏んだ「リトライの罠」を、診断順に整理します。コード例はすべて Node.js(公式 SDK 不使用の最小構成)で示しますが、Python やその他の言語でも考え方はそのまま使えます。
まずは usage と response headers を見る
リトライ戦略の見直しは、コードを書く前にサーバーから何が返っているかを確認するところから始めます。429 が返ってきたとき、レスポンスには次のヘッダーが含まれていることがあります。
retry-after: 再試行までに最低限待つべき秒数(数値、または HTTP 日付)anthropic-ratelimit-requests-remaining: 残りリクエスト数anthropic-ratelimit-requests-reset: 残数がリセットされる UNIX 時刻anthropic-ratelimit-tokens-remaining: 残りトークン数anthropic-ratelimit-tokens-reset: トークン枠がリセットされる UNIX 時刻
最初にやるべき診断は、429 を受け取ったときにこれらの値を全てログに出すことです。私は最初これを見ていなかったので、「なんとなく混雑しているらしい」程度の認識でした。実際にログを取り始めると、ある特定のリクエストでだけトークン枠を一気に使い切っていることに気づきました。原因はキャッシュ無効な巨大プロンプトを並列送信していたことで、リトライ戦略の問題というより設計の問題でした。
// 失敗時に必ずヘッダーをログに出す(最低限のフィールドだけ抜粋)
async function callClaude(body) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
// ★ 429 / 529 の時は必ずヘッダーを保存して原因分析に使う
console.error({
status: res.status,
retryAfter: res.headers.get("retry-after"),
reqRemaining: res.headers.get("anthropic-ratelimit-requests-remaining"),
reqReset: res.headers.get("anthropic-ratelimit-requests-reset"),
tokRemaining: res.headers.get("anthropic-ratelimit-tokens-remaining"),
tokReset: res.headers.get("anthropic-ratelimit-tokens-reset"),
requestId: res.headers.get("request-id"),
});
}
return res;
}request-id も忘れずに記録してください。Anthropic サポートに問い合わせる必要が出たとき、これが無いと調査が進みません。
ありがちな悪手 1: 固定秒数で待つだけのリトライ
最初に書きがちなのが、こういうコードです。
// ❌ 悪い例: 何度失敗しても同じ間隔で再送する
async function retryFixed(body, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const res = await callClaude(body);
if (res.ok) return res;
if (res.status === 429 || res.status === 529) {
await new Promise((r) => setTimeout(r, 3000)); // 常に 3 秒
continue;
}
return res;
}
throw new Error("max retries exceeded");
}これが本当に良くない理由は、複数のクライアントが同時に 429 を受けると、ちょうど 3 秒後に全員が同時に再送する点にあります。サーバー側から見れば「同期したスパイク」が等間隔で繰り返し来る状態になります。これがいわゆる thundering herd(雷のような群衆)問題で、リトライを書いたつもりが攻撃に近い波形を生み出すことになります。
ありがちな悪手 2: ジッターなしの指数バックオフ
「指数バックオフ」と聞いて、こう書く人も多いです。
// ❌ 微妙な例: ジッター無し
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s...
await new Promise((r) => setTimeout(r, delay));固定秒よりはマシですが、全クライアントの待機時間が同じである以上、波形は同期したままです。Anthropic 側から見ると「皆が 1 秒後に押し寄せ → 失敗 → 皆が 2 秒後に押し寄せ → 失敗」という綺麗な階段状のスパイクが残ります。
私の経験上、ここを直すだけで 429 のピーク発生率が体感半分以下になりました。
正しい実装: Retry-After 優先 + Full Jitter 指数バックオフ
AWS Architecture Blog で紹介されている "Full Jitter" バックオフは、Claude API でも非常に有効です。考え方はシンプルで、待機時間の上限を指数的に増やしつつ、その範囲でランダムに選ぶだけです。
// ✅ 推奨: Retry-After を最優先 + Full Jitter
function computeBackoffMs(attempt, retryAfter) {
// 1) サーバーが Retry-After を返したら最優先で従う
if (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds)) return seconds * 1000;
// HTTP 日付形式の場合
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
}
// 2) ヘッダー無しなら Full Jitter 指数バックオフ
const cap = 60_000; // 上限 60 秒
const base = 500; // 初期 0.5 秒
const exp = Math.min(cap, base * 2 ** attempt);
return Math.floor(Math.random() * exp); // 0〜exp の範囲でランダム
}
async function retryWithBackoff(body, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await callClaude(body);
if (res.ok) return res;
// 5xx と 429 のみリトライ。4xx 系(401, 400 等)は即時失敗
const retriable = res.status === 429 || res.status === 529 || (res.status >= 500 && res.status < 600);
if (!retriable) return res;
const wait = computeBackoffMs(attempt, res.headers.get("retry-after"));
console.warn(`[retry ${attempt + 1}/${maxRetries}] status=${res.status} waiting=${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("max retries exceeded");
}期待する挙動は次の通りです。サーバーが「3 秒待って」と言えばその通りに従い、何も指示が無ければ 0〜0.5s → 0〜1s → 0〜2s → 0〜4s … と、各クライアントが独立した時間で再送するため、波形が散らばります。
見落としやすい注意点
実装を直してもまだ 429 が減らない場合、以下を順に疑ってください。
- 冪等性の欠如: 同じリクエストを 2 回送ると副作用が出る処理(DB 書き込み等)に Claude 応答を直結している場合、リトライがバグになります。
message.idを使って重複検知するか、リクエスト側で UUID を発行して冪等キーを管理してください - 並列度が上限を超えている: アカウント Tier ごとに同時リクエスト数の暗黙的な上限があります。バックオフを正しくしてもプール全体が制限を超えていれば、何度直しても改善しません。
p-limitなどで明示的に並列数を絞るのが最短です - レスポンス未読のまま中断している: HTTP 接続を中断するとサーバー側ではリクエストが完了扱いされないことがあり、レート枠が無駄に消費されます。タイムアウトは AbortController で明示的に行い、応答がエラーでも
res.bodyを読み切るのが安全です - トークン枠の枯渇を 429 と勘違いしている:
anthropic-ratelimit-tokens-remainingがゼロなら、リクエスト数を絞っても解決しません。プロンプトキャッシュ導入や入力長削減が効きます。プロンプトキャッシュの実装パターンは Claude API のプロンプトキャッシュがヒットしない時の診断手順 にまとめました
ストリーミング応答との組み合わせ
ストリーミングを使っている場合、リトライ判断は「最初の message_start イベントが返ってきたかどうか」で分岐します。ストリームの途中で切れたものを最初からやり直すと、トークンが二重に課金される上にレート枠も二度消費されます。途中切断時の安全な扱いは Claude API ストリーミングを本番で落とさない実装 で詳しくまとめていますので、ストリーミング派の方はそちらと合わせて読んでみてください。
リトライの設計を本格的に
新規プロジェクトに毎回入れている小さな共通モジュール
私は新しい Claude API 連携を始めるとき、必ず同じヘルパーモジュールを最初に入れています。中身は fetch のラッパーで、ここで紹介したリトライ戦略を実装し、concurrency オプションで内部的に並列数を絞り、構造化ログを他のテレメトリと同じ場所に流すだけです。だいたい 80 行程度の小さなコードですが、価値は「最初にデプロイした時点で 429 をまともに扱える」ことです。本番のトラフィックが来てから教訓を学び直す必要がなくなります。複数の Claude 連携を運用しているなら、この小さな抽象化を一度作ってしまうのがおすすめです。
次にやるべき一歩
まず、いま動いている Claude API クライアントの 429 ハンドラーを開いて、retry-after ヘッダーを読んでいるかだけ確認してください。読んでいなければ、上記の computeBackoffMs をコピーして組み込むのが最短です。それだけでピーク帯のエラー率は明確に下がります。並列度が原因で詰まっている場合は、続けて p-limit での並列制御を入れる、という順番で進めるのが安全でした。