fix(failover): detect bare leading 402 assistant errors (#47579)

Merged via squash.

Prepared head SHA: ff336a0d97
Co-authored-by: bwjoke <1284814+bwjoke@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
bwjoke
2026-04-18 03:06:55 +08:00
committed by GitHub
parent 169b68d709
commit f7422e1fbc
5 changed files with 71 additions and 1 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
## 2026.4.15

View File

@@ -441,6 +441,28 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => {
});
});
it("falls back across providers after a bare leading 402 quota-refresh assistant error", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
mockPrimaryErrorThenFallbackSuccess(
"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 result = await runEmbeddedFallback({
agentDir,
workspaceDir,
sessionKey: "agent:test:bare-402-cross-provider",
runId: "run:bare-402-cross-provider",
});
expect(result.provider).toBe("groq");
expect(result.model).toBe("mock-2");
expect(result.attempts[0]?.reason).toBe("rate_limit");
expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok");
expectOpenAiThenGroqAttemptOrder();
});
});
it("surfaces a bounded overloaded summary when every fallback candidate is overloaded", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);

View File

@@ -485,6 +485,16 @@ describe("runWithModelFallback", () => {
});
});
it("falls back on bare leading 402 quota-refresh errors", async () => {
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
firstError: new Error(
"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.",
),
});
});
it("records 400 insufficient_quota payloads as billing during fallback", async () => {
const cfg = makeCfg();
const run = vi

View File

@@ -911,6 +911,17 @@ describe("classifyFailoverReasonFromHttpStatus 402 temporary limits", () =>
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
});
it("classifies bare leading 402 quota-refresh payloads as rate_limit", () => {
const zenMuxMessage =
"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.";
expect(classifyFailoverReason(zenMuxMessage)).toBe("rate_limit");
});
it("does not classify numeric references that merely start with 402", () => {
expect(classifyFailoverReason("402 items found in the database")).toBeNull();
expect(classifyFailoverReason("402 records processed")).toBeNull();
});
it("keeps plan-upgrade 402 limit messages in billing", () => {
const billingMessage = "Your usage limit has been reached. Please upgrade your plan.";
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing");

View File

@@ -295,6 +295,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [
] 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;
const BARE_LEADING_402_RE = /^\s*402\b/i;
const LEADING_402_WRAPPER_RE =
/^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i;
const TIMEOUT_ERROR_CODES = new Set([
@@ -476,6 +477,15 @@ function hasRetryable402TransientSignal(text: string): boolean {
);
}
function hasKnownBareLeading402Signal(text: string): boolean {
return (
hasQuotaRefreshWindowSignal(text) ||
hasExplicit402BillingSignal(text) ||
isRateLimitErrorMessage(text) ||
hasRetryable402TransientSignal(text)
);
}
function normalize402Message(raw: string): string {
return normalizeOptionalLowercaseString(raw)?.replace(LEADING_402_WRAPPER_RE, "").trim() ?? "";
}
@@ -506,7 +516,14 @@ function classify402Message(message: string): PaymentRequiredFailoverReason {
}
function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null {
if (!RAW_402_MARKER_RE.test(raw)) {
if (RAW_402_MARKER_RE.test(raw)) {
return classify402Message(raw);
}
if (!BARE_LEADING_402_RE.test(raw)) {
return null;
}
const normalized = normalize402Message(raw);
if (!normalized || !hasKnownBareLeading402Signal(normalized)) {
return null;
}
return classify402Message(raw);
@@ -1157,6 +1174,15 @@ export function classifyFailoverReason(
): FailoverReason | null {
const trimmed = raw.trim();
const leadingStatus = extractLeadingHttpStatus(trimmed);
const reasonFrom402Text =
leadingStatus?.code === 402 ? classifyFailoverReasonFrom402Text(trimmed) : null;
if (
leadingStatus?.code === 402 &&
!reasonFrom402Text &&
!isHtmlErrorResponse(trimmed, leadingStatus.code)
) {
return null;
}
return failoverReasonFromClassification(
classifyFailoverSignal({
status: leadingStatus?.code,