fix(resilience): count resolved 5xx results against the provider breaker (#12360)

CircuitBreaker.execute() treated every resolved promise as a success, but handleChatCore() reports most upstream failures by resolving with { success: false, status: 5xx }. On the chat path that spurious _onSuccess() decayed failureCount right before the call site's _onFailure() for the same attempt, so a provider answering 503s indefinitely stayed CLOSED at failureCount: 1 and kept receiving traffic — the breaker was structurally unable to open. Combo dispatches hit the same cancellation through the shared per-provider breaker.

execute() now takes an optional per-call classifyResult; without it the resolved-means-success contract every throw-based caller relies on is unchanged. executeChatWithBreaker() passes ignore and the chat path accounts for the outcome exactly once where the request context lives, so a combo success is no longer counted twice.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:14:54 +02:00
committed by GitHub
parent 62e2481eef
commit 5a34111125
8 changed files with 337 additions and 10 deletions

View File

@@ -150,6 +150,25 @@ interface CircuitBreakerOptions {
backoffEscalationCount?: number;
}
/**
* How a RESOLVED `execute()` result is accounted (#12254). Callers such as
* `handleChatCore()` report most upstream failures by resolving with
* `{ success: false, status: 5xx }` instead of throwing, so a breaker that reads every
* resolution as a success never trips on that path.
*/
export type CircuitBreakerResultOutcome = "success" | "failure" | "ignore";
export interface CircuitBreakerExecuteOptions<T> {
/**
* Classify a resolved result. Omitted: every resolution is a success (the
* throw-based contract every other caller relies on). Return "ignore" when the
* call site accounts for the outcome itself with request context the breaker
* does not have — the chat path does (`classifyProviderBreakerResult()` in
* chat.ts, `recordProviderFailure()`/`recordProviderSuccess()` in combo.ts).
*/
classifyResult?: (result: T) => CircuitBreakerResultOutcome;
}
export interface TransitionRecord {
from: string;
to: string;
@@ -300,7 +319,7 @@ export class CircuitBreaker {
);
}
async execute<T>(fn: () => Promise<T>): Promise<T> {
async execute<T>(fn: () => Promise<T>, options?: CircuitBreakerExecuteOptions<T>): Promise<T> {
this._refreshOpenState();
if (this.state === STATE.OPEN) {
@@ -325,7 +344,7 @@ export class CircuitBreaker {
try {
const result = await fn();
this._onSuccess();
this._recordResolvedResult(result, options?.classifyResult);
return result;
} catch (error) {
if (this.isFailure(error)) {
@@ -387,6 +406,29 @@ export class CircuitBreaker {
// ─── Internal ─────────────────────────────────
/**
* Account a resolved `execute()` result exactly once. A classifier that throws
* falls back to the legacy "resolved = success" reading, mirroring `classifyError`.
*/
_recordResolvedResult<T>(
result: T,
classifyResult?: (result: T) => CircuitBreakerResultOutcome
): void {
let outcome: CircuitBreakerResultOutcome = "success";
if (classifyResult) {
try {
outcome = classifyResult(result);
} catch {
outcome = "success";
}
}
if (outcome === "failure") {
this._onFailure();
} else if (outcome === "success") {
this._onSuccess();
}
}
_onSuccess() {
if (this.state === STATE.OPEN) {
this._transition(STATE.CLOSED, "success-recovery");