第16章 错误处理与韧性——优雅地失败

引言

在分布式系统中,错误不是例外,而是常态。网络会中断、服务会过载、令牌会过期、上下文会溢出。一个真正健壮的系统,不是从不失败的系统,而是能够优雅地处理失败、从失败中恢复、并在失败中学习的系统。

Claude Code 作为一个与云服务深度集成的 AI 编程助手,其错误处理系统展现了工程韧性的精髓。它不仅要处理各种 API 错误,还要管理重试逻辑、上下文压缩、降级策略、中断传播等复杂场景。这个系统的设计哲学,可以类比为现代飞机的冗余系统:当某个引擎失效时,系统不会崩溃,而是自动切换到备用引擎,同时向飞行员提供清晰的诊断信息。

本章将深入分析 Claude Code 的错误处理机制,从错误分类、重试策略、降级逻辑到日志系统,全面探讨如何构建一个具有韧性的分布式系统。

概念讲解

错误分类的必要性

在处理 API 错误时,首先要回答的问题是:这个错误是否值得重试?不同的错误类型需要不同的处理策略:

  • 瞬时错误(Transient Errors):网络抖动、服务过载、临时限流。这些错误可以通过重试解决。
  • 永久错误(Permanent Errors):认证失败、权限不足、资源不存在。重试无济于事,需要立即失败。
  • 可恢复错误(Recoverable Errors):上下文溢出、令牌过期。需要特殊处理才能重试。

Claude Code 通过 categorizeRetryableAPIError 函数实现了错误分类的策略模式,将复杂的错误判断逻辑封装为清晰的可测试单元。

指数退避与熔断机制

在重试时,如果所有客户端同时重试,会造成"惊群效应",加剧服务压力。指数退避(Exponential Backoff)是一种经典的解决方案:每次重试的等待时间按指数增长(如 500ms、1s、2s、4s…),同时加入随机抖动(Jitter)避免同步。

熔断机制(Circuit Breaker)则更进一步:当连续失败达到阈值时,暂时停止请求,直接返回错误或降级响应,避免雪崩效应。Claude Code 通过 MAX_529_RETRIESFallbackTriggeredError 实现了熔断逻辑。

上下文压缩与自动恢复

当对话历史过长导致 413 错误时,系统需要自动压缩上下文。这涉及两个关键问题:

  1. 检测:如何识别 413 错误并触发压缩?
  2. 恢复:压缩后如何继续对话而不丢失用户意图?

Claude Code 通过 isPromptTooLongMessage 检测错误,通过 buildPostCompactMessages 重建消息序列,实现了无缝的上下文压缩体验。

中断传播与资源清理

用户可能随时中断操作(Ctrl+C),系统需要确保:

  1. 快速响应:立即停止正在进行的网络请求
  2. 资源清理:关闭连接、释放内存
  3. 状态一致:避免留下半完成的操作

AbortController 提供了标准的取消信号机制,Claude Code 将其级联传播到所有异步操作中。

源码分析

错误分类:策略模式的实现

src/services/api/errors.ts 中的 categorizeRetryableAPIError 函数展示了错误分类的清晰逻辑:

export function categorizeRetryableAPIError(
  error: APIError,
): SDKAssistantMessageError {
  if (
    error.status === 529 ||
    error.message?.includes('"type":"overloaded_error"')
  ) {
    return 'rate_limit'
  }
  if (error.status === 429) {
    return 'rate_limit'
  }
  if (error.status === 401 || error.status === 403) {
    return 'authentication_failed'
  }
  if (error.status !== undefined && error.status >= 408) {
    return 'server_error'
  }
  return 'unknown'
}

这个函数遵循了策略模式的核心思想:将变化的算法封装为独立的策略。每个错误类型对应一个处理策略,函数只是根据错误状态选择合适的策略。这种设计的优势在于:

  1. 可测试性:每个错误分类逻辑可以独立测试
  2. 可扩展性:新增错误类型只需添加新的条件分支
  3. 可读性:意图清晰,易于理解
重试逻辑:指数退避与熔断

src/services/api/withRetry.ts 中的 withRetry 函数是重试逻辑的核心,它实现了指数退避、熔断和降级:

export async function* withRetry<T>(
  getClient: () => Promise<Anthropic>,
  operation: (
    client: Anthropic,
    attempt: number,
    context: RetryContext,
  ) => Promise<T>,
  options: RetryOptions,
): AsyncGenerator<SystemAPIErrorMessage, T> {
  const maxRetries = getMaxRetries(options)
  const retryContext: RetryContext = {
    model: options.model,
    thinkingConfig: options.thinkingConfig,
    ...(isFastModeEnabled() && { fastMode: options.fastMode }),
  }
  let client: Anthropic | null = null
  let consecutive529Errors = options.initialConsecutive529Errors ?? 0
  let lastError: unknown
  let persistentAttempt = 0
  
  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
    if (options.signal?.aborted) {
      throw new APIUserAbortError()
    }
    
    // ... 重试逻辑 ...
  }
}

这个函数使用了 TypeScript 的 AsyncGenerator 类型,允许在重试过程中向调用者 yield 系统消息(如"正在重试…"),实现实时进度反馈。

指数退避的实现体现在 getRetryDelay 函数中:

export function getRetryDelay(
  attempt: number,
  retryAfterHeader?: string | null,
  maxDelayMs = 32000,
): number {
  if (retryAfterHeader) {
    const seconds = parseInt(retryAfterHeader, 10)
    if (!isNaN(seconds)) {
      return seconds * 1000
    }
  }

  const baseDelay = Math.min(
    BASE_DELAY_MS * Math.pow(2, attempt - 1),
    maxDelayMs,
  )
  const jitter = Math.random() * 0.25 * baseDelay
  return baseDelay + jitter
}

这里有几个精妙的设计:

  1. 优先使用服务器的 Retry-After 头:服务器知道最佳的等待时间
  2. 指数增长BASE_DELAY_MS * Math.pow(2, attempt - 1) 实现指数退避
  3. 上限保护Math.min(..., maxDelayMs) 防止无限增长
  4. 随机抖动Math.random() * 0.25 * baseDelay 避免客户端同步

熔断逻辑体现在连续 529 错误的计数中:

if (
  is529Error(error) &&
  (process.env.FALLBACK_FOR_ALL_PRIMARY_MODELS ||
    (!isClaudeAISubscriber() && isNonCustomOpusModel(options.model)))
) {
  consecutive529Errors++
  if (consecutive529Errors >= MAX_529_RETRIES) {
    if (options.fallbackModel) {
      throw new FallbackTriggeredError(
        options.model,
        options.fallbackModel,
      )
    }
    // ...
  }
}

当连续 529 错误达到 MAX_529_RETRIES(默认为 3)时,系统会抛出 FallbackTriggeredError,触发降级到备用模型的逻辑。

413 错误恢复:自动压缩触发

src/query.ts 中的错误处理逻辑展示了 413 错误的检测和恢复:

import {
  PROMPT_TOO_LONG_ERROR_MESSAGE,
  isPromptTooLongMessage,
} from './services/api/errors.js'

// 在查询管道中
if (isPromptTooLongMessage(error)) {
  // 触发上下文压缩
  const compactResult = await reactiveCompact?.({
    messages,
    systemPrompt,
    tools,
    signal,
  })
  
  if (compactResult) {
    messages = compactResult.messages
    // 继续重试
    continue
  }
}

isPromptTooLongMessage 函数在 src/services/api/errors.ts 中定义:

export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'prompt too long'

export function isPromptTooLongMessage(error: unknown): boolean {
  if (!(error instanceof Error)) {
    return false
  }
  return error.message.toLowerCase().includes(
    PROMPT_TOO_LONG_ERROR_MESSAGE.toLowerCase(),
  )
}

这种设计的优势在于:

  1. 松耦合:错误检测与压缩逻辑分离
  2. 可扩展:可以轻松添加新的错误类型和处理策略
  3. 可测试:每个组件可以独立测试
中断处理:AbortController 的级联传播

src/QueryEngine.ts 中展示了中断信号的传播:

import { createAbortController } from './utils/abortController.js'

export class QueryEngine {
  private abortController = createAbortController()
  
  async ask(userMessage: string): Promise<void> {
    try {
      await this.query(userMessage, {
        signal: this.abortController.signal,
      })
    } catch (error) {
      if (error instanceof APIUserAbortError) {
        // 用户中断,清理资源
        return
      }
      throw error
    }
  }
  
  abort(): void {
    this.abortController.abort()
  }
}

withRetry 函数在每个重试循环开始时检查中断信号:

for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
  if (options.signal?.aborted) {
    throw new APIUserAbortError()
  }
  
  // ... 执行操作 ...
}

这种设计的优势在于:

  1. 快速响应:中断信号立即传播,无需等待网络请求完成
  2. 资源安全:所有异步操作都会收到中断信号,可以正确清理资源
  3. 类型安全:TypeScript 的类型系统确保信号的正确传递
max_output_tokens 恢复:输出截断的自动续写

max_output_tokens 设置过小时,模型可能无法完成输出。Claude Code 通过动态调整 maxTokensOverride 来解决这个问题:

if (error instanceof APIError) {
  const overflowData = parseMaxTokensContextOverflowError(error)
  if (overflowData) {
    const { inputTokens, contextLimit } = overflowData

    const safetyBuffer = 1000
    const availableContext = Math.max(
      0,
      contextLimit - inputTokens - safetyBuffer,
    )
    if (availableContext < FLOOR_OUTPUT_TOKENS) {
      logError(
        new Error(
          `availableContext ${availableContext} is less than FLOOR_OUTPUT_TOKENS ${FLOOR_OUTPUT_TOKENS}`,
        ),
      )
      throw error
    }
    
    const minRequired =
      (retryContext.thinkingConfig.type === 'enabled'
        ? retryContext.thinkingConfig.budgetTokens
        : 0) + 1
    const adjustedMaxTokens = Math.max(
      FLOOR_OUTPUT_TOKENS,
      availableContext,
      minRequired,
    )
    retryContext.maxTokensOverride = adjustedMaxTokens

    logEvent('tengu_max_tokens_context_overflow_adjustment', {
      inputTokens,
      contextLimit,
      adjustedMaxTokens,
      attempt,
    })

    continue
  }
}

这个逻辑的关键在于:

  1. 安全缓冲safetyBuffer = 1000 确保不会触碰到上下文边界
  2. 最小保证FLOOR_OUTPUT_TOKENS 确保至少有 3000 个输出 token
  3. 思考预算:如果启用了思考模式,需要预留思考 token
  4. 事件记录logEvent 记录调整事件,便于监控和调试
FallbackTriggeredError:降级策略

src/services/api/withRetry.ts 中定义了降级错误:

export class FallbackTriggeredError extends Error {
  constructor(
    public readonly originalModel: string,
    public readonly fallbackModel: string,
  ) {
    super(`Model fallback triggered: ${originalModel} -> ${fallbackModel}`)
    this.name = 'FallbackTriggeredError'
  }
}

这个错误类的设计体现了几个原则:

  1. 信息丰富:包含原始模型和备用模型的信息
  2. 类型安全:继承自 Error,可以被 instanceof 检测
  3. 可追溯:保留了完整的错误消息

当这个错误被抛出时,上层逻辑会捕获并处理降级:

try {
  result = await withRetry(/* ... */)
} catch (error) {
  if (error instanceof FallbackTriggeredError) {
    // 切换到备用模型
    options.model = error.fallbackModel
    // 重试
    continue
  }
  throw error
}
错误日志:logError 与诊断日志

src/utils/log.ts 中的 logError 函数实现了多目的地日志记录:

export function logError(error: unknown): void {
  const err = toError(error)
  if (feature('HARD_FAIL') && isHardFailMode()) {
    // biome-ignore lint/suspicious/noConsole:: intentional crash output
    console.error('[HARD FAIL] logError called with:', err.stack || err.message)
    // eslint-disable-next-line custom-rules/no-process-exit
    process.exit(1)
  }
  try {
    // Check if error reporting should be disabled
    if (
      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
      isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
      isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
      process.env.DISABLE_ERROR_REPORTING ||
      isEssentialTrafficOnly()
    ) {
      return
    }
    
    // Write to debug log
    logForDebugging(`Error: ${err.message}`)
    
    // Add to in-memory error log
    getInMemoryErrors().push({
      timestamp: Date.now(),
      error: err,
    })
    
    // Persist to error log file (ant-only)
    if (process.env.USER_TYPE === 'ant') {
      // ... 写入文件 ...
    }
  } catch (loggingError) {
    // Don't let logging errors crash the application
    console.error('Failed to log error:', loggingError)
  }
}

这个设计的优势在于:

  1. 多目的地:同时写入调试日志、内存日志和文件日志
  2. 环境感知:根据环境变量和用户类型调整日志行为
  3. 容错性:日志记录本身不会导致应用崩溃
  4. 可配置:可以通过环境变量禁用错误报告

设计启示

1. 错误分类是策略模式的经典应用

将错误分类逻辑封装为独立的函数,使得:

  • 测试更容易:每个错误类型可以独立测试
  • 扩展更简单:新增错误类型只需添加新的条件分支
  • 意图更清晰:代码的可读性大大提高
2. 指数退避是分布式系统的最佳实践

指数退避 + 随机抖动的组合是处理重试的标准做法:

  • 避免惊群效应:随机抖动防止所有客户端同时重试
  • 快速收敛:指数增长确保快速达到稳定状态
  • 上限保护:防止无限增长导致资源浪费
3. 熔断机制是防止雪崩的关键

当连续失败达到阈值时,立即停止请求,避免雪崩效应:

  • 保护服务:避免过载的服务进一步恶化
  • 快速失败:让用户立即知道问题,而不是等待超时
  • 自动恢复:熔断后可以自动尝试恢复
4. 上下文压缩需要无缝体验

当上下文溢出时,自动压缩并继续对话,用户几乎感知不到:

  • 检测准确:通过错误消息精确识别 413 错误
  • 恢复透明:压缩后自动继续,无需用户干预
  • 状态保持:保留用户意图和对话历史
5. 中断传播需要快速响应

用户中断时,立即停止所有操作,释放资源:

  • 信号级联:AbortController 传播到所有异步操作
  • 资源安全:确保所有资源被正确清理
  • 类型安全:TypeScript 的类型系统确保正确性
6. 降级策略是提高可用性的有效手段

当主模型不可用时,自动降级到备用模型:

  • 透明切换:用户几乎感知不到模型切换
  • 信息丰富:错误对象包含完整的上下文信息
  • 可追溯:记录降级事件,便于分析和优化
7. 错误日志是系统可观测性的基础

多目的地日志记录确保错误信息不会丢失:

  • 实时调试:调试日志提供即时反馈
  • 历史追溯:内存日志保存最近的错误
  • 长期分析:文件日志支持离线分析

思考题

  1. 错误分类的设计:如果需要新增一种错误类型(如"配额超限"),应该如何扩展 categorizeRetryableAPIError 函数?

  2. 重试策略的优化:当前的指数退避策略在某些场景下可能不够灵活(如突发流量)。如何设计更智能的重试策略?

  3. 熔断机制的改进:当前的熔断机制基于连续失败次数。如何设计更智能的熔断策略(如基于失败率、响应时间等)?

  4. 上下文压缩的权衡:自动压缩上下文可能会丢失一些重要信息。如何设计更智能的压缩策略,保留最相关的信息?

  5. 中断处理的边界:在某些场景下(如文件写入),立即中断可能导致数据不一致。如何设计更细粒度的中断处理?

  6. 降级策略的选择:如何选择最合适的备用模型?是否应该根据任务类型动态选择?

  7. 错误日志的优化:当前的错误日志可能包含敏感信息。如何设计更安全的日志系统,在可观测性和隐私保护之间取得平衡?

  8. 错误处理的测试:如何设计全面的测试用例,覆盖各种错误场景和边界条件?

总结

Claude Code 的错误处理系统展示了工程韧性的精髓。通过错误分类、重试策略、熔断机制、上下文压缩、中断传播、降级策略和多目的地日志,系统构建了一个能够优雅地处理失败、从失败中恢复、并在失败中学习的健壮系统。

这个系统的设计哲学可以总结为:

  • 失败是常态:假设所有操作都可能失败,设计相应的恢复机制
  • 快速失败:尽早检测和报告错误,避免浪费资源
  • 自动恢复:在可能的情况下,自动从错误中恢复
  • 透明降级:当无法恢复时,优雅地降级,保持基本功能
  • 可观测性:记录详细的错误信息,便于分析和优化

正如飞机的冗余系统确保了飞行的安全性,Claude Code 的错误处理系统确保了用户体验的可靠性。在分布式系统中,错误处理不是锦上添花,而是系统稳定运行的基石。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