エージェントを3つ同時に走らせた夜、朝のログが 429 で埋め尽くされていました。1つが制限に当たり、同じ瞬間にリトライし、それが他の2つのリクエストと重なって、さらに制限を踏む——リトライが渋滞を悪化させる典型でした。私自身、個人開発で4つのサイトを並列に自動更新しているので、この「リトライが自分の首を絞める」現象には何度も付き合ってきました。
レート制限はエラーではなく、混雑の信号です。信号に対して全員が同じタイミングで動き直せば、混雑はほどけません。バックオフとジッターは、リトライのタイミングを意図的にずらして、混雑を自然に解消させるための設計です。
固定間隔リトライがなぜ危険か
最初に多くの人が書くのは「失敗したら5秒待って再試行」という固定間隔のループです。これは1つのジョブだけなら問題なく見えます。ところが複数のジョブが同時に制限に当たると、全員がきっちり5秒後に揃って再試行します。結果として、同じ瞬間に同じ数のリクエストが再び殺到し、また全員が落ちます。
この「足並みが揃ってしまう」状態を thundering herd と呼びます。固定間隔は、落ちたジョブの足並みを揃えてしまう点で、混雑を解消するどころか固定化します。Antigravity の Managed Agents を並列で呼ぶときも、複数のエージェントが共通の API クォータを分け合うため、まったく同じ罠が待っています。これは本番運用で一度ハマると原因が見えにくい落とし穴です。
指数バックオフだけでは足りない
次の改善は、待ち時間を試行ごとに2倍にする指数バックオフです。1回目は1秒、2回目は2秒、3回目は4秒、と広げていきます。待ち時間が指数的に伸びるため、暴走的な再試行は確かに抑えられます。
しかし指数バックオフ単体には、まだ落とし穴が残っています。同時に落ちた複数のジョブは、同じ計算式で同じ待ち時間を算出します。1秒、2秒、4秒——全員が同じ階段を、同じタイミングで上ります。間隔こそ広がりますが、足並みは揃ったままです。ここを崩すのがジッターの役割です。
| 方式 | 3回目の待ち時間 | 足並み |
| 固定間隔 | 常に5秒 | 完全に揃う(最悪) |
| 指数バックオフのみ | 常に4秒 | 揃ったまま |
| 指数 + Full Jitter | 0〜4秒の乱数 | 自然にばらける |
Full Jitter を実装する
ジッターにはいくつか流派がありますが、私が個人開発の自動運用で採用しているのは Full Jitter です。指数で算出した上限値の範囲内で、待ち時間を一様乱数にします。式そのものは単純で、random.uniform(0, cap) を挟むだけです。
import random, time
def backoff_sleep(attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
# 指数で上限を伸ばし、その範囲で一様乱数にする(Full Jitter)
ceiling = min(cap, base * (2 ** attempt))
delay = random.uniform(0, ceiling)
time.sleep(delay)
return delay
上限 cap で待ちすぎを防ぐ
上限 cap を設けるのが要点です。これを忘れると、試行回数が増えたときに待ち時間が数分〜数十分に膨らみ、スケジュールの次のサイクルに食い込みます。私は夜間ジョブでは cap=30 秒に固定し、それ以上は待たせない方針を推奨します。
サーバーの Retry-After を尊重する
待つだけでなく、サーバーが返してきた指示を尊重することも大切です。多くの API は 429 と一緒に Retry-After ヘッダを返します。これがあるときは、自前の計算よりサーバーの指示を優先してください。これだけでも不要な再試行をかなり回避できます。
def next_delay(attempt: int, retry_after: float | None) -> float:
if retry_after is not None:
# サーバー指示があれば従い、わずかなジッターだけ足す
return retry_after + random.uniform(0, 1.0)
ceiling = min(30.0, 1.0 * (2 ** attempt))
return random.uniform(0, ceiling)
リトライ上限と全体デッドラインを二重で持つ
ジッターでタイミングをばらしても、リトライを無限に許せば別の問題が起きます。一過性でない失敗を延々と待ち続け、スケジュール全体が後ろにずれていきます。そこで上限を二重に持たせます。
ひとつは試行回数の上限です。私は並列ジョブでは6回を上限にしています。もうひとつは、ジョブ全体のデッドラインです。「このジョブは開始から10分以内に終わらなければ諦める」という壁を時間で引きます。回数だけだと、Retry-After が長いときに想定外に粘ってしまうため、時間の壁が効きます。
import time, random
def run_with_retry(call, max_attempts: int = 6, deadline_s: float = 600.0):
start = time.monotonic()
for attempt in range(max_attempts):
try:
return call()
except RateLimitError as e:
if time.monotonic() - start > deadline_s:
raise # 時間の壁。これ以上は粘らずデッドレターへ
time.sleep(next_delay(attempt, getattr(e, "retry_after", None)))
raise RuntimeError("max attempts exceeded")
ここで諦めたジョブを例外として上に投げ、デッドレターへ退避させる流れにしておくと、リトライと退避がきれいにつながります。粘る上限を決めておくことが、結果的にジョブ全体の完走率を上げます。
並列実行では「同時発射」を散らす
リトライのタイミングだけでなく、最初の発射タイミングも散らす価値があります。私が4サイトを回していて効果が大きかったのは、各サイトのジョブ開始時刻を数十分ずらす、というシンプルな対策でした。Antigravity 2.0 がエージェントの並列実行を前面に出したことで、つい全部を同時に投げたくなりますが、共通クォータを分け合う以上、発射を揃えると最初の1回から制限を踏みやすくなります。
実務的な目安として、私は次の3点を守っています。
並列数は共通クォータを意識して欲張らない
同時に走らせるエージェント数は、共通の API クォータから逆算して決めます。並列数を1つ増やすたびに、一過性の混雑が起きる確率も上がるため、必要最小限にとどめるのが安全です。AdMob のレポート取得のように外部 API を叩くジョブは、特にこの上限を低めに置いています。
開始時刻をオフピークに分散する
ジョブ同士の山が重ならないよう、開始時刻をずらして配置します。私の4サイト運用では、サイトごとに開始を数十分ずらすだけで、最初の発射での衝突がほぼ消えました。
リトライには必ずジッターを入れる
固定間隔のリトライは一切使わず、すべて Full Jitter に統一します。ここを徹底するだけで、落ちたジョブの足並みが揃う事故を構造的に回避できます。
レート制限は避けられない相手ですが、こちらの動き方次第で「全滅」か「軽い遅延」かが分かれます。タイミングを意図的にばらすという一点を設計に組み込むだけで、夜間に並列で走るエージェントの完走率は目に見えて変わります。まずは手元の固定間隔リトライを1本、Full Jitter に置き換えるところから試してみてください。同じ429の渋滞に悩む方の参考になれば嬉しいです。