Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter

Node.js Circuit Breaker Retry opossum cockatiel バックエンド TypeScript 耐障害性
結論
  • Circuit Breaker は連続失敗時に依存先への呼び出しを遮断し、Retry + 指数バックオフ + Full Jitter は一時的障害からの回復を助ける——役割が異なるのでCB 外側・Retry 内側で組み合わせる。
  • Node.js では opossum(CB 特化)か cockatiel(ポリシー合成)を選び、429/503 のみリトライ、Open 時はフォールバックまたは API エラーハンドリング に沿った 503 を返す。
  • 依存先保護には API レート制限 も併用する。

なぜ Circuit Breaker と Retry をセットで考えるのか

マイクロサービスや外部 SaaS API を呼び出す Node.js アプリケーションは、一時的なネットワーク障害持続的な依存先ダウンの両方に直面する。Retry だけを入れると、依存先が完全に落ちているときも全クライアントが延々と再試行し、障害を増幅する(Retry Storm)。逆に Circuit Breaker だけだと、数ミリ秒の瞬断や 503 一発で Open に遷移し、回復可能なエラーを過剰に遮断してしまう。

正しい組み合わせは次のとおりだ。

  1. Retry(指数バックオフ + Jitter) — 一時的障害(503、ECONNRESET、タイムアウト)から回復を試みる
  2. Circuit Breaker — 連続失敗が閾値を超えたら呼び出しを止め、依存先と自サービスの両方を守る
  3. Timeout — 1 回の呼び出しが無限に待たないよう上限を設ける
  4. Fallback — Open 時や最終失敗時にキャッシュ・デフォルト値・graceful degradation を返す

API エラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け では、クライアントが「再試行すべきか」を HTTP ステータスと error_code で判断する設計を扱った。本記事はサーバー側(または SDK 側)が依存先を呼ぶときの耐障害レイヤーに焦点を当てる。

Circuit Breaker の三状態

Michael Nygard の Release It! で広まった Circuit Breaker は、電気のブレーカーと同様に異常電流(連続失敗)で回路を開くパターンだ。

状態動作遷移条件
Closed通常どおりリクエストを通す失敗率が閾値を超える → Open
Open即座に失敗(フォールバック)resetTimeout 経過 → Half-Open
Half-Open限定的なプローブリクエストを許可成功 → Closed、失敗 → Open

状態遷移のイメージ

         失敗率 > 閾値
Closed ─────────────────► Open
   ▲                        │
   │ プローブ成功            │ resetTimeout 経過
   │                        ▼
   └────────────────── Half-Open

                   プローブ失敗


                          Open

Half-Open は「依存先は復旧したか?」を少数のリクエストで確認するフェーズだ。ここで全トラフィックを一気に流すと再び過負荷になるため、opossum や cockatiel はプローブ件数を制限する。

設定パラメータの意味

パラメータopossum意味
timeout1 リクエストのタイムアウト(ms)
errorThresholdPercentageこの失敗率を超えると Open
resetTimeoutOpen 状態の最短維持時間(ms)
volumeThreshold統計に必要な最小リクエスト数
rollingCountTimeout失敗率を測るローリングウィンドウ(ms)

volumeThreshold が小さすぎると、たまたま 2 件連続失敗しただけで Open になる。本番では 10 件以上から始め、トラフィック量に応じて調整する。

Retry と指数バックオフ・Jitter

Retry は「同じ操作を再度試す」パターンだが、即座に再試行すると依存先への負荷が倍増する。指数バックオフ(Exponential Backoff)は待機時間を base × 2^attempt のように増やし、Jitter はその待機にランダム性を加える。

Full Jitter の数式

AWS が推奨する Full Jitter は次のとおり。

sleep = random(0, min(cap, base × 2^attempt))
  • base — 初期待機(例: 100ms)
  • cap — 最大待機(例: 30_000ms)
  • attempt — 0 始まりの試行番号

Full Jitter は Equal Jitter より分散が大きく、Thundering Herd 抑制に効果的とされる。

リトライ対象の判別

API エラーハンドリング設計 の原則に従い、次のように分類する。

分類リトライ
一時的サーバー障害500, 502, 503, 504✓(回数上限あり)
レート制限429 + Retry-After✓(ヘッダー優先)
ネットワークECONNRESET, ETIMEDOUT, fetch failed
クライアントエラー400, 401, 403, 404, 422
冪等性なしの副作用POST 決済(Idempotency-Key なし)✗ または慎重に

