Lesson 44

오류 처리와 복구

타입드 예외 클래스부터 API 재시도 루프, 터미널 오버레이, 턴 도중 끊긴 세션 복구까지, Claude Code 오류 체계 전체를 살펴봅니다.

01 개요

Claude Code는 Anthropic API와 통신하고, 셸 명령을 실행하고, 파일을 읽고 쓰고, 서브 에이전트를 관리하는 장시간 실행 네트워크 프로세스입니다. 이 모든 단계에서 실패가 발생할 수 있습니다. 코드베이스는 오류가 제멋대로 전파되게 두지 않고, 계층형 오류 아키텍처로 실패를 분류하고, 재시도할지 사용자에게 보여줄지 결정하며, 사용자가 항상 의미 있는 메시지를 보게 합니다.

다루는 소스 파일
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 검사를 쓸 수 있고, 이 방식은 프로덕션 빌드에서 난독화나 클래스 이름 변형이 일어나도 살아남습니다.

클래스 목적 핵심 필드
ClaudeError 베이스 클래스, this.name을 하위 클래스 생성자 이름으로 설정
AbortError 사용자 주도 취소 (Escape / Ctrl-C) name = 'AbortError'
MalformedCommandError 슬래시 명령 파싱 실패
ConfigParseError 손상됐거나 읽을 수 없는 설정 파일, 폴백할 기본값을 함께 보관 filePath, defaultConfig
ShellError 셸 명령이 0이 아닌 종료 코드로 끝남 stdout, stderr, code, interrupted
TeleportOperationError 사용자용 메시지 형식이 필요한 Teleport SSH 작업 formattedMessage
TelemetrySafeError_I_VERIFIED_... 텔레메트리로 보내도 안전한 오류, 파일 경로나 코드 없음 telemetryMessage

isAbortError의 3중 검사

abort 신호는 상황에 따라 서로 다른 세 출처에서 오며, 프로덕션 빌드에서는 클래스 이름도 바뀔 수 있습니다. 이 헬퍼는 셋 다 막아 줍니다.

export function isAbortError(e: unknown): boolean {
  return (
    e instanceof AbortError ||           // 자체 클래스
    e instanceof APIUserAbortError ||    // SDK 클래스, instanceof로 검사
    (e instanceof Error && e.name === 'AbortError')  // AbortController에서 온 DOMException
  )
}
왜 세 경우 모두 e.name만 검사하지 않을까요?
SDK의 APIUserAbortErrorthis.name을 전혀 설정하지 않고, 난독화된 빌드에서는 생성자 이름이 'nJT' 같은 짧은 문자열로 바뀝니다. 문자열 비교는 프로덕션에서 조용히 실패합니다. 소스 주석도 이 점을 분명히 적어 둡니다.

유틸리티 헬퍼, catch 지점 경계

모든 곳에서 unknownError로 캐스팅하는 대신, 작은 함수 집합이 경계 지점에서 catch 값을 정규화합니다.

// unknown을 Error로 정규화, Error 인스턴스가 필요한 catch 지점에서 사용
export function toError(e: unknown): Error {
  return e instanceof Error ? e : new Error(String(e))
}

// 메시지 문자열만 필요할 때, 로깅이나 표시용
export function errorMessage(e: unknown): string {
  return e instanceof Error ? e.message : String(e)
}

// tool_result payload가 토큰을 낭비하지 않도록 stack을 상위 N개 프레임으로 자름
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은 모델에 보내는 tool result를 위해 특별히 설계됐습니다. 전체 stack은 대부분 내부 프레임으로 500자에서 2000자까지 차지합니다. 5개 프레임으로 자르면 모델 컨텍스트 창을 더 중요한 정보에 쓸 수 있습니다.

파일시스템 오류 헬퍼

Node.js 파일시스템 오류 객체에는 errno 코드가 담기지만, TypeScript는 이를 any로 취급합니다. 코드베이스는 안전하지 않은 캐스팅 패턴 대신 타입이 붙은 헬퍼를 씁니다.

// (e as NodeJS.ErrnoException).code 대신 쓸 안전한 대안
export function getErrnoCode(e: unknown): string | undefined

// 포함 대상: 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는 의도적으로 이름이 깁니다. 이 이름은 개발자가 이 오류 메시지에 민감한 데이터가 없다는 점을 의식적으로 확인하게 만듭니다. 2인자 형태를 쓰면 사용자에게는 전체 메시지(파일 경로 포함)를 보여 주고, 텔레메트리에는 정제된 버전만 보낼 수 있습니다.

// 2인자 형태: 사용자에게는 전체 메시지, 텔레메트리에는 정제된 메시지
throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
  `MCP tool이 ${ms}ms 후 timeout되었습니다`,  // 전체 메시지
  'MCP tool timeout'              // 텔레메트리 메시지, 시간 정보 없음
)
03 도구 오류 포매팅 (utils/toolErrors.ts)

도구가 실패하면 오류는 두 소비자를 위해 형식화돼야 합니다. 사용자용 터미널, 그리고 모델용 tool_result 블록입니다. 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()
    || '출력 없이 명령이 실패했습니다'
  if (fullMessage.length <= 10000) return fullMessage

  // 중앙 잘라내기, 큰 출력의 앞부분과 뒷부분을 모두 유지
  const halfLength = 5000
  return `${fullMessage.slice(0, halfLength)}\n\n...${fullMessage.length - 10000}자가 잘렸습니다...\n\n${fullMessage.slice(-halfLength)}`
}

ShellError의 경우 파트는 우선순위대로 조립됩니다. 먼저 종료 코드, 그다음 stderr, 마지막으로 stdout입니다. 개발자가 가장 진단에 도움이 되는 정보를 먼저 보게 하려는 순서입니다.

Zod 검증 오류를 LLM 친화 메시지로 바꾸기

모델이 잘못된 스키마로 도구를 호출하면, ZodError는 모델이 이해하고 수정할 수 있는 구조화된 영어 메시지로 변환됩니다.

// 입력: issue 두 개가 있는 ZodError, 누락된 파라미터 + 잘못된 타입
// 출력:
"FileEditTool이 다음 문제로 실패했습니다:
필수 파라미터 `old_string`이 누락되었습니다
파라미터 `new_string`의 타입은 `string`이어야 하지만 `number`가 전달됐습니다"
설계 의도
"Required" 같은 일반적인 Zod 메시지는 LLM에게 혼란스럽습니다. 경로를 todos[0].activeForm처럼 포매팅하고 기대 타입과 실제 타입을 명시하면, 사람 개입 없이도 모델이 다음 시도에서 스스로 수정할 수 있습니다.
04 오류 로그 싱크 (utils/errorLogSink.ts)

오류 로깅은 sink 패턴을 통해 실제 쓰기 구현과 분리됩니다. log.ts는 의존성이 없고, sink가 붙을 때까지 이벤트를 큐에 쌓습니다. errorLogSink.ts는 무거운 구현체(file I/O, axios 정보 보강)를 담고 있으며 시작 시 한 번 초기화됩니다.

flowchart LR A["logError(err)\nlog.ts"] -->|"sink가 없으면 큐에 적재"| Q["메모리 내 큐"] A -->|"sink가 붙으면 비우며 전달"| S["logErrorImpl()\nerrorLogSink.ts"] S --> D["logForDebugging()\n디버그 로그"] S --> F["JSONL 추가\n~/.cache/claude/errors/DATE.jsonl"] S --> AX{"axios\n오류인가?"} AX -->|"yes"| EN["보강:\nurl, status, body"] AX -->|"no"| F EN --> F style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style F fill:#1e251b,stroke:#6e9468,color:#b8b0a4

이 sink는 오류를 JSONL 형식, 줄마다 JSON 객체 하나, 으로 날짜가 붙은 파일에 기록합니다. 각 항목에는 timestamp, session ID, cwd, version이 포함됩니다. axios 오류라면 요청 URL, HTTP status, 서버 오류 body를 꺼내는데, API 문제 진단에 가장 유용한 세 필드입니다.

// 버퍼링된 JSONL writer, 1초마다 또는 50개 항목마다 flush
// 첫 write 시: mkdirSync가 부모 디렉터리를 만들고, 그다음 appendFileSync 실행
// process 종료 시 flush되도록 cleanupRegistry에 등록
function createJsonlWriter(options: {
  writeFn: (content: string) => void
  flushIntervalMs?: number     // 기본값: 1000
  maxBufferSize?: number        // 기본값: 50
}): JsonlWriter
Ant 전용 로깅
appendToLog 함수는 process.env.USER_TYPE !== 'ant' 조건으로 보호됩니다. 오류 로그는 외부 사용자가 아니라 Anthropic 내부 직원에게만 기록됩니다. 이렇게 해서 민감할 수 있는 사용자 데이터가 로그 파일에 쌓이지 않게 합니다.
05 API 재시도 엔진 (services/api/withRetry.ts)

withRetry는 모든 Anthropic API 호출을 감싸는 async generator입니다. 일시적 네트워크 실패, rate limit, 인증 토큰 만료, 컨텍스트 초과, 그리고 Claude 특유의 529 overload status까지 처리하는 정교한 재시도 정책을 구현합니다.

flowchart TD START["API 호출 시도"] --> TRY["try: operation()"] TRY -->|"success"| RETURN["결과 반환"] TRY -->|"error"| CLASSIFY["오류 분류"] CLASSIFY --> ABORT{"AbortSignal\n설정됨?"} ABORT -->|"yes"| THROW_ABORT["APIUserAbortError throw"] ABORT -->|"no"| FM{"Fast mode\n활성 + 429/529?"} FM -->|"짧은 retry-after"| CONTINUE["continue (fast mode 유지)"] FM -->|"길거나 알 수 없음"| COOLDOWN["triggerFastModeCooldown()\nretryContext.fastMode = false"] COOLDOWN --> CONTINUE FM -->|"no"| BG529{"백그라운드\nquery source?"} BG529 -->|"yes"| DROP["CannotRetryError throw\n(증폭 방지)"] BG529 -->|"no"| CTX{"컨텍스트\n초과?"} CTX -->|"yes"| ADJUST["adjustedMaxTokens 계산\nretryContext.maxTokensOverride = N\ncontinue"] CTX -->|"no"| AUTH{"인증\n오류?"} AUTH -->|"401/403 OAuth"| REFRESH["handleOAuth401Error()\n토큰 강제 갱신\n다음 시도에서 새 client"] AUTH -->|"Bedrock 403"| CLEAR_AWS["clearAwsCredentialsCache()\n다음 시도에서 새 client"] AUTH -->|"Vertex 401"| CLEAR_GCP["clearGcpCredentialsCache()"] REFRESH --> RETRY_CHECK CLEAR_AWS --> RETRY_CHECK CLEAR_GCP --> RETRY_CHECK CTX -->|"인증 아님"| RETRY_CHECK{"attempt ≤\nmaxRetries?"} RETRY_CHECK -->|"yes + retryable"| BACKOFF["getRetryDelay(attempt)\n지수 백오프 + jitter\nyield SystemAPIErrorMessage"] RETRY_CHECK -->|"no"| THROW_RETRY["CannotRetryError throw"] 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

재시도 지연 공식

백오프는 많은 클라이언트가 동시에 rate limit에 걸렸을 때 thundering herd를 막기 위해 ±25% jitter가 붙은 지수 지연을 사용합니다.

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(
    500 * Math.pow(2, attempt - 1),   // 500ms, 1s, 2s, 4s... 최대 32s
    maxDelayMs,
  )
  const jitter = Math.random() * 0.25 * baseDelay
  return baseDelay + jitter
}

529 overload 오류, 별도 처리

HTTP 529는 API가 과부하 상태라는 뜻의 Claude 전용 상태 코드입니다. 이 코드에 전용 로직이 있는 이유는 다음과 같습니다.

  • 백그라운드 query source, 제목 생성기나 요약기 등, 는 즉시 중단합니다. 용량 이벤트 중 수십 클라이언트가 재시도하면 연쇄 장애를 더 키우게 됩니다
  • Opus 모델에서 529가 3번 연속 나오면, 엔진은 FallbackTriggeredError를 통해 설정된 fallback 모델로 전환합니다
  • SDK는 스트리밍 중 가끔 status=529를 설정하지 못합니다. 이때는 error.message.includes('"type":"overloaded_error"')를 검사하는 fallback을 씁니다
export function is529Error(error: unknown): boolean {
  if (!(error instanceof APIError)) return false
  return (
    error.status === 529 ||
    // SDK 스트리밍 버그, status가 비어 있으면 메시지 내용을 검사
    (error.message?.includes('"type":"overloaded_error"') ?? false)
  )
}

컨텍스트 초과 자동 조정

요청이 400, "input length and max_tokens exceed context limit" 오류로 거절되면, withRetry는 메시지에서 토큰 수를 파싱하고 다음 시도를 위해 maxTokens를 자동으로 줄입니다. 사용자 동작은 필요 없습니다.

// 오류 메시지 형식:
// "input length and `max_tokens` exceed context limit: 188059 + 20000 > 200000"

