Claude Code 源码分析第 44 课 · 第 05
第 44 课

错误处理

Claude Code 错误的完整分类 - 从类型化异常类到 API 重试循环、终端覆盖和中途会话恢复。

01 Overview

Claude Code 是一个长时间运行的网络进程,它与 Anthropic API 通信、执行 shell 命令、读取和写入文件以及管理子代理 — 所有这些都可能失败。 代码库不会让错误任意传播,而是具有分层错误架构,可以对故障进行分类,决定是否重试或显示错误,并确保用户始终看到有意义的消息。

覆盖源文件
utils/errors.tsutils/toolErrors.tsutils/errorLogSink.tsservices/api/withRetry.tsservices/api/errors.tsink/components/ErrorOverview.tsxutils/conversationRecovery.tscomponents/SentryErrorBoundary.ts

错误堆栈中有四个不同的层:

第 1 层

类型错误类

utils/errors.ts — 每个故障域的命名异常词汇表

第 2 层

API重试引擎

services/api/withRetry.ts — 指数退避、529 回退、身份验证刷新、上下文溢出自动调整

第 3 层

终端错误叠加

ink/components/ErrorOverview.tsx — 内联源代码摘录,其中突出显示了崩溃行

第 4 层

对话恢复

utils/conversationRecovery.ts — 通过清理和重播脚本从中断/中间的会话中恢复

02 错误分类法(utils/errors.ts)

每个主要故障模式都有自己的命名类别。 这不仅仅是风格——它让呼叫者可以使用 instanceof 检查在生产版本中是否可以进行缩小和类名修改。

Class Purpose 关键领域
ClaudeError 基类; 套 this.name 到子类构造函数名称
AbortError 用户发起的取消(Escape / Ctrl-C) name = 'AbortError'
MalformedCommandError 斜杠命令解析失败
ConfigParseError 配置文件损坏或无法读取; 带有默认回退到 filePath, defaultConfig
ShellError Shell 命令以非零代码退出 stdout, stderr, code, interrupted
TeleportOperationError Teleport 需要面向用户的格式化消息的 SSH 操作 formattedMessage
TelemetrySafeError_I_VERIFIED_... 可以安全发送到遥测的错误(无文件路径,无代码) telemetryMessage

The isAbortError 三向检查

中止信号来自三个不同的源,具体取决于上下文,并且它们的类名在生产版本中被破坏。 助手守护着这三个人:

export function isAbortError(e: unknown): boolean {
  return (
    e instanceof AbortError ||           // our own class
    e instanceof APIUserAbortError ||    // SDK's class — checked by instanceof
    (e instanceof Error && e.name === 'AbortError')  // DOMException from AbortController
  )
}
为什么不直接检查一下 e.name 对于所有三个?
SDK 的 APIUserAbortError 永远不会设置 this.name,并缩小将 mangle 构造函数名称构建为短字符串,例如 'nJT'。 字符串匹配在生产中默默失败。 来源中的评论明确表明了这一点。

实用助手——捕获点边界

而不是铸造 unknown to Error 在任何地方,一小组函数都会对边界处的 catch 值进行标准化:

// Normalize unknown → Error (use at catch-site when you need an Error instance)
export function toError(e: unknown): Error {
  return e instanceof Error ? e : new Error(String(e))
}

// Only need the message string (for logging, display)
export function errorMessage(e: unknown): string {
  return e instanceof Error ? e.message : String(e)
}

// Trim stack to top-N frames so tool_result payloads don't waste tokens
export function shortErrorStack(e: unknown, maxFrames = 5): string {
  if (!(e instanceof Error)) return String(e)
  if (!e.stack) return e.message
  const lines = e.stack.split('\n')
  const header = lines[0] ?? e.message
  const frames = lines.slice(1).filter(l => l.trim().startsWith('at '))
  if (frames.length <= maxFrames) return e.stack
  return [header, ...frames.slice(0, maxFrames)].join('\n')
}
背景预算
shortErrorStack 专为发送到模型的工具结果而设计。 Full stacks are 500–2000 characters of mostly internal frames. 截断到 5 帧可以使模型的上下文窗口保持空闲,以处理重要的事情。