429 の扱いは API レート制限(Rate Limiting)実装ガイド と整合させる。Retry-After 秒数を尊重し、自前の指数バックオフと長い方を採用するのが安全だ。

問題のあるコード:無制限リトライ

次は、よく見かけるアンチパターンだ。Circuit Breaker も Jitter もなく、固定 500ms 間隔で 10 回リトライする。

async function fetchUserProfile(userId: string): Promise<UserProfile> {
  const maxAttempts = 10;
  const delayMs = 500;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(`https://api.example.com/users/${userId}`, {
        headers: { Accept: 'application/json' },
      });

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

      return (await response.json()) as UserProfile;
    } catch (error) {
      if (attempt === maxAttempts) {
        throw error;
      }
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }

  throw new Error('unreachable');
}

interface UserProfile {
  id: string;
  name: string;
  email: string;
}

問題点:

  • 503 が続く間、全インスタンスが 10 回 × N 並列で依存先を叩き続ける
  • 固定 500ms 間隔のため、復旧直後に同期した再試行が集中する
  • 400 や 404 もリトライしてしまう
  • タイムアウトがなく、1 回の fetch がハングするとイベントループを占有する

以降のセクションで、このコードを段階的に改善する。

指数バックオフ + Full Jitter ユーティリティ

まず、ライブラリに依存しない Jitter 付きバックオフを実装する。

export interface BackoffOptions {
  baseMs: number;
  capMs: number;
  attempt: number;
}

export function computeFullJitterDelay(options: BackoffOptions): number {
  const { baseMs, capMs, attempt } = options;
  const exponential = baseMs * Math.pow(2, attempt);
  const capped = Math.min(capMs, exponential);
  return Math.floor(Math.random() * capped);
}

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

export interface RetryOptions {
  maxAttempts: number;
  baseMs: number;
  capMs: number;
  isRetryable: (error: unknown) => boolean;
  onRetry?: (context: { attempt: number; delayMs: number; error: unknown }) => void;
}

export async function retryWithJitter<T>(
  fn: () => Promise<T>,
  options: RetryOptions,
): Promise<T> {
  const { maxAttempts, baseMs, capMs, isRetryable, onRetry } = options;
  let lastError: unknown;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      if (!isRetryable(error)) {
        throw error;
      }

      if (attempt === maxAttempts - 1) {
        throw error;
      }

      const delayMs = computeFullJitterDelay({ baseMs, capMs, attempt });
      onRetry?.({ attempt: attempt + 1, delayMs, error });
      await sleep(delayMs);
    }
  }

  throw lastError;
}

export class HttpError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    public readonly retryAfterSec: number | null = null,
  ) {
    super(message);
    this.name = 'HttpError';
  }
}

export function isRetryableHttpError(error: unknown): boolean {
  if (error instanceof HttpError) {
    if (error.status === 429) return true;
    if (error.status >= 500 && error.status <= 599) return true;
    return false;
  }

  if (error instanceof Error) {
    const code = (error as NodeJS.ErrnoException).code;
    if (code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'ECONNREFUSED') {
      return true;
    }
  }

  return false;
}

onRetry コールバックで構造化ログに attemptdelayMs を残すと、本番の Retry Storm 調査が容易になる。

opossum による Circuit Breaker 実装

opossum は Node.js 向けの Circuit Breaker ライブラリで、Promise ベースの関数をラップする。npm で npm install opossum として導入する。

基本的なラップ

import CircuitBreaker from 'opossum';
import type { Options as OpossumOptions } from 'opossum';

interface PaymentRequest {
  orderId: string;
  amount: number;
  currency: string;
}

interface PaymentResult {
  transactionId: string;
  status: 'captured' | 'pending';
}

async function callPaymentGateway(request: PaymentRequest): Promise<PaymentResult> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 8000);

  try {
    const response = await fetch('https://payments.example.com/v1/charges', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
      },
      body: JSON.stringify(request),
      signal: controller.signal,
    });

    if (!response.ok) {
      const retryAfterHeader = response.headers.get('Retry-After');
      const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null;
      throw new HttpError(`Payment API error: ${response.status}`, response.status, retryAfterSec);
    }

    return (await response.json()) as PaymentResult;
  } finally {
    clearTimeout(timeoutId);
  }
}

const breakerOptions: OpossumOptions = {
  timeout: 10000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
  volumeThreshold: 10,
  rollingCountTimeout: 10000,
  rollingCountBuckets: 10,
  name: 'payment-gateway',
};

