関連する元記事: DevOps エンジニア面接で問われる設計判断。本稿はそこで触れた障害対応を題材に、再試行処理だけを独立した実装記事として書き直したものです。
結論
一時的な障害に対する再試行は、catch して同じリクエストを繰り返す処理ではありません。最低限、次の4点をコード上で分けておくと、障害時にも下流サービスをさらに悪化させにくくなります。
- 再試行する失敗を限定する: 典型例は 429、408、5xx です。認可エラーや入力エラーの 4xx は即座に呼び出し元へ返します。
- 待機を指数バックオフとジッターで分散する: 同時に失敗したクライアントが同時に復帰する retry storm を避けます。
- キャンセルを待機中にも伝播する: 画面遷移や上位タイムアウト後に、不要なリクエストを発生させません。
- 書き込みの冪等性を先に保証する: POST を再試行するなら、再試行ロジックより先に Idempotency-Key などの契約が必要です。
これはバックエンド・SRE系の設計面接でも、単に「指数バックオフを使います」で終わらせず、障害境界を説明するための基本形になります。
なぜ固定間隔の再試行では足りないのか
例えば障害中のAPIに対して、全クライアントが1秒ごとに3回再試行するとします。最初の失敗が同時刻なら、次の再試行もほぼ同時刻です。復旧しかけたAPIへ再び負荷が集中し、復旧を遅らせることがあります。
指数バックオフは試行回数 n に対して待機上限を増やします。
cap(n) = min(maxDelay, baseDelay × 2^(n - 1))
ここで Full Jitter を使い、実際の待機時間を 0 から cap(n) の一様乱数にします。全員が同じ秒数だけ待つより再試行が散り、回復中のサービスへかかる圧力を下げられます。
なお、サーバーが Retry-After を返した場合は、クライアント独自の短い待機よりその指示を優先するのが安全です。
依存ライブラリなしの TypeScript 実装
以下では、retries: 3 を「初回の実行に加えて最大3回再試行」と定義します。AbortSignal は実行中の fetch だけでなく、バックオフ中の sleep にも渡します。
class HttpError extends Error {
constructor(
readonly status: number,
readonly retryAfterMs?: number,
) {
super("HTTP " + status);
this.name = "HttpError";
}
}
type RetryEvent = {
attempt: number;
delayMs: number;
error: unknown;
};
type RetryOptions = {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
signal?: AbortSignal;
shouldRetry?: (error: unknown, attempt: number) => boolean;
onRetry?: (event: RetryEvent) => void;
};
function createAbortError(): Error {
const error = new Error("The operation was aborted");
error.name = "AbortError";
return error;
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError());
return;
}
const onAbort = () => {
clearTimeout(timer);
finish(createAbortError());
};
const timer = setTimeout(() => finish(), ms);
function finish(error?: Error) {
signal?.removeEventListener("abort", onAbort);
if (error) reject(error);
else resolve();
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function fullJitterDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
): number {
const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
return Math.floor(Math.random() * (cap + 1));
}
function defaultShouldRetry(error: unknown): boolean {
return (
error instanceof HttpError &&
(error.status === 408 || error.status === 429 || error.status >= 500)
);
}
export async function retry<T>(
operation: (attempt: number) => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
retries = 3,
baseDelayMs = 250,
maxDelayMs = 5_000,
signal,
shouldRetry = defaultShouldRetry,
onRetry,
} = options;
for (let attempt = 0; ; attempt += 1) {
try {
return await operation(attempt);
} catch (error) {
const retryNumber = attempt + 1;
if (retryNumber > retries || !shouldRetry(error, retryNumber)) {
throw error;
}
const jitterDelay = fullJitterDelay(
retryNumber,
baseDelayMs,
maxDelayMs,
);
const delayMs =
error instanceof HttpError && error.retryAfterMs !== undefined
? Math.max(jitterDelay, error.retryAfterMs)
: jitterDelay;
onRetry?.({ attempt: retryNumber, delayMs, error });
await sleep(delayMs, signal);
}
}
}
この関数はエラーを握りつぶしません。上限に達した場合、あるいは再試行対象外なら、最後のエラーをそのまま呼び出し元へ返します。onRetry はログやメトリクスへ接続するためのフックです。UI通知をここへ直接書かないことで、再利用する層の責務を小さくできます。
fetch と Retry-After をつなぐ
fetch 自体のネットワーク断は通常 TypeError として来ます。一方、HTTPステータスは Response を受け取るため、自分で HttpError に変換します。
function parseRetryAfter(value: string | null): number | undefined {
if (!value) return undefined;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const time = Date.parse(value);
return Number.isNaN(time) ? undefined : Math.max(0, time - Date.now());
}
async function getJson(url: string, signal?: AbortSignal): Promise<unknown> {
return retry(
async () => {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new HttpError(
response.status,
parseRetryAfter(response.headers.get("Retry-After")),
);
}
return response.json();
},
{
signal,
retries: 3,
shouldRetry: (error) =>
error instanceof TypeError || defaultShouldRetry(error),
onRetry: ({ attempt, delayMs, error }) => {
console.info("retrying request", { attempt, delayMs, error });
},
},
);
}
Retry-After がある場合に Math.max を取っているのは、サーバーが示した待機時間より早く再試行しないためです。サービス全体の締切がある場合は、上位で AbortController を使って時間切れを統一します。再試行関数の中に別の隠れたタイムアウトを増やすより、呼び出し境界で制御した方が挙動を追いやすくなります。
書き込みを再試行する前のチェックリスト
GET は多くの場合、安全に再試行できます。しかし注文作成、メール送信、課金、ジョブ投入のような書き込みを、通信断だけを理由に再送すると、サーバーでは成功済みなのにクライアントだけ失敗と見えることがあります。
そのため書き込みでは、次の順序にします。
- クライアントが一意な Idempotency-Key を生成する。
- サーバーがキーと処理結果を原子的に保存する。
- 同じキーの再送では、新しい副作用を起こさず元の結果を返す。
- その契約ができてから retry を適用する。
「HTTPメソッドが POST だから再試行不可」とだけ覚えるより、副作用を重複させない契約があるかで判断する方が実務に近いです。
面接で説明するなら、ここまで言えると強い
設計面接では、実装を示したあとに次のトレードオフまで説明できます。
- retry budget: リクエスト全体に対する再試行数の予算を持ち、障害時にトラフィックを無制限に増やさない。
- 観測性: retry_count、待機時間、最終失敗のステータス、Retry-After の採用回数を計測する。成功率だけでは劣化を見逃します。
- circuit breaker との役割分担: 再試行は短い一時障害を吸収する仕組み、circuit breaker は失敗が続く依存先へ新規要求を送らない仕組みです。
- キューとの境界: ユーザーが待つ同期リクエストは短い締切内で止め、長く待てる処理はキューへ移す方がよい場合があります。
再試行の本質は「成功するまで粘る」ことではありません。失敗の種類、待機、キャンセル、冪等性、観測の境界を明示して、復旧中のシステムを守ることです。