/** * FailoverError - 结构化错误分类 * * 参考 openclaw/src/agents/cli-runner/helpers.ts 中的错误处理模式 * * 将原始 Error 分类为可操作的 FailoverReason + 重试策略, * 替代 AgentManager._isRecoverableSessionError() 中的纯字符串匹配。 */ /** 错误分类枚举 */ const FailoverReason = Object.freeze({ TIMEOUT: 'timeout', NO_OUTPUT_TIMEOUT: 'no-output-timeout', RATE_LIMIT: 'rate-limit', AUTH: 'auth', MODEL_NOT_FOUND: 'model-not-found', NETWORK: 'network', TRANSPORT_CLOSED: 'transport-closed', SPAWN_ERROR: 'spawn-error', INTERNAL: 'internal', UNKNOWN: 'unknown', }); /** 可重试的错误类型集合 */ const RETRYABLE_REASONS = new Set([ FailoverReason.TIMEOUT, FailoverReason.NO_OUTPUT_TIMEOUT, FailoverReason.RATE_LIMIT, FailoverReason.NETWORK, FailoverReason.TRANSPORT_CLOSED, ]); /** * 从原始错误中推导分类 * * @param {Error} err * @returns {{ reason: string, retryable: boolean, delayMs: number }} */ function classifyError(err) { const msg = `${err?.message || ''}`.toLowerCase(); const code = err?.code || ''; // ── 超时 ── if (msg.includes('overall-timeout') || msg.includes('overall timeout')) { return { reason: FailoverReason.TIMEOUT, retryable: true, delayMs: 1000 }; } if (msg.includes('no-output-timeout') || msg.includes('no output timeout')) { return { reason: FailoverReason.NO_OUTPUT_TIMEOUT, retryable: true, delayMs: 1000 }; } if (msg.includes('timeout') || msg.includes('timed out') || code === 'ETIMEDOUT') { return { reason: FailoverReason.TIMEOUT, retryable: true, delayMs: 1000 }; } // ── 限流 ── if (msg.includes('rate limit') || msg.includes('429') || msg.includes('too many requests') || msg.includes('quota') || msg.includes('resource_exhausted')) { return { reason: FailoverReason.RATE_LIMIT, retryable: true, delayMs: 5000 }; } // ── 认证 ── if (msg.includes('unauthorized') || msg.includes('unauthenticated') || msg.includes('401') || msg.includes('403') || msg.includes('api key') || msg.includes('invalid key') || msg.includes('permission denied')) { return { reason: FailoverReason.AUTH, retryable: false, delayMs: 0 }; } // ── 模型不存在 ── if (msg.includes('model not found') || msg.includes('model_not_found') || msg.includes('does not exist') || msg.includes('404') || msg.includes('no such model')) { return { reason: FailoverReason.MODEL_NOT_FOUND, retryable: false, delayMs: 0 }; } // ── 传输层关闭 ── if (msg.includes('transport is closed') || msg.includes('transport closed') || msg.includes('disconnected') || msg.includes('econnreset') || msg.includes('broken pipe') || msg.includes('pipe') || msg.includes('session not connected') || msg.includes('connection lost')) { return { reason: FailoverReason.TRANSPORT_CLOSED, retryable: true, delayMs: 2000 }; } // ── 进程启动错误 ── if (msg.includes('spawn') || code === 'ENOENT' || code === 'EACCES' || msg.includes('command not found') || msg.includes('no such file')) { return { reason: FailoverReason.SPAWN_ERROR, retryable: false, delayMs: 0 }; } // ── 网络 ── if (msg.includes('econnrefused') || msg.includes('enetunreach') || msg.includes('ehostunreach') || msg.includes('dns') || msg.includes('socket hang up') || msg.includes('fetch failed') || code === 'ECONNREFUSED' || code === 'ENETUNREACH') { return { reason: FailoverReason.NETWORK, retryable: true, delayMs: 3000 }; } return { reason: FailoverReason.UNKNOWN, retryable: false, delayMs: 0 }; } /** * FailoverError — 封装原始错误 + 分类信息 * * 用法: * try { ... } catch (err) { * const failover = FailoverError.from(err); * if (failover.retryable) { * await sleep(failover.delayMs); * // retry * } * } */ class FailoverError extends Error { /** * @param {Error} originalError * @param {{ reason: string, retryable: boolean, delayMs: number }} classification */ constructor(originalError, classification) { super(originalError.message); this.name = 'FailoverError'; this.original = originalError; this.reason = classification.reason; this.retryable = classification.retryable; this.delayMs = classification.delayMs; this.stack = originalError.stack; } /** * 工厂方法 — 从任意 Error 创建 FailoverError * @param {Error} err * @returns {FailoverError} */ static from(err) { if (err instanceof FailoverError) return err; return new FailoverError(err, classifyError(err)); } } module.exports = { FailoverError, FailoverReason, classifyError, RETRYABLE_REASONS };