Skip to content

Commit 92648f9

Browse files
xialongleealtaywtf
andauthored
fix(agents): broaden 402 temporary-limit detection and allow billing cooldown probe (#38533)
Merged via squash. Prepared head SHA: 282b918 Co-authored-by: xialonglee <[email protected]> Co-authored-by: altaywtf <[email protected]> Reviewed-by: @altaywtf
1 parent d15b6af commit 92648f9

File tree

7 files changed

+343
-26
lines changed

7 files changed

+343
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ Docs: https://docs.openclaw.ai
361361
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
362362
- ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
363363
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
364+
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
364365

365366
## 2026.3.2
366367

src/agents/failover-error.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const GEMINI_RESOURCE_EXHAUSTED_MESSAGE =
1818
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota).";
1919
// OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors
2020
const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits";
21+
const TOGETHER_MONTHLY_SPEND_CAP_MESSAGE =
22+
"The account associated with this API key has reached its maximum allowed monthly spending limit.";
2123
// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400:
2224
// https://github.com/openclaw/openclaw/issues/23440
2325
const INSUFFICIENT_QUOTA_PAYLOAD =
@@ -182,6 +184,78 @@ describe("failover-error", () => {
182184
).toBe("billing");
183185
});
184186

187+
it("keeps temporary 402 spend limits retryable without downgrading explicit billing", () => {
188+
expect(
189+
resolveFailoverReasonFromError({
190+
status: 402,
191+
message: "Monthly spend limit reached. Please visit your billing settings.",
192+
}),
193+
).toBe("rate_limit");
194+
expect(
195+
resolveFailoverReasonFromError({
196+
status: 402,
197+
message: "Workspace spend limit reached. Contact your admin.",
198+
}),
199+
).toBe("rate_limit");
200+
expect(
201+
resolveFailoverReasonFromError({
202+
status: 402,
203+
message: `${"x".repeat(520)} insufficient credits. Monthly spend limit reached.`,
204+
}),
205+
).toBe("billing");
206+
expect(
207+
resolveFailoverReasonFromError({
208+
status: 402,
209+
message: TOGETHER_MONTHLY_SPEND_CAP_MESSAGE,
210+
}),
211+
).toBe("billing");
212+
});
213+
214+
it("keeps raw 402 wrappers aligned with status-split temporary spend limits", () => {
215+
const message = "Monthly spend limit reached. Please visit your billing settings.";
216+
expect(
217+
resolveFailoverReasonFromError({
218+
message: `402 Payment Required: ${message}`,
219+
}),
220+
).toBe("rate_limit");
221+
expect(
222+
resolveFailoverReasonFromError({
223+
status: 402,
224+
message,
225+
}),
226+
).toBe("rate_limit");
227+
});
228+
229+
it("keeps explicit 402 rate-limit wrappers aligned with status-split payloads", () => {
230+
const message = "rate limit exceeded";
231+
expect(
232+
resolveFailoverReasonFromError({
233+
message: `HTTP 402 Payment Required: ${message}`,
234+
}),
235+
).toBe("rate_limit");
236+
expect(
237+
resolveFailoverReasonFromError({
238+
status: 402,
239+
message,
240+
}),
241+
).toBe("rate_limit");
242+
});
243+
244+
it("keeps plan-upgrade 402 wrappers aligned with status-split billing payloads", () => {
245+
const message = "Your usage limit has been reached. Please upgrade your plan.";
246+
expect(
247+
resolveFailoverReasonFromError({
248+
message: `HTTP 402 Payment Required: ${message}`,
249+
}),
250+
).toBe("billing");
251+
expect(
252+
resolveFailoverReasonFromError({
253+
status: 402,
254+
message,
255+
}),
256+
).toBe("billing");
257+
});
258+
185259
it("infers format errors from error messages", () => {
186260
expect(
187261
resolveFailoverReasonFromError({

src/agents/model-fallback.probe.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,66 @@ describe("runWithModelFallback – probe logic", () => {
345345
allowTransientCooldownProbe: true,
346346
});
347347
});
348+
349+
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
350+
const cfg = makeCfg({
351+
agents: {
352+
defaults: {
353+
model: {
354+
primary: "openai/gpt-4.1-mini",
355+
fallbacks: [],
356+
},
357+
},
358+
},
359+
} as Partial<OpenClawConfig>);
360+
361+
// Billing cooldown far from expiry — would normally be skipped
362+
const expiresIn30Min = NOW + 30 * 60 * 1000;
363+
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
364+
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
365+
366+
await expect(
367+
runWithModelFallback({
368+
cfg,
369+
provider: "openai",
370+
model: "gpt-4.1-mini",
371+
fallbacksOverride: [],
372+
run: vi.fn().mockResolvedValue("billing-recovered"),
373+
}),
374+
).rejects.toThrow("All models failed");
375+
});
376+
377+
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {
378+
const cfg = makeCfg();
379+
// Cooldown expires in 1 minute — within 2-min probe margin
380+
const expiresIn1Min = NOW + 60 * 1000;
381+
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min);
382+
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
383+
384+
const run = vi.fn().mockResolvedValue("billing-probe-ok");
385+
386+
const result = await runPrimaryCandidate(cfg, run);
387+
388+
expect(result.result).toBe("billing-probe-ok");
389+
expect(run).toHaveBeenCalledTimes(1);
390+
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
391+
allowTransientCooldownProbe: true,
392+
});
393+
});
394+
395+
it("skips billing-cooldowned primary with fallbacks when far from cooldown expiry", async () => {
396+
const cfg = makeCfg();
397+
const expiresIn30Min = NOW + 30 * 60 * 1000;
398+
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
399+
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
400+
401+
const run = vi.fn().mockResolvedValue("ok");
402+
403+
const result = await runPrimaryCandidate(cfg, run);
404+
405+
expect(result.result).toBe("ok");
406+
expect(run).toHaveBeenCalledTimes(1);
407+
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
408+
expect(result.attempts[0]?.reason).toBe("billing");
409+
});
348410
});

