mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user