第16章 错误处理与韧性——优雅地失败
第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_RETRIES 和 FallbackTriggeredError 实现了熔断逻辑。
上下文压缩与自动恢复
当对话历史过长导致 413 错误时,系统需要自动压缩上下文。这涉及两个关键问题:
- 检测:如何识别 413 错误并触发压缩?
- 恢复:压缩后如何继续对话而不丢失用户意图?
Claude Code 通过 isPromptTooLongMessage 检测错误,通过 buildPostCompactMessages 重建消息序列,实现了无缝的上下文压缩体验。
中断传播与资源清理
用户可能随时中断操作(Ctrl+C),系统需要确保:
- 快速响应:立即停止正在进行的网络请求
- 资源清理:关闭连接、释放内存
- 状态一致:避免留下半完成的操作
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'
}
这个函数遵循了策略模式的核心思想:将变化的算法封装为独立的策略。每个错误类型对应一个处理策略,函数只是根据错误状态选择合适的策略。这种设计的优势在于:
- 可测试性:每个错误分类逻辑可以独立测试
- 可扩展性:新增错误类型只需添加新的条件分支
- 可读性:意图清晰,易于理解
重试逻辑:指数退避与熔断
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
}
这里有几个精妙的设计:
- 优先使用服务器的 Retry-After 头:服务器知道最佳的等待时间
- 指数增长:
BASE_DELAY_MS * Math.pow(2, attempt - 1)实现指数退避 - 上限保护:
Math.min(..., maxDelayMs)防止无限增长 - 随机抖动:
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(),
)
}
这种设计的优势在于:
- 松耦合:错误检测与压缩逻辑分离
- 可扩展:可以轻松添加新的错误类型和处理策略
- 可测试:每个组件可以独立测试
中断处理: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()
}
// ... 执行操作 ...
}
这种设计的优势在于:
- 快速响应:中断信号立即传播,无需等待网络请求完成
- 资源安全:所有异步操作都会收到中断信号,可以正确清理资源
- 类型安全: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
}
}
这个逻辑的关键在于:
- 安全缓冲:
safetyBuffer = 1000确保不会触碰到上下文边界 - 最小保证:
FLOOR_OUTPUT_TOKENS确保至少有 3000 个输出 token - 思考预算:如果启用了思考模式,需要预留思考 token
- 事件记录:
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'
}
}
这个错误类的设计体现了几个原则:
- 信息丰富:包含原始模型和备用模型的信息
- 类型安全:继承自
Error,可以被instanceof检测 - 可追溯:保留了完整的错误消息
当这个错误被抛出时,上层逻辑会捕获并处理降级:
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. 上下文压缩需要无缝体验
当上下文溢出时,自动压缩并继续对话,用户几乎感知不到:
- 检测准确:通过错误消息精确识别 413 错误
- 恢复透明:压缩后自动继续,无需用户干预
- 状态保持:保留用户意图和对话历史
5. 中断传播需要快速响应
用户中断时,立即停止所有操作,释放资源:
- 信号级联:AbortController 传播到所有异步操作
- 资源安全:确保所有资源被正确清理
- 类型安全:TypeScript 的类型系统确保正确性
6. 降级策略是提高可用性的有效手段
当主模型不可用时,自动降级到备用模型:
- 透明切换:用户几乎感知不到模型切换
- 信息丰富:错误对象包含完整的上下文信息
- 可追溯:记录降级事件,便于分析和优化
7. 错误日志是系统可观测性的基础
多目的地日志记录确保错误信息不会丢失:
- 实时调试:调试日志提供即时反馈
- 历史追溯:内存日志保存最近的错误
- 长期分析:文件日志支持离线分析
思考题
-
错误分类的设计:如果需要新增一种错误类型(如"配额超限"),应该如何扩展
categorizeRetryableAPIError函数? -
重试策略的优化:当前的指数退避策略在某些场景下可能不够灵活(如突发流量)。如何设计更智能的重试策略?
-
熔断机制的改进:当前的熔断机制基于连续失败次数。如何设计更智能的熔断策略(如基于失败率、响应时间等)?
-
上下文压缩的权衡:自动压缩上下文可能会丢失一些重要信息。如何设计更智能的压缩策略,保留最相关的信息?
-
中断处理的边界:在某些场景下(如文件写入),立即中断可能导致数据不一致。如何设计更细粒度的中断处理?
-
降级策略的选择:如何选择最合适的备用模型?是否应该根据任务类型动态选择?
-
错误日志的优化:当前的错误日志可能包含敏感信息。如何设计更安全的日志系统,在可观测性和隐私保护之间取得平衡?
-
错误处理的测试:如何设计全面的测试用例,覆盖各种错误场景和边界条件?
总结
Claude Code 的错误处理系统展示了工程韧性的精髓。通过错误分类、重试策略、熔断机制、上下文压缩、中断传播、降级策略和多目的地日志,系统构建了一个能够优雅地处理失败、从失败中恢复、并在失败中学习的健壮系统。
这个系统的设计哲学可以总结为:
- 失败是常态:假设所有操作都可能失败,设计相应的恢复机制
- 快速失败:尽早检测和报告错误,避免浪费资源
- 自动恢复:在可能的情况下,自动从错误中恢复
- 透明降级:当无法恢复时,优雅地降级,保持基本功能
- 可观测性:记录详细的错误信息,便于分析和优化
正如飞机的冗余系统确保了飞行的安全性,Claude Code 的错误处理系统确保了用户体验的可靠性。在分布式系统中,错误处理不是锦上添花,而是系统稳定运行的基石。
更多推荐

所有评论(0)