跳转至

Analysis | 错误处理模式全拆

目的:理解 Claude Code 的错误处理哲学和具体模式。 关联topics/async-generator-pattern.md 的 throw vs yield-error


1. 错误处理哲学

1.1 "错误是事件,不是异常"

Claude Code 的错误处理核心思想:

不要"抛异常就崩溃",要"分类错误,分类处理"

// ❌ 传统
try {
  const data = await fetch(url)
  return data
} catch (err) {
  throw new Error('Failed to fetch')  // 抛给上层
}

// ✅ Claude Code 风格
const result = await withRetry(() => fetch(url), {
  classifyError: (err) => {
    if (err.status === 429) return { retryable: true, backoff: 1000 }
    if (err.status === 401) return { retryable: false, code: 'AUTH' }
    if (err.status >= 500) return { retryable: true, backoff: 5000 }
    return { retryable: false, code: 'UNKNOWN' }
  },
})

1.2 "四类错误 + 四类处理"

错误类 例子 处理
可重试 429 限流、5xx 服务端错误、网络断开 withRetry(指数退避)
不可重试 - 用户错误 400 请求无效、参数错误 立即返回 + 用户提示
不可重试 - 鉴权错误 401 认证失败、403 权限不足 触发登录流程 / 提示用户
不可重试 - 系统错误 程序 bug、配置错误 报错 + 上报 telemetry

2. 错误分类实现

2.1 categorizeRetryableAPIError(推测)

// src/services/api/errors.ts
export function categorizeRetryableAPIError(err: Error): RetryableError | null {
  // 1. 网络错误
  if (err instanceof NetworkError) {
    return {
      retryable: true,
      backoff: 1000,
      reason: 'network',
    }
  }

  // 2. HTTP 状态码
  const status = (err as any).status
  if (status === 429) {
    return {
      retryable: true,
      backoff: parseRetryAfter(err),  // 读 Retry-After header
      reason: 'rate_limit',
    }
  }
  if (status === 408 || status === 504) {
    return {
      retryable: true,
      backoff: 2000,
      reason: 'timeout',
    }
  }
  if (status >= 500 && status < 600) {
    return {
      retryable: true,
      backoff: 5000,
      reason: 'server_error',
    }
  }

  // 3. 不可重试
  if (status === 400) return { retryable: false, reason: 'bad_request' }
  if (status === 401) return { retryable: false, reason: 'auth' }
  if (status === 403) return { retryable: false, reason: 'forbidden' }
  if (status === 404) return { retryable: false, reason: 'not_found' }

  return null  // 未知错误
}

2.2 withRetry 实现(推测)

// src/services/api/withRetry.ts
export async function* withRetry<T>(
  fn: () => AsyncGenerator<T>,
  options: RetryOptions
): AsyncGenerator<T> {
  let attempt = 0
  const maxAttempts = options.maxAttempts ?? 5

  while (true) {
    try {
      for await (const event of fn()) {
        yield event  // 透传
      }
      return  // 成功
    } catch (err) {
      const classification = options.classifyError(err as Error)
      if (!classification?.retryable) {
        throw err  // 不可重试
      }

      attempt++
      if (attempt >= maxAttempts) {
        throw new MaxRetriesExceededError(err, attempt)
      }

      // 指数退避
      const backoff = classification.backoff * Math.pow(2, attempt - 1)
      await sleep(backoff)
    }
  }
}

3. 自定义 Error 类

// src/services/api/errors.ts(推测)
export class FallbackTriggeredError extends Error {
  constructor(public originalError: Error, public fallbackModel: string) {
    super(`Fallback to ${fallbackModel}: ${originalError.message}`)
  }
}

export class MaxRetriesExceededError extends Error {
  constructor(public originalError: Error, public attempts: number) {
    super(`Failed after ${attempts} attempts: ${originalError.message}`)
  }
}

export class ImageSizeError extends Error {
  constructor(public width: number, public height: number, public maxSize: number) {
    super(`Image ${width}x${height} exceeds max ${maxSize}`)
  }
}

export class ImageResizeError extends Error {
  constructor(public reason: string) {
    super(`Image resize failed: ${reason}`)
  }
}

特点: - 类名带 Error 后缀 - 携带额外字段(attempts / model / size) - 支持 instanceof 检查


4. 错误边界(Error Boundary)

4.1 React Error Boundary

// src/components/ErrorBoundary.tsx(推测)
class ErrorBoundary extends React.Component<Props, State> {
  state = { hasError: false, error: null as Error | null }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // 1. 上报 telemetry
    diagnosticTracker.reportError(error, errorInfo)

    // 2. 恢复策略
    this.recoverFromError(error)
  }

  render() {
    if (this.state.hasError) {
      return <ErrorOverview error={this.state.error!} />
    }
    return this.props.children
  }
}

4.2 Ink 错误 UI

// src/ink/components/ErrorOverview.tsx
function ErrorOverview({ error }: { error: Error }) {
  return (
    <Box flexDirection="column">
      <Text color="red"> An error occurred</Text>
      <Text>{error.message}</Text>
      {error.stack && (
        <Box flexDirection="column" marginTop={1}>
          <Text dimColor>Stack trace:</Text>
          {error.stack.split('\n').map((line, i) => (
            <Text key={i} dimColor>  {line}</Text>
          ))}
        </Box>
      )}
    </Box>
  )
}

5. 错误恢复策略

5.1 "重试 + 降级"

// 推测的主 LLM 调用流程
async function* streamWithFallback(messages) {
  try {
    yield* withRetry(() => streamApi(messages, primaryModel), classifyApiError)
  } catch (err) {
    // 降级到次要模型
    if (err.status === 429 || err.status === 529) {
      yield* withRetry(() => streamApi(messages, fallbackModel), classifyApiError)
    } else {
      throw err
    }
  }
}

5.2 "优雅降级"

// 推测的权限检查失败降级
async function checkPermissionWithFallback(tool, input, ctx) {
  try {
    return await checkPermissionViaCloud(tool, input, ctx)
  } catch (err) {
    if (isNetworkError(err)) {
      // 离线时用本地缓存
      return checkPermissionFromLocalCache(tool, input, ctx)
    }
    throw err
  }
}

5.3 "保存现场 + 重启"

// 推测的 crash 恢复
async function gracefulRestart() {
  // 1. 保存当前 state 到磁盘
  await saveSessionState(getSessionId(), getAppState())

  // 2. 退出
  process.exit(0)

  // 3. 启动时检测未完成会话
  // → 自动 /resume
}

6. 错误处理在 agent 循环中的位置

// src/query.ts(简化)
async function* queryLoop(messages) {
  while (true) {
    try {
      // 1. 调 LLM(带重试)
      for await (const event of withRetry(() => streamApi(messages))) {
        yield event
      }
    } catch (err) {
      // 2. 不可重试:注入错误消息
      yield {
        type: 'error',
        error: formatError(err),
      }
      yield {
        type: 'text',
        text: 'API error occurred, please retry',
      }
      return  // 结束当前轮
    }

    // 3. 处理 tool_use
    if (hasToolUse) {
      for (const tool of toolUses) {
        try {
          const result = await executeTool(tool, ctx)
          messages = [...messages, result]
        } catch (err) {
          // 4. 工具失败:注入 tool_result with is_error
          messages = [...messages, {
            type: 'tool_result',
            tool_use_id: tool.id,
            content: `Tool failed: ${err.message}`,
            is_error: true,
          }]
        }
      }
    }
  }
}

关键: - 任何错误都不"panic" - LLM 错误 → 注入消息 + 结束 - 工具错误 → 注入 tool_result with is_error - LLM 下一轮会"看到"这些错误并调整


7. 错误信息的用户友好化

7.1 模式

// ❌ 错的:技术错误直接给用户
error.message  // "ECONNREFUSED 127.0.0.1:443"

// ✅ 对的:人类可读 + 建议
{
  title: '无法连接到 Claude API',
  detail: '网络连接失败,请检查你的网络。',
  suggestions: [
    '检查网络连接',
    '如果使用代理,设置 HTTPS_PROXY',
    '重试命令',
  ],
  technical: 'ECONNREFUSED 127.0.0.1:443',  // 折叠显示
}

7.2 实际渲染

function renderError(err: UserFacingError) {
  return (
    <Box flexDirection="column" borderColor="red" borderStyle="round">
      <Text color="red"> {err.title}</Text>
      <Text>{err.detail}</Text>

      {err.suggestions && (
        <Box flexDirection="column" marginTop={1}>
          <Text dimColor>建议</Text>
          {err.suggestions.map((s, i) => (
            <Text key={i}>  {i + 1}. {s}</Text>
          ))}
        </Box>
      )}

      {err.technical && (
        <Box marginTop={1}>
          <Text dimColor>技术细节: {err.technical}</Text>
        </Box>
      )}

      <Box marginTop={1}>
        <KeyboardShortcutHint keys="Enter" label="重试" />
        <KeyboardShortcutHint keys="Esc" label="取消" />
      </Box>
    </Box>
  )
}

8. 错误日志 + 上报

8.1 logError / logAntError / logForDebugging

// src/utils/log.ts(推测)
export function logError(err: Error, context?: Record<string, unknown>): void {
  // 1. 写到 ~/.claude/logs/error.log
  fs.appendFileSync('~/.claude/logs/error.log', formatError(err, context))

  // 2. 控制台输出
  console.error(chalk.red(`[ERROR] ${err.message}`))
}

export function logAntError(err: Error, context?: Record<string, unknown>): void {
  // Ant 内部:上报到 Sentry + Datadog
  Sentry.captureException(err, { contexts: { custom: context } })
  Datadog.log('error', err.message, context)
}

8.2 internalLogging

// src/services/internalLogging.ts(推测)
export class InternalLogger {
  // 1. 内存缓冲
  private buffer: LogEntry[] = []

  // 2. 定期 flush
  setInterval(() => this.flush(), 30_000)

  // 3. flush 到 telemetry
  private async flush() {
    if (this.buffer.length === 0) return
    await sendToTelemetry(this.buffer.splice(0))
  }

  // 4. 退出前 flush
  onExit(() => this.flush())
}

9. 错误处理的关键洞察

9.1 "错误是事件"

async function* 让错误可以作为事件 yield

yield { type: 'error', error: err }
LLM 下一轮会看到这个错误自动调整

9.2 "分类 + 决策"

不抛异常就完事。每个错误都有"分类"和"决策": - 可重试?等多久? - 降级?降级到哪? - 通知用户?显示什么?

9.3 "用户友好化"

技术错误 ≠ 用户看到的错误
永远有"建议"(不只是"失败")。

9.4 "现场保存"

会话状态定期持久化,crash 后能恢复。

9.5 "Error Boundary"

React 标准模式。Ink 也有对应实现。
单组件崩溃不毁整个 REPL

9.6 "可观测性"

  • logError 写文件
  • 内部 logger 缓冲
  • 退出前 flush
  • Sentry / Datadog 上报

没有可观测性 = 没法改进


10. 实战:写一个错误处理工具

// utils/errorHandling.ts
export class UserFacingError extends Error {
  constructor(
    public title: string,
    public detail: string,
    public suggestions: string[] = [],
    public technical?: string,
  ) {
    super(detail)
  }
}

export function wrapAsUserFacing(
  err: unknown,
  title: string,
  detail: string,
  suggestions: string[] = []
): UserFacingError {
  if (err instanceof UserFacingError) return err
  return new UserFacingError(
    title,
    detail,
    suggestions,
    err instanceof Error ? err.message : String(err),
  )
}

// 用法
try {
  await fetch(url)
} catch (err) {
  throw wrapAsUserFacing(
    err,
    '无法连接到服务',
    '请检查你的网络连接',
    [
      '检查网络',
      '检查代理设置',
      '重试命令',
    ],
  )
}

11. 阅读清单

  1. src/services/api/errors.ts(错误分类)
  2. src/services/api/withRetry.ts(重试)
  3. src/services/api/claude.ts(错误处理实战)
  4. 📌 src/utils/log.ts(日志)
  5. 📌 src/services/internalLogging.ts(内部日志)
  6. 📌 src/ink/components/ErrorOverview.tsx(错误 UI)
  7. 📌 src/components/ErrorBoundary.tsx(错误边界)

12. 练习任务

  1. 设计你的重试策略 —— 给一个具体场景写分类函数
  2. 写一个 UserFacingError 包装 —— 包装 fetch / file read / API call
  3. 画错误流图 —— 一次 API 失败经过的所有节点
  4. 思考:在 React 里抛异常 vs yield 错误事件,哪个好?Claude Code 选 yield 事件的理由是什么?