【JavaScript】fetchをリトライする方法|指数バックオフ・Retry-After・再試行条件

【JavaScript】fetchをリトライする方法|指数バックオフ・Retry-After・再試行条件 JavaScript

API通信は、サーバーが正常でも一時的な回線切断、アクセス集中、ロードバランサーの切り替えなどで失敗します。このような一時障害は、少し待ってから同じリクエストを送ると成功することがあります。

ただし、失敗するたび即座にfetch()を繰り返す実装は危険です。障害中のサーバーへさらに負荷をかけ、複数端末が同時に再送することで復旧を遅らせます。POSTを無条件に再送すれば、注文や登録が二重になる可能性もあります。

安全なリトライには、再試行してよい失敗だけを選ぶ、待機時間を段階的に延ばす、サーバーの指示を尊重する、利用者のキャンセルで待機も止めるという設計が必要です。

先に結論

  • fetch()は404や500だけではrejectされません。ネットワークエラーとHTTPエラーを分けて判定します。
  • 代表的な再試行候補は408、425、429、500、502、503、504です。ただしAPI仕様に合わせて絞ります。
  • 待機時間には指数バックオフとジッターを使い、同時再送による集中を避けます。
  • Retry-Afterがあれば、秒数またはHTTP日時を解析してサーバーの指定を優先します。
  • GETは再試行しやすい一方、POSTは冪等性キーなどサーバー側の二重実行対策が必要です。
  • AbortSignalを待機処理にも渡し、画面遷移やキャンセル操作で次の試行を止めます。
  • maxRetries: 3は通常、初回を含めて最大4回の通信を意味します。

GET・POSTやresponse.okの基本はfetch APIの使い方、通信のキャンセルとタイムアウトはAbortControllerでfetchをキャンセルする方法で詳しく解説しています。

スポンサーリンク

fetchが失敗する条件を理解する

fetch()のPromiseがrejectされるのは、主に名前解決失敗、接続断、CORSによる遮断、不正なURL、中断などでResponseを取得できなかった場合です。サーバーから404や500が返った場合は、Promiseは通常fulfilledになります。

fetch-error-boundary.js
try {
  const response = await fetch("/api/users");

  // 404や500はここで判定する
  if (!response.ok) {
    console.error("HTTPエラー", response.status);
  }
} catch (error) {
  // ネットワーク障害、CORS、中断など
  console.error("fetch自体が失敗", error);
}

そのため、リトライ処理では次の2経路を扱います。

  • fetchがrejectされた:一時的なネットワーク障害なら再試行候補です。利用者による中断やタイムアウトは、目的に応じて再試行せず上位へ返します。
  • Responseを受け取ったが成功ではない:ステータスコードを見て、再試行可能か判断します。

再試行するHTTPステータスを決める

すべてのエラーを再試行してはいけません。認証情報が間違っている401、権限がない403、存在しない404、入力不備の422は、同じ内容を送っても通常は成功しません。

一般的な再試行候補

  • 408 Request Timeout:サーバーがリクエスト待機を打ち切った場合
  • 425 Too Early:早すぎる送信をサーバーが拒否した場合
  • 429 Too Many Requests:レート制限を超えた場合
  • 500 Internal Server Error:一時障害であることがAPI仕様から分かる場合
  • 502 Bad Gateway:上流サーバーとの一時的な通信失敗
  • 503 Service Unavailable:メンテナンスや過負荷
  • 504 Gateway Timeout:上流サーバーの応答タイムアウト
retryable-status.js
const DEFAULT_RETRYABLE_STATUSES = new Set([
  408,
  425,
  429,
  500,
  502,
  503,
  504
]);

function isRetryableStatus(status, retryableStatuses) {
  return retryableStatuses.has(status);
}

500を常に再試行できるとは限りません。サーバー側のバグや永続的な設定不備なら、繰り返しても失敗します。APIの仕様、エラーコード、運用実績に合わせて対象を調整してください。409 Conflictも、楽観ロックの再読込後に再実行できる場合と、利用者による修正が必要な場合があります。

固定間隔ではなく指数バックオフを使う