文件系统错误助手

Node.js 文件系统错误带有 errno 错误对象上的代码,但 TypeScript 将其键入为 any。 代码库用类型化助手替换了不安全的转换模式:

// Safe alternative to (e as NodeJS.ErrnoException).code
export function getErrnoCode(e: unknown): string | undefined

// Covers: ENOENT | EACCES | EPERM | ENOTDIR | ELOOP
export function isFsInaccessible(e: unknown): e is NodeJS.ErrnoException

isFsInaccessible 涵盖五个 errno 代码,因为当尝试访问路径(名为的文件)时,它们中的任何一个都可能出现 .claude 预期目录的位置会触发 ENOTDIR; 循环符号链接给出 ELOOP。 仅检查 ENOENT 会想念这些。

遥测安全纪律

TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 是故意长的。 该名称迫使开发人员在使用之前有意识地承认错误消息不包含敏感数据。 使用两个参数的形式,您可以向用户记录完整消息(带有文件路径),同时将经过清理的版本发送到遥测:

// Two-arg form: full message for user, scrubbed for telemetry
throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
  `MCP tool timed out after ${ms}ms`,  // full message
  'MCP tool timed out'              // telemetry message (no timing data)
)
03 工具格式错误(utils/toolErrors.ts)

当一个工具失败时,它的错误必须针对两个消费者进行格式化:终端(对于用户)和模型(在一个 tool_result block). toolErrors.ts 处理这两个问题,包括带有中心截断的硬性 10,000 个字符上限,以保护上下文预算。

export function formatError(error: unknown): string {
  if (error instanceof AbortError) {
    return error.message || INTERRUPT_MESSAGE_FOR_TOOL_USE
  }
  if (!(error instanceof Error)) return String(error)
  const parts = getErrorParts(error)
  const fullMessage = parts.filter(Boolean).join('\n').trim()
    || 'Command failed with no output'
  if (fullMessage.length <= 10000) return fullMessage

  // Center-truncate: keep head + tail of large outputs
  const halfLength = 5000
  return `${fullMessage.slice(0, halfLength)}\n\n...${fullMessage.length - 10000} characters truncated...\n\n${fullMessage.slice(-halfLength)}`
}

For ShellError,各部分按优先级顺序组装:首先退出代码,然后是 stderr,然后是 stdout。 这反映了开发人员希望看到的内容——首先是最具诊断性的信息。

Zod 验证错误 → LLM 友好消息

当模型调用具有错误架构的工具时, ZodError 转换为模型可以理解和纠正的结构化英语消息:

// Input: ZodError with two issues — missing param + wrong type
// Output:
"FileEditTool failed due to the following issues:
The required parameter `old_string` is missing
The parameter `new_string` type is expected as `string` but provided as `number`"
设计意图
通用 Zod 消息,例如 "Required" 让法学硕士感到困惑。 通过将路径格式化为 todos[0].activeForm 并指定预期类型与接收类型,模型可以在下次尝试时进行自我纠正,而无需人工参与。
04 错误日志接收器 (utils/errorLogSink.ts)

错误日志记录通过接收器模式与实际写入实现分离。 log.ts 是无依赖性的——它对事件进行排队,直到连接了接收器。 errorLogSink.ts 包含繁重的实现(文件 I/O、axios 丰富),并在启动期间初始化一次。

flowchart LR A["logError(err)\nlog.ts"] -->|"queues if no sink"| Q["In-memory queue"] A -->|"drains to sink once attached"| S["logErrorImpl()\nerrorLogSink.ts"] S --> D["logForDebugging()\ndebug log"] S --> F["JSONL append\n~/.cache/claude/errors/DATE.jsonl"] S --> AX{"axios\nerror?"} AX -->|"yes"| EN["Enrich:\nurl, status, body"] AX -->|"no"| F EN --> F style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style F fill:#1e251b,stroke:#6e9468,color:#b8b0a4

接收器将错误作为 JSONL(每行一个 JSON 对象)写入带日期标记的文件。 每个条目包括时间戳、会话 ID、cwd 和版本。 对于 axios 错误,它会提取请求 URL、HTTP 状态和服务器错误正文——这三个字段对于诊断 API 问题最有用。

// Buffered JSONL writer — flushes every 1 second or after 50 entries
// On first write: mkdirSync creates parent dirs, then appendFileSync
// Registered with cleanupRegistry so it flushes on process exit
function createJsonlWriter(options: {
  writeFn: (content: string) => void
  flushIntervalMs?: number     // default: 1000
  maxBufferSize?: number        // default: 50
}): JsonlWriter
仅 Ant 日志记录
The appendToLog 功能守卫开启 process.env.USER_TYPE !== 'ant' — 错误日志仅针对内部 Anthropic 员工编写,而不针对外部用户。 这可以防止在日志文件中累积潜在敏感的用户数据。
05 API 重试引擎(services/api/withRetry.ts)

withRetry 是一个包装每个 Anthropic API 调用的异步生成器。 它实现了复杂的重试策略,可以处理瞬时网络故障、速率限制、身份验证令牌过期、上下文溢出和 Claude 特定的 529 过载状态。

flowchart TD START["API call attempt"] --> TRY["try: operation()"] TRY -->|"success"| RETURN["return result"] TRY -->|"error"| CLASSIFY["Classify error"] CLASSIFY --> ABORT{"AbortSignal\nset?"} ABORT -->|"yes"| THROW_ABORT["throw APIUserAbortError"] ABORT -->|"no"| FM{"Fast mode\nactive + 429/529?"} FM -->|"short retry-after"| CONTINUE["continue (fast mode preserved)"] FM -->|"long/unknown"| COOLDOWN["triggerFastModeCooldown()\nretryContext.fastMode = false"] COOLDOWN --> CONTINUE FM -->|"no"| BG529{"Background\nquery source?"} BG529 -->|"yes"| DROP["throw CannotRetryError\n(no amplification)"] BG529 -->|"no"| CTX{"Context\noverflow?"} CTX -->|"yes"| ADJUST["Compute adjustedMaxTokens\nretryContext.maxTokensOverride = N\ncontinue"] CTX -->|"no"| AUTH{"Auth\nerror?"} AUTH -->|"401/403 OAuth"| REFRESH["handleOAuth401Error()\nforce token refresh\nnew client on next attempt"] AUTH -->|"Bedrock 403"| CLEAR_AWS["clearAwsCredentialsCache()\nnew client on next attempt"] AUTH -->|"Vertex 401"| CLEAR_GCP["clearGcpCredentialsCache()"] REFRESH --> RETRY_CHECK CLEAR_AWS --> RETRY_CHECK CLEAR_GCP --> RETRY_CHECK CTX -->|"no auth"| RETRY_CHECK{"attempt ≤\nmaxRetries?"} RETRY_CHECK -->|"yes + retryable"| BACKOFF["getRetryDelay(attempt)\nexponential + jitter\nyield SystemAPIErrorMessage"] RETRY_CHECK -->|"no"| THROW_RETRY["throw CannotRetryError"] BACKOFF --> START style RETURN fill:#1e251b,stroke:#6e9468,color:#b8b0a4 style THROW_ABORT fill:#2c1d18,stroke:#c47a50,color:#b8b0a4 style THROW_RETRY fill:#2c1d18,stroke:#c47a50,color:#b8b0a4 style DROP fill:#31271d,stroke:#b8965e,color:#b8b0a4

重试延迟公式

当许多客户端同时达到速率限制时,退避使用具有 ±25% 抖动的指数延迟来避免惊群:

export function getRetryDelay(
  attempt: number,
  retryAfterHeader?: string | null,
  maxDelayMs = 32000,
): number {
  if (retryAfterHeader) {
    const seconds = parseInt(retryAfterHeader, 10)
    if (!isNaN(seconds)) return seconds * 1000  // honor server directive
  }
  const baseDelay = Math.min(
    500 * Math.pow(2, attempt - 1),   // 500ms, 1s, 2s, 4s... cap 32s
    maxDelayMs,
  )
  const jitter = Math.random() * 0.25 * baseDelay
  return baseDelay + jitter
}

529重载错误——特殊处理

