429 は前触れなく来たように見えて、実はずっと予告されていました。
あるバッチが走った夜、本番のワーカーが立て続けに 429 Too Many Requests を返し始めました。私が最初にやったのは、よくある対応です。指数バックオフを入れ、リトライ回数を増やした。翌朝、エラーは減っていましたが、代わりに処理が詰まって全体の遅延が伸び、月次のトークン請求も跳ねていました。リトライは問題を先送りしただけで、根っこには触れていなかったのです。
見落としていたのは、Claude API がレスポンスのたびに「あとどれだけ送れるか」を教えてくれていた事実でした。429 を受け取ってから反応するのは、常に後手です。このメモは、レート制限の余白を毎リクエスト計測し、枯渇する前に自分から発信を絞るまでの運用記録です。個人開発で回している小さな基盤での話ですが、私自身が同じ罠に二度はまりたくないので手順として残します。考え方は規模を問いません。
429 を待つ設計が後手になる理由
レート制限の対応には二つの立ち位置があります。反応型(429 が来たら待つ)と、先制型(余白が細ったら自分で絞る)です。多くの実装は前者から始まります。私もそうでした。
反応型の弱点は、429 が「もう手遅れ」の合図だという点です。制限に達した瞬間、その時点で走っている並列リクエストはまとめて弾かれます。バックオフで待てば個々のリクエストは救えますが、同じ制限窓に殺到した全員が待たされ、全体のスループットは階段状に落ちます。しかもリトライは同じトークンを二度・三度消費するため、請求はむしろ増える方向に働きます。
先制型は、制限に達する前に発信量を自分で調整します。そのために必要なのは、いま自分がどれだけ制限に近づいているかを知る手段です。それはすでに手元にあります。
余白はレスポンスヘッダに毎回書かれている
Claude API は 200 応答にも、レート制限の状態をヘッダで返します。主に見るのは残量と復帰時刻です。
| ヘッダ | 意味 | 使い方 |
|---|---|---|
| anthropic-ratelimit-requests-remaining | この窓で残り送れるリクエスト数 | 件数ベースの余白 |
| anthropic-ratelimit-tokens-remaining | 残り送れる入力トークン量の目安 | トークンベースの余白(実務ではこちらが先に枯れる) |
| anthropic-ratelimit-tokens-reset | トークン枠が回復する時刻(ISO) | 絞る時間の見積もり |
| retry-after | 429 時に待つべき秒数 | 反応型の最後の砦 |
大事なのは、これらが 429 のときだけでなく成功応答にも載っていることです。つまり平時から余白の推移を観測できます。私はこれを「残量」ではなく「余白率」に変換して扱うことにしました。上限に対する残りの割合にすれば、枠の大きさが違うテナント間でも同じ物差しで比べられます。
// src/lib/rateLimitMeter.ts
// レスポンスヘッダから余白率を算出し、逼迫を検知する軽量メーター
type Headroom = {
requestsRemaining: number;
tokensRemaining: number;
tokensReset: number | null; // epoch ms
tokensHeadroom: number; // 0.0(枯渇) 〜 1.0(潤沢)
};
// 上限は組織のプランに応じて設定(観測した最大残量を初期値にしてもよい)
const TOKEN_LIMIT = Number(process.env.ANTHROPIC_TOKEN_LIMIT ?? 200000);
export function readHeadroom(headers: Headers): Headroom {
const tokensRemaining = Number(
headers.get('anthropic-ratelimit-tokens-remaining') ?? TOKEN_LIMIT
);
const requestsRemaining = Number(
headers.get('anthropic-ratelimit-requests-remaining') ?? 0
);
const resetRaw = headers.get('anthropic-ratelimit-tokens-reset');
return {
requestsRemaining,
tokensRemaining,
tokensReset: resetRaw ? new Date(resetRaw).getTime() : null,
tokensHeadroom: Math.max(0, Math.min(1, tokensRemaining / TOKEN_LIMIT)),
};
}余白率という一つの数字に落とせば、あとはそれが細ったときにどう振る舞うかを決めるだけです。