fix(failover): classify HTTP 422 as format and OpenRouter credits as billing (#43823)
Merged via squash. Prepared head SHA: 4f48e977fe06c5662753d3900fe94f1835cc2dce Co-authored-by: jnMetaCode <12096460+jnMetaCode@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
This commit is contained in:
@@ -196,6 +196,7 @@ Docs: https://docs.openclaw.ai
|
||||
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
|
||||
- Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii.
|
||||
- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke.
|
||||
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("failover-error", () => {
|
||||
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
|
||||
expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout");
|
||||
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
|
||||
expect(resolveFailoverReasonFromError({ status: 422 })).toBe("format");
|
||||
// Keep the status-only path behavior-preserving and conservative.
|
||||
expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull();
|
||||
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
|
||||
@@ -162,6 +163,44 @@ describe("failover-error", () => {
|
||||
).toBe("billing");
|
||||
});
|
||||
|
||||
it("treats HTTP 422 as format error", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 422,
|
||||
message: "check open ai req parameter error",
|
||||
}),
|
||||
).toBe("format");
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 422,
|
||||
message: "Unprocessable Entity",
|
||||
}),
|
||||
).toBe("format");
|
||||
});
|
||||
|
||||
it("treats 422 with billing message as billing instead of format", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 422,
|
||||
message: "insufficient credits",
|
||||
}),
|
||||
).toBe("billing");
|
||||
});
|
||||
|
||||
it("classifies OpenRouter 'requires more credits' text as billing", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
message: "This model requires more credits to use",
|
||||
}),
|
||||
).toBe("billing");
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 402,
|
||||
message: "This model require more credits",
|
||||
}),
|
||||
).toBe("billing");
|
||||
});
|
||||
|
||||
it("treats zhipuai weekly/monthly limit exhausted as rate_limit", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
|
||||
@@ -110,6 +110,9 @@ describe("isBillingErrorMessage", () => {
|
||||
// Venice returns "Insufficient USD or Diem balance" which has extra words
|
||||
// between "insufficient" and "balance"
|
||||
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
|
||||
// OpenRouter returns "requires more credits" for underfunded accounts
|
||||
"This model requires more credits to use",
|
||||
"This endpoint require more credits",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isBillingErrorMessage(sample)).toBe(true);
|
||||
@@ -503,6 +506,18 @@ describe("isTransientHttpError", () => {
|
||||
});
|
||||
|
||||
describe("classifyFailoverReasonFromHttpStatus", () => {
|
||||
it("treats HTTP 422 as format error", () => {
|
||||
expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format");
|
||||
expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe(
|
||||
"format",
|
||||
);
|
||||
expect(classifyFailoverReasonFromHttpStatus(422, "Unprocessable Entity")).toBe("format");
|
||||
});
|
||||
|
||||
it("treats 422 with billing message as billing instead of format", () => {
|
||||
expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing");
|
||||
});
|
||||
|
||||
it("treats HTTP 499 as transient for structured errors", () => {
|
||||
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
|
||||
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
|
||||
@@ -718,6 +733,8 @@ describe("classifyFailoverReason", () => {
|
||||
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
|
||||
),
|
||||
).toBe("billing");
|
||||
// OpenRouter "requires more credits" billing text
|
||||
expect(classifyFailoverReason("This model requires more credits to use")).toBe("billing");
|
||||
});
|
||||
|
||||
it("classifies internal and compatibility error messages", () => {
|
||||
|
||||
@@ -431,7 +431,7 @@ export function classifyFailoverReasonFromHttpStatus(
|
||||
if (status === 529) {
|
||||
return "overloaded";
|
||||
}
|
||||
if (status === 400) {
|
||||
if (status === 400 || status === 422) {
|
||||
// Some providers return quota/balance errors under HTTP 400, so do not
|
||||
// let the generic format fallback mask an explicit billing signal.
|
||||
if (message && isBillingErrorMessage(message)) {
|
||||
|
||||
@@ -60,6 +60,7 @@ const ERROR_PATTERNS = {
|
||||
"plans & billing",
|
||||
"insufficient balance",
|
||||
"insufficient usd or diem balance",
|
||||
/requires?\s+more\s+credits/i,
|
||||
],
|
||||
authPermanent: [
|
||||
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
|
||||
|
||||
Reference in New Issue
Block a user