// 자동 조정:
const availableContext = Math.max(0, contextLimit - inputTokens - 1000) // 안전 버퍼
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초 heartbeat yield로 쪼개어, 호스트 환경(CI runner, tmux 세션)이 프로세스를 idle로 표시하지 않게 합니다.

06 사용자 노출 오류 메시지 상수 (services/api/errors.ts)

사용자에게 보이는 모든 오류 문자열은 한 파일에 이름 붙은 상수로 정의됩니다. 이렇게 하면 검색과 테스트가 쉬워지고, 오류를 던지는 곳과 감지하는 곳 사이에서 메시지가 어긋나는 일을 막을 수 있습니다.

export const INVALID_API_KEY_ERROR_MESSAGE = '로그인되지 않았습니다 · /login을 실행하세요'
export const TOKEN_REVOKED_ERROR_MESSAGE    = 'OAuth 토큰이 폐기되었습니다 · /login을 실행하세요'
export const REPEATED_529_ERROR_MESSAGE     = '529 Overloaded 오류가 반복 발생했습니다'
export const API_TIMEOUT_ERROR_MESSAGE      = '요청 시간이 초과되었습니다'
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = '크레딧 잔액이 너무 낮습니다'

인터랙티브 세션과 비인터랙티브 세션은 미디어 오류에 대해 서로 다른 안내를 받습니다. 같은 getImageTooLargeErrorMessage() 함수가 REPL 사용자에게는 "esc를 두 번 눌러 돌아가세요"를, SDK나 헤드리스 호출자에게는 "이미지 크기를 줄여 보세요"를 반환합니다.

export function getImageTooLargeErrorMessage(): string {
  return getIsNonInteractiveSession()
    ? '이미지가 너무 큽니다. 이미지를 줄이거나 다른 접근 방식을 시도해 보세요.'
    : '이미지가 너무 큽니다. esc를 두 번 눌러 돌아간 뒤 더 작은 이미지로 다시 시도하세요.'
}
07 터미널 오류 오버레이 (ink/components/ErrorOverview.tsx)

처리되지 않은 예외가 Ink 렌더 트리에 도달하면, ErrorOverview는 이를 터미널에 직접 표시합니다. 단순한 stack dump가 아니라, 크래시 위치와 인라인 소스 컨텍스트를 함께 보여 주는 포맷된 오버레이입니다. 여기에는 두 라이브러리가 쓰입니다. V8 stack frame을 파싱하는 StackUtils, 그리고 관련 소스 줄을 읽고 보여 주는 code-excerpt입니다.

// 1. 첫 stack frame을 파싱해서 file + line + column을 얻음
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined

// 2. 소스 파일을 동기적으로 읽음 (sync 허용, 오류 오버레이 경로라 async로 갈 수 없음)
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)

// 3. 렌더링, 크래시 줄은 빨간색 강조, 주변 줄은 흐리게 표시
const isCrashLine = line_0 === origin.line
<Text
  backgroundColor={isCrashLine ? 'ansi:red' : undefined}
  color={isCrashLine ? 'ansi:white' : undefined}
  dim={!isCrashLine}
>
  {' ' + value}
</Text>

소스 파일을 읽을 수 없으면, 예를 들어 프로세스의 작업 디렉터리가 바뀌었거나 파일이 삭제된 경우, readFileSync는 조용한 try/catch로 감싸집니다. 오버레이는 자연스럽게 성능이 아닌 기능을 축소합니다. 오류 메시지와 파싱된 전체 stack은 계속 보여 주고, 소스 발췌만 빠집니다.

왜 여기서는 sync file I/O를 쓸까요?
이 컴포넌트는 Ink reconciler 내부에서 동기적으로 렌더링됩니다. async로 바꾸려면 suspense 구조를 다시 짜야 합니다. 게다가 이 경로는 이미 프로세스가 깨진 오류 경로이므로 sync I/O를 허용할 수 있습니다. 막아야 할 REPL 루프도 없습니다.

SentryErrorBoundary

ErrorOverview와 함께, components/SentryErrorBoundary.ts에는 React error boundary도 있습니다. 이 경계는 자식 컴포넌트의 렌더 오류를 잡고, 전체 Ink 트리를 크래시 내는 대신 null을 렌더링해 조용히 실패합니다.

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

  render(): React.ReactNode {
    if (this.state.hasError) return null  // 조용히 처리, 전체 UI를 크래시 내지 않음
    return this.props.children
  }
}
서로 다른 두 가지 복구 전략
ErrorOverview는 현재 렌더를 끝내 버리는 치명적인 미처리 오류에 쓰이며, 이를 사용자에게 드러냅니다. SentryErrorBoundary는 전체 세션을 깨지 않고 조용히 제거해도 되는 비치명적 UI 컴포넌트를 감쌉니다.
08 대화 복구 (utils/conversationRecovery.ts)

