メインコンテンツまでスキップ

第29章:リトライと復旧(Retryable/Not)🔁✨

(通信失敗に強い“しぶといアプリ”を作る回だよ〜!💪🌈)


29-1. 今日のゴール🎯✨

「失敗したら、とりあえずリトライしとこ!」…ってやると、逆に地獄になることがあるの🥹💥 だからこの章では、

  • リトライしていい失敗 / ダメな失敗を見分ける
  • ✅ **待ち時間(バックオフ)**をちゃんと入れる
  • ✅ **二重送信(重複POST)**を防ぐ
  • キャンセルもできる
  • ✅ 状態機械で「設計として」事故を減らす

ここまでを、ちゃんと組み立てるよ〜🔧💖


29-2. まず大前提:「全部リトライ」は危険😱

リトライは優しさに見えるけど、失敗の原因が“過負荷”のとき、みんなが一斉に再送してさらに混む…っていう最悪パターンがあるの😭🔥 だから基本は、

  • リトライするポイントは1か所に絞る(あちこちで勝手に再試行しない)
  • 🕰️ **タイムアウト + バックオフ + ジッター(ゆらぎ)**で、再送タイミングをばらけさせる

が鉄則だよ〜!✨(AWSもこの考え方を強く推してるよ) (Amazon Web Services, Inc.)


29-3. Retryable / Not Retryable の仕分け表📋✨

ここ、状態機械にするなら 「ガード条件」 の中心になるよ🛡️

✅ リトライしていい(可能性が高い)💡

一時的な問題っぽいとき!

  • 🌐 ネットワーク不調(fetch が例外で落ちる系)
  • 🧱 503 Service Unavailable(サーバーが過負荷・メンテ中) (MDNウェブドキュメント)
  • 🚦 429 Too Many Requests(レート制限)+ Retry-After が来ることがある (MDNウェブドキュメント)
  • ⏳ タイムアウト(こちらの待ち時間切れ)

❌ リトライしちゃダメ(ほぼ治らない)🙅‍♀️

仕様や入力の問題っぽいとき!

  • 📝 400 Bad Request(入力不正)
  • 🔐 401 Unauthorized / 403 Forbidden(認証・権限)
  • 🧩 バリデーションエラー(未入力など)

※「404」はケースによるけど、普通のフォーム送信なら “入力やIDが間違ってる” 側が多いので、基本は Not Retry 寄りでOK🙆‍♀️


29-4. Retry-After を“最優先”で尊重しよう⏱️🙏

429/503 のとき、サーバーが「◯秒待ってね」って教えてくれることがあるよ〜! それが Retry-After ヘッダー🧾✨ (MDNウェブドキュメント)

ポイントはこれ👇

  • 🥇 Retry-After があるなら それに従う
  • 🥈 ないなら 自分のバックオフ規則に従う
  • 🧊 待ちすぎ防止のため **最大待ち時間(cap)**を決める

29-5. バックオフ + ジッター(ゆらぎ)🌪️✨

Retry Strategy

なぜ「ゆらぎ」が必要?

同じタイミングで落ちたクライアントが、同じ秒数待って、同じ瞬間に再送すると… また同じ瞬間に混むの😇💥 だから ちょっとランダムに散らすのが超大事! (Amazon Web Services, Inc.)

おすすめ:capped exponential backoff + full jitter

ざっくり式(イメージ)👇

  • base = 500ms とか

  • cap = 20s とか

  • attempt 回目(0,1,2…)で

    • temp = min(cap, base * 2^attempt)
    • delay = random(0, temp) ← これが full jitter 🎲

(AWSの “Exponential Backoff And Jitter” で紹介されてるやつだよ) (Amazon Web Services, Inc.)


29-6. 二重送信(重複POST)を絶対に起こさない設計🔒💥

Idempotency

リトライでいちばん怖いのがこれ😱 「成功したのにレスポンスが届かず、同じPOSTをもう一回投げて二重登録」みたいな事故…。

防御は“二段構え”が強い💪✨

① クライアント側の防御🖱️

  • ✅ Submitting中は SUBMIT を無視(ガード)
  • ✅ 送信ボタンを disabled
  • ✅ requestId を Context に入れて、古いレスポンスは捨てる(後述)

② サーバー側の防御(超重要)🏰

POST/PATCH を安全にリトライするために、Idempotency-Key ヘッダーを使う考え方が標準化・普及してきてるよ✨ 同じキーのリクエストは **“一回だけ処理”**して、あとは同じ結果を返す…みたいにできるの🧠💡 (MDNウェブドキュメント)


