Skip to content

Commit 158c764

Browse files
authored
refactor(rc-8): wire circuit-breaker half-open gate into request pipeline (#123)
Adds a non-throwing canAttempt() gate to CircuitBreaker and consults it from the main fetch dispatcher before every upstream call. Previously the breaker state machine existed but was never consulted, so OPEN-state short-circuiting and HALF-OPEN probe serialization were dead code. - lib/circuit-breaker.ts: add canAttempt() returning CanAttemptResult ({ allowed, state, reason }); preserve existing canExecute() throwing surface unchanged for backward compatibility. - lib/errors.ts: extend CircuitOpenError with optional breakerKey, state, and reason so callers can classify the short-circuit without parsing message strings; zero-arg constructor preserved. - index.ts: compute per-(account, family) breaker key inside the retry loop, call canAttempt() before fetch, short-circuit to rotation on denial (break + refund + circuit-open metric), and feed recordFailure() on 5xx/network errors plus recordSuccess() on successful responses. - test/circuit-breaker-wiring.test.ts: 10 new tests covering CLOSED->OPEN, OPEN->HALF_OPEN after cooldown, HALF_OPEN->CLOSED on probe success, HALF_OPEN->OPEN on probe failure (with cooldown reset), concurrent-probe rejection, customized halfOpenMaxAttempts, and CircuitOpenError payload. All 2131 tests pass (2121 baseline + 10 new). typecheck, lint, and build clean.
1 parent ef35af8 commit 158c764

4 files changed

Lines changed: 302 additions & 1 deletion

File tree

index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ import {
155155
resetRateLimitBackoff,
156156
} from "./lib/request/rate-limit-backoff.js";
157157
import { isEmptyResponse } from "./lib/request/response-handler.js";
158+
import { getCircuitBreaker } from "./lib/circuit-breaker.js";
158159
import {
159160
RetryBudgetTracker,
160161
resolveRetryBudgetLimits,
@@ -1866,10 +1867,36 @@ while (attempted.size < Math.max(1, accountCount)) {
18661867
break;
18671868
}
18681869

1870+
// RC-8: per-(account, family) circuit-breaker key. The breaker gates
1871+
// upstream calls so that repeated failures short-circuit to the
1872+
// rotation path instead of hammering a degraded endpoint.
1873+
const circuitBreakerKey = `${accountId}:${modelFamily}`;
1874+
const circuitBreaker = getCircuitBreaker(circuitBreakerKey);
1875+
18691876
while (true) {
18701877
let response: Response;
18711878
const fetchStart = performance.now();
18721879

1880+
// RC-8: consult the breaker BEFORE firing upstream. When the gate is
1881+
// closed every call passes through unchanged. When the gate denies
1882+
// (open within cooldown, or half-open with a probe already in
1883+
// flight) we short-circuit to the rotation path instead of
1884+
// retrying here. We classify the short-circuit as `circuit-open`
1885+
// so observability traces and the runtime metrics agree with the
1886+
// `CircuitOpenError` type exported from `lib/errors.ts`.
1887+
const breakerCheck = circuitBreaker.canAttempt();
1888+
if (!breakerCheck.allowed) {
1889+
const shortCircuitMessage = `Circuit ${breakerCheck.state} for ${circuitBreakerKey}`;
1890+
logWarn(
1891+
`[circuit-breaker] ${shortCircuitMessage} (reason=${breakerCheck.reason ?? "denied"}). Rotating account.`,
1892+
);
1893+
accountManager.refundToken(account, modelFamily, model);
1894+
runtimeMetrics.accountRotations++;
1895+
runtimeMetrics.lastError = shortCircuitMessage;
1896+
runtimeMetrics.lastErrorCategory = "circuit-open";
1897+
break;
1898+
}
1899+
18731900
// Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any)
18741901
const fetchController = new AbortController();
18751902
const requestTimeoutMs = fetchTimeoutMs;
@@ -1939,6 +1966,10 @@ while (attempted.size < Math.max(1, accountCount)) {
19391966
runtimeMetrics.lastErrorCategory = "network";
19401967
accountManager.refundToken(account, modelFamily, model);
19411968
accountManager.recordFailure(account, modelFamily, model);
1969+
// RC-8: network failures feed the breaker so a degraded upstream
1970+
// trips the gate for this (account, family) key after N hits
1971+
// inside the failure window.
1972+
circuitBreaker.recordFailure();
19421973
break;
19431974
} finally {
19441975
clearTimeout(fetchTimeoutId);
@@ -2195,6 +2226,11 @@ while (attempted.size < Math.max(1, accountCount)) {
21952226
runtimeMetrics.lastErrorCategory = "server";
21962227
accountManager.refundToken(account, modelFamily, model);
21972228
accountManager.recordFailure(account, modelFamily, model);
2229+
// RC-8: 5xx responses are treated the same as network failures by
2230+
// the breaker — they indicate an upstream fault rather than a
2231+
// client-side classifier decision (401/403/404/429 are handled
2232+
// upstream and do not feed the breaker).
2233+
circuitBreaker.recordFailure();
21982234
if (
21992235
!consumeRetryBudget(
22002236
"server",
@@ -2325,6 +2361,9 @@ while (attempted.size < Math.max(1, accountCount)) {
23252361
}
23262362

23272363
accountManager.recordSuccess(account, modelFamily, model);
2364+
// RC-8: closes a half-open gate or prunes the failure window so a
2365+
// sequence of successes keeps the breaker healthy.
2366+
circuitBreaker.recordSuccess();
23282367
runtimeMetrics.successfulRequests++;
23292368
runtimeMetrics.lastError = null;
23302369
runtimeMetrics.lastErrorCategory = null;

lib/circuit-breaker.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
1818

1919
export type CircuitState = "closed" | "open" | "half-open";
2020

21+
/**
22+
* Non-throwing gate check result returned by {@link CircuitBreaker.canAttempt}.
23+
*
24+
* Unlike {@link CircuitBreaker.canExecute} (which throws `CircuitOpenError`
25+
* when the gate denies the call), `canAttempt` returns this result so the
26+
* request pipeline can decide whether to short-circuit, rotate accounts,
27+
* or emit typed errors without a try/catch around every upstream call.
28+
*/
29+
export interface CanAttemptResult {
30+
/** True when the caller may proceed to the protected dependency. */
31+
allowed: boolean;
32+
/** Current breaker state at the time of the check. */
33+
state: CircuitState;
34+
/** Populated when `allowed` is false. Stable machine-readable reason. */
35+
reason?: "open" | "probe-in-flight";
36+
}
37+
2138
export class CircuitBreaker {
2239
private state: CircuitState = "closed";
2340
private failures: number[] = [];
@@ -29,6 +46,49 @@ export class CircuitBreaker {
2946
this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
3047
}
3148

49+
/**
50+
* Non-throwing gate check used by the request pipeline.
51+
*
52+
* Semantics parallel {@link canExecute} but the caller receives a
53+
* {@link CanAttemptResult} instead of an exception:
54+
* - `closed` → `{ allowed: true, state: "closed" }`
55+
* - `open` past cooldown → auto-transitions to `half-open` then admits a
56+
* single probe; returns `{ allowed: true, state: "half-open" }`
57+
* - `open` within cooldown → `{ allowed: false, state: "open", reason: "open" }`
58+
* - `half-open` with in-flight probe → `{ allowed: false, state: "half-open", reason: "probe-in-flight" }`
59+
*
60+
* HALF-OPEN probe serialization: the first caller increments
61+
* `halfOpenAttempts` and proceeds; subsequent callers see the budget
62+
* exhausted and are denied with `probe-in-flight`. The probe slot is
63+
* released on the next {@link recordSuccess} (closes) or
64+
* {@link recordFailure} (reopens), which both reset `halfOpenAttempts`.
65+
*/
66+
canAttempt(): CanAttemptResult {
67+
const now = Date.now();
68+
69+
if (this.state === "open") {
70+
if (now - this.lastStateChange >= this.config.resetTimeoutMs) {
71+
this.transitionToHalfOpen(now);
72+
} else {
73+
return { allowed: false, state: "open", reason: "open" };
74+
}
75+
}
76+
77+
if (this.state === "half-open") {
78+
if (this.halfOpenAttempts >= this.config.halfOpenMaxAttempts) {
79+
return {
80+
allowed: false,
81+
state: "half-open",
82+
reason: "probe-in-flight",
83+
};
84+
}
85+
this.halfOpenAttempts += 1;
86+
return { allowed: true, state: "half-open" };
87+
}
88+
89+
return { allowed: true, state: "closed" };
90+
}
91+
3292
canExecute(): boolean {
3393
const now = Date.now();
3494

lib/errors.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,40 @@ export class StorageError extends CodexError {
209209
}
210210
}
211211

212+
/**
213+
* Options carried by {@link CircuitOpenError} when raised from the request
214+
* pipeline so callers can classify the short-circuit without parsing the
215+
* message string.
216+
*/
217+
export interface CircuitOpenErrorOptions {
218+
/** The breaker key that denied the call, e.g. `account:modelFamily`. */
219+
breakerKey?: string;
220+
/** Snapshot of the breaker state at denial time (`open` | `half-open`). */
221+
state?: "open" | "half-open";
222+
/** Machine-readable denial reason from `CanAttemptResult`. */
223+
reason?: "open" | "probe-in-flight";
224+
}
225+
212226
/**
213227
* Error thrown when a circuit breaker is open (or half-open past its attempt
214228
* budget) and further calls must short-circuit instead of hitting the
215229
* protected dependency.
230+
*
231+
* When constructed from the request pipeline's gate check, {@link breakerKey},
232+
* {@link state}, and {@link reason} carry the metadata needed by the rotation
233+
* path to pick a different account/family without re-querying the breaker.
216234
*/
217235
export class CircuitOpenError extends CodexError {
218236
override readonly name = "CircuitOpenError";
237+
readonly breakerKey?: string;
238+
readonly state?: "open" | "half-open";
239+
readonly reason?: "open" | "probe-in-flight";
219240

220-
constructor(message = "Circuit is open") {
241+
constructor(message = "Circuit is open", options?: CircuitOpenErrorOptions) {
221242
super(message, { code: ErrorCode.CIRCUIT_OPEN });
243+
this.breakerKey = options?.breakerKey;
244+
this.state = options?.state;
245+
this.reason = options?.reason;
222246
}
223247
}
224248

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* RC-8 — wiring tests for the non-throwing `canAttempt` gate.
3+
*
4+
* These tests exercise the request-pipeline contract: `canAttempt` returns a
5+
* `CanAttemptResult` without throwing, so the dispatcher in `index.ts` can
6+
* short-circuit to the rotation path. State transitions (CLOSED → OPEN,
7+
* OPEN → HALF_OPEN after cooldown, HALF_OPEN → CLOSED on probe success,
8+
* HALF_OPEN → OPEN on probe failure, concurrent-probe rejection) are verified
9+
* here — the existing `test/circuit-breaker.test.ts` keeps coverage for the
10+
* legacy throwing `canExecute` surface.
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14+
import {
15+
CircuitBreaker,
16+
CircuitOpenError,
17+
DEFAULT_CIRCUIT_BREAKER_CONFIG,
18+
} from "../lib/circuit-breaker.js";
19+
20+
const { failureThreshold, resetTimeoutMs } = DEFAULT_CIRCUIT_BREAKER_CONFIG;
21+
22+
describe("circuit-breaker: wired gate (canAttempt)", () => {
23+
beforeEach(() => {
24+
vi.useFakeTimers();
25+
vi.setSystemTime(new Date(0));
26+
});
27+
28+
afterEach(() => {
29+
vi.useRealTimers();
30+
});
31+
32+
it("CLOSED: allows the call and reports state=closed", () => {
33+
const breaker = new CircuitBreaker();
34+
const result = breaker.canAttempt();
35+
expect(result.allowed).toBe(true);
36+
expect(result.state).toBe("closed");
37+
expect(result.reason).toBeUndefined();
38+
});
39+
40+
it("CLOSED → OPEN: denies the call once failureThreshold reached within the window", () => {
41+
const breaker = new CircuitBreaker();
42+
for (let i = 0; i < failureThreshold; i++) {
43+
breaker.recordFailure();
44+
}
45+
expect(breaker.getState()).toBe("open");
46+
47+
const result = breaker.canAttempt();
48+
expect(result.allowed).toBe(false);
49+
expect(result.state).toBe("open");
50+
expect(result.reason).toBe("open");
51+
});
52+
53+
it("OPEN → HALF_OPEN: auto-transitions after cooldown and admits a probe", () => {
54+
const breaker = new CircuitBreaker();
55+
for (let i = 0; i < failureThreshold; i++) {
56+
breaker.recordFailure();
57+
}
58+
expect(breaker.getState()).toBe("open");
59+
60+
// Advance past the cooldown window.
61+
vi.setSystemTime(new Date(resetTimeoutMs + 1));
62+
63+
const first = breaker.canAttempt();
64+
expect(first.allowed).toBe(true);
65+
expect(first.state).toBe("half-open");
66+
expect(breaker.getState()).toBe("half-open");
67+
});
68+
69+
it("HALF_OPEN: first probe allowed, concurrent probe rejected with probe-in-flight", () => {
70+
const breaker = new CircuitBreaker();
71+
for (let i = 0; i < failureThreshold; i++) {
72+
breaker.recordFailure();
73+
}
74+
vi.setSystemTime(new Date(resetTimeoutMs + 1));
75+
76+
const first = breaker.canAttempt();
77+
expect(first.allowed).toBe(true);
78+
expect(first.state).toBe("half-open");
79+
80+
// A second concurrent caller (no recordSuccess / recordFailure yet) sees
81+
// the probe slot already consumed and is denied without throwing.
82+
const second = breaker.canAttempt();
83+
expect(second.allowed).toBe(false);
84+
expect(second.state).toBe("half-open");
85+
expect(second.reason).toBe("probe-in-flight");
86+
});
87+
88+
it("HALF_OPEN → CLOSED: probe success closes the gate and allows the next caller", () => {
89+
const breaker = new CircuitBreaker();
90+
for (let i = 0; i < failureThreshold; i++) {
91+
breaker.recordFailure();
92+
}
93+
vi.setSystemTime(new Date(resetTimeoutMs + 1));
94+
95+
expect(breaker.canAttempt().allowed).toBe(true);
96+
breaker.recordSuccess();
97+
expect(breaker.getState()).toBe("closed");
98+
99+
const next = breaker.canAttempt();
100+
expect(next.allowed).toBe(true);
101+
expect(next.state).toBe("closed");
102+
});
103+
104+
it("HALF_OPEN → OPEN: probe failure reopens and resets the cooldown", () => {
105+
const breaker = new CircuitBreaker();
106+
for (let i = 0; i < failureThreshold; i++) {
107+
breaker.recordFailure();
108+
}
109+
vi.setSystemTime(new Date(resetTimeoutMs + 1));
110+
111+
// Admit the probe…
112+
expect(breaker.canAttempt().allowed).toBe(true);
113+
114+
// …and fail it. The breaker must reopen with a fresh cooldown window
115+
// so subsequent callers are denied until the timer elapses again.
116+
breaker.recordFailure();
117+
expect(breaker.getState()).toBe("open");
118+
119+
const immediate = breaker.canAttempt();
120+
expect(immediate.allowed).toBe(false);
121+
expect(immediate.state).toBe("open");
122+
expect(immediate.reason).toBe("open");
123+
124+
// Cooldown advances relative to the reopen timestamp, not the original
125+
// open transition.
126+
vi.setSystemTime(new Date(resetTimeoutMs + 2 + resetTimeoutMs + 1));
127+
const recovered = breaker.canAttempt();
128+
expect(recovered.allowed).toBe(true);
129+
expect(recovered.state).toBe("half-open");
130+
});
131+
132+
it("respects a custom halfOpenMaxAttempts > 1 before denying with probe-in-flight", () => {
133+
const breaker = new CircuitBreaker({ halfOpenMaxAttempts: 2 });
134+
for (let i = 0; i < failureThreshold; i++) {
135+
breaker.recordFailure();
136+
}
137+
vi.setSystemTime(new Date(resetTimeoutMs + 1));
138+
139+
expect(breaker.canAttempt().allowed).toBe(true);
140+
expect(breaker.canAttempt().allowed).toBe(true);
141+
const third = breaker.canAttempt();
142+
expect(third.allowed).toBe(false);
143+
expect(third.reason).toBe("probe-in-flight");
144+
});
145+
146+
it("canAttempt does not regress existing canExecute behavior", () => {
147+
// canExecute still throws on the denial paths so the legacy surface
148+
// used by other call sites is preserved.
149+
const breaker = new CircuitBreaker();
150+
for (let i = 0; i < failureThreshold; i++) {
151+
breaker.recordFailure();
152+
}
153+
expect(() => breaker.canExecute()).toThrow(CircuitOpenError);
154+
});
155+
});
156+
157+
describe("CircuitOpenError (typed short-circuit payload)", () => {
158+
it("carries breakerKey, state, and reason when constructed from the pipeline", () => {
159+
const error = new CircuitOpenError("Circuit open for acct:gpt-5", {
160+
breakerKey: "acct:gpt-5",
161+
state: "open",
162+
reason: "open",
163+
});
164+
expect(error).toBeInstanceOf(CircuitOpenError);
165+
expect(error.code).toBe("CODEX_CIRCUIT_OPEN");
166+
expect(error.breakerKey).toBe("acct:gpt-5");
167+
expect(error.state).toBe("open");
168+
expect(error.reason).toBe("open");
169+
});
170+
171+
it("preserves the zero-argument constructor for backward compatibility", () => {
172+
const error = new CircuitOpenError();
173+
expect(error.message).toBe("Circuit is open");
174+
expect(error.breakerKey).toBeUndefined();
175+
expect(error.state).toBeUndefined();
176+
expect(error.reason).toBeUndefined();
177+
});
178+
});

0 commit comments

Comments
 (0)