You need to sign in before continuing.
failover-error.js 4.68 KB
/**
 * 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 };