メインコンテンツへスキップ
友田 陽大
信頼性・非同期・リアルタイム
アーキテクチャ設計
TypeScript
AWS
サーバーレス
B2B SaaS

外部依存が落ちても落ちないシステム設計:リトライ・指数バックオフ+ジッター・サーキットブレーカー実装ガイド

信頼できない外部APIを相手に「落ちないシステム」を作るための実務ガイド。リトライの大原則(冪等性が前提)、指数バックオフ+ジッター、タイムアウト予算、サーキットブレーカー(closed/open/half-open)、バルクヘッド、フォールバックの罠まで、AWS/Azure公式に忠実な動くTypeScriptコードで解説します。

公開日
読了時間
33分
著者
友田 陽大
シェア
目次

「外部APIは落ちる」——これは悲観論ではなく、分散システムの前提条件です。決済プロバイダ、メール送信、在庫API、社内マイクロサービス。自分が制御できない依存先は、必ず・いつか・予測不能なタイミングで、遅延し、タイムアウトし、5xxを返します。問題は「落ちるかどうか」ではなく、「相手が落ちたとき、自分は落ちるか」です。

私は環境分野のサーバーレス決済プラットフォームで、小数演算と冪等性を中核とした決済信頼性レイヤー(exactly-once 処理・残高のアトミック更新)を設計・実装しました。また経済産業省 IT導入補助金関連で受賞した B2B SaaS では、Stripe Connect を相手に外部API依存の回復性を組みました。決済は「落ちても二重課金しない」が絶対条件です。その現場で効いた回復性パターンを、AWS と Microsoft の公式ドキュメントに忠実に、しかし「いつ・どう・なぜ使うか」の判断軸を厚くして、動くTypeScriptで解説します。

本記事の出典は一次情報のみです。指数バックオフ+ジッターは AWS Builders' Library Timeouts, retries, and backoff with jitter と AWS Architecture Blog Exponential Backoff And Jitter、サーキットブレーカー/リトライ/バルクヘッドは Microsoft Azure Architecture Center の各 pattern ページを根拠とします。具体的な数値(成功率の改善幅など)は計測していないので一切捏造しません

0. 全体像:回復性は「6つの層」の重ね合わせ

回復性(resilience)は単一の銀の弾丸ではありません。役割の異なる防御層を、正しい順序で重ねる設計です。混同すると、むしろ自分で自分を殺します(後述の「リトライストーム」がその典型)。

守るもの失敗時の挙動本記事の章
タイムアウト自分のスレッド/接続待ち続けない、早く諦める§3
リトライ一時的失敗の吸収待って再試行(冪等が前提)§1, §2
バックオフ+ジッター相手と自分の共倒れ防止再試行を時間的に分散§2
サーキットブレーカーカスケード障害の遮断即座に失敗(fail fast)§4
バルクヘッドリソース枯渇の隔離一部だけ落とす§5
冪等性上記すべての前提何度実行しても同じ結果§1(横断)

この順で進めます。まず「いつリトライしてよいか」という最重要原則から。ここを外すと、他の全部が逆効果になります。


1. 最重要原則:リトライしてよいのは「一時的 × 冪等」だけ

回復性パターンで最初に・絶対に・叩き込むべき原則はこれです。

リトライは「一時的な失敗(transient fault)」かつ「冪等な操作」に対してのみ行う。

両方を満たさないリトライは、改善ではなく破壊です。順に見ます。

1-1. 「一時的な失敗」とは何か

Azure の Retry pattern は、リトライ対象を「transient faults(一時的障害)」に限定します。曰く「これらの障害は通常自己修復され、適切な遅延の後に再実行すれば成功する可能性が高い(These faults are typically self-correcting, and if the action that triggered a fault is repeated after a suitable delay it's likely to be successful)」。

逆に、リトライしてはいけない失敗もはっきり定義されています。同ドキュメントは3つの戦略を挙げます。

  • Cancel(中止):障害が一時的でない、または再試行しても成功しそうにないなら、操作を中止して例外を報告する。
  • Retry immediately(即時再試行):稀なネットワークパケット破損のような例外的失敗なら、即座に再試行してよい。
  • Retry after delay(遅延後再試行):よくある接続障害やビジー状態なら、遅延を入れて再試行する。

実務では HTTP ステータスとエラー種別で機械的に分類するのが堅いです。4xx の大半(特に 400/401/403/404/422)はリトライ禁止——これらは「あなたの送り方が間違っている」というクライアントエラーであり、何度送っても結果は変わりません。リトライしてよいのは「相手の都合」を示すシグナルだけです。

失敗の種類リトライ?理由
ネットワーク系接続失敗、ECONNRESET、DNS一時失敗する典型的な一時的障害
タイムアウトレスポンスが返らないする(ただし冪等なら)相手が処理済みの可能性あり→冪等性必須
5xx(一部)502/503/504するサーバ側の一時的過負荷・再起動
429 Too Many Requestsレート制限する(Retry-After 尊重)相手の指示する待ち時間に従う
4xx クライアントエラー400/401/403/404/422しない送り直しても同じ結果。バグの隠蔽になる
ビジネスロジック例外残高不足、バリデーション失敗しない一時的でない。Azure 公式も明記

Azure 公式は「リトライパターンが役に立たないケース」として「アプリケーションのビジネスロジックのエラーに起因するような、一時的障害ではない失敗の処理(For handling failures that aren't due to transient faults, such as internal exceptions caused by errors in the business logic)」を明示しています。4xx をリトライするのは、バグを遅延させて見えなくする最悪の自己欺瞞です。

1-2. 「冪等」でなければ、リトライは二重実行になる

ここが決済で人を殺す論点です。Azure Retry pattern は冪等性についてこう書きます。

「操作が冪等かどうかを検討する。冪等なら本質的にリトライ安全。そうでなければ、リトライが操作を複数回実行させ、意図しない副作用を生む。たとえばサービスがリクエストを受信して処理に成功したが、レスポンス送信に失敗した場合、リトライロジックは最初のリクエストが届かなかったと仮定して再送する(a service might receive the request, process the request successfully, but fail to send a response. At that point, the retry logic might re-send the request)」

AWS Builders' Library も同じことを、より強い言葉で言います。「副作用を持つ API は、冪等性を提供しない限りリトライ安全ではない(APIs with side effects aren't safe to retry unless they provide idempotency)。これにより、何度リトライしても副作用は一度しか起きないことが保証される」。

つまりタイムアウトの本質的な怖さは、「失敗したのか、成功したのにレスポンスだけ失われたのか、呼び出し側からは区別できない」点にあります。POST /charge(課金)をタイムアウト後にナイーブに再送すれば、二重課金します。

これを防ぐのが**冪等性キー(idempotency key)**です。クライアントが生成した一意キーをリクエストに付与し、サーバはそのキーで「初回かリトライか」を判定します。

// クライアント側:操作ごとに「決定的な」冪等性キーを発行する。
// リトライ間で同じキーを使い回すのが肝(毎回新規発行するとキーの意味がない)。
import { randomUUID } from "node:crypto";

type ChargeRequest = {
  amount: number;
  currency: "JPY";
  customerId: string;
};

async function chargeWithIdempotency(req: ChargeRequest): Promise<void> {
  // 1回の「課金しようという意図」に対して1つのキー。リトライしても同じキー。
  const idempotencyKey = randomUUID();

  // この呼び出しは §7 の resilientFetch で包む(タイムアウト+リトライ+ブレーカー)
  await resilientFetch("https://api.example.com/charges", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey, // 同じキーなら二重課金しない契約
    },
    body: JSON.stringify(req),
  });
}

サーバ側は、このキーで「すでに処理済みなら、新たに課金せず前回と同じ結果を返す」ことを保証します。これが「exactly-once」の実体です。私が決済プラットフォームの信頼性レイヤーで設計したのもまさにこの層で、本番二重課金0件を維持しています。冪等な非同期処理の具体実装(DynamoDB 条件付き書き込みによる重複排除)はSQS×Lambda×EventBridge の冪等な非同期処理ガイドに、Stripe での冪等性キーと Webhook 処理はStripe決済 本番運用ガイドに、それぞれ実コードで書きました。

この記事で覚えるべき一文:リトライ機構を入れる前に、その操作が冪等であることを保証せよ。 順序が逆だと、回復性レイヤーが二重課金製造機になります。

1-3. 型で「リトライ可否」を表現する

判断をコードに埋め込むため、失敗を型付き Resultで表現します。例外を投げっぱなしにすると「これはリトライ可能か」がコールスタックのどこかに散逸します。判断を一箇所に集約しましょう。

// 失敗を「分類された値」として扱う。any や生の throw に逃げない。
export type Transient =
  | { kind: "network"; cause: unknown }
  | { kind: "timeout" }
  | { kind: "server"; status: 502 | 503 | 504 }
  | { kind: "throttled"; retryAfterMs?: number }; // 429

export type Permanent =
  | { kind: "client"; status: number } // 4xx(429除く)
  | { kind: "business"; code: string }; // ドメイン上の失敗

export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: Transient | Permanent };

// 「この失敗はリトライしてよいか」をただ1箇所で判定する(DRY)
export function isRetryable(e: Transient | Permanent): e is Transient {
  return (
    e.kind === "network" ||
    e.kind === "timeout" ||
    e.kind === "server" ||
    e.kind === "throttled"
  );
}

isRetryablee is Transient という型述語を返すのがポイントです。これで「リトライ可能と判定したブロック内では retryAfterMs 等の一時的失敗固有のフィールドに型安全にアクセスできる」状態になります。


2. 指数バックオフ+ジッター:ナイーブなリトライが障害を増幅する

リトライしてよいと決めたら、次は「どう待つか」です。ここで初学者がほぼ必ず作る地雷が「ナイーブな即時リトライ」と「ジッターなしの固定バックオフ」です。

2-1. なぜ「同時に再試行」が致命傷なのか(リトライストーム)