失敗直後に何度も再送すると、障害中のサーバーへ負荷が集中します。指数バックオフでは、試行回数に応じて待機時間をbaseDelay × 2の累乗で増やします。

exponential-backoff.js
function calculateExponentialDelay(
  retryIndex,
  baseDelayMs = 500,
  maxDelayMs = 30000
) {
  return Math.min(
    maxDelayMs,
    baseDelayMs * 2 ** retryIndex
  );
}

console.log(calculateExponentialDelay(0)); // 500
console.log(calculateExponentialDelay(1)); // 1000
console.log(calculateExponentialDelay(2)); // 2000
console.log(calculateExponentialDelay(3)); // 4000

retryIndexは最初の再試行を0としています。上限を設けないと長時間待ち続けるため、maxDelayMsで制限します。

ジッターで再送タイミングを分散する

多数のブラウザが同時に失敗すると、全端末が500ミリ秒後、1秒後、2秒後に一斉に再送します。この現象はthundering herdと呼ばれ、復旧したサーバーへ再び負荷を集中させます。

ジッターは待機時間に乱数を加え、端末ごとの再送時刻を分散します。次は0から指数バックオフ上限までの範囲を選ぶFull Jitterです。

full-jitter.js
function calculateRetryDelay({
  retryIndex,
  baseDelayMs = 500,
  maxDelayMs = 30000,
  random = Math.random
}) {
  const exponentialDelay = Math.min(
    maxDelayMs,
    baseDelayMs * 2 ** retryIndex
  );

  return Math.floor(random() * exponentialDelay);
}

randomを引数にしているのは、テスト時に乱数を固定するためです。実行時は既定のMath.randomを使います。

Retry-Afterヘッダーを解析する

429や503では、サーバーがRetry-Afterレスポンスヘッダーを返すことがあります。値は「待機する秒数」または「再試行してよいHTTP日時」です。

retry-after.http
HTTP/1.1 429 Too Many Requests
Retry-After: 120

HTTP/1.1 503 Service Unavailable
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT
parse-retry-after.js
function parseRetryAfter(value, now = Date.now()) {
  if (!value) {
    return null;
  }

  const seconds = Number(value);

  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }

  const retryAt = Date.parse(value);

  if (Number.isNaN(retryAt)) {
    return null;
  }

  return Math.max(0, retryAt - now);
}

解析できない値はnullにし、指数バックオフへフォールバックします。サーバー時刻と端末時刻にはずれがあるため、日時形式より秒数形式のほうが扱いやすい場合があります。

CORSでは公開ヘッダー設定が必要

別オリジンのAPIからRetry-AfterをJavaScriptで読み取るには、サーバーがAccess-Control-Expose-Headers: Retry-Afterを返す必要があります。ヘッダーが実際に届いていても公開されていなければ、response.headers.get("Retry-After")は取得できません。

待機処理もAbortSignalでキャンセルする

通信にだけSignalを渡しても、再試行前のsetTimeout()待機は止まりません。画面を離れた後に次の通信が始まらないよう、待機自体をキャンセル対応にします。

abortable-sleep.js
function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(
        signal.reason ??
          new DOMException("Aborted", "AbortError")
      );
      return;
    }

    const timerId = setTimeout(() => {
      cleanup();
      resolve();
    }, ms);

    const handleAbort = () => {
      clearTimeout(timerId);
      cleanup();
      reject(
        signal.reason ??
          new DOMException("Aborted", "AbortError")
      );
    };

    const cleanup = () => {
      signal?.removeEventListener("abort", handleAbort);
    };

    signal?.addEventListener("abort", handleAbort, {
      once: true
    });
  });
}

中断済みSignalが渡された場合は即座にrejectします。待機完了時にもイベントリスナーを解除し、長時間動く画面で不要な参照を残さないようにしています。

再試行してよいHTTPメソッドを制限する

GETやHEADは、同じリクエストを複数回送ってもサーバー状態を変えないことが期待されます。PUTやDELETEもHTTP上は冪等として設計されますが、実際のAPI実装がその性質を守っているか確認が必要です。

POSTは通常、注文作成、メール送信、決済などの新しい処理を発生させます。最初の通信でサーバー処理が完了し、レスポンスだけ失われた場合、再送によって二重実行されます。

