From 20a43f5a2ca491335e623c4ceb7d36cac1f225d7 Mon Sep 17 00:00:00 2001 From: RaoYu <2425198313@qq.com> Date: Thu, 17 Sep 2026 18:26:05 +0700 Subject: [PATCH] fix(auth): don't cool down an account for a request-scoped 4xx Do not trigger account cooldown or fallback for request-scoped 4xx errors that match no account rules so healthy credentials are not locked out for context length or validation errors. --- open-sse/services/accountFallback.js | 14 +++++++++ tests/unit/account-fallback-4xx.test.js | 38 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/unit/account-fallback-4xx.test.js diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 8d280da4..766b9981 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -45,6 +45,20 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { } } + // Request-scoped client errors that matched no rule above: a 400 caused by the + // request itself (context overflow, malformed body, unsupported parameter) says + // nothing about the credential, so cooling the account down only removes a + // healthy connection from rotation. With a single connection it is worse: every + // later request in the window fails with a copy of this very error + // ("all 1 accounts locked for | lastError=[400]: ..."), which hides the + // real cause from the caller and makes unrelated sessions look like they hit the + // same limit. Hand the upstream error back for this request instead. + // Account-scoped statuses keep their rules above (401/402/403/404/429), and the + // text rules still win for rate-limit / quota / capacity wording. + if (status >= 400 && status < 500 && status !== 401 && status !== 402 && status !== 403 && status !== 429) { + return { shouldFallback: false, cooldownMs: 0 }; + } + // Default: transient cooldown for any unmatched error return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS }; } diff --git a/tests/unit/account-fallback-4xx.test.js b/tests/unit/account-fallback-4xx.test.js new file mode 100644 index 00000000..ba94c16c --- /dev/null +++ b/tests/unit/account-fallback-4xx.test.js @@ -0,0 +1,38 @@ +// Regression: an unmatched 4xx (a request-scoped failure) used to hit the +// transient-cooldown default, which locked the account for 30s and — with a +// single connection — answered every other request in that window with a copy of +// the first error. A 400 "maximum context length" from one session therefore +// looked like the same failure in unrelated sessions. +import { describe, expect, it } from "vitest"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.js"; + +describe("checkFallbackError — request-scoped vs account-scoped failures", () => { + it("does not cool the account down for a 400 caused by the request", () => { + const result = checkFallbackError(400, JSON.stringify({ + error: { + message: "This model's maximum context length is 1048576 tokens. However, you requested 1186139 tokens", + type: "invalid_request_error", + }, + })); + + expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + }); + + it("still falls back for account-scoped statuses", () => { + for (const status of [401, 402, 403, 404, 429]) { + expect(checkFallbackError(status, "nope").shouldFallback).toBe(true); + } + }); + + it("still honours rate-limit / quota wording on any 4xx", () => { + expect(checkFallbackError(400, "rate limit reached").shouldFallback).toBe(true); + expect(checkFallbackError(422, "quota exceeded").shouldFallback).toBe(true); + }); + + it("keeps the transient cooldown for unmatched server errors", () => { + const result = checkFallbackError(503, "upstream exploded"); + + expect(result.shouldFallback).toBe(true); + expect(result.cooldownMs).toBeGreaterThan(0); + }); +});