29-7. 状態機械に落とす:Retry設計の“型”🧩✨

状態(例)🚦

  • Idle(待機)
  • Editing(入力中)
  • Submitting(送信中)
  • WaitingRetry(次の再試行待ち)
  • Success
  • Error(復旧不能 or リトライ打ち切り)
  • Cancelled(キャンセルで中断)

イベント(例)📣

  • SUBMIT
  • RESOLVE(成功レスポンス)
  • REJECT(失敗:HTTPや例外)
  • RETRY_TIMER_FIRED(待ち時間が終わった)
  • CANCEL
  • EDIT
  • RESET

Context(例)🧠

  • attempt(今何回目?)
  • maxAttempts
  • requestId(今回の送信の識別子)
  • idempotencyKey(同じ送信のリトライで同一にする)
  • lastError
  • nextDelayMs

ガード(例)🛡️

  • isRetryableFailure(429/503/ネットワーク等?)
  • hasAttemptsLeft(attempt < maxAttempts)
  • hasRetryAfter(ヘッダーある?)

29-8. 実装ミニ例(Reducer + Effect 方式)🧑‍💻✨

「状態遷移は純粋」「I/Oは Effect にして外で実行」の形だよ💖 (この形はテストも超しやすい🧪)

type State =
| { kind: "Editing" }
| { kind: "Submitting"; requestId: string; attempt: number; idempotencyKey: string }
| { kind: "WaitingRetry"; requestId: string; attempt: number; idempotencyKey: string; delayMs: number }
| { kind: "Success" }
| { kind: "Error"; message: string }
| { kind: "Cancelled" };

type Event =
| { type: "SUBMIT" }
| { type: "RESOLVE" }
| { type: "REJECT"; error: Failure }
| { type: "RETRY_TIMER_FIRED" }
| { type: "CANCEL" }
| { type: "EDIT" };

type Failure =
| { kind: "Http"; status: number; retryAfterMs?: number }
| { kind: "Network" }
| { kind: "Timeout" }
| { kind: "Abort" }
| { kind: "Validation"; message: string }
| { kind: "Auth" };

type Effect =
| { type: "START_REQUEST"; requestId: string; idempotencyKey: string }
| { type: "SCHEDULE_TIMER"; requestId: string; delayMs: number }
| { type: "ABORT_REQUEST"; requestId: string }
| { type: "LOG"; message: string };

const MAX_ATTEMPTS = 5;
const BASE_MS = 500;
const CAP_MS = 20_000;

function isRetryable(f: Failure): boolean {
if (f.kind === "Network" || f.kind === "Timeout") return true;
if (f.kind === "Http") return f.status === 429 || f.status === 503 || (f.status >= 500 && f.status <= 599);
return false;
}

function computeDelayMs(attempt: number): number {
const temp = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
return Math.floor(Math.random() * (temp + 1)); // full jitter
}

function reducer(state: State, event: Event): { state: State; effects: Effect[] } {
switch (state.kind) {
case "Editing": {
if (event.type === "SUBMIT") {
const requestId = crypto.randomUUID();
const idempotencyKey = crypto.randomUUID(); // 「この送信」に紐づくキー(リトライでは同じ)
return {
state: { kind: "Submitting", requestId, attempt: 0, idempotencyKey },
effects: [
{ type: "LOG", message: "submit start" },
{ type: "START_REQUEST", requestId, idempotencyKey },
],
};
}
return { state, effects: [] };
}

case "Submitting": {
if (event.type === "CANCEL") {
return {
state: { kind: "Cancelled" },
effects: [{ type: "ABORT_REQUEST", requestId: state.requestId }],
};
}

if (event.type === "RESOLVE") {
return { state: { kind: "Success" }, effects: [{ type: "LOG", message: "submit success" }] };
}

if (event.type === "REJECT") {
const f = event.error;

// Not Retryable は即エラー
if (!isRetryable(f)) {
return { state: { kind: "Error", message: "リトライできない失敗だよ🥲" }, effects: [] };
}

// 回数上限
if (state.attempt + 1 >= MAX_ATTEMPTS) {
return { state: { kind: "Error", message: "リトライ上限に到達…🧯" }, effects: [] };
}

// Retry-After があれば最優先
const delayMs = f.kind === "Http" && f.retryAfterMs != null
? Math.min(CAP_MS, f.retryAfterMs)
: computeDelayMs(state.attempt);

return {
state: { kind: "WaitingRetry", requestId: state.requestId, attempt: state.attempt + 1, idempotencyKey: state.idempotencyKey, delayMs },
effects: [
{ type: "LOG", message: `retry scheduled: ${delayMs}ms` },
{ type: "SCHEDULE_TIMER", requestId: state.requestId, delayMs },
],
};
}

return { state, effects: [] };
}

case "WaitingRetry": {
if (event.type === "CANCEL") {
return {
state: { kind: "Cancelled" },
effects: [{ type: "ABORT_REQUEST", requestId: state.requestId }],
};
}

if (event.type === "RETRY_TIMER_FIRED") {
return {
state: { kind: "Submitting", requestId: state.requestId, attempt: state.attempt, idempotencyKey: state.idempotencyKey },
effects: [{ type: "START_REQUEST", requestId: state.requestId, idempotencyKey: state.idempotencyKey }],
};
}

return { state, effects: [] };
}

default:
// Success / Error / Cancelled など
if (event.type === "EDIT") return { state: { kind: "Editing" }, effects: [] };
return { state, effects: [] };
}
}