const paymentBreaker = new CircuitBreaker(callPaymentGateway, breakerOptions);

paymentBreaker.fallback((request: PaymentRequest) => {
  return {
    transactionId: `fallback-${request.orderId}`,
    status: 'pending' as const,
  };
});

paymentBreaker.on('open', () => {
  console.error(JSON.stringify({
    level: 'warn',
    event: 'circuit_breaker_open',
    breaker: 'payment-gateway',
    timestamp: new Date().toISOString(),
  }));
});

paymentBreaker.on('halfOpen', () => {
  console.log(JSON.stringify({
    level: 'info',
    event: 'circuit_breaker_half_open',
    breaker: 'payment-gateway',
    timestamp: new Date().toISOString(),
  }));
});

paymentBreaker.on('close', () => {
  console.log(JSON.stringify({
    level: 'info',
    event: 'circuit_breaker_close',
    breaker: 'payment-gateway',
    timestamp: new Date().toISOString(),
  }));
});

export async function chargePayment(request: PaymentRequest): Promise<PaymentResult> {
  return paymentBreaker.fire(request);
}

fallback は Open 状態およびタイムアウト時に返す。決済のように副作用が大きい操作では、フォールバックを「非同期キューに積む」など非同期処理に回す設計も検討する。

opossum + Retry の合成

Circuit Breaker の内側で Retry を実行するパターンだ。

async function callPaymentWithRetry(request: PaymentRequest): Promise<PaymentResult> {
  return retryWithJitter(
    () => callPaymentGateway(request),
    {
      maxAttempts: 3,
      baseMs: 200,
      capMs: 5000,
      isRetryable: isRetryableHttpError,
      onRetry: ({ attempt, delayMs, error }) => {
        console.warn(JSON.stringify({
          level: 'warn',
          event: 'payment_retry',
          attempt,
          delayMs,
          error: error instanceof Error ? error.message : String(error),
          orderId: request.orderId,
        }));
      },
    },
  );
}

const resilientPaymentBreaker = new CircuitBreaker(callPaymentWithRetry, {
  timeout: 15000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
  volumeThreshold: 10,
  name: 'payment-gateway-resilient',
});

resilientPaymentBreaker.fallback((request: PaymentRequest) => ({
  transactionId: `queued-${request.orderId}`,
  status: 'pending' as const,
}));

export async function chargePaymentResilient(request: PaymentRequest): Promise<PaymentResult> {
  return resilientPaymentBreaker.fire(request);
}

timeout: 15000 は Retry 3 回分の待機を含めた上限だ。timeout が短すぎると、リトライ中に CB がタイムアウト扱いで失敗率を押し上げる。

cockatiel によるポリシー合成

cockatiel は Retry、Circuit Breaker、Timeout、Bulkhead をポリシーとして合成できる。npm install cockatiel で導入する。

Retry ポリシー

import {
  retry,
  handleAll,
  ExponentialBackoff,
  wrap,
  circuitBreaker,
  ConsecutiveBreaker,
  timeout,
  TimeoutStrategy,
  Policy,
} from 'cockatiel';

interface InventoryItem {
  sku: string;
  quantity: number;
  warehouse: string;
}

async function fetchInventory(sku: string): Promise<InventoryItem> {
  const response = await fetch(`https://inventory.example.com/v1/items/${encodeURIComponent(sku)}`, {
    headers: { Accept: 'application/json' },
  });

  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After');
    const sec = retryAfter ? parseInt(retryAfter, 10) : 60;
    throw new HttpError('Rate limited', 429, sec);
  }

  if (!response.ok) {
    throw new HttpError(`Inventory API error: ${response.status}`, response.status, null);
  }

  return (await response.json()) as InventoryItem;
}

const retryPolicy = retry(handleAll, {
  maxAttempts: 4,
  backoff: new ExponentialBackoff({
    initialDelay: 100,
    maxDelay: 10000,
    exponent: 2,
    jitter: 'full',
  }),
});

const circuitPolicy = circuitBreaker(handleAll, {
  breaker: new ConsecutiveBreaker(5),
  halfOpenAfter: 30_000,
});

const timeoutPolicy = timeout(8000, TimeoutStrategy.Cooperative);

const inventoryPolicy = wrap(retryPolicy, circuitPolicy, timeoutPolicy);

export async function getInventory(sku: string): Promise<InventoryItem> {
  return inventoryPolicy.execute(() => fetchInventory(sku));
}

cockatiel の jitter: 'full' は Full Jitter に相当する。ConsecutiveBreaker(5)連続 5 失敗で Open になる設定で、失敗率ベースの opossum とは異なる統計モデルだ。

観点 opossum / cockatiel
主な用途 opossum: CB 単体。cockatiel: Retry + CB + Timeout 合成
統計モデル opossum: ローリングウィンドウの失敗率。cockatiel: ConsecutiveBreaker または SamplingBreaker
Jitter opossum: 組み込みなし(自前 Retry と併用)。cockatiel: ExponentialBackoff に full/equal 内蔵
イベント opossum: open/halfOpen/close イベント。cockatiel: onBreak/onReset/onFailure ハンドラ
学習コスト opossum: 低(fire するだけ)。cockatiel: 中(Policy.wrap の順序理解が必要)
向いている規模 opossum: 単一 CB の追加。cockatiel: 複数ポリシーを統一 API で管理

Policy.wrap の順序

cockatiel で wrap(retryPolicy, circuitPolicy, timeoutPolicy) とした場合、外側から timeout → circuitBreaker → retry → 実処理の順に適用される。Retry を CB の内側に置くことで、Open 中は Retry すら実行されない。

Express ミドルウェアへの統合

BFF(Backend for Frontend)が複数の downstream API を呼ぶ場合、サービスごとに CB インスタンスを分ける。

import express, { Request, Response, NextFunction } from 'express';
import CircuitBreaker from 'opossum';

interface OrderSummary {
  orderId: string;
  total: number;
  status: string;
}

async function fetchOrderFromService(orderId: string): Promise<OrderSummary> {
  const response = await fetch(`https://orders.internal.svc.cluster.local/v1/orders/${orderId}`, {
    headers: {
      Accept: 'application/json',
      'X-Request-Id': crypto.randomUUID(),
    },
  });

  if (!response.ok) {
    throw new HttpError(`Orders service: ${response.status}`, response.status, null);
  }

  return (await response.json()) as OrderSummary;
}

const orderServiceCall = async (orderId: string): Promise<OrderSummary> => {
  return retryWithJitter(
    () => fetchOrderFromService(orderId),
    {
      maxAttempts: 3,
      baseMs: 100,
      capMs: 3000,
      isRetryable: isRetryableHttpError,
    },
  );
};

const orderBreaker = new CircuitBreaker(orderServiceCall, {
  timeout: 12000,
  errorThresholdPercentage: 50,
  resetTimeout: 20000,
  volumeThreshold: 5,
  name: 'orders-service',
});

const app = express();

app.get('/api/orders/:orderId', async (req: Request, res: Response, next: NextFunction) => {
  const { orderId } = req.params;

  try {
    const order = await orderBreaker.fire(orderId);
    res.json(order);
  } catch (error) {
    if (orderBreaker.opened) {
      res.status(503).json({
        type: 'https://api.example.com/errors/service_unavailable',
        title: 'Service Unavailable',
        status: 503,
        detail: '注文サービスが一時的に利用できません。しばらく待ってから再試行してください。',
        error_code: 'ORDERS_SERVICE_CIRCUIT_OPEN',
        retry_after: 30,
      });
      return;
    }

    if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
      res.status(error.status).json({
        type: 'https://api.example.com/errors/upstream_client_error',
        title: 'Upstream Client Error',
        status: error.status,
        detail: error.message,
        error_code: 'ORDERS_UPSTREAM_4XX',
      });
      return;
    }

    next(error);
  }
});

app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(JSON.stringify({
    level: 'error',
    event: 'unhandled_error',
    message: err.message,
    stack: process.env.NODE_ENV === 'production' ? undefined : err.stack,
  }));

  res.status(500).json({
    type: 'https://api.example.com/errors/internal',
    title: 'Internal Server Error',
    status: 500,
    detail: '予期しないエラーが発生しました。',
    error_code: 'INTERNAL_ERROR',
  });
});

export { app };

503 レスポンスは API エラーハンドリング設計 の Problem Details 形式に揃え、retry_after でクライアントに待機時間を伝える。自サービス側にも API レート制限 を入れ、CB Open 時の再試行集中を抑える。

429 と Retry-After の尊重

レート制限された依存先への呼び出しでは、指数バックオフより Retry-After ヘッダーを優先する。