サーバが一時的に過負荷で 503 を返したとします。クライアントが1000台あり、全台が「同じタイミングで」リトライしたら何が起きるか。回復しかけたサーバに、また1000リクエストが同時に殺到します。AWS Builders' Library はこう書きます。

「もし失敗した呼び出しがすべて同じ時刻にバックオフすると、リトライ時に再び競合や過負荷を引き起こす(If all the failed calls back off to the same time, they cause contention or overload again when they are retried)」

これがリトライストーム / thundering herd(雷鳴のような群れ)です。さらに悪いのが多層リトライの増幅。AWS は具体的な数字を挙げます。

「各層が独立してリトライすると、データベースへの負荷は 243倍 に増える(If each layer retries independently, the load on the database will increase 243x)」——5層のサービスが各3回リトライすれば 3^5 = 243。

対策は2つ。Azure/AWS 両方が指示しています。

  1. リトライは「スタックの1箇所」でだけ行う(AWS: "retry at a single point in the stack")。Azure も「リトライポリシーを持つタスクが、別のリトライポリシーを持つタスクを呼ぶと、長大な遅延を生む。下位タスクは fail fast させ、上位だけがポリシーで処理すべき」と書きます。
  2. バックオフにジッター(ランダム性)を加える

2-2. 公式の4つのバックオフ式(ジッターの正体)

AWS Architecture Blog Exponential Backoff And Jitter が示す式を正確に引用します。base は基準待ち時間、cap は上限(capped backoff)、attempt は試行回数。

1. 素の指数バックオフ(ジッターなし — 悪い)
   sleep = min(cap, base * 2^attempt)

2. Full Jitter(フルジッター — 推奨)
   sleep = random_between(0, min(cap, base * 2^attempt))

3. Equal Jitter(イコールジッター)
   temp  = min(cap, base * 2^attempt)
   sleep = temp/2 + random_between(0, temp/2)

4. Decorrelated Jitter(デコリレーテッドジッター)
   sleep = min(cap, random_between(base, prev_sleep * 3))

同記事の結論を引きます。「ジッターなしの指数バックオフは明確な敗者である。より多くの仕事をし、しかもジッター版より時間もかかる(The no-jitter exponential backoff approach is the clear loser. It not only takes more work, but also takes more time than the jittered approaches)」。そして Full と Decorrelated の比較では「Full Jitter はより少ない仕事量で済む("Full Jitter" approach uses less work)」とし、いずれもクライアント仕事量とサーバ負荷の大幅な減少をもたらす、と結論づけます。

方式待ち時間の散らばり仕事量採用判断
ジッターなしゼロ(全台同時)最多・最遅使うな(公式が "clear loser")
Full Jitter最大(0〜上限で一様)最少既定の第一選択
Equal Jitter中(半分は固定)「最低限これだけは待ちたい」時
Decorrelatedやや多前回値に連動。状態を持てる時

迷ったら Full Jitter です。理由は単純で、公式が「仕事量最少」と結論づけており、実装も最も単純だからです。

2-3. ナイーブ実装(悪い)vs Full Jitter(良い)

まずやってはいけない例。これは「親切心で書いた爆弾」です。

// アンチパターン:固定間隔・ジッターなし・冪等性チェックなし・上限なし
async function badRetry<T>(fn: () => Promise<T>): Promise<T> {
  while (true) {                 // ❌ 無制限(unbounded)— 一生回り続ける
    try {
      return await fn();
    } catch {
      await sleep(1000);         // ❌ 固定1秒 — 全クライアントが同時に殺到する
      // ❌ 何の失敗かを区別していない(4xxもビジネス失敗もリトライしてしまう)
    }
  }
}

次に、§1の isRetryable と Full Jitter を組み合わせた正しい実装

export type RetryOptions = {
  maxAttempts: number; // 上限は必須。無制限は禁止
  baseMs: number;      // 基準待ち(例: 100)
  capMs: number;       // 上限待ち(例: 20_000)capped backoff
  signal?: AbortSignal; // 全体デッドライン(§3)と連動
};

// Full Jitter: sleep = random(0, min(cap, base * 2^attempt))
function fullJitterDelay(attempt: number, baseMs: number, capMs: number): number {
  const exp = Math.min(capMs, baseMs * 2 ** attempt);
  return Math.random() * exp; // 0〜exp の一様乱数。これがストームを砕く
}

export async function retry<T>(
  op: () => Promise<Result<T>>,
  opts: RetryOptions,
): Promise<Result<T>> {
  let lastError: Transient | Permanent = { kind: "timeout" };

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    const res = await op();
    if (res.ok) return res;

    lastError = res.error;
    // 恒久的失敗は即座に諦める(4xx/ビジネス失敗をリトライしない)
    if (!isRetryable(res.error)) return res;

    // 最終試行ならもう待たない
    if (attempt === opts.maxAttempts - 1) break;

    // 429 は相手が指定した Retry-After を尊重しつつ、最低限ジッターを足す
    const jittered = fullJitterDelay(attempt, opts.baseMs, opts.capMs);
    const delay =
      lastError.kind === "throttled" && lastError.retryAfterMs
        ? Math.max(lastError.retryAfterMs, jittered)
        : jittered;

    await sleepAbortable(delay, opts.signal); // 全体デッドラインで中断可能に
    // 観測性:リトライ回数はメトリクスとして必ず出す(§8)
    emitMetric("retry.attempt", { attempt: attempt + 1, kind: lastError.kind });
  }
  return { ok: false, error: lastError };
}

