夕方、6 サイト分のコンテンツを順番に Claude API へ投げているスケジュールタスクから「429 Too Many Requests」が立て続けに返ってきて、その日の自動投稿が半分しか終わらないという日が、個人開発を続けるなかで何度かありました。私はコンテンツサイトを 6 つ並列で運用していて、記事生成・整合性チェック・SEO レポートと、API を一日中叩き続けています。加えて自作アプリの運用でも、レポート集計や画像分類で Claude API を継続的に使っています。
「公式の指数バックオフ実装をコピーしてもなぜか 429 が止まらない」という相談を何度も受けるなかで、自分のパイプラインで稼働しているコードと、観測してきたメトリクスを整理しました。Retry-After ヘッダーだけを信じて書いた最初の実装はすぐに破綻しました。ヘッダーの値が実態より短い場面、Retry-After が返ってこない場面、リトライ全体が同期して集中バーストになる場面があり、それぞれ別の手当てが必要でした。
Claude API の 429 が出る 3 つの軸を一度に握る
Claude API のレート制限は 3 つの独立した軸で同時に効きます。1 軸でも上限を超えると 429 が返ります。
RPM — 1 分あたりのリクエスト数
TPM — 1 分あたりの入出力トークン数(入力プロンプト + max_tokens を含む)
TPD — 1 日あたりの累計トークン数
私が最初にハマったのは TPM です。RPM だけ見て「1 秒 1 リクエストなら問題ないはず」と決め打ちしていたのですが、長文の MDX 生成では 1 リクエストで 8,000 〜 12,000 トークン使うこともあり、結果的に TPM 側で詰まっていました。プランごとの上限値はダッシュボードで確認できますが、運用側の感覚値としては「RPM の半分以上の頻度で叩く場合は、ほぼ TPM 側で先に詰まる」と覚えておくと安全です。
レスポンスヘッダには次の情報が常に返ります。
RateLimit-Limit-Requests: 50
RateLimit-Limit-Tokens: 90000
RateLimit-Remaining-Requests: 35
RateLimit-Remaining-Tokens: 72000
RateLimit-Reset-Requests: 2026-03-29T22:05:00Z
RateLimit-Reset-Tokens: 2026-03-29T22:05:00Z
Retry-After: 15
Retry-After は秒、*-Reset-* は ISO8601 の絶対時刻で返ってきます。後述しますが、この 2 つの間に微妙なズレが出る瞬間があり、ここを設計で吸収する必要があります。
6 サイト並行運用で観測した 429 の発生分布
私の環境で過去 90 日間に Claude API へ投げたリクエストのうち、429 が返った割合と内訳を整理しました。サンプル母集団はおよそ 18 万リクエストです。
全体の 429 発生率: 約 0.42%(修正前は約 4.1%)
RPM 起因: 全 429 のうち約 12%
TPM 起因: 全 429 のうち約 71%
単発の overloaded 同居 429: 約 17%
Retry-After 中央値: 8 秒、95 パーセンタイル値: 38 秒
リトライ 1 回目で復帰: 約 78%、3 回以内で復帰: 約 96%
「修正前 4.1%」というのは、最初に書いた「Retry-After をそのまま sleep してリトライ」というナイーブ実装の数字で、これは見るに堪えない値でした。後述するジッター + トークンバケットを入れてから 10 分の 1 程度まで下がっています。
時間帯で見ると、日本時間の 02:00〜04:00 と 11:00〜15:00 に発生が偏ります。スケジュールタスクが集中している時間帯と一致するため、ここはオフピーク分散をかけるとさらに改善できる余地があります。
公式ドキュメントが触れない 4 つの落とし穴
実装段階で詰まりやすかったのは、ドキュメントを読んでも書いていない次の 4 点でした。
1. Retry-After は短めに返ってくる傾向がある
経験上、Retry-After の値で待ってリトライしても、20 〜 30% の確率で同じ 429 をまた踏みます。サーバー側のレート制限が「分単位の固定窓」ではなく「ローリングウィンドウ寄り」で計算されているためで、待った直後にぴったり境界へ着地すると、わずかな計算ズレで弾かれる挙動になります。対処は単純で、Retry-After の値に必ず正のジッターを足します。私は Retry-After * (1.2 + random(0, 0.3)) を使っています。
2. Retry-After が返ってこない 429 がある
過負荷起因(HTTP 529 と同居して 429 が返るケース)では Retry-After ヘッダーが付かないことがあります。このとき、Retry-After の値を null チェックせずに float() キャストすると例外で落ちます。デフォルト値(私は 30 秒)を必ず噛ませてください。
3. リトライが全並列で同期するとバーストする
並列度 4 でリクエストを投げているとき、4 本すべてが同時に 429 を踏むと、リトライタイマも同時に切れて、復帰直後にまた 4 本同時で発射されます。これが連鎖して 429 が雪崩のように増えます。並列リクエストごとに独立した乱数ジッターを入れるだけでこの現象は消えます。
4. RateLimit-Reset-* と Retry-After の精度が異なる
Retry-After は秒の整数で、四捨五入された値が返ります。RateLimit-Reset-* は ISO8601 の絶対時刻で、こちらはミリ秒精度を保っていることがあります。長時間ポーリングする場合は Reset-* 側を信頼するほうが収束が早いです。
ジッター付き指数バックオフと冪等性キーの最小実装(Python)
私のパイプラインで実際に稼働しているリトライ実装の最小版です。Anthropic 公式 SDK と組み合わせて使えます。
import time
import random
import hashlib
import json
import anthropic
from anthropic import RateLimitError, APIStatusError
client = anthropic.Anthropic()
def idempotency_key (model: str , messages: list , max_tokens: int ) -> str :
"""Same request -> same key. Dedup is the caller's job."""
payload = json.dumps({ "m" : model, "ms" : messages, "t" : max_tokens}, sort_keys = True )
return hashlib.sha256(payload.encode()).hexdigest()[: 24 ]
def call_with_retry (model, messages, max_tokens = 1024 , max_retries = 5 ):
"""Jittered exponential backoff with Retry-After honoring."""
base = 1.0 # base seconds
key = idempotency_key(model, messages, max_tokens)
for attempt in range (max_retries):
try :
resp = client.messages.create(
model = model,
max_tokens = max_tokens,
messages = messages,
extra_headers = { "X-Idempotency-Key" : key},
)
return resp
except RateLimitError as e:
retry_after = e.response.headers.get( "Retry-After" ) if e.response else None
if retry_after:
wait = float (retry_after)
else :
wait = base * ( 2 ** attempt)
# 1. positive jitter (avoids window-edge collisions)
wait = wait * ( 1.2 + random.random() * 0.3 )
# 2. cap (prevents runaway 60s+ sleeps)
wait = min (wait, 60.0 )
if attempt == max_retries - 1 :
raise
time.sleep(wait)
except APIStatusError as e:
# 529 overloaded -- same treatment as 429
if e.status_code != 529 :
raise
time.sleep(base * ( 2 ** attempt) * ( 1.2 + random.random() * 0.3 ))
raise RuntimeError ( "retry exhausted" )
3 点だけ押さえれば動きます。ジッターは必ず正方向(最低 1.2 倍)にすること、上限を 60 秒程度でキャップすること、X-Idempotency-Key をリクエストに付けて、同一リクエストが二重で発火したときに自分側のログで冪等に扱えるようにすること。Anthropic 側で冪等性キーが正規にサポートされているわけではないため、ここはあくまで自前のログ照合用です。
TypeScript 版(Cloudflare Workers でも動く)
私のサイトの裏側は Cloudflare Workers で動いているため、TypeScript 版も持っています。@anthropic-ai/sdk をそのまま使う前提です。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ();
async function sleep ( ms : number ) {
return new Promise (( r ) => setTimeout (r, ms));
}
export async function callWithRetry (
model : string ,
messages : Anthropic . MessageParam [],
maxTokens = 1024 ,
maxRetries = 5
) {
const base = 1000 ; // ms
for ( let attempt = 0 ; attempt < maxRetries; attempt ++ ) {
try {
return await client.messages. create ({
model,
max_tokens: maxTokens,
messages,
});
} catch (err) {
const status = (err as any )?.status;
const retryAfter = (err as any )?.headers?.[ "retry-after" ];
if (status !== 429 && status !== 529 ) throw err;
if (attempt === maxRetries - 1 ) throw err;
let waitMs = retryAfter
? parseFloat (retryAfter) * 1000
: base * 2 ** attempt;
// positive jitter + cap
waitMs = Math. min (waitMs * ( 1.2 + Math. random () * 0.3 ), 60_000 );
await sleep (waitMs);
}
}
throw new Error ( "retry exhausted" );
}
Workers では setTimeout の精度が控えめなので、ジッターを正方向にしておくことで境界衝突を確実に避けられます。
トークンバケットで自分側に上限を設計する
429 を受けてからリトライするのは「受け身」の設計です。トラフィックが多いときは、こちら側で先にレートを絞ったほうが安定します。私のパイプラインでは、サイトごとに独立したトークンバケットを持たせて、RateLimit-Remaining-Tokens が一定値を下回ったらバケットの補充レートを自動的に下げる、という構成にしています。
import time
import threading
class TokenBucket :
"""Self-imposed rate limit. Survives bursts; smooths long-running batches."""
def __init__ (self, capacity: int , refill_per_sec: float ):
self .capacity = capacity
self .tokens = float (capacity)
self .refill = refill_per_sec
self .last = time.monotonic()
self .lock = threading.Lock()
def take (self, cost: int = 1 , timeout: float = 30.0 ) -> bool :
deadline = time.monotonic() + timeout
while True :
with self .lock:
now = time.monotonic()
delta = now - self .last
self .tokens = min ( self .capacity, self .tokens + delta * self .refill)
self .last = now
if self .tokens >= cost:
self .tokens -= cost
return True
short = cost - self .tokens
wait = short / self .refill
if time.monotonic() + wait > deadline:
return False
time.sleep( min (wait, 1.0 ))
def slow_down (self, factor: float = 0.5 ):
"""Called when RateLimit-Remaining-Tokens drops below threshold."""
with self .lock:
self .refill *= factor
# usage
tpm_bucket = TokenBucket( capacity = 90_000 , refill_per_sec = 90_000 / 60 )
def call (model, messages, max_tokens = 1024 ):
estimated = sum ( len (m[ "content" ]) for m in messages) // 3 + max_tokens
if not tpm_bucket.take(estimated, timeout = 60 ):
raise RuntimeError ( "local bucket starved -- backpressure upstream" )
resp = call_with_retry(model, messages, max_tokens)
remaining = int (resp.response.headers.get( "RateLimit-Remaining-Tokens" , 0 ))
if remaining < 10_000 :
tpm_bucket.slow_down( 0.6 )
return resp
押さえどころは 3 点です。
トークン数の見積もりは「メッセージ文字数 ÷ 3 + max_tokens」程度の雑な計算で十分です。日本語混じりのプロンプトでもオーバー側に倒れてくれるほうが安全です。
補充レートは TPM 上限の 90% 程度に設定しています。100% にすると境界で必ず弾かれます。
レスポンスの RateLimit-Remaining-Tokens を見て、しきい値を割ったら補充レートを動的に絞ります。これで「自分のせいで自分のレートを使い切る」現象が消えます。
Batch API へ切り替えるべき判断基準
リアルタイム応答が不要なジョブは、できるだけ Batch API(Messages Batches)へ寄せるのを推奨します。私の経験で使っている判断基準を整理します。
応答が 60 秒以内に必要ない場合 : Batch を採用します。レート枠が事実上 50% 程度緩く、コストも約半分(投入時の課金率が低い)になります。私のサイトでは記事の事後整合性チェックや SEO リファレンス生成をこちらに寄せました。
応答が即時必要だが 100 件以上のリクエストが連なる場合 : 通常 API + トークンバケットを採用します。Batch の完了待ち(最大 24 時間)が許容できないためです。
応答が即時必要かつ 10 件以下の単発の場合 : 通常 API + リトライ。Batch のオーバーヘッドが見合わないためです。
私の 6 サイト運用では、当初すべて通常 API で叩いていたのを Batch 移行した結果、429 発生率が約 0.42% から 0.04% へさらに下がりました。Batch は失敗してもジョブ全体が無効になるわけではなく、リクエストごとに succeeded/errored が返るため、リトライ設計も通常 API より簡単です。
トークン側の 429 を減らす — 再利用コンテキストはプロンプトキャッシュに逃がす
429 の 3 軸のうち、リトライ設計とトークンバケットでは「リクエスト数」と「同時実行数」を抑えられますが、「1 分あたりのトークン数(TPM)」だけは送信内容そのものを減らさない限り下がりません。長いシステムプロンプトや共通ドキュメントを毎回フルで送っていると、リクエスト数を絞っても TPM 側で 429 に当たります。
ここで効くのがプロンプトキャッシュです。再利用する大きなコンテキストを cache_control 付きで送ると、2 回目以降はキャッシュ読み出し扱いになり、課金・TPM の両面で入力トークンが大きく下がります。
import anthropic
client = anthropic.Anthropic()
# 何度も使い回す大きな共通コンテキスト(数千〜数万トークン)
SHARED_CONTEXT = """(製品ドキュメントやスタイルガイドなど、毎回同じ前置き)"""
def ask_with_cache (question: str ) -> anthropic.types.Message:
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
system = [
{ "type" : "text" , "text" : "あなたは丁寧なアシスタントです。" },
{
"type" : "text" ,
"text" : SHARED_CONTEXT ,
"cache_control" : { "type" : "ephemeral" }, # ここをキャッシュ
},
],
messages = [{ "role" : "user" , "content" : question}],
)
u = resp.usage
# cache_read_input_tokens が伸びていれば TPM 負荷が減っている
print (u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens)
return resp
注意したいのは、キャッシュは「直前と同じプレフィックス」が一致したときだけ効くことです。サイトごとに前置きを少しずつ変えていると毎回キャッシュが作り直され、cache_creation_input_tokens ばかり増えて読み出しが伸びません。私自身、6 サイト分の共通指示を 1 つのテンプレートに固定してから、TPM 由来の 429 が目に見えて減りました。リトライ・トークンバケット・Batch API で「送り方」を整えたうえで、最後に「送る量」をキャッシュで削る、という順番が実務では収まりがよいと感じています。
観測指標と本番運用チェックリスト
最後に、運用中に必ず計測している指標と、デプロイ前に確認するチェックリストを置いておきます。
計測している指標(毎日 1 回の自動集計)
429 発生率(時間帯別 × モデル別)
リトライ後の成功率(1 回目、3 回以内、5 回以内)
Retry-After の中央値と P95
RateLimit-Remaining-Tokens の最小値
トークンバケットの「枯渇」回数
新しい呼び出し箇所を追加する前のチェック
リトライ実装にジッターが入っているか(純粋な指数バックオフだけは避ける)
Retry-After が null のときのデフォルト値が設定されているか
リトライ上限(私は 5 回 + 上限 60 秒 / 回)が設定されているか
トークン数の見積もりがバケットへ渡っているか
このジョブは Batch API へ寄せられないか、を必ず一度自問する
本番運用で押さえるべき要点
実体験ベースで一つだけ強調しておきたいのは「Retry-After だけを信じない」という点です。サーバー側のヒントは正確ですが、ローリングウィンドウの境界に対しては短すぎる傾向があり、必ず正方向のジッターで吸収する設計をお勧めします。そのうえでトークンバケットを自分側に置き、Batch API に寄せられるジョブは寄せる、という 3 段階で 429 はほぼ消えます。
私自身まだ 6 サイト並行運用の最適化は途中ですが、429 を気にせずスケジュールタスクが回るようになると、コンテンツ運用全体の心理的負荷が一気に下がります。同じ規模で個人開発を続けている方の参考になれば嬉しいです。お読みいただきありがとうございました。