fix(failover): widen raw 402 detection for third-party proxy messages (#45827)

Merged via squash.

Prepared head SHA: 5f4b5d7283
Co-authored-by: junyuc25 <10862251+junyuc25@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
junyuc25
2026-04-18 02:38:04 -07:00
committed by GitHub
parent eaaab098fb
commit ef3f9796c8
4 changed files with 35 additions and 16 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent.
- Matrix: honor `channels.matrix.network.dangerouslyAllowPrivateNetwork` when creating clients for private-network homeservers. (#68332) Thanks @kagura-agent.
- Cron/message tool: keep cron-owned runs with `delivery.mode: "none"` on the normal message-tool path so they can still send explicit messages, create threads, and route conditionally when no runner-owned delivery target is active. (#68482) Thanks @obviyus.
- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25.
## 2026.4.15

View File

@@ -923,6 +923,9 @@ describe("classifyFailoverReasonFromHttpStatus 402 temporary limits", () =>
expect(classifyFailoverReasonFromHttpStatus(402, undefined)).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, "")).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, "Payment required")).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, "402 custom proxy billing failure")).toBe(
"billing",
);
});
it("matches raw 402 wrappers and status-split payloads for the same message", () => {
@@ -938,6 +941,9 @@ describe("classifyFailoverReasonFromHttpStatus 402 temporary limits", () =>
it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => {
const transientMessage = "rate limit exceeded";
expect(classifyFailoverReasonFromHttpStatus(402, `402: ${transientMessage}`)).toBe(
"rate_limit",
);
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe(
"rate_limit",
);
@@ -1014,6 +1020,14 @@ describe("classifyFailoverReason", () => {
"402 You've used up your points! Visit https://poe.com/api/keys to get more.",
),
).toBe("billing");
// Third-party proxy 402 with non-standard wording (#45774)
expect(
classifyFailoverReason(
"402 No available asset for API access, please purchase a subscription",
),
).toBe("billing");
expect(classifyFailoverReason("402 items found in the database")).toBeNull();
expect(classifyFailoverReason("402 room is available")).toBeNull();
expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout");

View File

@@ -298,7 +298,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [
"exhausted",
] 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;
/["']?(?: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|.*used up your points\b|no available asset for api access\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;
@@ -464,7 +464,6 @@ function isOAuthCallbackTimeoutMessage(raw: string): boolean {
function isOAuthCallbackValidationMessage(raw: string): boolean {
return /\bcallback_validation_failed\b/i.test(raw);
}
function includesAnyHint(text: string, hints: readonly string[]): boolean {
return hints.some((hint) => text.includes(hint));
}
@@ -584,7 +583,7 @@ export function classifyFailoverReasonFromHttpStatus(
? classifyFailoverClassificationFromMessage(message, opts?.provider)
: null;
return failoverReasonFromClassification(
classifyFailoverClassificationFromHttpStatus(status, message, messageClassification),
classifyFailoverClassificationFromHttpStatus(status, message, messageClassification, status),
);
}
@@ -592,6 +591,7 @@ function classifyFailoverClassificationFromHttpStatus(
status: number | undefined,
message: string | undefined,
messageClassification: FailoverClassification | null,
explicitStatus: number | undefined,
): FailoverClassification | null {
const messageReason = failoverReasonFromClassification(messageClassification);
if (typeof status !== "number" || !Number.isFinite(status)) {
@@ -599,7 +599,20 @@ function classifyFailoverClassificationFromHttpStatus(
}
if (status === 402) {
return toReasonClassification(message ? classify402Message(message) : "billing");
if (!message) {
return toReasonClassification("billing");
}
const leadingStatus = extractLeadingHttpStatus(message.trim());
if (leadingStatus?.code === 402) {
const reasonFrom402Text = classifyFailoverReasonFrom402Text(message);
if (reasonFrom402Text) {
return toReasonClassification(reasonFrom402Text);
}
return typeof explicitStatus === "number"
? toReasonClassification(classify402Message(message))
: messageClassification;
}
return toReasonClassification(classify402Message(message));
}
if (status === 429) {
return toReasonClassification("rate_limit");
@@ -828,6 +841,7 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
inferredStatus,
signal.message,
messageClassification,
signal.status,
);
if (statusClassification) {
return statusClassification;
@@ -1239,20 +1253,8 @@ export function classifyFailoverReason(
raw: string,
opts?: { provider?: string },
): 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,
message: raw,
provider: opts?.provider,
}),

View File

@@ -217,6 +217,8 @@ export function isBillingErrorMessage(raw: string): boolean {
value.includes("upgrade") ||
value.includes("credits") ||
value.includes("payment") ||
value.includes("purchase") ||
value.includes("subscription") ||
value.includes("plan")
);
}