Antigravity 2.0 の dynamic sub-agents で「4つの作業を同時に進めてくれる」便利さに慣れてくると、次にぶつかるのは速度ではなく外部APIの上限でした。私自身、個人開発と並行して複数のブログを自動運用していて、各サブエージェントがそれぞれ GitHub にコミットを投げた瞬間に、まとめて 403 secondary rate limit を踏んだことがあります。1つずつ動かしていたときには一度も見なかったエラーでした。
問題の本質は単純です。サブエージェントを N 個並べると、外部から見た送信レートは N 倍になります。各エージェントが「自分は礼儀正しくバックオフしている」と思っていても、上限を共有している相手から見れば、N 個が同時に殺到しているだけなのです。
その N 倍問題を「各エージェントの良心」ではなく「全エージェントが必ず通る1つの蛇口」で解く方法を、動くコードと実測値で示していきます。題材は GitHub の secondary rate limit ですが、Stripe・AdMob レポート API・自前バックエンドなど、1つの上限を複数の実行主体で共有するすべての場面に同じ設計が効きます。
なぜ「各自バックオフ」は並行時に壊れるのか
最初に試したのは、いちばん素直な対処でした。各サブエージェントのHTTP呼び出しを、429を見たら指数バックオフで再送するラッパーで包む、というものです。
// 一見正しいが、並行時に破綻する「各自バックオフ」方式
async function callWithBackoff(fn: () => Promise<Response>): Promise<Response> {
let delay = 500;
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fn();
if (res.status !== 429 && res.status !== 403) return res;
await sleep(delay); // 全エージェントがほぼ同時に同じ delay を待つ
delay *= 2;
}
throw new Error("rate limit: gave up");
}
これは単一エージェントなら機能します。ところが6個のサブエージェントが並行で走ると、次の連鎖が起きます。
- 6個がほぼ同時に送信し、集約レートが上限を超える
- ほぼ同時に全員が429を受け取る
- 全員が同じ初期 delay(500ms)を待つ
- 500ms 後、6個が再び同時に再送する。そしてまた全員429
これがいわゆる**サンダリングハード(thundering herd)**です。再送のたびに同じ山が再生され、バックオフは「山の間隔」を広げるだけで「山そのもの」を崩しません。ジッターを足せば多少ばらけますが、それは衝突確率を下げる対症療法であって、集約レートを上限以下に保証する仕組みではないのです。
ここで発想を変えます。送ってから謝る(reactive)のをやめ、送る前に許可を取る(proactive)。 許可を出す主体を1つに集約すれば、集約レートは定義上、その主体の発行ペース以下に必ず収まります。これがトークンバケットの役割です。
単一プロセスの共有トークンバケット
トークンバケットは、容量 capacity のバケツに毎秒 refillPerSec 個のトークンが補充され、API を1回叩くごとにトークンを1つ消費する、という仕組みです。トークンが無ければ補充されるまで待ちます。全サブエージェントが同じバケツ1個を共有する点が肝になります。
// shared-limiter.ts — FIFO 公平な非同期トークンバケット
type Waiter = { cost: number; resolve: () => void };
export class TokenBucket {
private tokens: number;
private last: number;
private waiters: Waiter[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly capacity: number, // バースト許容量
private readonly refillPerSec: number // 定常レート(毎秒の発行数)
) {
this.tokens = capacity;
this.last = Date.now();
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.last) / 1000;
if (elapsed <= 0) return;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
this.last = now;
}
// 呼び出し前に必ず await する。待たされた分だけ送信が遅延し、集約レートが上限内に収まる
async acquire(cost = 1): Promise<void> {
if (cost > this.capacity) {
throw new Error("cost が capacity を超えています: 永久に取得できません");
}
this.refill();
// 先客がいなければ即時取得(待ち行列を追い越さない=公平性)
if (this.waiters.length === 0 && this.tokens >= cost) {
this.tokens -= cost;
return;
}
return new Promise<void>((resolve) => {
this.waiters.push({ cost, resolve });
this.startDraining();
});
}
private startDraining(): void {
if (this.timer) return;
this.timer = setInterval(() => {
this.refill();
// 先頭から順に、トークンが足りる限り解放(FIFO)
while (this.waiters.length > 0 && this.tokens >= this.waiters[0].cost) {
const w = this.waiters.shift()!;
this.tokens -= w.cost;
w.resolve();
}
if (this.waiters.length === 0 && this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}, 50); // 50ms ごとに補充と解放を判定
}
}
使い方は、外部API呼び出しの直前に acquire() を挟むだけです。
// GitHub のコンテンツ作成系は控えめに「毎秒1.0・バースト5」に絞る
const github = new TokenBucket(5, 1.0);
async function commitViaSubAgent(agentId: string, change: FileChange): Promise<void> {
await github.acquire(1); // ここで順番待ちが起きる
await githubApi.createCommit(change);
}
// 6個のサブエージェントが同じ github バケツを共有して並行実行
await Promise.all(
subAgents.map((a) => commitViaSubAgent(a.id, a.pendingChange))
);
ポイントは3つあります。第一に、acquire() を待っている間も他のサブエージェントの計算は進むので、スループットの実害は「上限に張り付いた分」だけです。第二に、待ち行列を FIFO にしてあるので、特定のエージェントが永久に後回しにされる**飢餓(starvation)**が起きません。第三に、容量 capacity がバースト許容量を表すので、ここを小さくするほど瞬間的な突出が抑えられます。
バケツのパラメータをどう決めるか
refillPerSec は「相手の上限の何割を使うか」で決めます。私は本番では上限の60〜70%を目安にしています。残りを空けておくのは、自動運用のサブエージェント以外(自分の手動操作・CIの別ジョブ)も同じ上限を食うからです。GitHub の secondary rate limit のように明示されていない上限が相手のときは、さらに保守的に振るようにしています。
capacity は「許してよい一瞬の突出」です。ここを refillPerSec と同じくらい小さくすると限りなく等間隔送信に近づき、大きくすると「普段は貯めておいて、必要なときに一気に出す」挙動になります。バースト耐性のない相手には capacity を小さく、というのが基本方針です。私の場合、相手の上限が公開されていないときは「容量=定常レートの数倍まで」に抑えておくと、初回の突出で痛い目を見ずに済みました。
効果を「観測」で確かめる検証ゲート
設計が正しくても、実際に集約レートが上限内に収まっているかを観測しなければ意味がありません。私は送信をラップして、1秒あたりの実送信数と429/403の発生数を必ず記録するようにしています。
// metered-fetch.ts — 集約レートと拒否数を観測する薄いラッパー
let windowStart = Date.now();
let sentInWindow = 0;
let rejected = 0;
let peakRate = 0;
export async function meteredFetch(
bucket: TokenBucket,
input: RequestInfo,
init?: RequestInit
): Promise<Response> {
await bucket.acquire(1);
const res = await fetch(input, init);
sentInWindow++;
if (res.status === 429 || res.status === 403) rejected++;
const now = Date.now();
if (now - windowStart >= 1000) {
peakRate = Math.max(peakRate, sentInWindow);
console.log("[rate] sent/s=" + sentInWindow + " peak=" + peakRate + " rejected=" + rejected);
windowStart = now;
sentInWindow = 0;
}
return res;
}
この計測を、共有バケツ導入の前後で比較すると差が一目で分かります。私の自動運用パイプライン(6サブエージェントが同じ GitHub トークンで push)で取った実測では、次のようになりました。
| 項目 | 各自バックオフのみ | 共有トークンバケット |
| 観測したピーク送信レート | 毎秒9件(瞬間的に突出) | 毎秒5件以下で安定 |
| 5回の実行で踏んだ429/403 | 合計31回 | 0回 |
| 無駄な再送によるAPI消費 | 約27%が再送 | 再送ほぼ0% |
| 全タスク完了までの実時間 | 4分12秒(再送待ちで膨張) | 3分40秒 |
意外だったのは、先回りで待つ方が全体の完了時間はむしろ短くなったことです。各自バックオフは429を踏むたびに数百ミリ秒から数秒を捨てるので、衝突が多いほど総時間が膨らみます。集約レートを上限内に収めると衝突自体が消えるため、待ち時間が「再送のための無駄待ち」から「必要なだけの整列待ち」に変わります。スループットを上げるために並列化したのに再送で時間を溶かす、という本末転倒を避けられました。
複数プロセス・複数マシンへ広げる(Redis + Lua)
ここまでは全サブエージェントが同一プロセスで動く前提でした。ところが Antigravity 2.0 はサブエージェントをバックグラウンドで独立に走らせられますし、スケジュールタスクを複数マシンに分散すると、トークンバケットがプロセスごとに別インスタンスになって共有が壊れます。各プロセスが「自分のバケツ」で上限を守っても、相手から見れば台数分のバケツが並列に発行しているだけだからです。
解決策は、バケツの状態(残トークンと最終補充時刻)を Redis に置き、acquire を原子的に行うことです。複数の Redis コマンドに分けると競合するので、Lua スクリプトで1つの不可分操作にまとめます。
-- token_bucket.lua — KEYS[1]=バケツのキー
-- ARGV: capacity, refillPerSec, now(ms), cost
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then tokens = capacity; ts = now end
-- 経過時間ぶんを補充
local elapsed = math.max(0, (now - ts) / 1000)
tokens = math.min(capacity, tokens + elapsed * refill)
local allowed = 0
local wait_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
-- 不足ぶんが貯まるまでの待ち時間(ms)を返す
wait_ms = math.ceil(((cost - tokens) / refill) * 1000)
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return {allowed, wait_ms}
呼び出し側は、許可が出るまで「返ってきた待ち時間」だけ眠って再試行します。待ち時間をサーバー側で計算して返すので、クライアントが当てずっぽうでバックオフする必要がありません。
// distributed-limiter.ts
import { createClient } from "redis";
import { readFile } from "node:fs/promises";
const redis = createClient();
const script = await readFile("token_bucket.lua", "utf8");
export async function acquireDistributed(
key: string, capacity: number, refillPerSec: number, cost = 1
): Promise<void> {
for (;;) {
const [allowed, waitMs] = (await redis.eval(script, {
keys: [key],
arguments: [String(capacity), String(refillPerSec), String(Date.now()), String(cost)],
})) as [number, number];
if (allowed === 1) return;
// サーバーが算出した必要待ち時間 + 小さなジッターで同時再起床を散らす
await new Promise((r) => setTimeout(r, waitMs + Math.random() * 50));
}
}
注意点を本番運用で2つ踏みました。1つ目は時刻の扱いで、補充計算に各クライアントのローカル時刻を使うと、マシン間の時計ずれがそのまま誤差になります。now は呼び出し側ではなく Redis 側の TIME コマンドで取るようにすると、単一の時間軸に揃って安定しました。2つ目はジッターで、Lua が返す待ち時間が全クライアントで同じ値になり得るため、ここを完全に消すと再び同時再起床(小さなサンダリングハード)が起きます。数十ミリ秒の乱数を足すだけで十分にばらけました。
どの段階でどの実装を選ぶか
3つの実装を並べましたが、最初から Redis 版を入れる必要はありません。サブエージェントの動かし方に合わせて段階的に選ぶことをお勧めします。
- 同一プロセスで Promise.all 並行:
TokenBucket 単体で十分です。依存ゼロで、上のコードをそのまま貼れます。
- 同一マシンの別プロセス・複数ターミナル: Redis 版へ移ります。ローカル Redis なら時計ずれの心配もほぼありません。
- 複数マシン・スケジュール分散: Redis + Lua 版に、
TIME 由来の時刻とジッターを必ず入れます。ここを省くと「各マシンは正しいのに全体で超過する」という最も気づきにくい壊れ方をします。
共有する上限が1つではなく複数(GitHub 用・Stripe 用・自前API用)あるなら、バケツもキーを分けて複数持たせます。1つのエージェントが複数の上限を消費するなら、それぞれの acquire() を直列に待ってから本処理に入る、という形で素直に合成できます。
並列化は速さのための手段ですが、外部の上限を共有している以上、速さは「全体でどれだけ礼儀正しく送れるか」に縛られます。各エージェントの善意に任せるのをやめ、全員が必ず通る1つの蛇口を置く。この発想の転換だけで、429を追いかけてジッターを微調整する消耗から解放されました。まずは手元の並行サブエージェントに TokenBucket を1個挟み、meteredFetch でピークレートを観測してみてください。数字が上限の内側で平らになる瞬間が、いちばん手応えのあるところだと思います。