export async function fetchWithRateLimitAwareRetry(
  url: string,
  init?: RequestInit,
): Promise<Response> {
  const maxAttempts = 5;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const response = await fetch(url, init);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxAttempts - 1) {
      return response;
    }

    const retryAfterHeader = response.headers.get('Retry-After');
    let delayMs: number;

    if (retryAfterHeader) {
      const parsed = parseInt(retryAfterHeader, 10);
      if (!Number.isNaN(parsed)) {
        delayMs = parsed * 1000;
      } else {
        const dateMs = Date.parse(retryAfterHeader);
        delayMs = Math.max(0, dateMs - Date.now());
      }
    } else {
      delayMs = computeFullJitterDelay({
        baseMs: 1000,
        capMs: 60_000,
        attempt,
      });
    }

    console.warn(JSON.stringify({
      level: 'warn',
      event: 'rate_limit_retry',
      url,
      attempt: attempt + 1,
      delayMs,
    }));

    await sleep(delayMs);
  }

  throw new Error('unreachable');
}

自サービスが 429 を返す側になる場合の Token Bucket 設計は API レート制限(Rate Limiting)実装ガイド を参照。

Bulkhead:障害の伝播を防ぐ

Circuit Breaker が依存先への遮断を担うのに対し、Bulkhead(隔壁)は自サービス内のリソース分離だ。cockatiel の Bulkhead は同時実行数を制限する。

import { bulkhead, wrap, retry, handleAll, ExponentialBackoff } from 'cockatiel';

const searchBulkhead = bulkhead(20, 50);

const searchRetry = retry(handleAll, {
  maxAttempts: 2,
  backoff: new ExponentialBackoff({
    initialDelay: 50,
    maxDelay: 2000,
    exponent: 2,
    jitter: 'full',
  }),
});

const searchPolicy = wrap(searchBulkhead, searchRetry);

interface SearchHit {
  id: string;
  title: string;
  score: number;
}

async function querySearchEngine(query: string): Promise<SearchHit[]> {
  const response = await fetch('https://search.example.com/v1/query', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ q: query, limit: 20 }),
  });

  if (!response.ok) {
    throw new HttpError(`Search error: ${response.status}`, response.status, null);
  }

  const body = (await response.json()) as { hits: SearchHit[] };
  return body.hits;
}

export async function search(query: string): Promise<SearchHit[]> {
  return searchPolicy.execute(() => querySearchEngine(query));
}

Bulkhead により、検索 API の遅延が注文 API 用のイベントループを圧迫しない。Kubernetes 環境では Pod リソース制限と合わせて調整する。

監視指標とアラート

Circuit Breaker と Retry を入れても、見えなければ調整できない。最低限、次のメトリクスを記録する。

メトリクス説明アラート例
circuit_breaker_state0=closed, 1=half-open, 2=openOpen が 5 分継続
circuit_breaker_failures_total失敗カウント5 分で 100 超
retry_attempts_totalリトライ回数1 分あたり QPS の 50% 超
dependency_latency_seconds依存先 P50/P99P99 > 3s
fallback_invocations_totalフォールバック発動急増

opossum は prometheus 連携用のイベントを発行できる。cockatiel は policy.onSuccess / onFailure でカスタムメトリクスを記録する。

import { Registry, Counter, Gauge } from 'prom-client';

const register = new Registry();

const cbStateGauge = new Gauge({
  name: 'circuit_breaker_state',
  help: 'Circuit breaker state (0=closed, 1=halfOpen, 2=open)',
  labelNames: ['name'],
  registers: [register],
});

const retryCounter = new Counter({
  name: 'dependency_retry_total',
  help: 'Total retry attempts for external dependencies',
  labelNames: ['dependency', 'attempt'],
  registers: [register],
});

paymentBreaker.on('open', () => cbStateGauge.set({ name: 'payment-gateway' }, 2));
paymentBreaker.on('halfOpen', () => cbStateGauge.set({ name: 'payment-gateway' }, 1));
paymentBreaker.on('close', () => cbStateGauge.set({ name: 'payment-gateway' }, 0));

export { register, retryCounter };

導入手順

1

依存先一覧を作成し、タイムアウト・リトライ可否・冪等性を分類する

2

Full Jitter 付き指数バックオフと isRetryable 判別関数を実装する

3

opossum または cockatiel で Circuit Breaker を設定し、volumeThreshold と resetTimeout を調整する

4

Open 時のフォールバックと 503 Problem Details レスポンスを設計する

5

CB 状態・リトライ回数・レイテンシをメトリクス化し、アラート閾値を設定する

6

負荷試験で Retry Storm と Half-Open プローブを検証し、レート制限と併用する

テスト戦略

耐障害ロジックは ユニットテスト + 統合テスト + 障害注入 の三段構えが効果的だ。