retryable-method.js
const IDEMPOTENT_METHODS = new Set([
  "GET",
  "HEAD",
  "PUT",
  "DELETE",
  "OPTIONS"
]);

function canRetryMethod(method, idempotencyKey) {
  const normalizedMethod = method.toUpperCase();

  return (
    IDEMPOTENT_METHODS.has(normalizedMethod) ||
    Boolean(idempotencyKey)
  );
}

POSTを再試行する場合は、クライアントが一意な冪等性キーを送り、サーバーが同じキーの処理結果を保存・再利用する仕組みが必要です。ヘッダー名や保持期間はAPI仕様で決めます。

post-with-idempotency-key.js
const idempotencyKey = crypto.randomUUID();

const response = await fetchWithRetry("/api/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey
  },
  body: JSON.stringify({
    productId: "P-100",
    quantity: 1
  })
}, {
  idempotencyKey
});
キーを付けるだけでは不十分

サーバーが冪等性キーを保存し、同じキーの二重処理を拒否または同じ結果として返す必要があります。クライアントだけでキーを生成しても二重登録は防げません。

実用的なfetchWithRetry関数を作る

ここまでの要件を一つの関数へまとめます。再試行回数、対象ステータス、指数バックオフ、Full Jitter、Retry-After、AbortSignal、冪等性を扱います。

fetch-with-retry.js
const DEFAULT_RETRYABLE_STATUSES = new Set([
  408,
  425,
  429,
  500,
  502,
  503,
  504
]);

const IDEMPOTENT_METHODS = new Set([
  "GET",
  "HEAD",
  "PUT",
  "DELETE",
  "OPTIONS"
]);

export async function fetchWithRetry(
  input,
  init = {},
  retryOptions = {}
) {
  const {
    maxRetries = 3,
    baseDelayMs = 500,
    maxDelayMs = 30000,
    maxRetryAfterMs = 60000,
    retryableStatuses = DEFAULT_RETRYABLE_STATUSES,
    idempotencyKey,
    requestFactory,
    shouldRetryError = (error) =>
      error?.name === "TypeError",
    onRetry = () => {},
    fetchImpl = fetch,
    sleepImpl = sleep,
    random = Math.random,
    now = Date.now
  } = retryOptions;

  const isRequest =
    typeof Request !== "undefined" &&
    input instanceof Request;
  const method = (
    init.method ??
    (isRequest ? input.method : "GET")
  ).toUpperCase();
  const signal =
    init.signal ??
    (isRequest ? input.signal : undefined);
  const inputHasBody = isRequest && input.body !== null;
  const methodCanRetry =
    IDEMPOTENT_METHODS.has(method) ||
    Boolean(idempotencyKey);
  const canRetry =
    methodCanRetry &&
    (!inputHasBody || typeof requestFactory === "function");
  let requestInit = init;

  if (idempotencyKey) {
    const headers = new Headers(
      isRequest ? input.headers : undefined
    );

    new Headers(init.headers).forEach((value, name) => {
      headers.set(name, value);
    });

    if (!headers.has("Idempotency-Key")) {
      headers.set("Idempotency-Key", idempotencyKey);
    }

    requestInit = { ...init, headers };
  }

  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
    let currentInput = input;
    let currentInit = requestInit;

    if (requestFactory) {
      const createdRequest = requestFactory({
        attempt,
        input,
        init: requestInit,
        signal,
        idempotencyKey
      });

      if (!(createdRequest instanceof Request)) {
        throw new TypeError(
          "requestFactory must return a Request"
        );
      }

      currentInput = addIdempotencyKey(
        createdRequest,
        idempotencyKey
      );
      currentInit = undefined;
    }

    let response;

    try {
      response = await fetchImpl(currentInput, currentInit);
    } catch (error) {
      if (
        signal?.aborted ||
        error.name === "AbortError" ||
        error.name === "TimeoutError"
      ) {
        throw error;
      }

      if (
        !canRetry ||
        !shouldRetryError(error) ||
        attempt === maxRetries
      ) {
        throw error;
      }

      const delayMs = calculateRetryDelay({
        retryIndex: attempt,
        baseDelayMs,
        maxDelayMs,
        random
      });

      onRetry({
        attempt: attempt + 1,
        delayMs,
        status: null,
        error
      });

      await sleepImpl(delayMs, signal);
      continue;
    }

    if (
      response.ok ||
      !canRetry ||
      !retryableStatuses.has(response.status) ||
      attempt === maxRetries
    ) {
      return response;
    }

    const retryAfterMs = parseRetryAfter(
      response.headers.get("Retry-After"),
      now()
    );

    // サーバー指定より早く再送せず、長すぎる場合は呼び出し側へ返す
    if (
      retryAfterMs !== null &&
      retryAfterMs > maxRetryAfterMs
    ) {
      return response;
    }

    const delayMs = retryAfterMs === null
      ? calculateRetryDelay({
          retryIndex: attempt,
          baseDelayMs,
          maxDelayMs,
          random
        })
      : retryAfterMs;

    // 再試行するResponseの本文は利用しないため接続を解放する
    await cancelResponseBody(response);

    onRetry({
      attempt: attempt + 1,
      delayMs,
      status: response.status,
      error: null
    });

    await sleepImpl(delayMs, signal);
  }

  throw new Error("Unexpected retry state");
}