sleep 系のユーティリティはこうです。AbortSignal で全体デッドラインに従わせる点が実務上重要です。

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(new Error("aborted"));
    const t = setTimeout(resolve, ms);
    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(t);
        reject(new Error("aborted")); // デッドライン超過なら待たずに抜ける
      },
      { once: true },
    );
  });
}

2-4. トークンバケットで「リトライ総量」に蓋をする

ジッターはタイミングを散らしますが、総量は抑えません。障害が続くと、ジッターありでもリトライの絶対数は増え続けます。AWS Builders' Library の追加策がこれです。

「トークンバケットを使ってローカルでリトライを制限する。トークンがある限りすべての呼び出しがリトライでき、トークンが尽きたら固定レートでリトライする(Limiting retries locally using a token bucket ... allows all calls to retry as long as there are tokens, and then retry at a fixed rate when the tokens are exhausted)」

// リトライ「予算」を管理する。成功でトークン回復、リトライで消費。
// 障害が長引いてもリトライ総量に上限がかかる(自己DDoS防止)。
export class RetryBudget {
  private tokens: number;
  constructor(
    private readonly capacity: number,
    private readonly refillPerSuccess: number = 1,
  ) {
    this.tokens = capacity;
  }
  tryAcquire(): boolean {
    if (this.tokens <= 0) return false; // 予算切れ→リトライせず即失敗
    this.tokens -= 1;
    return true;
  }
  onSuccess(): void {
    this.tokens = Math.min(this.capacity, this.tokens + this.refillPerSuccess);
  }
}

これで「ジッター(タイミング分散)+トークンバケット(総量制限)」という二重の防御になり、リトライストームを構造的に潰せます。


3. タイムアウト予算:待ち続けないことが回復性の土台

リトライの議論はすべて「そもそも適切なタイムアウトがある」前提に立ちます。タイムアウトがなければ、遅い依存先にスレッドと接続を無限に握られ、Azure Circuit Breaker の言う「メモリ・スレッド・DB接続といった重要なシステムリソースを保持し続け、リソース枯渇で無関係な部分まで巻き込む(hold critical system resources ... This problem can exhaust resources, which might fail other unrelated parts of the system)」事態を招きます。

3-1. 2種類のタイムアウト:試行ごと と 全体

実務では2層で持ちます。

  • 試行ごとタイムアウト(per-attempt timeout):1回の呼び出しが返らないと判断する閾値。
  • 全体デッドライン(overall deadline):リトライを含む「この操作に許される総時間」。ユーザーが待てる上限から逆算する。
// 試行ごとのタイムアウト。AbortController で確実に下層をキャンセルする。
async function withTimeout<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  ms: number,
  parentSignal?: AbortSignal, // 全体デッドラインを伝播
): Promise<T> {
  const ctrl = new AbortController();
  const onParentAbort = () => ctrl.abort();
  parentSignal?.addEventListener("abort", onParentAbort, { once: true });

  const timer = setTimeout(() => ctrl.abort(), ms);
  try {
    return await fn(ctrl.signal);
  } finally {
    clearTimeout(timer);
    parentSignal?.removeEventListener("abort", onParentAbort);
  }
}

// 全体デッドライン:リトライ総時間に蓋をする
function deadline(totalMs: number): AbortSignal {
  return AbortSignal.timeout(totalMs); // Node 18+/モダンブラウザ
}

全体デッドラインを retrysignal に渡せば、「試行ごとは5秒・全体は15秒まで」のように予算を組めます。15秒に達したら、残り試行回数があっても待たずに打ち切ります。

3-2. タイムアウト値の決め方(勘でなく分位点で)

タイムアウトを「なんとなく30秒」にするのは設計放棄です。AWS Builders' Library は方法を明示します。

「許容できる誤タイムアウト率(たとえば 0.1%)を選び、対応する分位点のレイテンシを見る(choose an acceptable rate of false timeouts (such as 0.1%). Then, we look at the corresponding latency percentile)」

つまり下流の実測レイテンシ分布の p99.9 を基準にタイムアウトを置く、ということ。これより短いと正常応答を殺し、長いとリソースを無駄に握ります。

3-3. 非同期キューでの「再配信フォークボム」に注意

タイムアウト設計の落とし穴が非同期処理です。AWS Builders' Library Avoiding insurmountable queue backlogs は、過負荷でレイテンシが SQS の VisibilityTimeout を超えると「サービスが自分自身をフォークボムする(essentially fork-bomb itself)」と警告します。可視性タイムアウトが切れて同じメッセージが再配信され、同一メッセージの複数コピーが並列処理され、問題が連鎖する。対策は「メッセージ期限が切れたら処理を止めるか、ハートビートで『まだ処理中』を SQS に知らせる」こと。ここでも冪等性が最後の砦です(同一メッセージが二重処理されても結果が壊れない)。トランザクショナルな配信保証の実装はTransactional Outbox パターンのガイドに詳述しました。