Claude Code가 턴 도중 크래시 나거나 강제로 종료되면, 디스크에 있는 대화 트랜스크립트는 불일치 상태가 될 수 있습니다. conversationRecovery.ts는 이 트랜스크립트를 로드하고, 정리하고, 다시 이어서 사용할 수 있는 상태로 복원하는 역할을 맡습니다.

4단계 역직렬화 파이프라인

deserializeMessagesWithInterruptDetection는 저장된 원시 메시지를 네 단계 필터를 거친 뒤 REPL에 돌려줍니다.

단계 1

레거시 마이그레이션

예전 attachment 타입을 변환하고 (new_filefile, new_directorydirectory), 빠진 displayPath 필드를 채웁니다

단계 2

잘못된 permission mode 제거

현재 빌드의 PERMISSION_MODES 집합에 없는 permissionMode 값을 제거해, 오래된 설정 때문에 나는 크래시를 막습니다

단계 3

유효하지 않은 메시지 필터링

해결되지 않은 tool_use 쌍, 고아 상태의 thinking 전용 assistant 메시지, 공백만 있는 assistant 메시지를 제거합니다

단계 4

중단 감지

트랜스크립트 끝을 none (완료) / interrupted_prompt (사용자 메시지는 갔지만 AI 응답 없음) / interrupted_turn (AI가 도구 사용 도중 중단됨) 으로 분류합니다

중단 분류

필터링 후에는 마지막 "턴 관련" 메시지, system, progress, API 오류 assistant는 제외, 가 무슨 일이 있었는지 결정합니다.

// 마지막 메시지가 assistant이면 턴이 정상 종료된 것
if (lastMessage.type === 'assistant') return { kind: 'none' }

// 마지막 메시지가 일반 사용자 프롬프트이면 CC가 아직 응답을 시작하지 않은 것
if (lastMessage.type === 'user' && !isToolUseResultMessage(lastMessage))
  return { kind: 'interrupted_prompt', message: lastMessage }

// 마지막 메시지가 tool_result이면 AI가 도구 사용 도중이었던 것
if (isToolUseResultMessage(lastMessage)) {
  // 특수 사례, brief mode는 SendUserMessage tool_result로 정상 종료될 수 있음
  if (isTerminalToolResult(lastMessage, messages, lastMessageIdx))
    return { kind: 'none' }
  return { kind: 'interrupted_turn' }
}

합성 continuation 메시지

interrupted_turn, 즉 AI가 도구 사용 중 종료된 경우, 은 합성 사용자 메시지 "중단된 지점부터 이어서 진행해 주세요."를 주입해 interrupted_prompt로 바뀝니다. 이렇게 하면 두 중단 종류가 하나로 통일되어, 소비자는 한 경우만 처리하면 됩니다.

if (internalState.kind === 'interrupted_turn') {
  const [continuationMessage] = normalizeMessages([
    createUserMessage({ content: '중단된 지점부터 이어서 진행해 주세요.', isMeta: true })
  ])
  filteredMessages.push(continuationMessage!)
  turnInterruptionState = { kind: 'interrupted_prompt', message: continuationMessage! }
}

API 유효성을 위한 sentinel

모든 필터링이 끝난 뒤 마지막 관련 메시지가 사용자 메시지라면, Anthropic API는 이 대화를 거절합니다. 스트리밍 대화는 assistant 턴으로 끝나야 하기 때문입니다. 그래서 합성 NO_RESPONSE_REQUESTED assistant sentinel을 그 사용자 메시지 뒤에 끼워 넣어, 재개 작업을 하지 않더라도 대화가 항상 API에 유효한 상태가 되게 만듭니다.

removeInterruptedMessage splice 계약
이 sentinel은 배열 끝이 아니라 lastRelevantIdx + 1 위치에 삽입됩니다. 의도된 동작입니다. removeInterruptedMessagesplice(idx, 2)를 호출해 사용자 메시지와 sentinel을 한 쌍으로 제거합니다. 끝에 넣으면 뒤에 system이나 progress 메시지가 있을 때 이 계약이 깨집니다.

스킬 상태 복원

