mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 15:20:23 +00:00
refactor(agents): share failover error matchers
This commit is contained in:
@@ -2,11 +2,10 @@ import { readErrorName } from "../infra/errors.js";
|
|||||||
import {
|
import {
|
||||||
classifyFailoverReason,
|
classifyFailoverReason,
|
||||||
isAuthPermanentErrorMessage,
|
isAuthPermanentErrorMessage,
|
||||||
|
isTimeoutErrorMessage,
|
||||||
type FailoverReason,
|
type FailoverReason,
|
||||||
} from "./pi-embedded-helpers.js";
|
} from "./pi-embedded-helpers.js";
|
||||||
|
|
||||||
const TIMEOUT_HINT_RE =
|
|
||||||
/timeout|timed out|deadline exceeded|context deadline exceeded|connection error|network error|network request failed|fetch failed|socket hang up|econnrefused|econnreset|econnaborted|enotfound|eai_again|stop reason:\s*(?:abort|error)|reason:\s*(?:abort|error)|unhandled stop reason:\s*(?:abort|error)/i;
|
|
||||||
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
|
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
|
||||||
|
|
||||||
export class FailoverError extends Error {
|
export class FailoverError extends Error {
|
||||||
@@ -125,7 +124,7 @@ function hasTimeoutHint(err: unknown): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
return Boolean(message && TIMEOUT_HINT_RE.test(message));
|
return Boolean(message && isTimeoutErrorMessage(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTimeoutError(err: unknown): boolean {
|
export function isTimeoutError(err: unknown): boolean {
|
||||||
|
|||||||
@@ -3,8 +3,26 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js";
|
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js";
|
||||||
import { stableStringify } from "../stable-stringify.js";
|
import { stableStringify } from "../stable-stringify.js";
|
||||||
|
import {
|
||||||
|
isAuthErrorMessage,
|
||||||
|
isAuthPermanentErrorMessage,
|
||||||
|
isBillingErrorMessage,
|
||||||
|
isOverloadedErrorMessage,
|
||||||
|
isRateLimitErrorMessage,
|
||||||
|
isTimeoutErrorMessage,
|
||||||
|
matchesFormatErrorPattern,
|
||||||
|
} from "./failover-matches.js";
|
||||||
import type { FailoverReason } from "./types.js";
|
import type { FailoverReason } from "./types.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isAuthErrorMessage,
|
||||||
|
isAuthPermanentErrorMessage,
|
||||||
|
isBillingErrorMessage,
|
||||||
|
isOverloadedErrorMessage,
|
||||||
|
isRateLimitErrorMessage,
|
||||||
|
isTimeoutErrorMessage,
|
||||||
|
} from "./failover-matches.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("errors");
|
const log = createSubsystemLogger("errors");
|
||||||
|
|
||||||
export function formatBillingErrorMessage(provider?: string, model?: string): string {
|
export function formatBillingErrorMessage(provider?: string, model?: string): string {
|
||||||
@@ -163,10 +181,6 @@ const ERROR_PREFIX_RE =
|
|||||||
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
|
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
|
||||||
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
|
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
|
||||||
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
|
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
|
||||||
const BILLING_ERROR_HEAD_RE =
|
|
||||||
/^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i;
|
|
||||||
const BILLING_ERROR_HARD_402_RE =
|
|
||||||
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|^\s*402\s+payment/i;
|
|
||||||
const HTTP_STATUS_PREFIX_RE = /^(?:http\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_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
|
||||||
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
|
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
|
||||||
@@ -614,93 +628,6 @@ export function isRateLimitAssistantError(msg: AssistantMessage | undefined): bo
|
|||||||
return isRateLimitErrorMessage(msg.errorMessage ?? "");
|
return isRateLimitErrorMessage(msg.errorMessage ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorPattern = RegExp | string;
|
|
||||||
|
|
||||||
const ERROR_PATTERNS = {
|
|
||||||
rateLimit: [
|
|
||||||
/rate[_ ]limit|too many requests|429/,
|
|
||||||
"model_cooldown",
|
|
||||||
"cooling down",
|
|
||||||
"exceeded your current quota",
|
|
||||||
"resource has been exhausted",
|
|
||||||
"quota exceeded",
|
|
||||||
"resource_exhausted",
|
|
||||||
"usage limit",
|
|
||||||
/\btpm\b/i,
|
|
||||||
"tokens per minute",
|
|
||||||
],
|
|
||||||
overloaded: [
|
|
||||||
/overloaded_error|"type"\s*:\s*"overloaded_error"/i,
|
|
||||||
"overloaded",
|
|
||||||
"service unavailable",
|
|
||||||
"high demand",
|
|
||||||
],
|
|
||||||
timeout: [
|
|
||||||
"timeout",
|
|
||||||
"timed out",
|
|
||||||
"deadline exceeded",
|
|
||||||
"context deadline exceeded",
|
|
||||||
"connection error",
|
|
||||||
"network error",
|
|
||||||
"network request failed",
|
|
||||||
"fetch failed",
|
|
||||||
"socket hang up",
|
|
||||||
/\beconn(?:refused|reset|aborted)\b/i,
|
|
||||||
/\benotfound\b/i,
|
|
||||||
/\beai_again\b/i,
|
|
||||||
/without sending (?:any )?chunks?/i,
|
|
||||||
/\bstop reason:\s*(?:abort|error)\b/i,
|
|
||||||
/\breason:\s*(?:abort|error)\b/i,
|
|
||||||
/\bunhandled stop reason:\s*(?:abort|error)\b/i,
|
|
||||||
],
|
|
||||||
billing: [
|
|
||||||
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i,
|
|
||||||
"payment required",
|
|
||||||
"insufficient credits",
|
|
||||||
"credit balance",
|
|
||||||
"plans & billing",
|
|
||||||
"insufficient balance",
|
|
||||||
],
|
|
||||||
authPermanent: [
|
|
||||||
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
|
|
||||||
"invalid_api_key",
|
|
||||||
"key has been disabled",
|
|
||||||
"key has been revoked",
|
|
||||||
"account has been deactivated",
|
|
||||||
/could not (?:authenticate|validate).*(?:api[_ ]?key|credentials)/i,
|
|
||||||
"permission_error",
|
|
||||||
"not allowed for this organization",
|
|
||||||
],
|
|
||||||
auth: [
|
|
||||||
/invalid[_ ]?api[_ ]?key/,
|
|
||||||
"incorrect api key",
|
|
||||||
"invalid token",
|
|
||||||
"authentication",
|
|
||||||
"re-authenticate",
|
|
||||||
"oauth token refresh failed",
|
|
||||||
"unauthorized",
|
|
||||||
"forbidden",
|
|
||||||
"access denied",
|
|
||||||
"insufficient permissions",
|
|
||||||
"insufficient permission",
|
|
||||||
/missing scopes?:/i,
|
|
||||||
"expired",
|
|
||||||
"token has expired",
|
|
||||||
/\b401\b/,
|
|
||||||
/\b403\b/,
|
|
||||||
"no credentials found",
|
|
||||||
"no api key found",
|
|
||||||
],
|
|
||||||
format: [
|
|
||||||
"string should match pattern",
|
|
||||||
"tool_use.id",
|
|
||||||
"tool_use_id",
|
|
||||||
"messages.1.content.1.tool_use.id",
|
|
||||||
"invalid request format",
|
|
||||||
/tool call id was.*must be/i,
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TOOL_CALL_INPUT_MISSING_RE =
|
const TOOL_CALL_INPUT_MISSING_RE =
|
||||||
/tool_(?:use|call)\.(?:input|arguments).*?(?:field required|required)/i;
|
/tool_(?:use|call)\.(?:input|arguments).*?(?:field required|required)/i;
|
||||||
const TOOL_CALL_INPUT_PATH_RE =
|
const TOOL_CALL_INPUT_PATH_RE =
|
||||||
@@ -711,58 +638,6 @@ const IMAGE_DIMENSION_ERROR_RE =
|
|||||||
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
|
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
|
||||||
const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i;
|
const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i;
|
||||||
|
|
||||||
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
|
||||||
if (!raw) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const value = raw.toLowerCase();
|
|
||||||
return patterns.some((pattern) =>
|
|
||||||
pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRateLimitErrorMessage(raw: string): boolean {
|
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTimeoutErrorMessage(raw: string): boolean {
|
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum character length for a string to be considered a billing error message.
|
|
||||||
* Real API billing errors are short, structured messages (typically under 300 chars).
|
|
||||||
* Longer text is almost certainly assistant content that happens to mention billing keywords.
|
|
||||||
*/
|
|
||||||
const BILLING_ERROR_MAX_LENGTH = 512;
|
|
||||||
|
|
||||||
export function isBillingErrorMessage(raw: string): boolean {
|
|
||||||
const value = raw.toLowerCase();
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Real billing error messages from APIs are short structured payloads.
|
|
||||||
// Long text (e.g. multi-paragraph assistant responses) that happens to mention
|
|
||||||
// "billing", "payment", etc. should not be treated as a billing error.
|
|
||||||
if (raw.length > BILLING_ERROR_MAX_LENGTH) {
|
|
||||||
// Keep explicit status/code 402 detection for providers that wrap errors in
|
|
||||||
// larger payloads (for example nested JSON bodies or prefixed metadata).
|
|
||||||
return BILLING_ERROR_HARD_402_RE.test(value);
|
|
||||||
}
|
|
||||||
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!BILLING_ERROR_HEAD_RE.test(raw)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
value.includes("upgrade") ||
|
|
||||||
value.includes("credits") ||
|
|
||||||
value.includes("payment") ||
|
|
||||||
value.includes("plan")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMissingToolCallInputError(raw: string): boolean {
|
export function isMissingToolCallInputError(raw: string): boolean {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return false;
|
return false;
|
||||||
@@ -777,18 +652,6 @@ export function isBillingAssistantError(msg: AssistantMessage | undefined): bool
|
|||||||
return isBillingErrorMessage(msg.errorMessage ?? "");
|
return isBillingErrorMessage(msg.errorMessage ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthPermanentErrorMessage(raw: string): boolean {
|
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.authPermanent);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAuthErrorMessage(raw: string): boolean {
|
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isOverloadedErrorMessage(raw: string): boolean {
|
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonApiInternalServerError(raw: string): boolean {
|
function isJsonApiInternalServerError(raw: string): boolean {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return false;
|
return false;
|
||||||
@@ -852,7 +715,7 @@ export function isImageSizeError(errorMessage?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
||||||
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
return !isImageDimensionErrorMessage(raw) && matchesFormatErrorPattern(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
|
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||||
|
|||||||
149
src/agents/pi-embedded-helpers/failover-matches.ts
Normal file
149
src/agents/pi-embedded-helpers/failover-matches.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
type ErrorPattern = RegExp | string;
|
||||||
|
|
||||||
|
const ERROR_PATTERNS = {
|
||||||
|
rateLimit: [
|
||||||
|
/rate[_ ]limit|too many requests|429/,
|
||||||
|
"model_cooldown",
|
||||||
|
"cooling down",
|
||||||
|
"exceeded your current quota",
|
||||||
|
"resource has been exhausted",
|
||||||
|
"quota exceeded",
|
||||||
|
"resource_exhausted",
|
||||||
|
"usage limit",
|
||||||
|
/\btpm\b/i,
|
||||||
|
"tokens per minute",
|
||||||
|
],
|
||||||
|
overloaded: [
|
||||||
|
/overloaded_error|"type"\s*:\s*"overloaded_error"/i,
|
||||||
|
"overloaded",
|
||||||
|
"service unavailable",
|
||||||
|
"high demand",
|
||||||
|
],
|
||||||
|
timeout: [
|
||||||
|
"timeout",
|
||||||
|
"timed out",
|
||||||
|
"deadline exceeded",
|
||||||
|
"context deadline exceeded",
|
||||||
|
"connection error",
|
||||||
|
"network error",
|
||||||
|
"network request failed",
|
||||||
|
"fetch failed",
|
||||||
|
"socket hang up",
|
||||||
|
/\beconn(?:refused|reset|aborted)\b/i,
|
||||||
|
/\benotfound\b/i,
|
||||||
|
/\beai_again\b/i,
|
||||||
|
/without sending (?:any )?chunks?/i,
|
||||||
|
/\bstop reason:\s*(?:abort|error)\b/i,
|
||||||
|
/\breason:\s*(?:abort|error)\b/i,
|
||||||
|
/\bunhandled stop reason:\s*(?:abort|error)\b/i,
|
||||||
|
],
|
||||||
|
billing: [
|
||||||
|
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i,
|
||||||
|
"payment required",
|
||||||
|
"insufficient credits",
|
||||||
|
"credit balance",
|
||||||
|
"plans & billing",
|
||||||
|
"insufficient balance",
|
||||||
|
],
|
||||||
|
authPermanent: [
|
||||||
|
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
|
||||||
|
"invalid_api_key",
|
||||||
|
"key has been disabled",
|
||||||
|
"key has been revoked",
|
||||||
|
"account has been deactivated",
|
||||||
|
/could not (?:authenticate|validate).*(?:api[_ ]?key|credentials)/i,
|
||||||
|
"permission_error",
|
||||||
|
"not allowed for this organization",
|
||||||
|
],
|
||||||
|
auth: [
|
||||||
|
/invalid[_ ]?api[_ ]?key/,
|
||||||
|
"incorrect api key",
|
||||||
|
"invalid token",
|
||||||
|
"authentication",
|
||||||
|
"re-authenticate",
|
||||||
|
"oauth token refresh failed",
|
||||||
|
"unauthorized",
|
||||||
|
"forbidden",
|
||||||
|
"access denied",
|
||||||
|
"insufficient permissions",
|
||||||
|
"insufficient permission",
|
||||||
|
/missing scopes?:/i,
|
||||||
|
"expired",
|
||||||
|
"token has expired",
|
||||||
|
/\b401\b/,
|
||||||
|
/\b403\b/,
|
||||||
|
"no credentials found",
|
||||||
|
"no api key found",
|
||||||
|
],
|
||||||
|
format: [
|
||||||
|
"string should match pattern",
|
||||||
|
"tool_use.id",
|
||||||
|
"tool_use_id",
|
||||||
|
"messages.1.content.1.tool_use.id",
|
||||||
|
"invalid request format",
|
||||||
|
/tool call id was.*must be/i,
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const BILLING_ERROR_HEAD_RE =
|
||||||
|
/^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i;
|
||||||
|
const BILLING_ERROR_HARD_402_RE =
|
||||||
|
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|^\s*402\s+payment/i;
|
||||||
|
const BILLING_ERROR_MAX_LENGTH = 512;
|
||||||
|
|
||||||
|
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
||||||
|
if (!raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const value = raw.toLowerCase();
|
||||||
|
return patterns.some((pattern) =>
|
||||||
|
pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesFormatErrorPattern(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRateLimitErrorMessage(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimeoutErrorMessage(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBillingErrorMessage(raw: string): boolean {
|
||||||
|
const value = raw.toLowerCase();
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.length > BILLING_ERROR_MAX_LENGTH) {
|
||||||
|
return BILLING_ERROR_HARD_402_RE.test(value);
|
||||||
|
}
|
||||||
|
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!BILLING_ERROR_HEAD_RE.test(raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
value.includes("upgrade") ||
|
||||||
|
value.includes("credits") ||
|
||||||
|
value.includes("payment") ||
|
||||||
|
value.includes("plan")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthPermanentErrorMessage(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.authPermanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthErrorMessage(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOverloadedErrorMessage(raw: string): boolean {
|
||||||
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user