4. サーキットブレーカー:カスケード障害を「即断」で止める

リトライは「いずれ回復する」前提のパターンです。しかし障害が長引くとき、リトライし続けるのは害でしかありません。Azure Circuit Breaker pattern はこう区別します。

「サーキットブレーカーはリトライパターンとは異なる目的を持つ。リトライは操作がいずれ成功する期待のもとで再試行する。サーキットブレーカーは、失敗する可能性が高い操作の実行をそもそも防ぐ(The Retry pattern enables an application to retry an operation with the expectation that it eventually succeeds. The Circuit Breaker pattern prevents an application from performing an operation that's likely to fail)」

両者は組み合わせて使います。Azure 曰く「リトライパターンを使ってサーキットブレーカー経由で操作を呼ぶ。ただしリトライロジックはブレーカーが返す例外に敏感であるべきで、ブレーカーが『一時的でない』と示したらリトライを止めるべき」。

4-1. 3つの状態(公式の定義に忠実に)

Azure の定義をそのまま実装に落とします。

  • Closed(閉):通常動作。リクエストは通る。失敗カウントを数え、一定期間内に閾値を超えたら Open に遷移し、タイムアウトタイマーを開始する。失敗カウンタは時間ベースで定期的にリセットされる(たまの失敗で開かないように)。
  • Open(開):リクエストは即座に失敗し、例外を返す(fail fast)。タイマー満了で Half-Open に遷移。
  • Half-Open(半開)限られた数の試験リクエストだけ通す。成功すれば「直った」と判断して Closed に戻し、失敗カウンタをリセット。1つでも失敗すれば「まだダメ」と判断して即 Open に戻り、タイマーを再起動。

Azure はこの Half-Open の意義を明記します。「回復しかけたサービスが突然リクエストで溢れるのを防ぐ(The Half-Open state helps prevent a recovering service from suddenly being flooded with requests)」。回復途中のサービスに全負荷をかければ、また落ちるからです。

遷移トリガー公式の根拠
Closed → Open期間内の失敗が閾値超過"failure threshold is reached"
Open → Half-Openタイムアウトタイマー満了"time-out timer expired"
Half-Open → Closed連続成功が閾値到達"success count threshold is reached"
Half-Open → Open試験リクエストが1つでも失敗"the operation failed"

4-2. 型付きサーキットブレーカーの実装

type BreakerState = "closed" | "open" | "half-open";

export type BreakerConfig = {
  failureThreshold: number;   // Closed中、この回数失敗でOpenへ
  successThreshold: number;   // Half-Open中、この連続成功でClosedへ
  openDurationMs: number;     // Open維持時間(満了でHalf-Openへ)
  rollingWindowMs: number;    // 失敗カウンタの時間窓(古い失敗は忘れる)
};

export class CircuitBreaker {
  private state: BreakerState = "closed";
  private failures: number[] = []; // 失敗タイムスタンプ(rolling window)
  private successesInHalfOpen = 0;
  private openedAt = 0;

  constructor(
    private readonly cfg: BreakerConfig,
    private readonly now: () => number = Date.now, // テスト容易性のため注入
    private readonly onStateChange?: (s: BreakerState) => void, // 観測性
  ) {}

  /** 呼び出し前に「通してよいか」を判定する */
  private canPass(): boolean {
    if (this.state === "open") {
      // Open維持時間が過ぎたらHalf-Openへ(試しに通してみる)
      if (this.now() - this.openedAt >= this.cfg.openDurationMs) {
        this.transition("half-open");
        this.successesInHalfOpen = 0;
        return true;
      }
      return false; // まだOpen → fail fast
    }
    return true; // closed / half-open は通す
  }

  async execute<T>(op: () => Promise<Result<T>>): Promise<Result<T>> {
    if (!this.canPass()) {
      // Open中は即座に失敗。下流を一切叩かない(これが本質)
      return { ok: false, error: { kind: "server", status: 503 } };
    }
    const res = await op();
    if (res.ok) this.onSuccess();
    else if (isRetryable(res.error)) this.onFailure(); // 一時的失敗のみ計上
    // 恒久的失敗(4xx等)はブレーカーの故障判定に含めない(下流の健康とは無関係)
    return res;
  }

  private onSuccess(): void {
    if (this.state === "half-open") {
      this.successesInHalfOpen++;
      if (this.successesInHalfOpen >= this.cfg.successThreshold) {
        this.failures = [];
        this.transition("closed"); // 回復確認 → 通常運転へ
      }
    } else {
      this.failures = []; // Closedでの成功はカウンタをきれいに
    }
  }

  private onFailure(): void {
    if (this.state === "half-open") {
      this.trip(); // 試験中の失敗は即Openへ戻す
      return;
    }
    const t = this.now();
    // rolling window 外の古い失敗は捨てる(たまの失敗で開かないように)
    this.failures = this.failures.filter((ts) => t - ts < this.cfg.rollingWindowMs);
    this.failures.push(t);
    if (this.failures.length >= this.cfg.failureThreshold) this.trip();
  }

  private trip(): void {
    this.openedAt = this.now();
    this.transition("open");
  }

  private transition(next: BreakerState): void {
    if (this.state !== next) {
      this.state = next;
      this.onStateChange?.(next); // 状態遷移は必ずメトリクス/アラートに出す
    }
  }
}

設計上のキモは2つ。(1) 恒久的失敗(4xx)をブレーカーの故障判定に含めない——4xx は「下流が不健康」のシグナルではなく「自分の送り方の問題」なので、これで開くのは誤検知です。(2) 状態遷移を必ず観測(onStateChange)に出す。Azure も「サーキットブレーカーが状態変化のたびにイベントを上げれば、保護対象コンポーネントの健康監視や、Open になった際の管理者アラートに使える」と推奨します。ブレーカーが Open になった瞬間は、ほぼ常にアラート対象です。

4-3. いつ使い、いつ使わないか

Azure は適用条件を明示します。使うのは「カスケード障害を防ぎたい」「遅い依存先から SLO を守りたい(protect against slow dependencies)」とき。

逆に使うべきでないケースも公式が列挙しており、これを知らないと無駄に複雑化します。

  • ローカルのプライベートリソース(メモリ内データ構造など)へのアクセス管理 → ブレーカーはオーバーヘッドにしかならない。
  • ビジネスロジックの例外処理の代替として使う → 用途が違う。
  • メッセージ駆動/イベント駆動アーキテクチャ → 失敗メッセージは DLQ に送る仕組みがあり、組み込みの隔離・リトライで足りることが多い。
  • 障害回復がインフラ/プラットフォーム層で管理されている(ロードバランサやサービスメッシュのヘルスチェック)→ アプリ層で重複させる必要がない。

実際、サービスメッシュ(Istio/Envoy 等)を使っているなら、ブレーカーはサイドカーに寄せるのが Azure 推奨です。アプリコードを汚さずに済みます。


5. バルクヘッド:一つの依存先の障害で「全部」を道連れにしない

サーキットブレーカーが「時間軸」の隔離なら、バルクヘッドは「リソース軸」の隔離です。Azure Bulkhead pattern は船の防水隔壁が名前の由来——「船体が損傷しても、損傷した区画だけが浸水し、沈没を防ぐ」。

5-1. 解く問題:接続プール枯渇のカスケード

Azure が描く典型的な事故。「消費者が応答しないサービスにリクエストを送り続けると、そのリクエストが使うリソース(クライアントの接続プールなど)が枯渇する。その時点で、消費者の他のサービスへのリクエストも影響を受ける。最終的に、元の応答しないサービスだけでなく、どのサービスにもリクエストを送れなくなる」。

遅い依存先 A が、共有接続プールを食い尽くし、無関係な依存先 B・C への呼び出しまで道連れにする——これがリソース枯渇によるカスケードです。

5-2. 解決:依存先ごとに同時実行枠を分ける

Azure の解は「消費者がリソースを分割し、サービスAを呼ぶリソースがサービスBを呼ぶリソースに影響しないようにする。サービスごとに接続プールを割り当てる。サービスが落ち始めても、影響はそのサービス用のプールだけに留まる」。TypeScript ではセマフォ(同時実行数の上限)で表現できます。

// 依存先ごとに「同時実行枠」を切る。Aが詰まってもB/Cの枠は無事。
export class Semaphore {
  private active = 0;
  private queue: Array<() => void> = [];
  constructor(private readonly max: number) {}

  async run<T>(fn: () => Promise<T>): Promise<T> {
    if (this.active >= this.max) {
      await new Promise<void>((resolve) => this.queue.push(resolve));
    }
    this.active++;
    try {
      return await fn();
    } finally {
      this.active--;
      this.queue.shift()?.(); // 次の待機者を起こす
    }
  }
}

// 依存先ごとにバルクヘッドを分離。AとBは互いのリソースを侵さない。
const bulkheads = {
  paymentApi: new Semaphore(10), // 決済は重要:10枠確保
  emailApi: new Semaphore(3),    // メールは詰まっても本筋を止めない:3枠だけ
} as const;

// メールAPIが全部詰まっても、消費するのは最大3枠。決済の10枠は無傷。
await bulkheads.emailApi.run(() => sendReceiptEmail(order));

Azure はバルクヘッド単位での粒度・スレッドプール・セマフォ・キュー分離を挙げ、Java なら resilience4j、.NET なら Polly といったライブラリも紹介しています。Node では上記のような軽量セマフォか、p-limit 等のライブラリで十分です。

使うべきでないケースも公式にあります。「リソース利用効率の低下が許容できない場合」「追加の複雑さが不要な場合」。バルクヘッドは枠を分ける分、リソースを使い切れないトレードオフがあります。クリティカルな依存先と、落ちても困らない依存先が混在するときに最も効きます。


6. リトライしない勇気:フォールバックの罠

回復性というと「何があっても代替手段で動かす(フォールバック)」を思い浮かべがちです。しかし AWS Builders' Library Avoiding fallback in distributed systems は、分散システムでのフォールバックは多くの場合危険だと強く警告します。これは直感に反するので、必ず押さえてください。

6-1. なぜフォールバックは裏切るのか

核心のパラドックスを引用します。

「もしDBに直接当たる方がキャッシュ経由より信頼性が高いなら、そもそもなぜキャッシュを使うのか?(If hitting the database directly was more reliable than going through the cache, why bother with the cache in the first place?)」

フォールバック(例:キャッシュが死んだらDB直接)は「バックアップ手段が劣っている前提でしか意味を持たない」。なのに我々は「主系が死んだとき、その劣ったバックアップが成功してくれる」と祈っている。AWS が挙げる2001年の Amazon 障害では、キャッシュが同時に落ちた際のDB直接へのフォールバックが「DBを完全にロックアップさせるほどの負荷を生み」、部分障害を全サイト障害に拡大させました。

さらに「分散フォールバック戦略はテストが難しく」「潜在バグを抱え、稀な偶然が重なったときだけ、導入から数ヶ月〜数年後に顕在化する(latent bugs that show up only when an unlikely set of coincidences occur ... months or years after their introduction)」。

6-2. フォールバックの代わりに何をするか

AWS の代替策はこうです。

  1. 主系の信頼性を上げる(フォールバック分岐を増やすより、そもそも落ちにくくする。例:本質的に高可用な DynamoDB を使う)。
  2. エラーは呼び出し側に処理させる(サービス内でフォールバックせず、呼び出し側にリトライさせる)。
  3. failover に変える:「バックアップ経路を本番で常時動かし、主系と同じくらい信頼できる状態にする(Exercise backup paths continuously in production)」。普段使わないコードは、いざというとき必ず壊れている。
  4. リトライ率を監視し、リトライが「事実上のフォールバック」になっていないか見張る。

実務的な落とし所:安易なフォールバックを書く前に、「主系を強くする」「呼び出し側に正直にエラーを返す(fail fast)」を先に検討する。決済では特にそうで、「課金APIが落ちたから別経路で課金」のようなフォールバックは、整合性を壊す最短経路です。正直に失敗を返し、冪等性キーで安全にリトライさせる方が、はるかに堅い。


7. 全部入り:回復性のある外部APIクライアント

ここまでの層を1つのラッパーに合成します。順序が決定的に重要です。外側から「ブレーカー → リトライ → バルクヘッド → タイムアウト → 実呼び出し」。

// 依存先ごとに1インスタンス。状態(ブレーカー/予算/枠)を共有する。
export class ResilientClient {
  constructor(
    private readonly breaker: CircuitBreaker,
    private readonly budget: RetryBudget,
    private readonly bulkhead: Semaphore,
    private readonly retryOpts: RetryOptions,
    private readonly perAttemptTimeoutMs: number,
  ) {}

  async call<T>(
    fn: (signal: AbortSignal) => Promise<T>,
    parse: (raw: unknown) => Result<T>, // 境界で型を絞る(信頼しない)
    overallDeadline: AbortSignal,
  ): Promise<Result<T>> {
    // 最外層:ブレーカー。Open中はここで即fail(下流を一切叩かない)
    return this.breaker.execute(() =>
      // 次層:リトライ(冪等な操作前提・Full Jitter)
      retry<T>(async () => {
        if (!this.budget.tryAcquire()) {
          return { ok: false, error: { kind: "throttled" } }; // 予算切れ
        }
        // 次層:バルクヘッド(同時実行枠の隔離)
        return this.bulkhead.run(async () => {
          try {
            // 最内層:試行ごとタイムアウト(全体デッドラインも伝播)
            const raw = await withTimeout(fn, this.perAttemptTimeoutMs, overallDeadline);
            const res = parse(raw);
            if (res.ok) this.budget.onSuccess(); // 成功で予算回復
            return res;
          } catch (e) {
            // タイムアウト/ネットワーク例外を型付き Transient に正規化
            const err: Transient =
              e instanceof Error && e.name === "AbortError"
                ? { kind: "timeout" }
                : { kind: "network", cause: e };
            return { ok: false, error: err };
          }
        });
      }, { ...this.retryOpts, signal: overallDeadline }),
    );
  }
}

fetch を相手にした具体的な利用例。冪等性キー(§1)と組み合わせて完成です。

// 依存先ごとに設定。決済は重要なので枠もリトライも厚めに。
const paymentClient = new ResilientClient(
  new CircuitBreaker(
    { failureThreshold: 5, successThreshold: 2, openDurationMs: 30_000, rollingWindowMs: 10_000 },
    Date.now,
    (s) => emitMetric("breaker.state", { dependency: "payment", state: s }),
  ),
  new RetryBudget(100),
  new Semaphore(10),
  { maxAttempts: 4, baseMs: 100, capMs: 20_000 },
  5_000, // per-attempt 5s
);

async function chargeOrder(order: Order): Promise<Result<Charge>> {
  const overall = AbortSignal.timeout(15_000); // 全体15秒デッドライン
  const idempotencyKey = order.id; // 注文IDを決定的キーに(リトライで不変)

  return paymentClient.call<Charge>(
    (signal) =>
      fetch("https://api.example.com/charges", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey, // 二重課金を構造的に防ぐ
        },
        body: JSON.stringify({ amount: order.total, currency: "JPY" }),
        signal, // タイムアウト/デッドラインで確実にキャンセル
      }).then(async (r) => {
        // HTTPステータスを型付き Result に正規化(境界で分類)
        if (r.ok) return await r.json();
        if (r.status === 429) {
          const ra = r.headers.get("Retry-After");
          throw Object.assign(new Error("throttled"), {
            transient: { kind: "throttled", retryAfterMs: ra ? Number(ra) * 1000 : undefined },
          });
        }
        if (r.status >= 500) throw new Error(`server ${r.status}`);
        throw new Error(`client ${r.status}`); // 4xx → リトライされない
      }),
    (raw) => ({ ok: true, value: raw as Charge }), // 実際はzod等で検証
    overall,
  );
}

