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 )),
};
}
余白率という一つの数字に落とせば、あとはそれが細ったときにどう振る舞うかを決めるだけです。
余白が閾値を割ったら発信側を絞る
先制スロットルの中身はシンプルです。毎リクエストの応答から余白率を読み、それが決めておいた下限を割ったら、次に発信するリクエストへ意図的に間隔を差し込みます。私は二段階にしました。余白 30% を切ったら緩やかに、10% を切ったら強く絞ります。
// src/lib/preemptiveThrottle.ts
import { readHeadroom } from './rateLimitMeter' ;
let currentDelayMs = 0 ; // 発信側に差し込む待機
function decideDelay ( headroom : number ) : number {
if (headroom < 0.10 ) return 2000 ; // 逼迫: 強く絞る
if (headroom < 0.30 ) return 600 ; // 警戒: 緩やかに絞る
return 0 ; // 潤沢: 素通し
}
export async function callWithHeadroomControl (
send : () => Promise < Response >,
onMeter : ( h : ReturnType < typeof readHeadroom>, tenant : string ) => void ,
tenant : string
) : Promise < Response > {
if (currentDelayMs > 0 ) {
await new Promise (( r ) => setTimeout (r, currentDelayMs));
}
const res = await send ();
const h = readHeadroom (res.headers);
currentDelayMs = decideDelay (h.tokensHeadroom); // 次回発信へ反映
onMeter (h, tenant); // 計測ログへ(帰属のため tenant を渡す)
return res;
}
ここで 429 が完全になくなるわけではありません。狙いは、429 に「落ちる」のではなく、余白が薄いあいだだけ自分で速度を落として制限窓を跨ぐことです。実際、この先制スロットルを入れてから、私の環境では 429 の発生が週あたり百数十件から一桁まで落ちました。処理時間は逼迫時にだけ伸び、平時は素通しなので全体の遅延はむしろ改善しています。
反応型のバックオフを捨てる必要はありません。先制で防ぎきれなかった 429 の最後の受け皿として残します。この二層構成の考え方は、サーバ側過負荷(529)への耐性設計と共通する部分が多く、Claude API の 529 過負荷に耐える本番レジリエンス設計 で扱った切り分けがそのまま土台になります。
誰が余白を食い潰したかを帰属させる
先制スロットルは全体を守りますが、それだけだと「なぜ今夜は逼迫したのか」が分かりません。私が最後に足したのは帰属です。onMeter に渡した tenant(またはジョブID)ごとに、消費トークンと逼迫の発生を記録します。
テナント/ジョブ 週間トークン消費 逼迫イベント(余白<10%) 所見
定期バッチ A 約 4.2M 38 回 夜間に集中。時間分散で解消可
対話API B 約 1.1M 2 回 健全
再処理ジョブ C 約 3.8M 51 回 リトライ暴走。冪等化が必要
この表を見て初めて、逼迫の主因が定期バッチと再処理ジョブの時間帯衝突だと分かりました。対策は先制スロットルの外側にあります。バッチを夜間の別々の窓へずらし、再処理ジョブの重複実行を止めただけで、逼迫イベントは翌週に 3 分の 1 以下になりました。請求が跳ねていた正体も、この再処理ジョブの二重・三重消費でした。
トークン消費の帰属を継続的に見るなら、コスト側の最適化と一体で運用するのが結局は近道です。発信量そのものを削る打ち手はAnthropic API のコスト最適化 に、計上のズレを突き合わせる話は従量課金の使用量イベントのズレを突き合わせる運用メモ にまとめています。余白の計測は、その二つを結ぶ蝶番のような位置にあります。
導入手順 — 反応型に一枚だけ先制層をかぶせる
既存の反応型リトライを捨てずに、その手前へ先制層を一枚足すだけで済みます。順序はこうです。
すべての Claude API 呼び出しを callWithHeadroomControl() で包み、tenant を必ず渡す
readHeadroom() で毎応答の tokens-remaining を余白率に変換し、計測ログへ流す
余白 30% / 10% の二段閾値で発信側に待機を差し込む(数値は自分のトラフィックで調整)
帰属ログを週次で集計し、逼迫イベント上位のテナント/ジョブを特定する
逼迫の主因(時間帯衝突・リトライ暴走)はスロットルの外側で潰す
最初から全経路に入れる必要はありません。まずは最もトークンを食う一系統だけに適用し、429 の減り方を一週間観察してから広げることをおすすめします。閾値は保守的に始め、余白が余るようなら緩めていくのが安全です。
まとめ — 制限は事後に受けるものではなく事前に読むもの
整理すると、やったのは三つです。レスポンスヘッダから余白率を毎回読むこと、余白が閾値を割った瞬間に発信側を先回りで絞ること、消費と逼迫をテナント/ジョブに帰属させて原因を名指しすること。
429 を消すためにリトライを厚くするのは、いちばん手が伸びやすい対症療法です。けれど制限の状態は、失敗する前から毎回のレスポンスに書かれています。次に本番で 429 を見たら、リトライ回数を増やす前に、直前の応答ヘッダに残っていたはずの余白を確かめてみてください。
運用の一助になれば幸いです。お読みいただきありがとうございました。