fix(model-fallback): add HTTP 410 to failover reason classification (#55201)

Merged via squash.

Prepared head SHA: 9c1780b739
Co-authored-by: nikus-pan <71585761+nikus-pan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
nikus-pan 2026-03-28 19:03:20 +08:00 committed by GitHub
parent b31cd35b36
commit bef4fa55f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 121 additions and 3 deletions

View File

@ -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

View File

@ -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({

View File

@ -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 <!DOCTYPE html>
<html lang="en-US">

View File

@ -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", () => {

View File

@ -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";
}

View File

@ -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*(?:<!doctype\s+html\b|<html\b)/i;
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);