ユニットテスト:Jitter 計算

import { describe, it, expect } from 'vitest';
import { computeFullJitterDelay } from './backoff';

describe('computeFullJitterDelay', () => {
  it('returns value between 0 and capped exponential', () => {
    for (let i = 0; i < 100; i++) {
      const delay = computeFullJitterDelay({ baseMs: 100, capMs: 5000, attempt: 3 });
      expect(delay).toBeGreaterThanOrEqual(0);
      expect(delay).toBeLessThan(5000);
    }
  });

  it('respects cap for large attempt', () => {
    const delay = computeFullJitterDelay({ baseMs: 100, capMs: 1000, attempt: 20 });
    expect(delay).toBeLessThan(1000);
  });
});

統合テスト:モックサーバーで CB 遷移

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import CircuitBreaker from 'opossum';

describe('circuit breaker integration', () => {
  let server: http.Server;
  let failCount = 0;
  let port = 0;

  beforeAll(async () => {
    server = http.createServer((_req, res) => {
      failCount++;
      if (failCount <= 6) {
        res.writeHead(503);
        res.end('unavailable');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ ok: true }));
    });

    await new Promise<void>((resolve) => {
      server.listen(0, () => {
        const addr = server.address();
        if (addr && typeof addr === 'object') {
          port = addr.port;
        }
        resolve();
      });
    });
  });

  afterAll(async () => {
    await new Promise<void>((resolve) => server.close(() => resolve()));
  });

  it('opens after consecutive failures', async () => {
    failCount = 0;

    async function callHealth(): Promise<{ ok: boolean }> {
      const response = await fetch(`http://127.0.0.1:${port}/health`);
      if (!response.ok) throw new Error(`status ${response.status}`);
      return (await response.json()) as { ok: boolean };
    }

    const breaker = new CircuitBreaker(callHealth, {
      timeout: 3000,
      errorThresholdPercentage: 50,
      resetTimeout: 5000,
      volumeThreshold: 3,
      name: 'test-health',
    });

    for (let i = 0; i < 5; i++) {
      try {
        await breaker.fire();
      } catch {
        // expected failures
      }
    }

    expect(breaker.opened).toBe(true);
  });
});

設計チェックリスト

本番投入前に、次の項目を確認する。

#チェック項目OK
1Retry は CB の内側(Closed 時のみ)
24xx(400/401/403/404/422)はリトライしない
3429 は Retry-After を尊重
4Full Jitter または Equal Jitter を使用
5各呼び出しにタイムアウトあり
6Open 時のフォールバックまたは 503 が定義済み
7503 レスポンスが RFC 9457 / Problem Details 形式
8CB 状態変化がログ・メトリクスに記録される
9非冪等 POST に Idempotency-Key またはリトライ禁止
10自サービスにレート制限があり Retry Storm を抑止

レイヤー全体像

クライアント


┌─────────────────────────────────────┐
│  自 API(レート制限・認証・バリデーション)  │  ← /blog/api-rate-limit-実装-設計/
└─────────────────────────────────────┘


┌─────────────────────────────────────┐
│  Circuit Breaker(Open 時は遮断)     │
│    └─ Retry + Full Jitter           │
│         └─ Timeout                  │
│              └─ 依存先 HTTP 呼び出し   │
└─────────────────────────────────────┘


┌─────────────────────────────────────┐
│  フォールバック / 503 Problem Details │  ← /blog/api-error-handling-設計-ベストプラクティス/
└─────────────────────────────────────┘

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
APIキャッシュ設計ガイドAPIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド
Redisキャッシュスタンピード防止ガイドRedisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効
PostgreSQL接続プール枯渇の対処法PostgreSQL接続プール枯渇の対処法|too many connections・PgBouncer・Node.js pg

まとめ

Circuit Breaker と Retry は対立するパターンではなく、レイヤーが異なる補完関係にある。Retry は一時的障害からの回復を試み、Circuit Breaker は連続失敗時に依存先への負荷を止める。Node.js では opossum で CB を素早く導入するか、cockatiel で Retry・Timeout・Bulkhead を統合するかを選ぶ。

指数バックオフには必ず Full Jitter を入れ、429 では Retry-After を尊重する。Open 時のレスポンスは API エラーハンドリング設計 に沿った 503 と retry_after を返し、自サービス側は API レート制限 で再試行の集中を防ぐ。メトリクスと障害注入テストで、Half-Open プローブと Retry Storm の両方を検証してから本番に載せよう。