From 65786625206b2813bbd6ac2b73d6395b1fe70e97 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Sun, 15 Mar 2026 22:24:21 +0000 Subject: [PATCH 1/2] fix(failover): treat bare leading 402 assistant error strings as 402 markers (#47576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZenMux quota-refresh 402s can surface to the embedded runner as a bare assistant error string like: 402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window ... The existing RAW_402_MARKER_RE only matched 'status: 402', 'HTTP 402', '402 Payment Required', etc. — bare leading '402 ' was not caught, so classifyFailoverReasonFrom402Text() returned null and the run ended without entering model fallback. Fix: add a branch to RAW_402_MARKER_RE that catches any bare leading '402 ' string, then delegate the sub-classification to the existing classify402Message() path (which already recognises the quota-refresh window signal and returns rate_limit). Tests: - classifyFailoverReason('402 You have reached ...') === 'rate_limit' - classifyFailoverReason('402 Quota exceeded') === 'rate_limit' - classifyFailoverReason('402 Please add more credits ...') === 'billing' - coerceToFailoverError(new Error('402 ...')) is non-null with reason rate_limit --- src/agents/failover-error.test.ts | 14 ++++++++++++++ ...embedded-helpers.isbillingerrormessage.test.ts | 15 +++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 38e3530f011..7da28ac2fa0 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -467,4 +467,18 @@ describe("failover-error", () => { expect(described.message).toBe("123"); expect(described.reason).toBeUndefined(); }); + + it("coerces bare leading 402 assistant error string to FailoverError (#47576)", () => { + // ZenMux quota-refresh 402 arrives as a bare assistant error string — must coerce to FailoverError + // so the embedded runner can enter model fallback. + const zenmuxQuota402 = + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access."; + const err = coerceToFailoverError(new Error(zenmuxQuota402), { + provider: "zenmux", + model: "gpt-4o", + }); + expect(err).not.toBeNull(); + expect(err?.reason).toBe("rate_limit"); + expect(err?.provider).toBe("zenmux"); + }); }); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 8c0a0b1994d..78e73ac01fc 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -860,4 +860,19 @@ describe("classifyFailoverReason", () => { ), ).toBe("timeout"); }); + + it("classifies bare leading 402 assistant error strings as rate_limit (#47576)", () => { + // ZenMux quota-refresh 402 surfaces as a bare assistant error string — must enter model fallback. + expect( + classifyFailoverReason( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ), + ).toBe("rate_limit"); + // Bare 402 with generic text should be treated as a 402 marker. + expect(classifyFailoverReason("402 Quota exceeded")).toBe("rate_limit"); + // Bare 402 with typical billing text should classify as billing. + expect( + classifyFailoverReason("402 Please add more credits to continue using this service."), + ).toBe("billing"); + }); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6e38d831ad9..69cea0cbeb6 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -270,7 +270,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ "exhausted", ] as const; const RAW_402_MARKER_RE = - /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i; + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b|^\s*402\s+\S/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; From 1addb1098e8db7b06a30ffa56080826aab9ca270 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Sun, 15 Mar 2026 22:36:56 +0000 Subject: [PATCH 2/2] refactor(failover): remove dead-code alternatives superseded by bare-402 branch The newly added `^\s*402\s+\S` branch is a strict superset of both `^\s*402\s+payment required\b` and `^\s*402\s+.*used up your points\b`. Remove the two now-unreachable alternatives to keep RAW_402_MARKER_RE concise. All 105 tests pass. Addresses review comment on #47665. --- src/agents/pi-embedded-helpers/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 69cea0cbeb6..e285e11df01 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -270,7 +270,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ "exhausted", ] as const; const RAW_402_MARKER_RE = - /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b|^\s*402\s+\S/i; + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+\S/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i;