結論から書きます。サーキットブレーカーの復旧判定を「成功したら閉じる」で実装すると、開いた直後に閉じます。
開いた瞬間、その接続先には開く前に投げたリクエストがまだ in-flight のまま残っています。遅いものは数秒後に成功して戻ってくるので、素朴な recordChannelSuccess() はそれを「復旧」と読み、開いたばかりのブレーカーを閉じます。手元で再現したら、開いてから 1.85 秒で閉じました。
複数の上流チャネルをフォールバックチェーンでつないでいるサービスで、この穴を踏みました。ブレーカーが早く閉じると、次のリクエストはまた同じ壊れた道に送られます。この経路の TTFT タイムアウトは 60 秒なので、閉じているはずだった時間がそのままユーザーの待ち時間になります。3 本ぶつければ、また閾値に達するまで 3 分です。
以前、単体のサーキットブレーカー実装をこちらの記事に書きました。そのときの recordChannelSuccess() は、まさにこの記事で直す形をしていました。本番では閾値の判定も「連続失敗」から「窓内の累計」に変えていて、その理由は後半に書きます。
何が起きるか
時系列で書くとこうです。
| 時刻 | 出来事 |
|---|---|
| t=0 | 遅いリクエスト A を投げる(この時点では上流は正常。2 秒かけて成功して戻る予定) |
| t=50 / 100 / 150 | 後続の 3 本が 503 で落ちる。閾値 3 に到達してブレーカーが開く |
| t=2000 | A が成功して戻ってくる。recordChannelSuccess() が呼ばれる |
| t=2000 | ブレーカーが閉じる(開いてから 1.85 秒) |
A が証明しているのは「開く前の一瞬、この経路が通っていた」ことだけです。開いたあとに通るかどうかは何も言っていません。それでも素朴な実装は、これを復旧として数えてしまいます。
// 素朴な実装
export function recordChannelSuccess(channel: string, model: string): void {
const entry = snapshot.get(modelKey(channel, model));
if (!entry) return; // 無効化されていなければ何もしない
close(entry); // 成功した = 復旧した、と読む
}
close() を呼ぶ根拠が「成功した」ことだけで、その成功がどの時点の話かを見ていません。ここが穴でした。
再現する
本番の判定ロジックから、この部分だけを抜き出したものです。Redis はインメモリに、時計は注入して決定論的にしました。Node.js でそのまま動きます(bun breaker-demo.ts、または node --experimental-strip-types breaker-demo.ts)。
type Entry = { key: string; openedAt: number; until: number };
/** Redis の代わり。hsetnx / hget / hdel しか使わない。 */
class FakeRedis {
private hash = new Map<string, string>();
async hsetnx(key: string, value: string): Promise<number> {
if (this.hash.has(key)) return 0;
this.hash.set(key, value);
return 1;
}
async hget(key: string): Promise<string | null> {
return this.hash.get(key) ?? null;
}
async hdel(key: string): Promise<number> {
return this.hash.delete(key) ? 1 : 0;
}
has(key: string): boolean {
return this.hash.has(key);
}
}
class Breaker {
/** 各ワーカーのローカルスナップショット(本番は 2 秒ごとに Redis から引き直す)。 */
local = new Map<string, Entry>();
private counts = new Map<string, number>();
private redis: FakeRedis;
private mode: 'naive' | 'guard';
private now: () => number;
private threshold: number;
private cooldownMs: number;
constructor(
redis: FakeRedis,
mode: 'naive' | 'guard',
now: () => number,
threshold = 3,
cooldownMs = 30_000,
) {
this.redis = redis;
this.mode = mode;
this.now = now;
this.threshold = threshold;
this.cooldownMs = cooldownMs;
}
private key(channel: string, model: string) {
return `mo:${channel}|${model}`;
}
/** ローカルスナップショットを取り直す。 */
async refresh() {
this.local.clear();
for (const [key, value] of this.redis['hash'].entries()) {
this.local.set(key, JSON.parse(value) as Entry);
}
}
/** 失敗を 1 件記録し、窓内で閾値に届いたら開く。 */
async fail(channel: string, model: string) {
const key = this.key(channel, model);
if (this.redis.has(key)) return; // 開いている間は数えない
const n = (this.counts.get(key) ?? 0) + 1;
this.counts.set(key, n);
if (n < this.threshold) return;
const entry: Entry = {
key,
openedAt: this.now(),
until: this.now() + this.cooldownMs,
};
await this.redis.hsetnx(key, JSON.stringify(entry));
this.local.set(key, entry); // 別ワーカーが開いた場合も即座に反映する
}
/**
* 成功を 1 件記録する。startedAt は「そのリクエストを投げた時刻」。
* naive: ローカルスナップショットに載っていれば閉じる。
* guard: 投げたのが開いた後であることを確かめてから閉じる。
*/
async success(channel: string, model: string, startedAt: number) {
const key = this.key(channel, model);
const entry = this.local.get(key);
if (!entry) return { closed: false as const, reason: 'not-open' };
if (this.mode === 'naive') {
await this.redis.hdel(key);
this.local.delete(key);
this.counts.delete(key);
return { closed: true as const, downtimeMs: this.now() - entry.openedAt };
}
if (entry.openedAt > startedAt)
return { closed: false as const, reason: 'started-before-open(local)' };
const raw = await this.redis.hget(key);
if (!raw) return { closed: false as const, reason: 'closed-elsewhere' };
const latest = JSON.parse(raw) as Entry;
if (latest.openedAt > startedAt)
return { closed: false as const, reason: 'started-before-open(redis)' };
await this.redis.hdel(key);
this.local.delete(key);
this.counts.delete(key);
return { closed: true as const, downtimeMs: this.now() - latest.openedAt };
}
}
const pad = (label: string, value: string) =>
console.log(`${label.padEnd(12)} ${value}`);
// ── デモ 1 ──────────────────────────────────────────────
for (const mode of ['naive', 'guard'] as const) {
let t = 0;
const now = () => t;
const breaker = new Breaker(new FakeRedis(), mode, now);
// t=0: 障害が始まる前の最後のリクエストを投げる(上流は 2 秒かけて成功する)
const inFlightStartedAt = now();
// t=50 / 100 / 150: 後続の 3 本が素早く 503 で落ちる
for (const at of [50, 100, 150]) {
t = at;
await breaker.fail('pool-a', 'model-x');
}
const openedAt = now();
// t=2000: 最初のリクエストが成功して戻ってくる
t = 2000;
const res = await breaker.success('pool-a', 'model-x', inFlightStartedAt);
pad(
`[1] ${mode}`,
res.closed
? `open at t=+${openedAt}ms -> CLOSED by in-flight success (downtime ${res.downtimeMs}ms)`
: `open at t=+${openedAt}ms -> still open (${res.reason})`,
);
}
// ── デモ 2 ──────────────────────────────────────────────
for (const mode of ['naive', 'guard'] as const) {
let t = 0;
const now = () => t;
const redis = new FakeRedis();
// ワーカー A と B が同じ Redis を見ている。ローカルスナップショットは別物。
const workerA = new Breaker(redis, mode, now, 1);
const workerB = new Breaker(redis, mode, now, 1);
// t=0: 1 回目のブレーカーが開く
await workerB.fail('pool-a', 'model-x');
await workerA.refresh(); // A は openedAt=0 を掴んだきり、次の更新は 2 秒後
// t=100: 障害前に投げていたリクエスト(このあと成功して戻る)
const inFlightStartedAt = 100;
// t=200: プローブが通って 1 回目が閉じ、直後に 2 回目のブレーカーが開く(開いたのは B)
t = 200;
await redis.hdel('mo:pool-a|model-x');
await workerB.fail('pool-a', 'model-x');
// t=250: そのリクエストが成功して A に戻る
t = 250;
const res = await workerA.success('pool-a', 'model-x', inFlightStartedAt);
pad(
`[2] ${mode}`,
res.closed
? `CLOSED the 2nd breaker opened at t=+200ms (success started at t=+100ms)`
: `kept the 2nd breaker open (${res.reason})`,
);
pad(`[2] ${mode}`, `redis still has entry: ${redis.has('mo:pool-a|model-x')}`);
}
mode を 'naive' と 'guard' で差し替えて走らせた結果です。
[1] naive open at t=+150ms -> CLOSED by in-flight success (downtime 1850ms)
[1] guard open at t=+150ms -> still open (started-before-open(local))
naive 側は、閾値に達して開いた 1.85 秒後に閉じています。閉じた根拠は「A が成功した」だけです。
直し方:成功に「投げた時刻」を持たせる
recordChannelSuccess に、そのリクエストを投げた時刻を渡します。
export function recordChannelSuccess(
channel: string,
model: string,
/** このリクエストを投げた時刻(Date.now() 口径)。 */
startedAt: number,
): void {
const entries = [modelKey(channel, model), channelKey(channel)]
.map((key) => snapshot.get(key))
.filter(
(entry): entry is BreakerEntry =>
!!entry && entry.openedAt <= startedAt, // ← 投げたのは無効化より後
);
if (entries.length === 0) return;
// ... ここで閉じる
}
呼び出し側は、リクエストを投げる前に時刻を取ります。
const tripStartedAt = Date.now(); // 投げる前に取る
const trip = await runHedgedTrip(/* ... */);
if (trip.ok) {
recordChannelSuccess(trip.target.channel, trip.target.model, tripStartedAt);
}
openedAt <= startedAt が選んでいるのは、無効化されてから投げたリクエストの成功だけです。無効化中は新しいリクエストが isTargetUsable() で弾かれるので、この条件を通るのは検証用のプローブだけになります(全チャネルが無効化されているときの強制再試行を除く)。判定を「成功したか」から「いつ投げたか」に移すと、復旧の証拠として使える成功が自動的にプローブだけに絞られます。
もう 1 つの罠:ワーカーのローカルスナップショットが 1 世代古い
ブレーカーの状態は Redis に置いて複数ワーカーで共有し、ホットパスはローカルスナップショットだけを読みます(2 秒ごとに引き直す)。この構成だと、閉じる判断をするワーカーの手元だけが古いことがあります。
- t=0: 1 回目のブレーカーが開く。ワーカー A はスナップショットに
openedAt=0を掴む - t=100: リクエスト B を投げる(このあと成功して戻る)
- t=200: プローブが通って 1 回目が閉じ、直後に 2 回目のブレーカーが開く(開いたのはワーカー B)
- t=250: B が成功してワーカー A に戻る。A のスナップショットはまだ
openedAt=0
A の手元だけで判定すると 0 <= 100 で条件を通ってしまい、t=200 に開いたばかりのブレーカーを、t=100 に投げたリクエストの成功で閉じます。再現スクリプトのデモ 2 がその実行結果です。
[2] naive CLOSED the 2nd breaker opened at t=+200ms (success started at t=+100ms)
[2] naive redis still has entry: false
[2] guard kept the 2nd breaker open (started-before-open(redis))
[2] guard redis still has entry: true
直しは、閉じる直前に Redis から読み直して、同じ判定をもう一度やることです。
const raw = await redis.hget(OPEN_HASH_KEY, entry.key);
if (!raw) return; // 別のワーカーが先に閉じていた
const latest = JSON.parse(raw) as BreakerEntry;
if (latest.openedAt > startedAt) return; // その間に開き直されていた
await redis.hdel(OPEN_HASH_KEY, entry.key);
ローカルスナップショットは「候補を絞る」ためだけに使い、最終判断は共有状態でやります。スナップショットの遅延は性能のための妥協なので、正しさの判定には混ぜません。
閾値は窓内の累計で数える
本番では閾値を「連続失敗 N 回」ではなく「120 秒の窓内で累計 N 回」にしています。連続で数えると、間欠的に失敗する依存先が、間に 1 回成功を挟んだ瞬間にカウントが 0 に戻り、障害が永久に隠れます。
この変更は復旧側にも同じ規律を要求します。1 回の成功で窓のカウンタを消さないことです。成功したからといってカウンタを消すと、同じことがまた起きます。カウンタを消すのは復旧が確定したとき(プローブが通ったとき)だけにしています。
割り切ったこと
- 手動で止めたものは自動で戻さない。 人が「このチャネルは使わない」と決めた状態を、プローブの成功で勝手に開放しない。
- 全チャネルが無効化されているときは、最も冷却の浅いものを 1 つだけ強制で試す。 全部を避けて即失敗を返すのは、こちらの(古くなっているかもしれない)判断でユーザーに失敗を作ることになります。最悪でも壁に 1 回ぶつかるだけで、ブレーカーを入れる前と等価です。
- アラートは開いた瞬間ではなく、プローブが失敗した時点で送る。 30 秒で自己回復する揺らぎで「⛔ 開いた → ✅ 直った」の対を鳴らすと、本当の障害が埋もれます。
-
プローブは
SET NXのロックで 1 本に絞る。 ワーカーごとに復旧ループが回っているので、ロックがないと同じ壊れたチャネルに同時に何本も打ちます。
まとめ
- 復旧判定を「成功したら閉じる」にすると、開く前に投げた in-flight の成功で開いた直後に閉じます(再現で 1.85 秒)
- 成功には「投げた時刻」を持たせ、
openedAt <= startedAtのものだけを復旧として数えます。無効化中に投げられるのはプローブだけなので、これで証拠が絞られます - ワーカーのローカルスナップショットは 1 世代古くなります。閉じる直前に共有状態(Redis)から読み直して同じ判定をやり直します
- 閾値を窓内の累計で数えているなら、成功 1 回でカウンタを消しません
「成功した」は「復旧した」の証明になりません。 どの時点の成功かを一緒に持たないと、開けたばかりの蓋を閉めてしまいます。
この判定は、筆者が関わっている面接練習AI(AceRound)のリアルタイム経路で保守しています。上流が落ちている間もユーザーの面接は続くので、ブレーカーを閉じる判断を間違えると、そのまま誰かの待ち時間になります。