この例の“設計の芯”💎

  • Submitting 中に SUBMIT を受けない(編集状態からしか SUBMIT できない)→ 二重送信を設計で潰す🔨
  • idempotencyKey は「同じ送信のリトライで同一」→ 重複POST事故を防ぎやすい🛡️ (MDNウェブドキュメント)
  • 429/503 は Retry-After を尊重 → サーバーに優しい🥹💖 (MDNウェブドキュメント)
  • バックオフ + ジッター → リトライ嵐を抑える🌪️✨ (Amazon Web Services, Inc.)

29-9. “古いレスポンス”で状態が巻き戻る事故を防ぐ🌀

非同期って、順番が入れ替わることあるの😵‍💫 だから Effect 実行側で、

  • リクエスト開始時に requestId を持つ
  • RESOLVE / REJECT にも requestId を付ける
  • いまの状態の requestId と違ったら 捨てる

これを入れると超堅牢になるよ💪✨ (この章の主題「復旧」って、こういう“現実の事故”を潰すことなんだよね…!🧯)


29-10. キャンセル(Abort)も状態に含める✋🧊

送信中に「やっぱやめたい!」って普通にあるよね🥺 AbortController は、fetch を止める定番の仕組みだよ🧨(広く使える仕様として定着してる) (MDNウェブドキュメント)

状態機械的には:

  • CANCEL イベントが来たら

    • Submitting / WaitingRetry → Cancelled に遷移
    • ABORT_REQUEST の Effect を出す

って感じで、道筋が一個に決まるのが強い✨


29-11. テスト観点(ここ超実務!)🧪✨

表駆動でテストしやすいポイント🎁

  • isRetryable の判定(429/503/Network/Timeout は true、Validation/Auth は false)
  • Retry-After があると delayMs がそれ優先
  • attempt 上限で Error になる
  • CANCEL で Cancelled + ABORT_REQUEST が出る

ジッターのテストどうする?🎲

  • computeDelayMs を差し替え可能にする(乱数注入)
  • もしくは PRNG を固定 seed にする こうするとテストが安定するよ〜😊

29-12. AIに頼ると爆速になる質問テンプレ🤖✨

コピペで使えるやつ置いとくね💕

  • 「この失敗は Retryable?理由もつけて分類して。HTTPステータス別に表で」
  • 「Retry-After がある時/ない時の待ち時間の決め方を、状態機械のガードとアクションに分けて提案して」
  • 「二重送信防止を、(1)状態遷移、(2)UI制御、(3)Idempotency-Key の3層で設計案出して」
  • 「古いレスポンスが後から返ってきても壊れないように requestId 方式で設計して」

29-13. 章末チェックリスト✅✨

できたら勝ち〜!🎉

  • ✅ Retryable / Not をルール化した
  • ✅ Retry-After を尊重する
  • ✅ バックオフ + ジッター + cap を入れた (Amazon Web Services, Inc.)
  • ✅ 二重送信を「設計で」防いだ(SUBMITの入口を絞った)
  • ✅ Idempotency-Key を“同じ送信のリトライで同一”にする方針にした (MDNウェブドキュメント)
  • ✅ CANCEL の経路がある
  • ✅ 古いレスポンス破壊を requestId で防げる

おまけ:いまのTypeScript周りの最新メモ🧷✨

TypeScript は本日時点で 5.9 が最新として案内されてるよ(公式) (TypeScript) この章の内容(リトライ・復旧設計)は、バージョンが変わっても “設計の骨格” としてずっと使えるやつだよ〜🔁💖


次はこの第29章の設計を、**第30章の総合演習(フォーム送信)**にそのまま組み込んで「実戦で動く」まで持っていこうね〜!📨🎓✨