function addIdempotencyKey(request, idempotencyKey) {
  if (
    !idempotencyKey ||
    request.headers.has("Idempotency-Key")
  ) {
    return request;
  }

  const headers = new Headers(request.headers);
  headers.set("Idempotency-Key", idempotencyKey);

  return new Request(request, { headers });
}

async function cancelResponseBody(response) {
  try {
    await response.body?.cancel();
  } catch {
    // 本文の解放失敗は元の再試行判定へ影響させない
  }
}

function calculateRetryDelay({
  retryIndex,
  baseDelayMs,
  maxDelayMs,
  random
}) {
  const exponentialDelay = Math.min(
    maxDelayMs,
    baseDelayMs * 2 ** retryIndex
  );

  return Math.floor(random() * exponentialDelay);
}

function parseRetryAfter(value, now) {
  if (!value) return null;

  const seconds = Number(value);

  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }

  const retryAt = Date.parse(value);

  if (Number.isNaN(retryAt)) {
    return null;
  }

  return Math.max(0, retryAt - now);
}

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(
        signal.reason ??
          new DOMException("Aborted", "AbortError")
      );
      return;
    }

    const timerId = setTimeout(() => {
      cleanup();
      resolve();
    }, ms);

    const handleAbort = () => {
      clearTimeout(timerId);
      cleanup();
      reject(
        signal.reason ??
          new DOMException("Aborted", "AbortError")
      );
    };

    const cleanup = () => {
      signal?.removeEventListener("abort", handleAbort);
    };

    signal?.addEventListener("abort", handleAbort, {
      once: true
    });
  });
}

再試行できないステータスや、最大回数まで失敗したResponseはそのまま返します。呼び出し側は通常のfetchと同様にresponse.okを確認できます。ネットワークエラーが最大回数まで続いた場合は、最後のエラーをthrowします。

maxRetryAfterMsは、異常に大きなRetry-Afterで画面処理が長時間止まらないためのクライアント上限です。上限を超えた場合はサーバー指定より早く再送せず、そのResponseを呼び出し側へ返します。

idempotencyKeyを指定すると、関数はIdempotency-Keyヘッダーが未設定の場合に自動追加します。すでに呼び出し側がヘッダーを設定している場合は、その値を上書きしません。

既定のshouldRetryErrorは、ブラウザのfetchがネットワーク層の失敗で返すTypeErrorだけを候補にします。ただしブラウザでは、回線障害、CORS拒否、名前解決失敗などが同じTypeErrorになり、完全には区別できません。CORS設定不備は再試行しても直らないため、監視ログで連続失敗を検出してください。

HTTPエラーを再試行するときは、使わないResponse本文をresponse.body.cancel()で解放してから待機します。本文をエラーログへ保存したい場合は、キャンセル前に必要な範囲だけ読み取る設計へ変更してください。

try...catchで囲むのはfetchImpl()だけです。onRetry()、待機処理、Request生成処理のバグは通信障害として再試行せず、そのまま上位へthrowします。