src/agents/model-fallback.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,23 @@ function resolveCooldownDecision(params: {
419419
profileIds: params.profileIds,
420420
now: params.now,
421421
}) ?? "rate_limit";
422-
const isPersistentIssue =
423-
inferredReason === "auth" ||
424-
inferredReason === "auth_permanent" ||
425-
inferredReason === "billing";
426-
if (isPersistentIssue) {
422+
const isPersistentAuthIssue = inferredReason === "auth" || inferredReason === "auth_permanent";
423+
if (isPersistentAuthIssue) {
424+
return {
425+
type: "skip",
426+
reason: inferredReason,
427+
error: `Provider ${params.candidate.provider} has ${inferredReason} issue (skipping all models)`,
428+
};
429+
}
430+
431+
// Billing is semi-persistent: the user may fix their balance, or a transient
432+
// 402 might have been misclassified. Probe the primary only when fallbacks
433+
// exist; otherwise repeated single-provider probes just churn the disabled
434+
// auth state without opening any recovery path.
435+
if (inferredReason === "billing") {
436+
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
437+
return { type: "attempt", reason: inferredReason, markProbe: true };
438+
}
427439
return {
428440
type: "skip",
429441
reason: inferredReason,
@@ -518,7 +530,11 @@ export async function runWithModelFallback<T>(params: {
518530
if (decision.markProbe) {
519531
lastProbeAttempt.set(probeThrottleKey, now);
520532
}
521-
if (decision.reason === "rate_limit" || decision.reason === "overloaded") {
533+
if (
534+
decision.reason === "rate_limit" ||
535+
decision.reason === "overloaded" ||
536+
decision.reason === "billing"
537+
) {
522538
runOptions = { allowTransientCooldownProbe: true };
523539
}
524540
}

src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
classifyFailoverReason,
4+
classifyFailoverReasonFromHttpStatus,
45
isAuthErrorMessage,
56
isAuthPermanentErrorMessage,
67
isBillingErrorMessage,
@@ -505,6 +506,87 @@ describe("image dimension errors", () => {
505506
});
506507
});
507508

509+
describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => {
510+
it("reclassifies periodic usage limits as rate_limit", () => {
511+
const samples = [
512+
"Monthly spend limit reached.",
513+
"Weekly usage limit exhausted.",
514+
"Daily limit reached, resets tomorrow.",
515+
];
516+
for (const sample of samples) {
517+
expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit");
518+
}
519+
});
520+
521+
it("reclassifies org/workspace spend limits as rate_limit", () => {
522+
const samples = [
523+
"Organization spending limit exceeded.",
524+
"Workspace spend limit reached.",
525+
"Organization limit exceeded for this billing period.",
526+
];
527+
for (const sample of samples) {
528+
expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit");
529+
}
530+
});
531+
532+
it("keeps 402 as billing when explicit billing signals are present", () => {
533+
expect(
534+
classifyFailoverReasonFromHttpStatus(
535+
402,
536+
"Your credit balance is too low. Monthly limit exceeded.",
537+
),
538+
).toBe("billing");
539+
expect(
540+
classifyFailoverReasonFromHttpStatus(
541+
402,
542+
"Insufficient credits. Organization limit reached.",
543+
),
544+
).toBe("billing");
545+
expect(
546+
classifyFailoverReasonFromHttpStatus(
547+
402,
548+
"The account associated with this API key has reached its maximum allowed monthly spending limit.",
549+
),
550+
).toBe("billing");
551+
});
552+
553+
it("keeps long 402 payloads with explicit billing text as billing", () => {
554+
const longBillingPayload = `${"x".repeat(520)} insufficient credits. Monthly spend limit reached.`;
555+
expect(classifyFailoverReasonFromHttpStatus(402, longBillingPayload)).toBe("billing");
556+
});
557+
558+
it("keeps 402 as billing without message or with generic message", () => {
559+
expect(classifyFailoverReasonFromHttpStatus(402, undefined)).toBe("billing");
560+
expect(classifyFailoverReasonFromHttpStatus(402, "")).toBe("billing");
561+
expect(classifyFailoverReasonFromHttpStatus(402, "Payment required")).toBe("billing");
562+
});
563+
564+
it("matches raw 402 wrappers and status-split payloads for the same message", () => {
565+
const transientMessage = "Monthly spend limit reached. Please visit your billing settings.";
566+
expect(classifyFailoverReason(`402 Payment Required: ${transientMessage}`)).toBe("rate_limit");
567+
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
568+
569+
const billingMessage =
570+
"The account associated with this API key has reached its maximum allowed monthly spending limit.";
571+
expect(classifyFailoverReason(`402 Payment Required: ${billingMessage}`)).toBe("billing");
572+
expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing");
573+
});
574+
575+
it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => {
576+
const transientMessage = "rate limit exceeded";
577+
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe(
578+
"rate_limit",
579+
);
580+
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
581+
});
582+
583+
it("keeps plan-upgrade 402 limit messages in billing", () => {
584+
const billingMessage = "Your usage limit has been reached. Please upgrade your plan.";
585+
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing");
586+
expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing");
587+
});
588+
});
589+
508590
describe("classifyFailoverReason", () => {
509591
it("classifies documented provider error messages", () => {
510592
expect(classifyFailoverReason(OPENAI_RATE_LIMIT_MESSAGE)).toBe("rate_limit");

0 commit comments

Comments
 (0)