なぜ「再試行処理」が業務ユーティリティとして必須になるのか
業務システムで一番よくあるのが「外部としゃべる処理」です。
API 呼び出し、DB アクセス、外部サービス連携、キュー処理など、どれも「たまに失敗する」のが前提です。
ここで大事なのは、「失敗=終わり」ではなく
「一回くらい失敗しても、もう一度やれば普通に成功することが多い」という現実です。
ネットワークが一瞬不安定だっただけかもしれない。
相手サーバーが一瞬だけ重かっただけかもしれない。
だからこそ、「失敗したらすぐ諦める」のではなく
「決められた回数だけ、間隔を空けて再試行する」という仕組みを
ユーティリティとして持っておくと、システム全体の安定性が一気に上がります。
これが「再試行処理(リトライ)」ユーティリティの役割です。
まずはイメージをつかむ:ざっくりした再試行の流れ
再試行処理の流れを、言葉でシンプルにするとこうなります。
一度処理を実行してみる。
失敗したら、少し待ってからもう一度やってみる。
それでもダメなら、また少し待ってもう一度。
それを「最大何回までやるか」を決めておく。
この「パターン」を毎回手書きするのではなく、
一つのユーティリティ関数にしてしまうのが、業務での書き方です。
非同期処理を前提にした再試行ユーティリティの基本形
再試行したい処理は、たいてい非同期(Promise / async)です。
まずは「Promise を返す関数を、最大 N 回まで再試行する」ユーティリティを作ってみましょう。
async function retry(fn, options = {}) {
const maxAttempts = options.maxAttempts ?? 3;
const delayMs = options.delayMs ?? 0;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts) {
break;
}
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
JavaScriptここでやっていることを、ゆっくり分解してみます。
まず、fn は「一回分の処理」を表す関数です。
API を叩く処理などを fn として渡します。
maxAttempts は「最大何回まで試すか」です。デフォルトは 3 回にしています。delayMs は「失敗したとき、次の試行まで何ミリ秒待つか」です。デフォルトは 0(待たない)です。
for ループで、1 回目から maxAttempts 回目まで順番に試します。fn() が成功したら return で即終了。fn() が例外(Promise の reject)を投げたら catch に入り、
最後の試行でなければ、必要に応じて setTimeout で待ってから次のループに進みます。
全部失敗したら、最後に捕まえたエラーを投げ直します。
これが「再試行処理」の一番素直な形です。
実際に API 呼び出しに使ってみる
例えば、ユーザー情報を取得する API を、最大 3 回まで再試行したいとします。
async function fetchUser() {
const res = await fetch("/api/user");
if (!res.ok) {
throw new Error("API error");
}
return res.json();
}
JavaScriptこの fetchUser を、そのまま呼ぶのではなく retry 経由で呼びます。
async function loadUser() {
const user = await retry(fetchUser, {
maxAttempts: 3,
delayMs: 1000,
});
console.log("取得できたユーザー:", user);
}
JavaScriptこのコードの意味はこうです。
まず fetchUser を実行してみる。
失敗したら 1 秒待って、もう一度 fetchUser を実行する。
それでも失敗したら、また 1 秒待って、3 回目を試す。
3 回全部失敗したら、最後のエラーを投げる。
呼び出し側から見ると、retry(fetchUser, …) は「普通の async 関数」と同じように await できます。
中で何回試したかは、呼び出し側は意識しなくてよくなります。
重要ポイントその1:再試行していいエラーと、してはいけないエラー
ここが実務ではかなり重要です。
「全部のエラーを再試行していいわけではない」ということです。
例えば、次のようなエラーは「再試行しても意味がない」ことが多いです。
リクエストのパラメータが不正(バリデーションエラー)
認証エラー(トークンが無効など)
権限エラー(そもそもアクセス権がない)
こういうものは、何回やり直しても結果は変わりません。
一方で、次のようなエラーは「再試行すると成功する可能性がある」タイプです。
ネットワーク一時障害
タイムアウト
サーバーの一時的な 5xx エラー
なので、本気で実務に使うときは「どのエラーなら再試行するか」を判定する仕組みを入れます。
例えば、HTTP ステータスコードで判断するイメージです。
function isRetryableError(err) {
if (!err || !err.response) return true;
const status = err.response.status;
if (status >= 500 && status < 600) return true;
return false;
}
JavaScriptそして retry の中でこう使います。
async function retry(fn, options = {}) {
const maxAttempts = options.maxAttempts ?? 3;
const delayMs = options.delayMs ?? 0;
const shouldRetry = options.shouldRetry ?? (() => true);
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (!shouldRetry(err) || attempt === maxAttempts) {
break;
}
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
JavaScriptこれで、「再試行しても意味のないエラーは、最初の一回で諦める」という制御ができます。
ここをきちんと設計するかどうかで、「賢い再試行」か「ただの連打」かが変わります。
重要ポイントその2:待ち時間の設計(固定 vs エクスポネンシャルバックオフ)
再試行の「待ち時間」も、実務ではかなり大事です。
毎回同じ時間だけ待つ「固定間隔」
試行回数が増えるごとに待ち時間を伸ばす「エクスポネンシャルバックオフ」
後者は、特に外部 API などでよく使われます。
「サーバーが重いときに、クライアントが一斉に連打してさらに負荷をかける」のを防ぐためです。
簡単なエクスポネンシャルバックオフの例を見てみましょう。
async function retryWithBackoff(fn, options = {}) {
const maxAttempts = options.maxAttempts ?? 3;
const baseDelayMs = options.baseDelayMs ?? 500;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts) {
break;
}
const delayMs = baseDelayMs * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
JavaScriptこの場合、待ち時間はこうなります。
1 回目失敗後の待ち時間は baseDelayMs(例: 500ms)
2 回目失敗後は 1000ms
3 回目失敗後は 2000ms
というように、試行回数に応じて伸びていきます。
「すぐには諦めないけど、何度も失敗するようなら間隔を空けてあげる」という、
外部サービスに優しい再試行の仕方です。
重要ポイントその3:再試行の「上限」を必ず決める
再試行処理で絶対にやってはいけないのが、「無限リトライ」です。
何度失敗しても永遠に再試行し続けると、次のような問題が起きます。
ユーザーから見ると「いつまでも終わらない」
サーバー側から見ると「ずっと叩かれ続ける」
ログが膨れ上がる
だからこそ、maxAttempts のような「最大試行回数」は必須です。
業務では、処理の重要度や性質に応じて、例えば 3 回、5 回、10 回などを決めます。
さらに、「全体として何秒以内に諦めるか」という「タイムアウト」も合わせて設計すると、
システム全体としての振る舞いが安定します。
実務での具体的な利用イメージ
例えば、「注文確定 API」を叩く処理を考えてみます。
async function confirmOrderApi(orderId) {
const res = await fetch(`/api/orders/${orderId}/confirm`, {
method: "POST",
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json();
}
JavaScriptこれを、そのまま一発で呼ぶのではなく、再試行付きで呼びます。
async function confirmOrder(orderId) {
const result = await retry(confirmOrderApi.bind(null, orderId), {
maxAttempts: 3,
delayMs: 1000,
});
return result;
}
JavaScriptここで confirmOrderApi.bind(null, orderId) としているのは、
「引数付きの関数を fn として渡す」ためのテクニックです。
これで、「一時的なネットワークエラーで注文確定が失敗した」ようなケースでも、
ユーザーにエラーを返す前に、裏で数回リトライしてくれるようになります。
小さな練習で感覚をつかむ
まずは、わざと失敗する関数を作って、retry を試してみると感覚がつかみやすいです。
let count = 0;
async function unstableTask() {
count++;
console.log("試行回数:", count);
if (count < 3) {
throw new Error("まだ失敗させる");
}
return "成功!";
}
async function main() {
const result = await retry(unstableTask, {
maxAttempts: 5,
delayMs: 500,
});
console.log("結果:", result);
}
main();
JavaScriptこのコードを実行すると、
1 回目と 2 回目はわざと失敗し、3 回目で成功します。retry がなければ 1 回目で終わってしまう処理が、
「最大 5 回まで、500ms 間隔で粘る」処理に変わっているのが分かるはずです。
ここまで理解できたら、あとはあなたのプロジェクトの中で
「ここ、たまにネットワークエラーで落ちるんだよな」
「この処理、一回失敗しただけで諦めるのはもったいないよな」
という場所に、少しずつこの再試行ユーティリティを差し込んでいけば、
システムの“しぶとさ”が確実に上がっていきます。
