mirror of https://github.com/openclaw/openclaw.git
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:
parent
b31cd35b36
commit
bef4fa55f5
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue