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になります。
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:上流サーバーの応答タイムアウト
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の累乗で増やします。
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です。
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日時」です。
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
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にし、指数バックオフへフォールバックします。サーバー時刻と端末時刻にはずれがあるため、日時形式より秒数形式のほうが扱いやすい場合があります。
別オリジンのAPIからRetry-AfterをJavaScriptで読み取るには、サーバーがAccess-Control-Expose-Headers: Retry-Afterを返す必要があります。ヘッダーが実際に届いていても公開されていなければ、response.headers.get("Retry-After")は取得できません。
待機処理もAbortSignalでキャンセルする
通信にだけSignalを渡しても、再試行前のsetTimeout()待機は止まりません。画面を離れた後に次の通信が始まらないよう、待機自体をキャンセル対応にします。
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は通常、注文作成、メール送信、決済などの新しい処理を発生させます。最初の通信でサーバー処理が完了し、レスポンスだけ失われた場合、再送によって二重実行されます。
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仕様で決めます。
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、冪等性を扱います。
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を呼び出す
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回の再試行で総時間が長くなります。
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を同じまま再送すると失敗することがあります。
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、個人情報をログへ出してはいけません。
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でリトライ処理をテストする
実際に数秒待つテストは遅く不安定です。先ほどの関数はfetchImpl、sleepImpl、random、nowを差し替えられるため、待機なしで決定論的に確認できます。
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.aborted、AbortError、TimeoutErrorは再試行ループから抜けます。
navigator.onLineだけで通信可能と判断する
navigator.onLineはネットワーク接続の参考情報であり、対象APIへ到達できる保証ではありません。最終判断は実際の通信結果で行います。
画面側だけでPOSTの二重実行を防ぐ
ボタンの無効化は連打対策にはなりますが、レスポンス消失後の自動再送は防げません。重要な更新処理はサーバー側の一意制約、冪等性キー、処理状態管理と組み合わせます。
よくある質問
Retry-Afterを優先し、存在しない・解析できない場合に指数バックオフを使います。極端に長い値をどう扱うかは画面の用途に合わせて決めます。まとめ
fetch()のrejectとHTTPエラーを分け、再試行してよい失敗だけを選びます。- 408、425、429、500、502、503、504は候補ですが、API仕様に合わせて調整します。
- 指数バックオフとFull Jitterで、障害中と復旧直後の負荷集中を避けます。
Retry-Afterの秒数・HTTP日時を解析し、サーバーの待機指示を尊重します。- 別オリジンでは
Access-Control-Expose-Headersが必要です。 - 待機処理にもAbortSignalを渡し、不要になった再試行を止めます。
- POSTは冪等性キーとサーバー側の二重実行防止がある場合だけ再試行します。
- 依存関数を注入すると、実時間を待たずにVitestで検証できます。
リトライはエラーを隠す仕組みではなく、一時障害から利用者体験を守るための制御です。失敗の種類、待機、キャンセル、冪等性、監視を一組として設計してください。