HTTP 529 是 Claude 特定的状态代码,表示 API 过载。 它有自己的逻辑,因为:

  • 后台查询源(标题生成器、摘要生成器)立即退出 - 在容量事件期间从数十个客户端重试会放大级联
  • 在 Opus 模型上连续 3 个 529 后,引擎通过以下方式回退到配置的后备模型 FallbackTriggeredError
  • SDK 有时无法设置 status=529 流式传输期间 — 后备检查 error.message.includes('"type":"overloaded_error"')
export function is529Error(error: unknown): boolean {
  if (!(error instanceof APIError)) return false
  return (
    error.status === 529 ||
    // SDK streaming bug: status not set, check message content
    (error.message?.includes('"type":"overloaded_error"') ?? false)
  )
}

上下文溢出自动调整

当请求被拒绝且输入长度为 400" 时 max_tokens 超出上下文限制”错误, withRetry 解析消息中的令牌计数并自动减少 maxTokens 进行下一次尝试 - 无需任何用户操作:

// Error message format:
// "input length and `max_tokens` exceed context limit: 188059 + 20000 > 200000"

// Auto-adjustment:
const availableContext = Math.max(0, contextLimit - inputTokens - 1000) // safety buffer
retryContext.maxTokensOverride = Math.max(FLOOR_OUTPUT_TOKENS, availableContext, minRequired)
楼层输出代币
下限设定为 3,000 个代币。 如果甚至不适合(上下文绝对已满),则会重新抛出错误,而不是尝试会产生截断或空输出的调用。

持久重试模式(CLAUDE_CODE_UNATTENDED_RETRY)

对于无人值守/CI 会话,设置 CLAUDE_CODE_UNATTENDED_RETRY=1 允许无限期重试 429/529,最大退避时间为 5 分钟,总上限为 6 小时。 长时间的等待被分成 30 秒的心跳产量,因此主机环境(CI 运行程序,tmux 会话)不会将进程标记为空闲。

06 面向用户的错误消息常量(services/api/errors.ts)

所有用户可见的错误字符串都定义为一个文件中的命名常量。 这使得它们可搜索、可测试,并防止抛出位置和检测位置之间的消息漂移:

export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
export const TOKEN_REVOKED_ERROR_MESSAGE    = 'OAuth token revoked · Please run /login'
export const REPEATED_529_ERROR_MESSAGE     = 'Repeated 529 Overloaded errors'
export const API_TIMEOUT_ERROR_MESSAGE      = 'Request timed out'
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'

交互式会话与非交互式会话针对媒体错误获得不同的指导。 相同 getImageTooLargeErrorMessage() 函数为 REPL 用户返回“双击 esc 返回”,为 SDK/无头调用者返回“尝试调整图像大小”:

export function getImageTooLargeErrorMessage(): string {
  return getIsNonInteractiveSession()
    ? 'Image was too large. Try resizing the image or using a different approach.'
    : 'Image was too large. Double press esc to go back and try again with a smaller image.'
}
07 终端错误覆盖(ink/components/ErrorOverview.tsx)

当未处理的异常到达 Ink 渲染树时, ErrorOverview 直接在终端中显示它——不是堆栈转储,而是带有崩溃位置和内联源上下文的格式化覆盖层。 它使用两个库: StackUtils 解析 V8 堆栈帧,以及 code-excerpt 读取并显示相关源代码行。

// 1. Parse the first stack frame to get file + line + column
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined

// 2. Read source file synchronously (sync OK: error overlay, can't go async)
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)

// 3. Render: crash line is highlighted in red; surrounding lines are dim
const isCrashLine = line_0 === origin.line
<Text
  backgroundColor={isCrashLine ? 'ansi:red' : undefined}
  color={isCrashLine ? 'ansi:white' : undefined}
  dim={!isCrashLine}
>
  {' ' + value}
</Text>

如果源文件不可读(例如,进程移动了工作目录或文件被删除),则 readFileSync 被包裹在一片寂静之中 try/catch。 覆盖层会优雅地降级 - 它仍然显示错误消息和完整解析的堆栈,只是没有源代码摘录。

