JavaScript Tips | 基本・共通ユーティリティ:汎用 – 再試行処理

JavaScript JavaScript
スポンサーリンク

なぜ「再試行処理」が業務ユーティリティとして必須になるのか

業務システムで一番よくあるのが「外部としゃべる処理」です。
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 間隔で粘る」処理に変わっているのが分かるはずです。

ここまで理解できたら、あとはあなたのプロジェクトの中で

「ここ、たまにネットワークエラーで落ちるんだよな」
「この処理、一回失敗しただけで諦めるのはもったいないよな」

という場所に、少しずつこの再試行ユーティリティを差し込んでいけば、
システムの“しぶとさ”が確実に上がっていきます。

タイトルとURLをコピーしました