このラッパー1つで、タイムアウト・Full Jitter リトライ・リトライ予算・バルクヘッド・サーキットブレーカー・冪等性キーが、依存先ごとに独立して効きます。設定値(閾値・枠数・タイムアウト)は依存先ごとに変えられるのが要点です。決済は厚く、メールは薄く。


8. よくある落とし穴(実際に事故るやつ)

最後に、レビューで頻出する地雷を挙げます。1つでも当てはまれば、回復性レイヤーが逆に障害源になります。

落とし穴何が起きるか正しい対処
冪等でない書き込みをリトライタイムアウト後の再送で二重課金/二重登録冪等性キーを先に導入(§1)。順序を逆にしない
ジッターなしの固定/指数バックオフ全クライアント同時殺到(リトライストーム)Full Jitter(§2)。公式が "clear loser" と断言
4xx をリトライバグを遅延・隠蔽し、無駄に負荷isRetryable で恒久的失敗を除外(§1)
無制限リトライ(while true)一生回り続け、デッドロック化maxAttempts +全体デッドライン+トークンバケット
タイムアウトなし遅い依存先にスレッド/接続を握られ枯渇per-attempt + overall の2層(§3)
ブレーカーなし長期障害でリトライし続けカスケードサーキットブレーカーで fail fast(§4)
多層で各々リトライ負荷が指数増幅(3^5=243倍)リトライは1箇所だけ。下層は fail fast
安易なフォールバック主系障害時にバックアップも共倒れ主系を強化+fail fast(§6)
ブレーカー状態を観測しないOpen に気づかず障害が長引く状態遷移を必ずメトリクス/アラート化(§4)
4xx でブレーカーが開く自分のバグで下流を誤って遮断故障判定に恒久的失敗を含めない(§4-2)