为什么要在这里同步文件 I/O?
该组件在 Ink 协调器内同步渲染。 异步需要重组悬念。 由于这是错误路径(进程已损坏),因此同步 I/O 是可接受的 — 没有 REPL 循环要阻止。

The SentryErrorBoundary

Alongside ErrorOverview,有一个 React 错误边界 components/SentryErrorBoundary.ts。 它捕获来自任何子组件的渲染错误并渲染 null (无声失败)而不是使整个 Ink 树崩溃:

export class SentryErrorBoundary extends React.Component<Props, State> {
  static getDerivedStateFromError(): State {
    return { hasError: true }
  }

  render(): React.ReactNode {
    if (this.state.hasError) return null  // silent: don't crash the whole UI
    return this.props.children
  }
}
两种不同的恢复策略
ErrorOverview 用于终止当前渲染的严重未处理错误 - 它将它们呈现给用户。 SentryErrorBoundary 包装非关键 UI 组件,这些组件可以静默删除而不会中断整个会话。
08 对话恢复(utils/conversationRecovery.ts)

当 Claude Code 中途崩溃或被强制终止时,磁盘上的对话记录可能会处于不一致的状态。 conversationRecovery.ts 负责加载、清理转录本并将其恢复为可恢复状态。

四阶段反序列化管道

deserializeMessagesWithInterruptDetection 通过四个过滤器运行原始持久消息,然后将它们传回 REPL:

第一阶段

旧版迁移

转换旧的附件类型(new_filefile, new_directorydirectory)和回填缺失 displayPath fields

第二阶段

去除不良的权限模式

Remove permissionMode 值不在当前版本中 PERMISSION_MODES set — 防止因过时的配置而崩溃

第三阶段

过滤无效消息

删除未解析的 tool_use 对、孤立的仅思考辅助消息和仅空白辅助消息

第四阶段

中断检测

将转录结束分类为:none(已完成)/interrupted_prompt(用户发送消息,AI 从未回复)/interrupted_turn(AI 正在使用工具)

中断分类

过滤后,最后一条“回合相关”消息(跳过 system, progress和 API 错误助手)确定发生了什么:

// Last message is an assistant → turn completed normally
if (lastMessage.type === 'assistant') return { kind: 'none' }

// Last message is a plain user prompt → CC hadn't started responding
if (lastMessage.type === 'user' && !isToolUseResultMessage(lastMessage))
  return { kind: 'interrupted_prompt', message: lastMessage }

// Last message is a tool_result → AI was mid-tool-use
if (isToolUseResultMessage(lastMessage)) {
  // Special case: brief mode ends on SendUserMessage tool_result legitimately
  if (isTerminalToolResult(lastMessage, messages, lastMessageIdx))
    return { kind: 'none' }
  return { kind: 'interrupted_turn' }
}

综合连续消息

An interrupted_turn (AI被杀时处于中间工具状态)转换为 interrupted_prompt 通过注射合成物 “从上次停下的地方继续。” 用户留言。 这统一了两种中断类型,因此消费者只需要处理一种情况:

if (internalState.kind === 'interrupted_turn') {
  const [continuationMessage] = normalizeMessages([
    createUserMessage({ content: 'Continue from where you left off.', isMeta: true })
  ])
  filteredMessages.push(continuationMessage!)
  turnInterruptionState = { kind: 'interrupted_prompt', message: continuationMessage! }
}

API 有效哨兵

如果所有过滤后的最后一条相关消息是用户消息,则 Anthropic API 将拒绝该对话(该对话必须在助理轮流进行流式传输时结束)。 一种合成的 NO_RESPONSE_REQUESTED Assistant Sentinel 拼接在用户消息之后,因此即使不采取恢复操作,对话也始终是 API 有效的。

removeInterruptedMessage拼接合约
哨兵插入于 lastRelevantIdx + 1,不在数组末尾。 这是故意的: removeInterruptedMessage calls splice(idx, 2) 删除用户消息和哨兵作为一对。 如果有尾随,在末尾插入会破坏这个 system or progress messages.

技能状态恢复

反序列化之前, restoreSkillStateFromMessages 行走的成绩单 invoked_skills 附件并在进程状态中重新注册这些技能。 如果没有这个,恢复后的第二次压缩将失去对哪些技能处于活动状态的跟踪:

for (const message of messages) {
  if (message.attachment?.type === 'invoked_skills') {
    for (const skill of message.attachment.skills) {
      addInvokedSkill(skill.name, skill.path, skill.content, null)
    }
  }
  // Suppress re-sending skill listing if already in transcript
  if (message.attachment?.type === 'skill_listing') suppressNextSkillListing()
}
09 端到端错误流程
flowchart TD subgraph "Runtime Errors" TE["Tool execution\nfails"] --> FE["formatError()\ntoolErrors.ts"] FE --> TR["tool_result block\nto model"] FE --> TER["Terminal display"] end subgraph "API Errors" AE["Anthropic API\nreturns error"] --> WR["withRetry()\nwithRetry.ts"] WR -->|"retryable"| BACK["Backoff + yield\nSystemAPIErrorMessage"] BACK --> WR WR -->|"exhausted"| CNR["CannotRetryError\nwraps original"] WR -->|"529 × 3 Opus"| FBT["FallbackTriggeredError"] CNR --> EL["logError()\nerrorLogSink.ts"] EL --> JSONL["JSONL log file\n~/.cache/claude/errors/"] CNR --> UI["ErrorOverview.tsx\nterminal overlay"] end subgraph "Session Recovery" CRASH["Process killed\nmid-turn"] --> RESUME["--continue / --resume"] RESUME --> DSR["deserializeMessages()\nconversationRecovery.ts"] DSR --> FILTER["4-stage filter pipeline"] FILTER --> DETECT["detectTurnInterruption()"] DETECT -->|"interrupted_prompt"| AUTO["Auto-resumes\nwith user message"] DETECT -->|"interrupted_turn"| SYNTH["Inject synthetic\n'Continue...' message"] SYNTH --> AUTO DETECT -->|"none"| CLEAN["Clean resume\nno action needed"] end style UI fill:#2c1d18,stroke:#c47a50,color:#b8b0a4 style JSONL fill:#1e251b,stroke:#6e9468,color:#b8b0a4 style AUTO fill:#1c2228,stroke:#7d9ab8,color:#b8b0a4

要点

  • 每个故障域都有一个专用的类型化错误类 - ShellError, ConfigParseError, AbortError 等等——所以调用者使用 instanceof 而不是字符串匹配,它在缩小后仍然存在。
  • isAbortError 检查三个不同的中止形状(自己的类、SDK 类、DOMException),因为缩小破坏 constructor.name 并且 SDK 永远不会设置 this.name.
  • withRetry 在一个循环中处理 10 多种不同的故障模式:529 模型回退、上下文溢出自动调整、OAuth 令牌刷新、Bedrock/Vertex 身份验证缓存清除和过时连接保持活动禁用。
  • 后台查询源(标题生成器、摘要生成器)在 529 上立即退出,无需重试 - 放大来自数十个并发客户端的容量事件将使中断变得更糟。
  • 工具错误在发送到模型之前会被截断为 10,000 个字符(5k 头 + 5k 尾)——否则大型编译器输出会浪费整个上下文窗口。
  • 航站楼 ErrorOverview 同步读取崩溃源文件并突出显示确切的行 - 可以接受,因为这是错误路径并且 REPL 已经损坏。
  • 对话恢复运行 4 级过滤器管道来清理损坏的记录,然后对中断类型进行分类并注入合成消息,以使对话 API 在恢复之前有效。
  • TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 使用故意长的名称作为代码审查强制功能 - 开发人员必须有意识地确认消息中没有敏感数据。

知识检查

Q1. 为什么会 isAbortError use instanceof APIUserAbortError 而不是检查 e.name === 'APIUserAbortError'?
Q2. 什么是 withRetry 当它收到上下文溢出 400 错误时怎么办?
Q3. 为什么像标题生成器这样的后台查询源会在 529 上立即退出而不重试?
Q4. When deserializeMessages 找到一个 interrupted_turn (进程在工具使用过程中被杀死),它注入了什么?
Q5. 为什么会 ErrorOverview.tsx 使用同步 readFileSync 而不是异步读取?