Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
- 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 に遷移し、回復可能なエラーを過剰に遮断してしまう。
正しい組み合わせは次のとおりだ。
- Retry(指数バックオフ + Jitter) — 一時的障害(503、ECONNRESET、タイムアウト)から回復を試みる
- Circuit Breaker — 連続失敗が閾値を超えたら呼び出しを止め、依存先と自サービスの両方を守る
- Timeout — 1 回の呼び出しが無限に待たないよう上限を設ける
- 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 | 意味 |
|---|---|---|
timeout | ✓ | 1 リクエストのタイムアウト(ms) |
errorThresholdPercentage | ✓ | この失敗率を超えると Open |
resetTimeout | ✓ | Open 状態の最短維持時間(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 コールバックで構造化ログに attempt と delayMs を残すと、本番の 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_state | 0=closed, 1=half-open, 2=open | Open が 5 分継続 |
circuit_breaker_failures_total | 失敗カウント | 5 分で 100 超 |
retry_attempts_total | リトライ回数 | 1 分あたり QPS の 50% 超 |
dependency_latency_seconds | 依存先 P50/P99 | P99 > 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 };
導入手順
依存先一覧を作成し、タイムアウト・リトライ可否・冪等性を分類する
Full Jitter 付き指数バックオフと isRetryable 判別関数を実装する
opossum または cockatiel で Circuit Breaker を設定し、volumeThreshold と resetTimeout を調整する
Open 時のフォールバックと 503 Problem Details レスポンスを設計する
CB 状態・リトライ回数・レイテンシをメトリクス化し、アラート閾値を設定する
負荷試験で 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 |
|---|---|---|
| 1 | Retry は CB の内側(Closed 時のみ) | |
| 2 | 4xx(400/401/403/404/422)はリトライしない | |
| 3 | 429 は Retry-After を尊重 | |
| 4 | Full Jitter または Equal Jitter を使用 | |
| 5 | 各呼び出しにタイムアウトあり | |
| 6 | Open 時のフォールバックまたは 503 が定義済み | |
| 7 | 503 レスポンスが RFC 9457 / Problem Details 形式 | |
| 8 | CB 状態変化がログ・メトリクスに記録される | |
| 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 の両方を検証してから本番に載せよう。