import { describe, expect, it } from "vitest"; import {
coerceToFailoverError,
describeFailoverError,
FailoverError,
isTimeoutError,
resolveFailoverReasonFromError,
resolveFailoverStatus,
} from "./failover-error.js"; import { classifyFailoverSignal } from "./pi-embedded-helpers/errors.js"; import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
// OpenAI 429 example shape: https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors const OPENAI_RATE_LIMIT_MESSAGE = "Rate limit reached for gpt-4.1-mini in organization org_test on requests per min. Limit: 3.000000 / min. Current: 3.000000 / min."; // Anthropic overloaded_error example shape: https://docs.anthropic.com/en/api/errors const ANTHROPIC_OVERLOADED_PAYLOAD = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_test"}'; // Gemini RESOURCE_EXHAUSTED troubleshooting example: https://ai.google.dev/gemini-api/docs/troubleshooting const GEMINI_RESOURCE_EXHAUSTED_MESSAGE = "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota)."; // OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; const OPENROUTER_MODEL_NOT_FOUND_PAYLOAD = '{"error":{"message":"Healer Alpha was a stealth model revealed on March 18th as an early testing version of MiMo-V2-Omni. Find it here: https://openrouter.ai/xiaomi/mimo-v2-omni","code":404},"user_id":"user_33GTyP8uDSYYbaeBO48AGHXyuMC"}'; const TOGETHER_MONTHLY_SPEND_CAP_MESSAGE = "The account associated with this API key has reached its maximum allowed monthly spending limit."; // Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: // https://github.com/openclaw/openclaw/issues/23440 const INSUFFICIENT_QUOTA_PAYLOAD = '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // Issue-backed ZhipuAI/GLM quota-exhausted log from #33785: // https://github.com/openclaw/openclaw/issues/33785 const ZHIPUAI_WEEKLY_MONTHLY_LIMIT_EXHAUSTED_MESSAGE = "LLM error 1310: Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-03-06 22:19:54 (request_id: 20260303141547610b7f574d1b44cb)"; // AWS Bedrock 429 ThrottlingException / 503 ServiceUnavailable: // https://docs.aws.amazon.com/bedrock/latest/userguide/troubleshooting-api-error-codes.html const BEDROCK_THROTTLING_EXCEPTION_MESSAGE = "ThrottlingException: Your request was denied due to exceeding the account quotas for Amazon Bedrock."; const BEDROCK_SERVICE_UNAVAILABLE_MESSAGE = "ServiceUnavailable: The service is temporarily unable to handle the request."; // Groq error codes examples: https://console.groq.com/docs/errors const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance.";
describe("failover-error", () => {
it("infers failover reason from HTTP status", () => {
expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing"); // Anthropic Claude Max plan surfaces rate limits as HTTP 402 (#30484)
expect(
resolveFailoverReasonFromError({
status: 402,
message: "HTTP 402: request reached organization usage limit, try again later",
}),
).toBe("rate_limit"); // Explicit billing messages on 402 stay classified as billing
expect(
resolveFailoverReasonFromError({
status: 402,
message: "insufficient credits — please top up your account",
}),
).toBe("billing"); // Ambiguous "quota exceeded" + billing signal → billing wins
expect(
resolveFailoverReasonFromError({
status: 402,
message: "HTTP 402: You have exceeded your current quota. Please add more credits.",
}),
).toBe("billing");
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"); // 400/422 with no body returns null — avoids triggering a compaction loop // when the provider returns an empty or wrapper-only 400/422 (e.g. // transient proxy issue).
expect(resolveFailoverReasonFromError({ status: 400 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 422 })).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 400,
message: "400 status code (no body)",
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "HTTP 422: No body",
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "HTTP 422: No response body",
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "Error: HTTP 422: No response body",
}),
).toBeNull();
expect(resolveFailoverReasonFromError({ message: "400 status code (no body)" })).toBeNull();
expect(resolveFailoverReasonFromError({ message: "HTTP 422: No body" })).toBeNull();
expect(resolveFailoverReasonFromError({ message: "HTTP 422: No response body" })).toBeNull();
expect(
resolveFailoverReasonFromError({
message: "outer wrapper",
cause: {
status: 422,
message: "HTTP 422: No response body",
},
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "check open ai req parameter error",
cause: {
status: 422,
message: "HTTP 422: No response body",
},
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "check open ai req parameter error",
cause: new Error("No response body"),
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "Unprocessable Entity",
error: {
message: "HTTP 422: No response body",
},
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
message: "Unprocessable Entity",
cause: {
message: "Unprocessable Entity",
error: {
message: "HTTP 422: No response body",
},
},
}),
).toBeNull();
expect(
resolveFailoverReasonFromError({
status: 422,
error: {
message: "missing required property",
},
cause: {},
}),
).toBe("format");
expect(
resolveFailoverReasonFromError({
status: 422,
error: {
message: "missing required property",
},
cause: {
message: "HTTP 422: No response body",
},
}),
).toBe("format"); // Transient server errors (500/502/503/504) should trigger failover as timeout.
expect(resolveFailoverReasonFromError({ status: 500 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 503 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 504 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 521 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 522 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 523 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 524 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 529 })).toBe("overloaded");
});
it("stops on cyclic cause chains", () => { const first: { cause?: unknown } = {}; const second: { cause?: unknown } = { cause: first };
first.cause = second;
it("classifies OpenRouter no-endpoints 404s as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
status: 404,
message: "No endpoints found for deepseek/deepseek-r1:free.",
}),
).toBe("model_not_found");
expect(
resolveFailoverReasonFromError({
message: "404 No endpoints found for deepseek/deepseek-r1:free.",
}),
).toBe("model_not_found");
});
it("classifies generic model-does-not-exist messages as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The model gpt-foo does not exist.",
}),
).toBe("model_not_found");
});
it("does not classify generic access errors as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The deployment does not exist or you do not have access.",
}),
).toBeNull();
});
it("does not classify generic deprecation transition messages as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The endpoint has been deprecated. Transition to v2 API for continued access.",
}),
).toBeNull();
});
it("classifies model-scoped deprecation transition messages as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "404 The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
}),
).toBe("model_not_found");
});
it("keeps status-only 503s conservative unless the payload is clearly overloaded", () => {
expect(
resolveFailoverReasonFromError({
status: 503,
message: "Internal database error",
}),
).toBe("timeout");
expect(
resolveFailoverReasonFromError({
status: 503,
message: '{"error":{"message":"The model is overloaded. Please try later"}}',
}),
).toBe("overloaded");
});
it("does not classify session lock wait errors as model timeout failover", () => { const sessionLockError = new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
});
expect(resolveFailoverReasonFromError(sessionLockError)).toBeNull();
expect(isTimeoutError(sessionLockError)).toBe(false);
it("does not misclassify structured HTTP 400 context overflow payloads as format", () => {
expect(
resolveFailoverReasonFromError({
status: 400,
message: "INVALID_ARGUMENT: input exceeds the maximum number of tokens",
}),
).toBeNull();
});
it("keeps context overflow first-class in the shared signal classifier", () => {
expect(
classifyFailoverSignal({
status: 400,
message: "INVALID_ARGUMENT: input exceeds the maximum number of tokens",
}),
).toEqual({ kind: "context_overflow" });
expect(
classifyFailoverSignal({
message: "prompt is too long: 150000 tokens > 128000 maximum",
}),
).toEqual({ kind: "context_overflow" });
});
it("treats invalid-model HTTP 400 payloads as model_not_found instead of format", () => {
expect(
resolveFailoverReasonFromError({
message: "openrouter/__invalid_test_model__ is not a valid model ID",
}),
).toBe("model_not_found");
expect(
resolveFailoverReasonFromError({
status: 400,
message: "HTTP 400: openrouter/__invalid_test_model__ is not a valid model ID",
}),
).toBe("model_not_found");
expect(
resolveFailoverReasonFromError({
status: 422,
message: "invalid model: openrouter/__invalid_test_model__",
}),
).toBe("model_not_found");
});
it("treats HTTP 422 as format error", () => {
expect(
resolveFailoverReasonFromError({
status: 422,
message: "check open ai req parameter error",
}),
).toBe("format");
expect(
resolveFailoverReasonFromError({
status: 422,
message: "Unprocessable Entity",
}),
).toBe("format");
});
it("treats 422 with billing message as billing instead of format", () => {
expect(
resolveFailoverReasonFromError({
status: 422,
message: "insufficient credits",
}),
).toBe("billing");
});
it("classifies OpenRouter 'requires more credits' text as billing", () => {
expect(
resolveFailoverReasonFromError({
message: "This model requires more credits to use",
}),
).toBe("billing");
expect(
resolveFailoverReasonFromError({
status: 402,
message: "This model require more credits",
}),
).toBe("billing");
});
it("coerces failover-worthy errors into FailoverError with metadata", () => { const err = coerceToFailoverError("credit balance too low", {
provider: "anthropic",
model: "claude-opus-4-6",
});
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-6");
});
it("preserves raw provider error text for diagnostic logs", () => { const err = new FailoverError("LLM request failed: provider rejected the request schema.", {
reason: "format",
provider: "openai",
model: "gpt-5.4",
status: 400,
rawError: "400 The following tools cannot be used with reasoning.effort 'minimal': web_search.",
});
expect(describeFailoverError(err)).toMatchObject({
message: "LLM request failed: provider rejected the request schema.",
rawError: "400 The following tools cannot be used with reasoning.effort 'minimal': web_search.",
reason: "format",
status: 400,
});
});
it("permission_error with organization denial stays auth_permanent", () => { const err = coerceToFailoverError( "HTTP 403 permission_error: OAuth authentication is currently not allowed for this organization.",
{ provider: "anthropic", model: "claude-opus-4-6" },
);
expect(err?.reason).toBe("auth_permanent");
});
it("'not allowed for this organization' classifies as auth_permanent", () => { const err = coerceToFailoverError( "OAuth authentication is currently not allowed for this organization",
{ provider: "anthropic", model: "claude-opus-4-6" },
);
expect(err?.reason).toBe("auth_permanent");
});
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.