アプリケーションが外部 API — 決済プロバイダー、マーケットプレイス、配送サービス — に依存しているとき、システムの一部が自分のコントロール外にあることを受け入れていることになります。これらの依存先は遅くなったり、エラーを返したり、単に応答しなくなったりします。保護がなければ、不安定な外部 API がシステム全体を落とす可能性があります。Circuit Breaker はそれを防ぐパターンです。
問題:連鎖障害
現実的なシナリオを想像してください:チェックアウト中に、アプリケーションがマーケットプレイスの API を呼び出します。通常この呼び出しは 200ms かかります。しかしマーケットプレイスに問題があり、応答に 30 秒かかり始めます — あるいは単に応答しません。
保護がない場合に起きること:
- ユーザーのチェックアウトがマーケットプレイスの応答を待ってフリーズする
- 待っている間に新しいリクエストが届き、それらもフリーズする
- サーバーのコネクションプールが枯渇する
- サーバーがすべてのユーザーへの応答を停止する — マーケットプレイスに依存しているユーザーだけではなく
- ひとつの外部依存先のせいでシステム全体がダウンする
これが連鎖障害です。不安定な依存先が障害をシステム全体に伝播させます。Circuit Breaker はこの伝播を断ち切ります。
Circuit Breaker の仕組み
このパターンは電気のブレーカーのように機能します。外部依存先への呼び出しを監視し、3 つの状態を持ちます:
- CLOSED(閉) — 通常の状態。リクエストは外部 API へ通ります。失敗が threshold を超えて蓄積すると、OPEN へ遷移します。
- OPEN(開) — 保護の状態。リクエストは API へ行きません。即座にフォールバックを返します。クールダウン期間の後、HALF-OPEN へ遷移します。
- HALF-OPEN(半開) — テストの状態。テストとして1 つのリクエストを通します。成功すれば CLOSED に戻ります。失敗すれば OPEN に戻ります。
Circuit Breaker の state machine の図 — CLOSED、OPEN、HALF-OPEN の状態と遷移TypeScript での実装
任意の外部呼び出しをラップできる汎用的な Circuit Breaker を実装します。異なる依存先に対して再利用できるようにするのが狙いです。
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'
interface CircuitBreakerOptions {
failureThreshold: number // 開く前の失敗回数
cooldownMs: number // テストする前の OPEN での時間
timeoutMs: number // リクエストごとのタイムアウト
}
class CircuitBreaker {
private state: CircuitState = 'CLOSED'
private failureCount = 0
private lastFailureTime = 0
private readonly options: CircuitBreakerOptions
constructor(options: Partial<CircuitBreakerOptions> = {}) {
this.options = {
failureThreshold: options.failureThreshold ?? 5,
cooldownMs: options.cooldownMs ?? 60_000,
timeoutMs: options.timeoutMs ?? 10_000,
}
}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime >= this.options.cooldownMs) {
this.state = 'HALF_OPEN'
} else {
return fallback()
}
}
try {
const result = await this.withTimeout(fn())
this.onSuccess()
return result
} catch (error) {
this.onFailure()
return fallback()
}
}
private onSuccess() {
this.failureCount = 0
this.state = 'CLOSED'
}
private onFailure() {
this.failureCount++
this.lastFailureTime = Date.now()
if (this.failureCount >= this.options.failureThreshold) {
this.state = 'OPEN'
}
}
private withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), this.options.timeoutMs)
),
])
}
getState(): CircuitState {
return this.state
}
}実践的な使い方:API 呼び出しを守る
クラスができたので、任意の外部呼び出しをラップするのは簡単です:
// 依存先ごとに 1 つの circuit breaker
const marketplaceBreaker = new CircuitBreaker({
failureThreshold: 3, // 3 回の失敗で開く
cooldownMs: 30_000, // テストする前に 30 秒待つ
timeoutMs: 5_000, // 呼び出しごとに 5 秒のタイムアウト
})
const paymentBreaker = new CircuitBreaker({
failureThreshold: 2, // より敏感に — 決済だから
cooldownMs: 60_000, // 長めのクールダウン
timeoutMs: 10_000, // 長めのタイムアウト — 決済プロバイダーは遅い
})
// 保護された呼び出し
async function getProductFromMarketplace(productId: string) {
return marketplaceBreaker.execute(
// 実際の呼び出し
() => fetch(`https://api.marketplace.com/products/${productId}`)
.then(res => res.json()),
// circuit が開いているときのフォールバック
() => getCachedProduct(productId)
)
}重要なポイント:各外部依存先は独自の Circuit Breaker を持つべきです。マーケットプレイスが落ちると、マーケットプレイスの circuit が開きます — しかし決済プロバイダーの circuit は閉じたまま、通常どおり動作し続けます。
フォールバック戦略
フォールバックとは、circuit が開いているときにシステムが返すものです。これはもっともプロダクト的な判断を要する部分です。なぜなら、ユーザーが劣化に気づかないように、フォールバックは十分に役立つ必要があるからです。いくつかの戦略:
- キャッシュ — 最後の有効なレスポンスを返す。変化の少ないデータ(商品カタログ、設定)に向いています。
- デフォルト値 — 事前に定義された安全な値を返す。配送計算(「送料は後ほど確定」)や在庫(「在庫状況をご確認ください」)に向いています。
- 機能の縮退 — チェックアウトはマーケットプレイスの検証なしで動作するが、価格は後で確定されるとユーザーに通知する。
- 後でリトライするためのキュー — サービスが復旧したときに再試行するために操作を保存する。即時のレスポンスを必要としない操作に向いています。
// キャッシュを使ったフォールバック
const productCache = new Map<string, Product>()
async function getProduct(id: string): Promise<Product> {
return marketplaceBreaker.execute(
async () => {
const product = await fetchFromMarketplace(id)
productCache.set(id, product) // 成功時にキャッシュを更新する
return product
},
() => {
const cached = productCache.get(id)
if (cached) return cached
// キャッシュなし — 在庫なしフラグ付きの商品を返す
return { id, name: 'Produto indisponível', available: false }
}
)
}リトライと組み合わせる
Circuit Breaker とリトライは異なる問題を解決します:
- リトライ — 単発の失敗向け(あるリクエストが失敗したが、次はおそらく成功する)。指数バックオフを使います。
- Circuit Breaker — 継続的な失敗向け(サービスがダウンしていて、試し続けても無意味)。システム全体を守ります。
正しい組み合わせ:circuit breaker の内側でリトライする。circuit はリトライが機能しているかを監視します。リトライしても呼び出しが失敗し続けるなら、circuit が開きます。
async function fetchWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
// 指数バックオフ:1s、2s、4s
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
}
}
throw lastError
}
// circuit breaker の内側でリトライ
async function getProductReliably(id: string) {
return marketplaceBreaker.execute(
() => fetchWithRetry(() => fetchFromMarketplace(id), 3),
() => getCachedProduct(id)
)
}監視:circuit がいつ開いたかを知る
静かに開く circuit breaker は危険です — 外部サービスがいつ不安定になっているかを知る必要があります。状態遷移のイベントを追加することでこれが解決します:
class ObservableCircuitBreaker extends CircuitBreaker {
private name: string
constructor(name: string, options?: Partial<CircuitBreakerOptions>) {
super(options)
this.name = name
}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
const previousState = this.getState()
const result = await super.execute(fn, fallback)
const currentState = this.getState()
if (previousState !== currentState) {
this.logTransition(previousState, currentState)
}
return result
}
private logTransition(from: string, to: string) {
const message = `[CircuitBreaker:${this.name}] ${from} → ${to}`
if (to === 'OPEN') {
console.error(message) // 開いたときにアラートを出す
// monitoring へ送る(Datadog、Sentry など)
} else if (to === 'CLOSED') {
console.info(message) // 復旧したときに通知する
} else {
console.warn(message) // half-open は遷移
}
}
}これにより、外部サービスがいつ失敗し始め、いつ復旧するかが正確に分かります。本番では、これらのログはアラートシステムへ送るべきです — 決済プロバイダーの circuit が開いたら、誰かに通知される必要があります。
現実のシナリオ:一貫性のないマーケットプレイス API
私が直面したシナリオ:あるマーケットプレイスの API が商品データを一貫性なく返していました。あるときは 200ms で動作し、あるときは 15 秒かかり、あるときは 500 を返しました。チェックアウトはこの API に価格と在庫の検証を依存していました。
解決策はこうでした:
- 失敗 3 回の threshold とクールダウン 30 秒の Circuit Breaker
- 3 秒のアグレッシブなタイムアウト(3 秒で応答しなければ失敗とみなす)
- フォールバックとしてのキャッシュ — 商品の最後の有効なレスポンス、「価格は確定待ち」のフラグ付き
- 後でのリコンサイル — キャッシュの価格がまだ正しいかを 5 分ごとに確認するジョブ
結果:チェックアウトはマーケットプレイスのせいで二度とフリーズしませんでした。API が不安定なとき、ユーザーはさりげない注意書きとともにキャッシュの価格を見ていました。正常に戻ると、circuit が閉じ、すべてがリアルタイムで動作するようになりました。
Circuit Breaker を使わないとき
すべての外部呼び出しに circuit breaker が必要なわけではありません:
- すでにレジリエントな呼び出し — 依存先がネイティブのリトライと高い SLA を持つ場合(例:AWS S3)、circuit のオーバーヘッドは不要かもしれません。
- キュー付きの冪等な操作 — すでにリトライ付きのキューを使っている場合(例:webhook 処理)、キューがすでに circuit の役割を果たしています。
- クリティカルでない呼び出し — analytics、外部ロギング、トラッキング。失敗してもユーザーに影響しません。
まとめ
Circuit Breaker は、外部サービスに依存するアプリケーションにとってもっとも重要なツールのひとつです。パターン自体はシンプルです — 3 つの状態を持つ state machine です。エンジニアリングを要するのは次の点です:
- threshold を調整する — 開く前に何回の失敗を許すか、クールダウンをどれだけにするか
- 役立つフォールバックを設計する — キャッシュ、デフォルト値、機能の縮退
- 遷移を監視する — いつ開いていつ閉じたかを知る
- 依存先ごとに 1 つの breaker — 障害を隔離し、ひとつの不安定な API が他に影響しないようにする
- リトライと組み合わせる — 単発の失敗にはリトライ、継続的な失敗には circuit
依存先とともにダウンするシステムと、優雅に劣化するシステムの違いは、ほとんど常に、適切に設定された Circuit Breaker です。
