Files
OmniRoute/tests/unit/combo-breaker-429.test.ts
Chirag Singhal d256fc367a fix(sse): stop combo path tripping whole-provider breaker on plain 429 (#6868)
* fix(sse): stop combo path tripping whole-provider breaker on plain 429

The combo path recorded a whole-provider circuit-breaker failure for a
plain rate-limit 429, opening the breaker after N consecutive 429s and
blocking every account+model on that provider. This contradicts the
single-model path and the documented RESILIENCE_GUIDE policy.

- Single-model path uses PROVIDER_BREAKER_FAILURE_STATUSES =
  Set([408, 500, 502, 503, 504]) (src/sse/handlers/chat.ts:206) — 429 excluded.
- Combo path gated shouldRecordProviderBreakerFailure on isProviderFailureCode
  (accountFallback.ts), whose PROVIDER_FAILURE_ERROR_CODES INCLUDES 429 for
  connection-cooldown scope — so a plain 429 wrongly tripped the whole-provider
  breaker.

Fix scopes tightly: comboPredicates now tests a local
PROVIDER_BREAKER_FAILURE_STATUSES set mirroring the single-model constant
(429 excluded), instead of isProviderFailureCode. The shared
isProviderFailureCode / PROVIDER_FAILURE_ERROR_CODES are deliberately left
untouched — they drive connection-cooldown / model-lockout logic where 429
must still count. A genuine quota/token-limit terminal 429 is handled
elsewhere; only the whole-provider breaker-recording gate changes.

Adds tests/unit/combo-breaker-429.test.ts covering the 429 exclusion,
the 408/5xx inclusion, and the sameProviderNext / skipProviderBreaker
suppression paths.

* test(quality): register combo-breaker-429.test.ts in stryker tap.testFiles

Fast Quality Gates' mutation-coverage drift check flagged this PR's new
covering test for comboPredicates.ts as unregistered.

Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com>

---------

Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
2026-07-12 01:59:09 -03:00

70 lines
2.3 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
/**
* Combo path must NOT trip the whole-provider circuit breaker on a plain rate-limit 429.
*
* Documented policy (docs/architecture/RESILIENCE_GUIDE.md + CLAUDE.md): only
* 408/500/502/503/504 trip the whole-provider breaker. A plain 429 is connection-cooldown
* / model-lockout scope, never a whole-provider outage. The single-model path already
* excludes 429 via `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/chat.ts:206). This
* asserts the combo predicate `shouldRecordProviderBreakerFailure` is aligned — it must NOT
* gate on `isProviderFailureCode` (accountFallback.ts), which INCLUDES 429 for the separate
* connection-cooldown scope.
*/
const { shouldRecordProviderBreakerFailure } =
await import("../../open-sse/services/combo/comboPredicates.ts");
const OTHER_ARGS = {
isStreamReadinessFailure: false,
sameProviderNext: false,
skipProviderBreaker: false,
} as const;
test("429 (plain rate limit) does NOT record a whole-provider breaker failure", () => {
assert.equal(
shouldRecordProviderBreakerFailure({ ...OTHER_ARGS, status: 429 }),
false,
"a plain 429 must not open the whole-provider breaker (cooldown/lockout scope)"
);
});
for (const status of [408, 500, 502, 503, 504]) {
test(`whole-provider failure status ${status} DOES record a breaker failure`, () => {
assert.equal(
shouldRecordProviderBreakerFailure({ ...OTHER_ARGS, status }),
true,
`status ${status} must trip the whole-provider breaker`
);
});
}
test("sameProviderNext:true suppresses recording regardless of status", () => {
for (const status of [408, 429, 500, 502, 503, 504]) {
assert.equal(
shouldRecordProviderBreakerFailure({
...OTHER_ARGS,
sameProviderNext: true,
status,
}),
false,
`sameProviderNext must suppress recording for status ${status}`
);
}
});
test("skipProviderBreaker:true suppresses recording regardless of status", () => {
for (const status of [408, 429, 500, 502, 503, 504]) {
assert.equal(
shouldRecordProviderBreakerFailure({
...OTHER_ARGS,
skipProviderBreaker: true,
status,
}),
false,
`skipProviderBreaker must suppress recording for status ${status}`
);
}
});