body付きのRequestは一度送ると再利用できないため、requestFactoryがなければ初回だけ実行します。再試行する場合は、試行ごとに新しいRequestを返す関数を指定してください。

fetchWithRetryを呼び出す

usage.js
import { fetchWithRetry } from "./fetch-with-retry.js";

const controller = new AbortController();

cancelButton.addEventListener("click", () => {
  controller.abort();
});

try {
  const response = await fetchWithRetry(
    "/api/products",
    {
      headers: {
        Accept: "application/json"
      },
      signal: controller.signal
    },
    {
      maxRetries: 3,
      baseDelayMs: 500,
      maxDelayMs: 8000,
      onRetry({ attempt, delayMs, status }) {
        console.info(
          `再試行 ${attempt}: ${delayMs}ms後`,
          status ? `HTTP ${status}` : "network error"
        );
      }
    }
  );

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const products = await response.json();
  renderProducts(products);
} catch (error) {
  if (error.name === "AbortError") {
    showMessage("取得をキャンセルしました");
  } else {
    showMessage("商品を取得できませんでした");
  }
}

初回通信が失敗すると、最大3回再試行します。つまり通信回数は最大4回です。画面に「4回失敗しました」と表示する場合は、この数え方を利用者向け文言と合わせてください。

タイムアウトとリトライを組み合わせる

1回の通信時間と、処理全体の制限時間は分けて考えます。各試行に10秒かかり、さらにバックオフするなら、3回の再試行で総時間が長くなります。

retry-with-total-timeout.js
const totalTimeout = AbortSignal.timeout(20000);

const response = await fetchWithRetry(
  "/api/report",
  {
    signal: totalTimeout
  },
  {
    maxRetries: 4,
    baseDelayMs: 500,
    maxDelayMs: 4000
  }
);

この例では、通信中かバックオフ待機中かにかかわらず、全体が20秒を超えると中断されます。各試行にも個別タイムアウトを設ける場合は、利用者操作用Signalと組み合わせてください。詳しい組み合わせ方はAbortSignal.timeoutとAbortSignal.anyの実装例を参照してください。

リクエストボディを再利用できるか確認する

文字列化したJSONやFormDataは、通常は各fetch()呼び出しで利用できます。一方、読み取り済みのRequestやStreamを同じまま再送すると失敗することがあります。

request-body-factory.js
const controller = new AbortController();

function createOrderRequest(order) {
  return new Request("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(order),
    signal: controller.signal
  });
}

const idempotencyKey = crypto.randomUUID();
const order = {
  productId: "P-100",
  quantity: 1
};

const response = await fetchWithRetry(
  "/api/orders",
  {
    method: "POST",
    signal: controller.signal
  },
  {
    idempotencyKey,
    requestFactory() {
      // 試行ごとに未使用のRequestとbodyを作る
      return createOrderRequest(order);
    }
  }
);

requestFactoryは初回を含む各試行の直前に呼ばれます。関数は返されたRequestへ冪等性キーを自動追加します。大容量アップロードやStreamでは、試行ごとに未使用のbodyを作ってください。body付きRequestを直接渡し、ファクトリーを省略した場合は安全のため再試行しません。

再試行ログに残す情報

障害調査では「最終的に成功した」だけでなく、その前に何回失敗したかが重要です。ただし、Authorizationヘッダー、Cookie、個人情報をログへ出してはいけません。

retry-logging.js
const response = await fetchWithRetry(
  "/api/products",
  {},
  {
    onRetry({
      attempt,
      delayMs,
      status,
      error
    }) {
      monitoring.capture("api_retry", {
        endpoint: "/api/products",
        attempt,
        delayMs,
        status,
        errorName: error?.name ?? null
      });
    }
  }
);

URLに検索語やユーザーIDが含まれる場合は、パス全体ではなくAPI名へ置き換えます。再試行回数、最終結果、ステータス、待機時間を集計すると、隠れた一時障害や過剰なレート制限を見つけられます。

Vitestでリトライ処理をテストする

実際に数秒待つテストは遅く不安定です。先ほどの関数はfetchImplsleepImplrandomnowを差し替えられるため、待機なしで決定論的に確認できます。

