mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user