上限が倍に引き上げられたという知らせを読んで、最初に頭をよぎったのは「では、ジョブの間隔を半分にできるな」という考えでした。Dolice Labs として複数のサイトに毎日決まった時刻で記事を流し込んでいる身としては、詰められる余地はそのまま運用の自由度です。
ところが、手元のログを開いて数分眺めているうちに、その手は止まりました。倍になったのは天井であって、私が実際に消費している量ではありません。詰めてよいかどうかは、天井ではなく「いま床から天井までどれだけ空いているか」で決まります。
この記事は、その日に私自身が引いた線の話です。レート上限を「使い切る対象」ではなく「予算として配る対象」に置き換えると、上限が動いても運用は驚くほど静かになります。共有 API キーで複数の定期ジョブを回している方に向けて、余白を測り、配り、据え置くまでの設計を、実際に使っているコードとともに整理します。
余白を「予算」として扱うと何が変わるか
レート上限の話は、しばしば 429 が出てからの対処、つまり再試行とバックオフの話に偏ります。それは事後の防御です。ここで扱いたいのは事前の配分、つまり「平常時にどこまで使うか」を先に決めておく話です。
両者は似て非なるものです。コストのペース配分は「今月いくらまで使うか」という金額の話で、消費すれば請求が増えます。一方の余白予算は「単位時間あたりの上限の何割まで使うか」という速度の話で、消費しても請求は変わりませんが、使い切ると後続のジョブや再試行が詰まります。
私が予算という言葉を選んだのは、余白が共有資源だからです。共有キーの下では、記事生成ジョブも Stripe のイベント処理も、同じ天井を分け合っています。片方が天井際まで使えば、もう片方は手元の上限が下がったかのように振る舞います。だからこそ、誰がどれだけ使ってよいかを先に決めておく価値があります。
現状をヘッダーから測る
予算を組む前に、いまの消費を知る必要があります。Claude API はレスポンスヘッダーに残量を載せて返してくれるので、推測ではなく実測から始められます。
主に見るのは次のヘッダーです。requests 系と tokens 系がそれぞれ独立して効く点に注意してください。
ヘッダー 意味
anthropic-ratelimit-requests-limit その窓でのリクエスト数上限
anthropic-ratelimit-requests-remaining 残りリクエスト数
anthropic-ratelimit-requests-reset 残量が回復する時刻(RFC3339)
anthropic-ratelimit-tokens-limit その窓でのトークン上限
anthropic-ratelimit-tokens-remaining 残りトークン数
anthropic-ratelimit-tokens-reset トークン残量が回復する時刻
retry-after 429 のとき、待つべき秒数
まずは、すべての呼び出しの直後にこれらを記録する薄い層を一枚かませます。SDK のレスポンスからヘッダーを取り、構造化して残すだけの関数です。
// ratelimit.ts — レスポンスヘッダーから残量を読み取る
type RateSnapshot = {
at : string ; // 計測時刻 (ISO)
job : string ; // どのジョブか
reqLimit : number ;
reqRemaining : number ;
reqResetSec : number ; // reset までの秒数
tokLimit : number ;
tokRemaining : number ;
tokResetSec : number ;
};
function secUntil ( iso : string | null ) : number {
if ( ! iso) return 0 ;
const ms = new Date (iso). getTime () - Date. now ();
return Math. max ( 0 , Math. round (ms / 1000 ));
}
export function readSnapshot ( job : string , headers : Headers ) : RateSnapshot {
const num = ( k : string ) => Number (headers. get (k) ?? "0" );
return {
at: new Date (). toISOString (),
job,
reqLimit: num ( "anthropic-ratelimit-requests-limit" ),
reqRemaining: num ( "anthropic-ratelimit-requests-remaining" ),
reqResetSec: secUntil (headers. get ( "anthropic-ratelimit-requests-reset" )),
tokLimit: num ( "anthropic-ratelimit-tokens-limit" ),
tokRemaining: num ( "anthropic-ratelimit-tokens-remaining" ),
tokResetSec: secUntil (headers. get ( "anthropic-ratelimit-tokens-reset" )),
};
}
SDK 経由でヘッダーへ届く方法はクライアントによって異なりますが、生レスポンスを受け取れる形(withResponse 相当)で一度だけ呼び、そこからヘッダーを取り出すのが確実です。ここで得た RateSnapshot を、後段の会計と予算判断の両方で使います。
ジョブ別に消費を割り当てる
共有キーの厄介なところは、ヘッダーが返す残量が「全ジョブ合算の残量」である点です。残り 10% と言われても、それを誰が使ったのかはヘッダーには書かれていません。そこで、消費量をジョブ名で会計する小さな台帳を別に持ちます。
考え方は単純です。各ジョブは呼び出しのたびに、自分が使ったトークン(usage から取れます)を自分の名前で足し込みます。窓がリセットされたら、その窓のぶんを締める。これだけで「誰がどれだけ使ったか」が見えるようになります。
// ledger.ts — ジョブ別の消費を窓単位で記録する
type WindowKey = string ; // 例: "2026-06-24T13:00" (分単位の窓に丸める)
const ledger = new Map < WindowKey , Map < string , number >>(); // window -> job -> tokens
function windowKey ( d = new Date ()) : WindowKey {
const z = ( n : number ) => String (n). padStart ( 2 , "0" );
return `${ d . getUTCFullYear () }-${ z ( d . getUTCMonth () + 1 ) }-${ z ( d . getUTCDate ()) }T${ z ( d . getUTCHours ()) }:${ z ( d . getUTCMinutes ()) }` ;
}
export function record ( job : string , usedTokens : number ) {
const wk = windowKey ();
if ( ! ledger. has (wk)) ledger. set (wk, new Map ());
const w = ledger. get (wk) ! ;
w. set (job, (w. get (job) ?? 0 ) + usedTokens);
}
export function shareOfWindow ( job : string ) : { tokens : number ; pct : number } {
const w = ledger. get ( windowKey ());
if ( ! w) return { tokens: 0 , pct: 0 };
const total = [ ... w. values ()]. reduce (( a , b ) => a + b, 0 );
const mine = w. get (job) ?? 0 ;
return { tokens: mine, pct: total ? Math. round ((mine / total) * 100 ) : 0 };
}
実運用では Map ではなく、KV や小さなテーブルに窓キーで書き出します。私はジョブ間でプロセスが分かれているので、共有ストレージに置いて締めの集計だけを別ジョブで回しています。インメモリのままにしておくと、ジョブごとにプロセスが違う環境では台帳が分断され、合算の意味が消えてしまう点に注意してください。
余白予算の決め方
ここからが本題です。会計で「誰がどれだけ使うか」が見えたら、次は「誰がどれだけ使ってよいか」を決めます。私は上限をそのまま配らず、三つの取り分に割ってから配っています。
平常運転枠 : 定期ジョブが普段使ってよい上限。私は天井の 70% を目安にしています。
再試行・バースト枠 : 一時的な失敗の再試行や、想定外の同時実行を吸収する余白。15% ほど。
手動枠 : 私自身が日中に手で叩く呼び出しのための余白。残りの 15%。
この三分割を式にすると、各定期ジョブに配ってよい一窓あたりの上限はこう書けます。
// budget.ts — 余白予算を計算する
type BudgetInput = {
tokLimit : number ; // ヘッダー由来の窓あたりトークン上限
steadyRatio : number ; // 平常運転に充てる割合 (例 0.70)
jobWeights : Record < string , number >; // 定期ジョブ間の配分比
};
export function perJobBudget ( input : BudgetInput ) : Record < string , number > {
const steady = Math. floor (input.tokLimit * input.steadyRatio);
const totalWeight = Object. values (input.jobWeights). reduce (( a , b ) => a + b, 0 );
const out : Record < string , number > = {};
for ( const [ job , w ] of Object. entries (input.jobWeights)) {
out[job] = Math. floor ((steady * w) / totalWeight);
}
return out;
}
// 使用例: 天井の70%を平常枠とし、3ジョブへ重み付けで配る
const budgets = perJobBudget ({
tokLimit: 400_000 ,
steadyRatio: 0.70 ,
jobWeights: { contentGen: 3 , stripeSync: 1 , integrityCheck: 1 },
});
// contentGen に多めの予算が割り当てられる
各ジョブは実行前に、自分の予算と台帳上の今窓の消費を突き合わせます。予算を超えそうなら、その窓では走らずに次の窓へ送る。これがバックオフより前に効く一段目のブレーキです。
// guard.ts — 走る前に予算を確認する
import { shareOfWindow } from "./ledger" ;
export function shouldRun ( job : string , budgetTokens : number , estTokens : number ) : boolean {
const used = shareOfWindow (job).tokens;
return used + estTokens <= budgetTokens;
}
estTokens は、そのジョブの直近の平均消費を使えば十分です。私は過去 20 回の中央値を見積もりに使っています。平均ではなく中央値にしているのは、たまに混ざる巨大な入力に予算を引きずられないためです。
上限が倍になっても予算を据え置いた理由
さて、冒頭の判断に戻ります。上限が 2 倍になったとき、平常運転枠 70% を「新しい天井の 70%」に張り替えれば、消費可能量はそのまま倍になります。詰めようと思えば詰められました。
それでも据え置いたのは、私の運用で詰まっていたのは天井ではなかったからです。台帳を見ると、平常時のピークでも旧上限の 40% 前後しか使っていませんでした。つまり詰まりの原因は速度ではなく、まれに重なる再試行と手動実行の同時着火でした。天井を上げても、この同時着火は同じ確率で起きます。
ここで効くのは、余った天井を消費に回すのではなく、再試行・バースト枠へ振り向けることです。新上限のもとで枠を 70/15/15 から 60/25/15 に組み替えると、平常消費は据え置いたまま、衝突を吸収する余白だけが厚くなります。
私の場合、この組み替えだけで日中の 429 はほぼ消えました。引き上げの恩恵を「もっと速く回す」ではなく「もっと静かに回す」に使ったわけです。個人開発で運用まで一人で見ていると、深夜に鳴るアラートが一本減ることの価値は、スループットの数字より大きく感じます。
判断の指針として、私は次のように考えています。平常消費が旧上限の半分を超えていたなら、引き上げは詰め直しに使う価値があります。半分に届いていなかったなら、引き上げぶんは余白に回すほうが、運用は安定します。
本番運用で見えた落とし穴
設計どおりに動かしてみると、ヘッダーと予算の境目でいくつかつまずきました。先に共有しておきます。
reset は窓の長さではありません。 requests-reset と tokens-reset が指すのは「次に残量が回復する時刻」であって、固定長の窓の境界ではありません。連続して呼ぶと reset 時刻は前に動いていきます。私は最初これを固定窓と勘違いして、台帳の締めタイミングをずらしてしまいました。台帳の窓は自分で決めた分単位で丸め、ヘッダーの reset は「あとどれだけ待てば空くか」の参考値として別に扱うのが無難です。
トークン上限が先に来ます。 requests に余裕があってもトークンで頭打ちになる場面が、長文を扱うジョブでは普通に起きます。予算はリクエスト数ではなくトークンで持つべきです。私は当初リクエスト数で予算を組んでいて、残量グラフが嘘をついているように見えて混乱しました。
429 の帰属を取り違えないこと。 共有キーで 429 を受けたとき、それを引き起こしたのは別ジョブかもしれません。429 を受けたジョブだけにペナルティを課すと、無実のジョブが繰り返し罰せられます。retry-after には素直に従いつつ、原因の特定は台帳のその窓の消費上位を見て行うのが正確です。
入力・出力トークンの上限は別腰です。 プランによっては入力トークンと出力トークンに個別の上限が付きます。出力が長いジョブは output-tokens-remaining を、入力が大きいジョブは input-tokens-remaining を、それぞれ主指標にしてください。一本のグラフだけ見ていると、見ていない側で詰まります。
次の一歩
もし共有キーで複数の定期ジョブを回しているなら、まずヘッダーを記録する一枚の層だけ入れてみてください。予算も会計も、その実測がなければただの当て推量になります。一週間ぶんの RateSnapshot がたまれば、自分の運用が天井のどのあたりを走っているかが、推測なしで見えてきます。
その数字を見たうえで、引き上げを速度に使うか余白に使うかを決める。順番はいつもこれだと、私は考えています。
お読みいただきありがとうございました。同じように一人で運用まで抱えている方の、深夜のアラートが一本でも減れば嬉しいです。