TypeScriptでサーキットブレーカーを実装する:Closed・Open・Half-Openと同時プローブ制御
参考にした自社記事(内容は再構成しています):https://aceround.app/blog/sre-interview-ai
外部APIやデータベースが遅延・障害を起こしたとき、呼び出し側がリクエストを送り続けると、障害が連鎖して自分のサービスまで応答できなくなります。サーキットブレーカーはこの連鎖を、呼び出し先を一時的に「開放」することで止めるパターンです。
この記事では、Node.jsでそのまま実行できるTypeScript実装を作ります。ポイントは次の3つです。
- 連続失敗が閾値に達したら
Openに遷移し、呼び出しを即時拒否する - 冷却時間が過ぎたら、同時に1本だけ
Half-Openのプローブを許可する - タイムアウト時には
AbortSignalで下流処理にもキャンセルを伝える
状態遷移
Half-Open は「復旧したかもしれない」という仮説を検証する状態です。ここで全トラフィックを通すと、復旧途中の依存先に再び負荷をかけてしまいます。そのため、実装ではプローブを1本に制限します。
実装
type BreakerState = "closed" | "open" | "half-open";
type CircuitBreakerOptions = {
failureThreshold: number;
resetTimeoutMs: number;
requestTimeoutMs: number;
};
export class CircuitOpenError extends Error {
constructor() {
super("circuit is open");
this.name = "CircuitOpenError";
}
}
export class CircuitBreaker<T> {
private state: BreakerState = "closed";
private consecutiveFailures = 0;
private openedAt = 0;
private probeInFlight = false;
constructor(
private readonly operation: (signal: AbortSignal) => Promise<T>,
private readonly options: CircuitBreakerOptions,
) {}
getState(): BreakerState {
return this.state;
}
async execute(): Promise<T> {
const probe = this.admit();
const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(new Error("downstream request timed out"));
}, this.options.requestTimeoutMs);
});
const result = await Promise.race([
this.operation(controller.signal),
timeout,
]);
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
throw error;
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
if (probe) this.probeInFlight = false;
}
}
private admit(): boolean {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.options.resetTimeoutMs) {
throw new CircuitOpenError();
}
this.state = "half-open";
}
if (this.state === "half-open") {
if (this.probeInFlight) throw new CircuitOpenError();
this.probeInFlight = true;
return true;
}
return false;
}
private recordSuccess(): void {
this.consecutiveFailures = 0;
this.state = "closed";
}
private recordFailure(): void {
if (this.state === "half-open") {
this.state = "open";
this.openedAt = Date.now();
return;
}
this.consecutiveFailures += 1;
if (this.consecutiveFailures >= this.options.failureThreshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
}
なぜ probeInFlight が必要か
Open の冷却時間が過ぎた直後は、複数のHTTPリクエストが同じイベントループのターンで execute() に到達する可能性があります。最初の呼び出しが Half-Open にした直後にフラグを立てることで、2本目以降は下流へ到達せずに拒否されます。
実行可能な境界テスト
次のテストは外部ライブラリを使わず、bun run だけで実行できます。実際の面接では、状態遷移を図にしてから「失敗を何回数えるか」「復旧判定の負荷をどう制限するか」を説明すると、実装の意図が伝わりやすくなります。
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assert = (condition: unknown, message: string) => {
if (!condition) throw new Error(message);
};
let attempts = 0;
let shouldFail = true;
const breaker = new CircuitBreaker(
async (signal) => {
attempts += 1;
if (signal.aborted) throw new Error("aborted");
if (shouldFail) throw new Error("downstream unavailable");
return "ok";
},
{ failureThreshold: 2, resetTimeoutMs: 20, requestTimeoutMs: 50 },
);
await Promise.allSettled([breaker.execute(), breaker.execute()]);
assert(breaker.getState() === "open", "2回の失敗でOpenになる");
const attemptsWhileOpen = attempts;
await Promise.allSettled([breaker.execute()]);
assert(attempts === attemptsWhileOpen, "Open中は下流を呼ばない");
shouldFail = false;
await sleep(25);
assert((await breaker.execute()) === "ok", "Half-Openのプローブが成功する");
assert(breaker.getState() === "closed", "成功後はClosedに戻る");
let releaseProbe!: () => void;
const probeStarted = new Promise<void>((resolve) => {
releaseProbe = resolve;
});
let probeCalls = 0;
const probeBreaker = new CircuitBreaker(
async () => {
probeCalls += 1;
if (probeCalls === 1) throw new Error("seed failure");
await probeStarted;
return "probe-ok";
},
{ failureThreshold: 1, resetTimeoutMs: 1, requestTimeoutMs: 100 },
);
await Promise.allSettled([probeBreaker.execute()]);
await sleep(2);
const firstProbe = probeBreaker.execute();
await sleep(1);
const secondProbe = await Promise.allSettled([probeBreaker.execute()]);
assert(
secondProbe[0]?.status === "rejected" &&
secondProbe[0].reason instanceof CircuitOpenError,
"Half-Openではプローブを同時に2本通さない",
);
releaseProbe();
assert((await firstProbe) === "probe-ok", "プローブ完了後に結果を返す");
let aborted = false;
const timeoutBreaker = new CircuitBreaker(
(signal) =>
new Promise<never>((_, reject) => {
signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
}),
{ failureThreshold: 1, resetTimeoutMs: 20, requestTimeoutMs: 5 },
);
await Promise.allSettled([timeoutBreaker.execute()]);
assert(aborted, "タイムアウト時に下流のAbortSignalを発火する");
実運用で追加する制約
この最小実装は、1プロセス内の状態だけを扱います。複数インスタンスで同じ下流を守る場合、各インスタンスが独立してプローブを送るため、共有ストアやサービスメッシュ側のサーキットブレーカーが必要です。
また、失敗をすべて同じ重みで数えるのも危険です。入力エラー(HTTP 400)を障害として扱うと、正常なクライアントのミスで回路が開きます。通常はタイムアウト、接続エラー、HTTP 5xx など、再試行しても改善しない失敗だけを対象にします。
最後に、回路が開いた回数、プローブの成功率、拒否したリクエスト数をメトリクスに出してください。サーキットブレーカーは障害を隠す機能ではなく、障害の影響範囲を小さくし、復旧の観測点を増やすための機能です。
まとめ
-
Closedでは失敗を数え、閾値を超えたらOpenにする -
Openでは即時拒否し、冷却時間後に1本だけHalf-Openのプローブを送る - タイムアウトは
AbortSignalで下流へ伝播し、入力エラーは失敗数から除外する - 複数インスタンスでは状態共有とメトリクスが別途必要
この4点をコードとテストで説明できれば、サーキットブレーカーを「用語として知っている」状態から「障害境界を設計できる」状態へ進めます。