역직렬화 전에 restoreSkillStateFromMessages는 트랜스크립트에서 invoked_skills attachment를 찾아 순회하고, 그 스킬들을 프로세스 상태에 다시 등록합니다. 이 과정이 없으면 재개 뒤 두 번째 compaction에서 어떤 스킬이 활성 상태였는지 잃어버리게 됩니다.

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)
    }
  }
  // 트랜스크립트에 이미 있다면 스킬 목록 재전송을 막음
  if (message.attachment?.type === 'skill_listing') suppressNextSkillListing()
}
09 엔드투엔드 오류 흐름
flowchart TD subgraph "런타임 오류" TE["도구 실행이\n실패"] --> FE["formatError()\ntoolErrors.ts"] FE --> TR["tool_result 블록\n모델로 전달"] FE --> TER["터미널 표시"] end subgraph "API 오류" AE["Anthropic API가\n오류 반환"] --> WR["withRetry()\nwithRetry.ts"] WR -->|"retryable"| BACK["Backoff + yield\nSystemAPIErrorMessage"] BACK --> WR WR -->|"소진됨"| CNR["CannotRetryError\n원본 오류를 감쌈"] WR -->|"529 × 3 Opus"| FBT["FallbackTriggeredError"] CNR --> EL["logError()\nerrorLogSink.ts"] EL --> JSONL["JSONL 로그 파일\n~/.cache/claude/errors/"] CNR --> UI["ErrorOverview.tsx\n터미널 오버레이"] end subgraph "세션 복구" CRASH["프로세스가 턴 도중\n종료됨"] --> RESUME["--continue / --resume"] RESUME --> DSR["deserializeMessages()\nconversationRecovery.ts"] DSR --> FILTER["4단계 필터 파이프라인"] FILTER --> DETECT["detectTurnInterruption()"] DETECT -->|"interrupted_prompt"| AUTO["사용자 메시지로\n자동 재개"] DETECT -->|"interrupted_turn"| SYNTH["합성\n'Continue...' 메시지 주입"] SYNTH --> AUTO DETECT -->|"none"| CLEAN["정상 재개\n추가 작업 없음"] 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는 abort의 세 형태, 자체 클래스, SDK 클래스, DOMException, 를 모두 검사합니다. 난독화가 constructor.name을 바꾸고 SDK는 this.name을 설정하지 않기 때문입니다.
  • withRetry는 하나의 루프 안에서 10개가 넘는 실패 모드를 처리합니다. 529 모델 폴백, 컨텍스트 초과 자동 조정, OAuth 토큰 갱신, Bedrock/Vertex 인증 캐시 정리, 오래된 연결 keep-alive 비활성화가 포함됩니다.
  • 백그라운드 query source, 제목 생성기나 요약기, 는 529에서 재시도 없이 바로 중단합니다. 수십 동시 클라이언트가 용량 이벤트를 증폭하면 장애가 더 심해지기 때문입니다.
  • 도구 오류는 모델로 보내기 전에 10,000자, 앞 5k + 뒤 5k, 로 잘립니다. 큰 컴파일러 출력이 아니면 전체 컨텍스트 창을 낭비할 수 있기 때문입니다.
  • 터미널 ErrorOverview는 크래시 난 소스 파일을 동기적으로 읽고 정확한 줄을 강조합니다. 오류 경로이고 REPL이 이미 깨진 상태라서 허용됩니다.
  • 대화 복구는 4단계 필터 파이프라인으로 깨진 트랜스크립트를 정리한 뒤 중단 유형을 분류하고, 재개 전에 대화를 API에 유효한 형태로 만들기 위해 합성 메시지를 주입합니다.
  • TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS는 코드 리뷰에서 강제로 확인하게 만드는 아주 긴 이름을 씁니다. 개발자가 메시지 안에 민감한 데이터가 없음을 의식적으로 확인해야 합니다.

이해도 확인

Q1. isAbortError는 왜 e.name === 'APIUserAbortError'를 검사하지 않고 instanceof APIUserAbortError를 쓸까요?
Q2. withRetry는 컨텍스트 초과 400 오류를 받으면 무엇을 할까요?
Q3. 제목 생성기 같은 백그라운드 query source는 왜 529에서 재시도하지 않고 바로 중단할까요?
Q4. deserializeMessagesinterrupted_turn을 찾으면, 즉 프로세스가 도구 사용 도중 종료됐다면, 무엇을 주입할까요?
Q5. ErrorOverview.tsx는 왜 async read 대신 동기식 readFileSync를 쓸까요?