mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 13:30:48 +00:00
* fix: treat HTTP 502/503/504 as failover-eligible (timeout reason) When a model API returns 502 Bad Gateway, 503 Service Unavailable, or 504 Gateway Timeout, the error object carries the status code directly. resolveFailoverReasonFromError() only checked 402/429/401/403/408/400, so 5xx server errors fell through to message-based classification which requires the status code to appear at the start of the error message. Many API SDKs (Google, Anthropic) set err.status = 503 without prefixing the message with '503', so the message classifier never matched and failover never triggered — the run retried the same broken model. Add 502/503/504 to the status-code branch, returning 'timeout' (matching the existing behavior of isTransientHttpError in the message classifier). Fixes #20999 * Changelog: add failover 502/503/504 note with credits * Failover: classify HTTP 504 as transient in message parser * Changelog: credit taw0002 and vincentkoc for failover fix --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
coerceToFailoverError,
|
|
describeFailoverError,
|
|
isTimeoutError,
|
|
resolveFailoverReasonFromError,
|
|
} from "./failover-error.js";
|
|
|
|
describe("failover-error", () => {
|
|
it("infers failover reason from HTTP status", () => {
|
|
expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing");
|
|
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
|
|
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
|
|
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
|
|
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
|
|
// Transient server errors (502/503/504) should trigger failover as timeout.
|
|
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
|
|
expect(resolveFailoverReasonFromError({ status: 503 })).toBe("timeout");
|
|
expect(resolveFailoverReasonFromError({ status: 504 })).toBe("timeout");
|
|
});
|
|
|
|
it("infers format errors from error messages", () => {
|
|
expect(
|
|
resolveFailoverReasonFromError({
|
|
message: "invalid request format: messages.1.content.1.tool_use.id",
|
|
}),
|
|
).toBe("format");
|
|
});
|
|
|
|
it("infers timeout from common node error codes", () => {
|
|
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe("timeout");
|
|
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe("timeout");
|
|
});
|
|
|
|
it("infers timeout from abort stop-reason messages", () => {
|
|
expect(resolveFailoverReasonFromError({ message: "Unhandled stop reason: abort" })).toBe(
|
|
"timeout",
|
|
);
|
|
expect(resolveFailoverReasonFromError({ message: "stop reason: abort" })).toBe("timeout");
|
|
expect(resolveFailoverReasonFromError({ message: "reason: abort" })).toBe("timeout");
|
|
});
|
|
|
|
it("treats AbortError reason=abort as timeout", () => {
|
|
const err = Object.assign(new Error("aborted"), {
|
|
name: "AbortError",
|
|
reason: "reason: abort",
|
|
});
|
|
expect(isTimeoutError(err)).toBe(true);
|
|
});
|
|
|
|
it("coerces failover-worthy errors into FailoverError with metadata", () => {
|
|
const err = coerceToFailoverError("credit balance too low", {
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-5",
|
|
});
|
|
expect(err?.name).toBe("FailoverError");
|
|
expect(err?.reason).toBe("billing");
|
|
expect(err?.status).toBe(402);
|
|
expect(err?.provider).toBe("anthropic");
|
|
expect(err?.model).toBe("claude-opus-4-5");
|
|
});
|
|
|
|
it("coerces format errors with a 400 status", () => {
|
|
const err = coerceToFailoverError("invalid request format", {
|
|
provider: "google",
|
|
model: "cloud-code-assist",
|
|
});
|
|
expect(err?.reason).toBe("format");
|
|
expect(err?.status).toBe(400);
|
|
});
|
|
|
|
it("describes non-Error values consistently", () => {
|
|
const described = describeFailoverError(123);
|
|
expect(described.message).toBe("123");
|
|
expect(described.reason).toBeUndefined();
|
|
});
|
|
});
|