iBetter Books
수정

실패와 재시도와 타임아웃

도구 호출의 실패는 두 층위에서 발생한다. 하나는 너의 하네스가 외부 자원(API·DB·파일시스템)을 부를 때 생기는 실패이고, 다른 하나는 Anthropic API 자체를 부를 때 생기는 실패다. 두 층위 모두 "어떤 에러는 재시도하고, 어떤 에러는 즉시 포기하는가"라는 같은 질문을 던진다. 이 구분을 잘못하면, 영원히 실패할 요청을 무한 재시도하며 비용만 태우거나, 일시적 장애를 영구 실패로 처리해 사용자 경험을 망친다.

재시도 가능한 에러와 재시도 불가능한 에러

가장 먼저 외워야 할 분류 기준이다. 일시적(transient) 장애는 재시도하면 성공할 가능성이 있으므로 재시도한다. 영구적(permanent) 장애는 같은 요청을 다시 보내도 같은 결과가 나오므로 재시도하지 않는다.

상태 의미 재시도
408 / 타임아웃 요청 시간 초과 한다
429 레이트 리밋 초과 한다(Retry-After 존중)
500 서버 내부 오류 보통 한다
502 / 503 / 529 일시적 과부하·게이트웨이 한다
400 잘못된 요청(스키마·파라미터 오류) 안 한다
401 인증 실패 안 한다
403 권한 없음 안 한다
404 리소스 없음 안 한다

재시도 여부와 멱등성 게이트, 백오프를 한 흐름으로 정리하면 다음과 같다.

%% 도구 호출 재시도 결정 flowchart TB E["도구 호출 실패"] Q{"재시도 가능?\n408·429·500·502·503·529"} P["재시도 안 함\n400·401·403·404 영구 실패"] I{"멱등 보장?\n부수효과 작업"} K["멱등성 키 적용"] B["지수 백오프 + full jitter\n최대 시도 한도까지"] FAIL["RetriesExhausted\nis_error로 모델에 보고"] E --> Q Q -->|"아니오"| P Q -->|"예"| I I -->|"아니오"| K --> B I -->|"예"| B B -->|"성공"| OK["완료"] B -->|"한도 소진"| FAIL

시험은 이 표를 변형해 묻는다. "다음 중 자동 재시도 대상이 아닌 것은?" 류 문제에서 401·403·400은 항상 재시도 대상이 아니다. 인증 토큰이 틀렸는데 재시도한들 같은 토큰으로 같은 실패를 반복할 뿐이다. 반대로 429와 529(과부하)는 대표적 재시도 대상이며, 429의 경우 응답의 Retry-After 헤더가 있으면 그 값을 우선 존중해야 한다는 점이 함정으로 자주 나온다.

지수 백오프와 지터

재시도를 즉시·고정 간격으로 하면 안 된다. 장애가 난 서버에 모든 클라이언트가 동시에 재시도를 퍼부으면 회복을 더 방해하는 thundering herd가 생긴다. 표준 해법은 지수 백오프(exponential backoff)에 무작위 지터(jitter)를 더하는 것이다. 대기 시간을 시도 횟수에 따라 기하급수적으로 늘리되, 무작위 흔들림을 섞어 재시도 시점을 분산한다.

# scripts/retry_with_backoff.pyimport randomimport timeRETRYABLE = {408, 429, 500, 502, 503, 529}def call_with_retry(do_request, max_attempts=5, base=1.0, cap=30.0):    for attempt in range(max_attempts):        result = do_request()        if result.ok:            return result        if result.status not in RETRYABLE:            raise PermanentError(result.status)        if attempt == max_attempts - 1:            raise RetriesExhausted(result.status)        retry_after = result.headers.get("retry-after")        if retry_after is not None:            delay = float(retry_after)        else:            delay = min(cap, base * (2 ** attempt))            delay = random.uniform(0, delay)        time.sleep(delay)

여기서 random.uniform(0, delay)가 full jitter다. 단순히 고정 지수만 쓰면 재시도가 동기화되어 다시 몰린다. 시험은 "왜 백오프에 지터가 필요한가"를 묻거나, 코드 조각을 주고 "이 재시도 로직의 결함은?"이라고 물으며 지터 누락이나 무한 재시도(최대 시도 한도 부재)를 답으로 유도한다. 또한 공식 Python·TypeScript SDK는 재시도 가능한 에러에 대해 내장 자동 재시도와 백오프를 제공하므로, 대부분의 경우 직접 구현하기보다 SDK 기본 동작을 신뢰하고 시도 횟수만 조정하는 것이 정답이라는 점도 자주 출제된다.

멱등성 — 재시도의 전제 조건

재시도를 안전하게 하려면 그 작업이 멱등(idempotent)해야 한다. 멱등성이란 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 동일하다는 성질이다. 조회(GET)는 본래 멱등하다. 그러나 "결제하기", "이메일 보내기", "주문 생성" 같은 부수효과가 있는 작업은 그냥 재시도하면 중복 결제·중복 발송이 일어난다. 이때는 멱등성 키(idempotency key)를 도입해, 서버가 같은 키의 중복 요청을 한 번만 처리하도록 만들어야 재시도가 안전해진다.