fetch-with-retry.test.js
import {
  describe,
  expect,
  it,
  vi
} from "vitest";
import { fetchWithRetry } from "./fetch-with-retry.js";

function response(status, headers = {}) {
  return new Response(null, {
    status,
    headers
  });
}

describe("fetchWithRetry", () => {
  it("503の後に成功したらResponseを返す", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValueOnce(response(503))
      .mockResolvedValueOnce(response(200));
    const sleepImpl = vi.fn().mockResolvedValue();

    const result = await fetchWithRetry(
      "/api/products",
      {},
      {
        fetchImpl,
        sleepImpl,
        random: () => 0.5
      }
    );

    expect(result.status).toBe(200);
    expect(fetchImpl).toHaveBeenCalledTimes(2);
    expect(sleepImpl).toHaveBeenCalledWith(
      250,
      undefined
    );
  });

  it("404は再試行しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(response(404));
    const sleepImpl = vi.fn();

    const result = await fetchWithRetry(
      "/api/missing",
      {},
      { fetchImpl, sleepImpl }
    );

    expect(result.status).toBe(404);
    expect(fetchImpl).toHaveBeenCalledTimes(1);
    expect(sleepImpl).not.toHaveBeenCalled();
  });

  it("Retry-Afterの秒数を優先する", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValueOnce(
        response(429, { "Retry-After": "3" })
      )
      .mockResolvedValueOnce(response(200));
    const sleepImpl = vi.fn().mockResolvedValue();

    await fetchWithRetry(
      "/api/rate-limited",
      {},
      { fetchImpl, sleepImpl }
    );

    expect(sleepImpl).toHaveBeenCalledWith(
      3000,
      undefined
    );
  });

  it("長すぎるRetry-Afterでは再送しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(
        response(429, { "Retry-After": "120" })
      );
    const sleepImpl = vi.fn();

    const result = await fetchWithRetry(
      "/api/rate-limited",
      {},
      {
        fetchImpl,
        sleepImpl,
        maxRetryAfterMs: 60000
      }
    );

    expect(result.status).toBe(429);
    expect(fetchImpl).toHaveBeenCalledTimes(1);
    expect(sleepImpl).not.toHaveBeenCalled();
  });

  it("POSTは冪等性キーなしで再試行しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(response(503));

    await fetchWithRetry(
      "/api/orders",
      { method: "POST" },
      { fetchImpl }
    );

    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it("POSTへ冪等性キーを自動追加する", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(response(200));

    await fetchWithRetry(
      "/api/orders",
      { method: "POST" },
      {
        fetchImpl,
        idempotencyKey: "order-request-123"
      }
    );

    const [, requestInit] = fetchImpl.mock.calls[0];

    expect(
      requestInit.headers.get("Idempotency-Key")
    ).toBe("order-request-123");
  });

  it("TypeError以外は既定で再試行しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockRejectedValue(new SyntaxError("invalid input"));

    await expect(
      fetchWithRetry(
        "/api/products",
        {},
        { fetchImpl }
      )
    ).rejects.toThrow("invalid input");

    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it("onRetry内のTypeErrorは再試行しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(response(503));

    await expect(
      fetchWithRetry(
        "/api/products",
        {},
        {
          fetchImpl,
          onRetry() {
            throw new TypeError("monitoring failed");
          }
        }
      )
    ).rejects.toThrow("monitoring failed");

    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it("body付きRequestはファクトリーなしで再送しない", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValue(response(503));
    const request = new Request(
      "https://example.test/api/orders",
      {
        method: "POST",
        body: JSON.stringify({ quantity: 1 })
      }
    );

    await fetchWithRetry(
      request,
      {},
      {
        fetchImpl,
        idempotencyKey: "order-request-123"
      }
    );

    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it("requestFactoryで試行ごとにRequestを作る", async () => {
    const fetchImpl = vi
      .fn()
      .mockResolvedValueOnce(response(503))
      .mockResolvedValueOnce(response(200));
    const requests = [];

    await fetchWithRetry(
      "https://example.test/api/orders",
      { method: "POST" },
      {
        fetchImpl: async (request) => {
          requests.push(request);
          return fetchImpl();
        },
        sleepImpl: vi.fn().mockResolvedValue(),
        idempotencyKey: "order-request-123",
        requestFactory() {
          return new Request(
            "https://example.test/api/orders",
            {
              method: "POST",
              body: JSON.stringify({ quantity: 1 })
            }
          );
        }
      }
    );

    expect(requests).toHaveLength(2);
    expect(requests[0]).not.toBe(requests[1]);
    expect(
      requests[1].headers.get("Idempotency-Key")
    ).toBe("order-request-123");
  });
});

最大試行回数、中断済みSignal、HTTP日時形式のRetry-After、Response本文の解放、sleepImpl内の例外も追加すると、回帰を防ぎやすくなります。Promiseと非同期テストの基本はPromiseとasync/awaitの実務パターンも参考になります。

よくある失敗

失敗するまで無制限に繰り返す

回数、1回の最大待機、処理全体の制限時間を必ず設けます。無限リトライは利用者を待たせ続け、障害中のサーバーへ負荷を送り続けます。

固定間隔で全端末が同時に再送する

指数バックオフだけでなくジッターも加えます。端末ごとの待機時間を分散し、復旧直後の再集中を防ぎます。

400系をすべて再試行する

401、403、404、422などは、認証更新、入力修正、URL修正が必要です。429や一部の408・425とは分けて扱います。

AbortErrorをネットワーク障害として再試行する

利用者がキャンセルした通信を勝手に再開してはいけません。signal.abortedAbortErrorTimeoutErrorは再試行ループから抜けます。

navigator.onLineだけで通信可能と判断する

navigator.onLineはネットワーク接続の参考情報であり、対象APIへ到達できる保証ではありません。最終判断は実際の通信結果で行います。

画面側だけでPOSTの二重実行を防ぐ

ボタンの無効化は連打対策にはなりますが、レスポンス消失後の自動再送は防げません。重要な更新処理はサーバー側の一意制約、冪等性キー、処理状態管理と組み合わせます。

よくある質問

Q. 何回リトライするのが適切ですか?
A. 画面操作では2〜3回程度から始め、APIのSLOと許容待ち時間に合わせて調整します。回数だけでなく、全体タイムアウトと最大待機時間も決めてください。
Q. 500は必ずリトライしてよいですか?
A. いいえ。障害が一時的と判断できるAPIだけを対象にします。入力内容やサーバー実装のバグが原因なら、繰り返しても成功しません。
Q. Retry-Afterと指数バックオフのどちらを優先しますか?
A. 通常はサーバーのRetry-Afterを優先し、存在しない・解析できない場合に指数バックオフを使います。極端に長い値をどう扱うかは画面の用途に合わせて決めます。
Q. POSTもリトライできますか?
A. サーバーが冪等性キーを処理し、同じ操作を二重実行しない場合に限って検討できます。決済や注文を、クライアント判断だけで自動再送しないでください。
Q. ライブラリを使うべきですか?
A. 共通要件が多い大規模アプリでは、利用中のHTTPクライアントやデータ取得ライブラリの再試行機能を優先してください。ただし、対象ステータス、POSTの扱い、ジッター、キャンセルの挙動は設定を確認する必要があります。

まとめ

  • fetch()のrejectとHTTPエラーを分け、再試行してよい失敗だけを選びます。
  • 408、425、429、500、502、503、504は候補ですが、API仕様に合わせて調整します。
  • 指数バックオフとFull Jitterで、障害中と復旧直後の負荷集中を避けます。
  • Retry-Afterの秒数・HTTP日時を解析し、サーバーの待機指示を尊重します。
  • 別オリジンではAccess-Control-Expose-Headersが必要です。
  • 待機処理にもAbortSignalを渡し、不要になった再試行を止めます。
  • POSTは冪等性キーとサーバー側の二重実行防止がある場合だけ再試行します。
  • 依存関数を注入すると、実時間を待たずにVitestで検証できます。

リトライはエラーを隠す仕組みではなく、一時障害から利用者体験を守るための制御です。失敗の種類、待機、キャンセル、冪等性、監視を一組として設計してください。