From 92263fadc0d16b25d19af9cf48b6df0c92e6296c Mon Sep 17 00:00:00 2001 From: Xinhua Gu Date: Thu, 5 Mar 2026 23:28:21 +0100 Subject: [PATCH] fix(failover): classify HTTP 402 as rate_limit when payload indicates usage limit (#30484) Some providers (notably Anthropic Claude Max plan) surface temporary usage/rate-limit failures as HTTP 402 instead of 429. Before this change, all 402s were unconditionally mapped to 'billing', which produced a misleading 'run out of credits' warning for Max plan users who simply hit their usage window. This follows the same pattern introduced for HTTP 400 in #36783: check the error message for an explicit rate-limit signal before falling back to the default status-code classification. - classifyFailoverReasonFromHttpStatus now returns 'rate_limit' for 402 when isRateLimitErrorMessage matches the payload text - Added regression tests covering both the rate-limit and billing paths on 402 --- src/agents/failover-error.test.ts | 14 ++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 4e4379bf5da..d550683923e 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -37,6 +37,20 @@ const GROQ_SERVICE_UNAVAILABLE_MESSAGE = describe("failover-error", () => { it("infers failover reason from HTTP status", () => { expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing"); + // Anthropic Claude Max plan surfaces rate limits as HTTP 402 (#30484) + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "HTTP 402: request reached organization usage limit, try again later", + }), + ).toBe("rate_limit"); + // Explicit billing messages on 402 stay classified as billing + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "insufficient credits — please top up your account", + }), + ).toBe("billing"); expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit"); expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth"); expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index e4944b0731c..6ac244ba3ea 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -260,6 +260,12 @@ export function classifyFailoverReasonFromHttpStatus( } if (status === 402) { + // Some providers (e.g. Anthropic Claude Max plan) surface temporary + // usage/rate-limit failures as HTTP 402. Prefer the explicit rate-limit + // signal from the payload text when available (#30484). + if (message && isRateLimitErrorMessage(message)) { + return "rate_limit"; + } return "billing"; } if (status === 429) {