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