横断的に効く設計原則を3つ。(1) 観測性:リトライ回数・ブレーカー状態遷移・タイムアウト発生は必ずメトリクスに出す。見えない回復性は回復していないのと同じです。(2) コスト:リトライはAPIコールとコンピュート時間に直結します。トークンバケットと全体デッドラインは、信頼性だけでなくコスト上限でもあります。(3) 型安全:失敗を Result<T>Transient | Permanent の判別可能ユニオンで表現し、「リトライ可否」の判断を isRetryable 一箇所に集約する。any や生 throw に逃げると、判断がコードベース中に散逸し、必ず誰かが4xxをリトライします。


まとめ:回復性は「重ねる」もの

外部依存が信頼できないのは前提です。それでも落ちないシステムは、役割の違う防御層を正しい順序で重ねることで作れます。最後に5行で。

  1. リトライは「一時的 × 冪等」だけ。冪等性キーを先に入れる。4xxとビジネス失敗はリトライしない(AWS/Azure 公式の核心)。
  2. バックオフには必ず Full Jitter。ジッターなしは公式が "clear loser" と断言。多層リトライは負荷を243倍に増幅する——リトライは1箇所で。
  3. タイムアウトは2層(試行ごと+全体デッドライン)。値は下流の p99.9 から逆算。待ち続けないことが回復性の土台。
  4. 長期障害はサーキットブレーカーで即断(closed/open/half-open)。状態遷移は必ず観測・アラート化。4xxで開かせない。
  5. バルクヘッドでリソースを隔離し、フォールバックは安易に書かない(主系強化+fail fast)。冪等性・観測性・型安全は全層を貫く横糸。

私はこの設計思想を、サーバーレス決済プラットフォームの信頼性レイヤー(exactly-once・アトミック残高更新・本番二重課金0件)と、受賞 B2B SaaS の Stripe Connect 連携で実装してきました。「一人 × 生成AI(Claude Code)で、速く・安く・安全に」——その"安全に"の中身が、本記事の回復性レイヤーです。信頼できない外部依存を相手にした堅牢なシステム設計のご相談は、お問い合わせからどうぞ。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

環境分野のサーバーレス決済プラットフォーム(フルスタック開発・決済信頼性レイヤーを主導)

ケーススタディを見る