定義
呼び出しが一時的な理由(相手が少し忙しい、ネットワークが不安定など)で失敗したときに、もう一度試すことがリトライ(retry) です。ただ、単純に「1秒後にもう一度」とするだけでは問題が大きくなることがあるので、次の二つを加えます。
- 指数バックオフ(exponential backoff): 失敗するたびに、待つ時間を1秒、2秒、4秒のように2倍ずつ増やします。
- ジッター(jitter): 待つ時間にランダムな値を混ぜて、呼び出し元ごとに少しずつ違うようにします。
なぜ必要か
10人の呼び出し元が同じ瞬間に失敗したとします。全員が「ちょうど1秒後にもう一度」を使うと、1秒後に10人が同時に戻ってきて、同じ理由でまた拒否されます。これが繰り返される現象をthundering herd(押し寄せる群れ)と呼びます。相手が実際に故障している場合でも、一定の間隔で叩き続けると復旧が遅れます。
バックオフは相手に休む時間を与え、ジッターは呼び出し元の到着時刻をばらして、一斉に集中するのを防ぎます。
動作原理
| 方式 | 待ち時間 | 問題 |
|---|---|---|
| 固定間隔 | 1s, 1s, 1s | 一緒に失敗した呼び出し元が一緒に戻ってきます |
| 指数バックオフ | 1s, 2s, 4s | 呼び出し元どうしのタイミングが依然として重なります |
| バックオフ + ジッター | 範囲内でランダム | 重なりが分散されます |
ジッターを作るもっとも簡単な方法は、範囲の中からランダムに選ぶことです。
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, minMs = 1000, maxMs = 4000 } = {},
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (error) {
if (!isRetryable(error) || attempt >= attempts) throw error;
await sleep(minMs + Math.random() * (maxMs - minMs));
}
}
}
二層に分ける構造もよく使われます。一回の呼び出しの中では、短い範囲(例: 1〜4秒)で数回だけリトライして瞬間的な競合を解消し、それでもだめならエラーを投げて、キューがより長い間隔(数秒〜数分)で再配信するようにします(queue-based-load-leveling を参照)。
実務での適用
- 何をリトライするかを分類します。 ロック待ち、一時的な5xx、タイムアウト、HTMLのエラーページなど、「もう一度やれば成功するかもしれない」ものだけをリトライします。不正な入力(4xx)や認証の失敗は、何度やっても同じ結果です。
- 上限を設けます。 回数の上限と間隔の上限(cap)がないと、リクエストがいつまでも生き続けたり、間隔が延び続けたりします。
- 諦めたあとのことを決めます。 限界に達したときにリクエストがどこへ行くのか(キューの再配信、エラー報告、ユーザーへの案内)を、コードではっきりさせます。
- 総試行回数を掛け算して見ます。 呼び出し内3回 × キューの再配信8回なら、一つのリクエストが最大24回まで相手に届きます。
トレードオフ
| 選択 | 利点 | コスト |
|---|---|---|
| リトライを多く | 一時的な障害をよりよく吸収 | 応答の遅延、相手の負荷の増加 |
| 間隔を長く | 相手に余裕ができる | ユーザーが結果を知るのが遅れます |
| ジッターの範囲を広く | 分散の効果が大きい | 最悪の待ち時間が長くなります |
使うべきでない場合
- 繰り返し実行が安全でない操作(二回実行されると二回決済されるものなど)は、冪等キーなしでリトライしてはいけません(idempotent-consumer-ack-timing)。
- ユーザーが画面の前で待っている経路で、長いバックオフを使ってはいけません。短くして、残りは非同期に回します。
- 恒久的な失敗(不正なリクエスト)をリトライすると、同じ失敗を繰り返しながら相手を叩くだけになります。
よくある間違い
- ジッターなしでバックオフだけを使う。 同じ瞬間に始めた呼び出し元のタイミングが重なり続けます。
- すべてのエラーをリトライする。 4xxまでリトライして、ログが増えるだけになります。
- リトライの回数と間隔に上限を設けない。
- 複数の層のリトライを掛け合わせて見ない。 各層は妥当でも、合わせてみると相手への負荷が過剰です。
関連概念
- queue-based-load-leveling — どの層でリトライするかを決める構造
- idempotent-consumer-ack-timing — リトライが同じリクエストを二回実行させてしまう理由と対応
- playwright-flaky-vs-failed-triage — リトライで通った失敗と、失敗し続ける欠陥を区別する視点