이것이 시험의 핵심 함정이다. "타임아웃이 났으니 재시도하라"는 단순 처방은 비멱등 작업에서는 위험하다. 정답은 "멱등성을 보장한 뒤 재시도하라" 또는 "비멱등 작업은 멱등성 키 없이 재시도하지 말라"다. 도구를 설계할 때 부수효과가 있는 도구는 멱등성 키를 인자로 받도록 설계하는 것이 강건한 패턴이다.

타임아웃 — 무한 대기를 막는 경계

타임아웃이 없는 도구는 영원히 매달릴 수 있고, 이는 에이전트 루프 전체를 멈춘다. 모든 외부 호출에는 타임아웃을 설정하고, 타임아웃을 재시도 가능한 일시 장애로 다루되 멱등성 조건과 최대 시도 한도를 함께 적용한다. 타임아웃 값은 작업 특성에 맞춰야 한다. 빠른 조회에 30초 타임아웃은 장애 감지를 너무 늦추고, 무거운 배치 작업에 5초 타임아웃은 정상 작업을 실패로 오인한다.

장시간 실행 도구와 pause_turn

오래 걸리는 작업(웹 검색, 대규모 분석 등)에서 모델 응답이 끝까지 완결되지 못하면 stop_reason"pause_turn"으로 돌아올 수 있다. 이는 에러가 아니라 "턴이 일시 중단되었으니 이어서 진행하라"는 신호다. 처리 방법은 반환된 응답 내용을 그대로 다음 요청의 메시지로 다시 넣어 모델이 중단된 지점부터 작업을 이어가게 하는 것이다.

# scripts/continue_paused_turn.pyimport anthropicclient = anthropic.Anthropic()messages = [{"role": "user", "content": "이 주제를 깊게 조사해서 정리해줘."}]while True:    response = client.messages.create(        model="claude-opus-4-8",        max_tokens=16000,        messages=messages,        tools=tools,        thinking={"type": "adaptive"},    )    messages.append({"role": "assistant", "content": response.content})    if response.stop_reason == "pause_turn":        continue    break

stop_reason 값의 의미를 구분하는 것도 단골 출제 포인트다. end_turn은 정상 완료, tool_use는 도구 호출 대기, max_tokens는 토큰 한도로 잘림(에러로 오인 주의), pause_turn은 장시간 턴의 일시 중단, refusal은 안전상 거부다. max_tokens로 잘린 응답을 정상으로 착각해 그대로 파싱하는 것은 흔한 버그이며, 시험은 "응답을 신뢰하기 전에 무엇을 먼저 확인해야 하는가"라는 형태로 stop_reason 확인을 답으로 유도한다. 또한 위 코드에서 thinking은 adaptive만 쓰고 budget_tokens·temperature·top_p는 최신 모델에서 제거되었으므로 함께 쓰지 않는다.

도구 실패를 모델에게 알리는 is_error

하네스가 도구 실행에 실패했고 재시도로도 복구되지 않았다면, 그 실패를 모델에게 보여 줘야 한다. Messages API에서는 tool_result 블록에 is_error: true를 달고 에러 내용을 content에 담아 되돌린다.

# scripts/tool_result_error.pytool_result = {    "type": "tool_result",    "tool_use_id": tool_use.id,    "is_error": True,    "content": [{"type": "text", "text": "Database timeout after 30s. The orders service is unavailable."}],}messages.append({"role": "user", "content": [tool_result]})

이렇게 하면 모델은 실패를 인지하고 대체 경로를 시도하거나 사용자에게 상황을 설명할 수 있다. 다음 절에서 다루지만, 결과 수준 에러(is_error)와 프로토콜 예외의 구분이 이 도메인의 가장 중요한 시험 포인트다. 핵심만 미리 말하면, 도구 실행 중 발생한 실패는 거의 항상 is_error로 모델에게 보여 주는 것이 정답이다.

정리

  • 재시도 가능 에러는 408·429·500·502·503·529 같은 일시 장애이고, 400·401·403·404는 재시도하지 않는다. 429는 Retry-After 헤더를 우선 존중한다.
  • 재시도는 지수 백오프에 full jitter를 더하고 최대 시도 한도를 둔다. 지터 누락과 무한 재시도가 대표적 결함이며, 공식 SDK의 내장 재시도를 활용하는 것이 보통 정답이다.
  • 멱등성은 안전한 재시도의 전제다. 부수효과 있는 도구는 멱등성 키를 받도록 설계하고, 비멱등 작업을 무턱대고 재시도하지 않는다.
  • 모든 외부 호출에 타임아웃을 두고, pause_turn은 에러가 아니라 턴 이어받기 신호로 처리한다. max_tokens로 잘린 응답을 정상으로 오인하지 않도록 stop_reason을 먼저 확인한다.
  • 복구 불가능한 도구 실패는 tool_result의 is_error로 모델에게 보여 주어 모델이 복구를 판단하게 한다.