TL;DR
- 素朴な指数バックオフ(
delay = base * 2^attempt)は、すべてのクライアントが同時にリトライするため、サンダリングハード問題を引き起こします - ジッター(ランダム性)を加えて、リトライを分散させます。イコールジッター(Equal jitter、
base/2 + random(base/2))がちょうどよい落としどころです - 最大遅延に上限を設け、リトライの合計期間にも上限を設けます。そして 4xx(リトライしない)と 5xx(リトライする)を分けます
- 連続でおよそ 20 回失敗したら、転送先の単位でサーキットブレーカーを作動させます。壊れたエンドポイントは、リトライでは直りません
- 積極的なリトライより、低コストの再送のほうが勝ります。再送がクリック 1 回で済むなら、リトライは一時的な失敗に対処できれば十分です
実際によく見かけるリトライのコードは、たいていこうなっています。
for (let i = 0; i < maxRetries; i++) {
try {
return await deliver(event);
} catch {
await sleep(1000 * Math.pow(2, i)); // 1s, 2s, 4s, 8s...
}
}
これは指数バックオフで、一定間隔のリトライよりはましです。ただし、本番環境でしか表に出ない形で間違っています。何百もの Webhook が同時に失敗すると、すべてが同じ瞬間にリトライし、復旧しかけたサーバーを再び地面に叩きつけるのです。
解決策はジッターです。その理由と、正しいやり方を説明します。
サンダリングハード問題
T+0 にエンドポイントがダウンしたとします。その後 60 秒の間に Stripe の Webhook が 500 件届き、すべて失敗します。リトライキューには、1 回目のリトライが 500 件 T+5min(あるいは基本遅延の値)に、2 回目のリトライが 500 件 T+10min に予定され、以下同様に続きます。
エンドポイントは T+4min に復旧します。めでたく T+5min には 500 件のリトライが同時に押し寄せ、まだ復旧途中のサーバーに再び限界を超えさせます。これがサンダリングハード問題です。
決定論的なバックオフは、設計上この問題を抱えています。同時に失敗したクライアントやイベントは、すべて同時にリトライします。
ジッター
ジッターはリトライのタイミングにランダム性を加え、リトライが一点に集中せず分散するようにします。よく使われる戦略は 3 つです。
フルジッター
const base = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s, 8s
const sleep = Math.random() * base; // 0 to base
各リトライは、0 からバックオフ間隔いっぱいまでの任意の時間だけ待ちます。分散は最大になりますが、ほぼ直後に発火するリトライもあり、非効率に感じられることがあります。
イコールジッター
const base = 1000 * Math.pow(2, attempt);
const sleep = base / 2 + Math.random() * (base / 2);
各リトライは、バックオフの半分からいっぱいまでの間で待ちます。最小値に下限があり、それでも十分に分散します。
非相関ジッター
let sleep = baseDelay;
// on each retry:
sleep = Math.min(maxDelay, baseDelay + Math.random() * (sleep * 3 - baseDelay));
各リトライの遅延は、前回の遅延をランダムに何倍かしたものになります。決まった式を使わずにバックオフを伸ばしたいときに向いています。
Webhook には、イコールジッターがちょうどよい落としどころです。リトライをおおむね指数的に保ちつつ、殺到を防げます。
本番運用に耐えるリトライスケジュールとは
ジッターのほかに、経験豊富なチームがしていることを挙げます。
最大遅延に上限を設けます。10 回目の試行に 17 分も待たせてはいけません。たとえば 60 秒などの上限を決め、上限に達したら、諦めるまでその値のまま続けます。
試行回数だけでなく、リトライの合計期間にも上限を設けます。「24 時間リトライする」のほうが「8 回リトライする」より明確な SLA です。後者は、誰も覚えていない遅延の計算に左右されるからです。
一時的なエラーと、リトライしないエラーを分けます。転送先からの 4xx、特に 401、403、404 は、リトライしても成功しません。サーバーは、リクエストが間違っていると伝えているのです。リトライするのは、5xx、429、タイムアウト、接続エラーのときだけにします。
リトライの状態を見えるようにします。ユーザーには、イベントが 5 回中 3 回目の試行中で、次のリトライがいつなのかがわかる必要があります。見えなければ、デバッグはつらいものになります。
転送先の単位でサーキットブレーカーを作動させます。ある転送先が 20 回連続で失敗したら、しばらくリトライを止めます。リトライでは直らない何かが起きていて、計算資源を燃やしているだけだからです。
AnyHook での扱い
AnyHook のリトライポリシーはプランごとに調整されており、内部では QStash のマネージドなリトライ基盤を使っています。配信試行の回数は Free が 3 回、Pro が 5 回、Scale が 10 回で、固定の指数的な間隔で行われます。5 分 → 30 分 → 3 時間 → 18 時間、以降は 1 回ごとに 24 時間です。これらの遅延にジッターは加えていません。タイムアウトの上限は、それぞれ 60 秒、120 秒、5 分です。20 回連続で失敗するとサーキットブレーカーが作動し、その転送先への配信を一時停止して(10 分、繰り返し作動するたびに 30 分、2 時間、24 時間)、メールで通知します。そのため、リトライの嵐は起きません。408 と 429 以外の 4xx はまったくリトライされず、その理由は失敗アラートに書かれます。そこではリトライが役に立たず、リクエストの形を直す必要があるからです。
リトライを使い切っても、イベントは失われません。status=failed としてログに永続化され、プランの再送可能期間(Free は 3 日、Pro は 30 日、Scale は 90 日)内なら、いつでもダッシュボードや API から手動で再送できます。
裏技
リトライの考え方を変える気づきがあります。再送のコストが低ければ、積極的なリトライは要らないということです。
再送がクリック 1 回で済むなら、1 時間で 3 回リトライすれば十分です。一時的な失敗のほとんどは、自動で拾えます。まれに長い障害が起きたときは、サーバーが正常に戻ってから、人が失敗した分をまとめて再送します。
リトライしかないシステムと比べてみてください。念のためにリトライ期間を 72 時間に延ばすと、ダウンしているとわかっているエンドポイントに、72 時間ずっと無駄な配信を続けることになります。
低コストの再送は、積極的なリトライより明らかに優れています。AnyHook は、その考えを中心に作られています。
まとめ
リトライは、一時的な失敗のための近道として扱ってください。永続化と再送の代わりにはなりません。悪い週を乗り切れるシステムは、失敗した配信をいつでも復旧できるシステムです。イベントが配信される前に記録されているからです。
各送信元の実際のスケジュールは、Webhook プロバイダー別リファレンスにあります。リトライには、誰にも制御できない上限もあります。失敗し続ければ、Shopify はサブスクリプションを削除することがあり、Stripe はエンドポイントを無効化します。そして重複や順不同の配信がまれではなく必ず起きるのは、まさにリトライがあるからです:「同じ Webhook が 2 回届いた」