diff --git a/CHANGELOG.md b/CHANGELOG.md index 31edbf32772..bd7bba84195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai - Brave/web search: normalize unsupported Brave `country` filters to `ALL` before request and cache-key generation so locale-derived values like `VN` stop failing with upstream 422 validation errors. (#55695) Thanks @chen-zhang-cs-code. - Discord/replies: preserve leading indentation when stripping inline reply tags so reply-tagged plain text and fenced code blocks keep their formatting. (#55960) Thanks @Nanako0129. - Daemon/status: surface immediate gateway close reasons from lightweight probes and prefer those concrete auth or pairing failures over generic timeouts in `openclaw daemon status`. (#56282) Thanks @mbelinky. +- Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan. ## 2026.3.24 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 9f312d5c928..5deb118d693 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -67,6 +67,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit"); expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth"); expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); + expect(resolveFailoverReasonFromError({ status: 410 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); expect(resolveFailoverReasonFromError({ status: 422 })).toBe("format"); @@ -82,6 +83,46 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ status: 529 })).toBe("overloaded"); }); + it("treats session-specific HTTP 410s differently from generic 410s", () => { + expect( + resolveFailoverReasonFromError({ + status: 410, + message: "session not found", + }), + ).toBe("session_expired"); + expect( + resolveFailoverReasonFromError({ + message: "HTTP 410: No body", + }), + ).toBe("timeout"); + expect( + resolveFailoverReasonFromError({ + message: "HTTP 410: conversation expired", + }), + ).toBe("session_expired"); + }); + + it("preserves explicit auth and billing signals on HTTP 410", () => { + expect( + resolveFailoverReasonFromError({ + status: 410, + message: "invalid_api_key", + }), + ).toBe("auth_permanent"); + expect( + resolveFailoverReasonFromError({ + status: 410, + message: "authentication failed", + }), + ).toBe("auth"); + expect( + resolveFailoverReasonFromError({ + status: 410, + message: "insufficient credits", + }), + ).toBe("billing"); + }); + it("classifies documented provider error shapes at the error boundary", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index c65774697b7..e4a54cfb9a4 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -228,6 +228,10 @@ describe("formatRawAssistantErrorForUi", () => { ); }); + it("formats colon-delimited HTTP status lines", () => { + expect(formatRawAssistantErrorForUi("HTTP 410: No body")).toBe("HTTP 410: No body"); + }); + it("sanitizes HTML error pages into a clean unavailable message", () => { const htmlError = `521 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index b5058ce40c4..f38d102d179 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -558,6 +558,47 @@ describe("classifyFailoverReasonFromHttpStatus", () => { ), ).toBe("overloaded"); }); + + it("treats generic HTTP 410 responses as retryable timeouts", () => { + expect(classifyFailoverReasonFromHttpStatus(410)).toBe("timeout"); + expect(classifyFailoverReasonFromHttpStatus(410, "")).toBe("timeout"); + expect(classifyFailoverReasonFromHttpStatus(410, "No body response")).toBe("timeout"); + }); + + it("treats session-specific HTTP 410 responses as session_expired", () => { + expect(classifyFailoverReasonFromHttpStatus(410, "session not found")).toBe("session_expired"); + expect(classifyFailoverReasonFromHttpStatus(410, "conversation expired")).toBe( + "session_expired", + ); + }); + + it("preserves explicit billing and auth signals on HTTP 410", () => { + expect(classifyFailoverReasonFromHttpStatus(410, "invalid_api_key")).toBe("auth_permanent"); + expect(classifyFailoverReasonFromHttpStatus(410, "authentication failed")).toBe("auth"); + expect(classifyFailoverReasonFromHttpStatus(410, "insufficient credits")).toBe("billing"); + }); +}); + +describe("classifyFailoverReason", () => { + it("treats generic 410 text as retryable timeout", () => { + expect(classifyFailoverReason("410")).toBe("timeout"); + expect(classifyFailoverReason("HTTP 410")).toBe("timeout"); + expect(classifyFailoverReason("410 Gone")).toBe("timeout"); + expect(classifyFailoverReason("410: No body")).toBe("timeout"); + expect(classifyFailoverReason("HTTP 410: No body")).toBe("timeout"); + expect(classifyFailoverReason("HTTP 410 Gone")).toBe("timeout"); + }); + + it("keeps session-specific 410 text mapped to session_expired", () => { + expect(classifyFailoverReason("HTTP 410: session not found")).toBe("session_expired"); + expect(classifyFailoverReason("410 conversation expired")).toBe("session_expired"); + }); + + it("keeps explicit billing and auth signals on 410 text", () => { + expect(classifyFailoverReason("HTTP 410: invalid_api_key")).toBe("auth_permanent"); + expect(classifyFailoverReason("HTTP 410: authentication failed")).toBe("auth"); + expect(classifyFailoverReason("HTTP 410: insufficient credits")).toBe("billing"); + }); }); describe("isFailoverErrorMessage", () => { diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 3300eccfddc..0bad1ed4c2a 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -484,6 +484,25 @@ export function classifyFailoverReasonFromHttpStatus( if (status === 408) { return "timeout"; } + if (status === 410) { + // HTTP 410 is only a true session-expiry signal when the payload says the + // remote session/conversation is gone. Generic 410/no-body responses from + // OpenAI-compatible proxies are better treated as retryable transport-path + // failures so we do not clear session state or poison auth-profile health. + if (message && isCliSessionExpiredErrorMessage(message)) { + return "session_expired"; + } + if (message && isBillingErrorMessage(message)) { + return "billing"; + } + if (message && isAuthPermanentErrorMessage(message)) { + return "auth_permanent"; + } + if (message && isAuthErrorMessage(message)) { + return "auth"; + } + return "timeout"; + } if (status === 503) { if (message && isOverloadedErrorMessage(message)) { return "overloaded"; @@ -973,6 +992,11 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isModelNotFoundErrorMessage(raw)) { return "model_not_found"; } + const trimmed = raw.trim(); + const leadingStatus = extractLeadingHttpStatus(trimmed); + if (leadingStatus?.code === 410) { + return classifyFailoverReasonFromHttpStatus(leadingStatus.code, leadingStatus.rest); + } const reasonFrom402Text = classifyFailoverReasonFrom402Text(raw); if (reasonFrom402Text) { return reasonFrom402Text; @@ -988,7 +1012,7 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { } if (isTransientHttpError(raw)) { // 529 is always overloaded, even without explicit overload keywords in the body. - const status = extractLeadingHttpStatus(raw.trim()); + const status = extractLeadingHttpStatus(trimmed); if (status?.code === 529) { return "overloaded"; } diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index 35bd82e326d..80598de0244 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -1,7 +1,14 @@ const ERROR_PAYLOAD_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error)(?:\s+\d{3})?[:\s-]+/i; -const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; -const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; +const HTTP_STATUS_DELIMITER_RE = /(?:\s*:\s*|\s+)/; +const HTTP_STATUS_PREFIX_RE = new RegExp( + `^(?:http\\s*)?(\\d{3})${HTTP_STATUS_DELIMITER_RE.source}(.+)$`, + "i", +); +const HTTP_STATUS_CODE_PREFIX_RE = new RegExp( + `^(?:http\\s*)?(\\d{3})(?:${HTTP_STATUS_DELIMITER_RE.source}([\\s\\S]+))?$`, + "i", +); const HTML_ERROR_PREFIX_RE = /^\s*(?: