Anthropic SDK의 max_retries가 자체 구현한 재시도 로직과 중첩되면서 발생한 문제: 작업당 36회 호출
요약
Anthropic SDK의 기본 재시도 로직과 개발자가 자체 구현한 여러 재시도 계층이 결합되면서, 하나의 실패 호출당 최대 36회의 과도한 API 호출(재시도 폭풍)이 발생했습니다. 이는 시스템에 심각한 부하를 주어 라이브 트래픽까지 다운시키는 결과를 초래했습니다. 해결책으로 모든 재시도 로직의 곱셈 값을 기록하고, SDK가 빠른 HTTP 재시도를 담당하게 하며 외부 재시도는 느리고 체크포인트 방식으로 처리해야 합니다.
핵심 포인트
- Anthropic SDK는 기본적으로 2회 재시도(총 3회 시도)를 수행합니다.
- 여러 재시도 계층이 결합되면 시도가 단순 합산이 아닌 곱셈으로 증가할 수 있습니다 (예: 3 x 4 x 3 = 36).
- 재시도 폭풍은 시스템 과부하와 라이브 트래픽 고갈을 유발할 수 있으므로 주의해야 합니다.
- 외부 재시도는 느리고 체크포인트 방식으로, SDK의 빠른 HTTP 재시도를 활용하는 것이 좋습니다.
제 애플리케이션 로그에 따르면 리포트 워커(report worker)가 22분 동안 Claude를 1,302번 호출했습니다. 다음 날 아침에 추가한 httpx 후크(hook)에서는 3,904개의 HTTP 요청이 있었다고 합니다. 둘 다 맞는 수치였는데, 이것이 문제가 되었습니다.
그 차이는 제가 직접 작성한 두 개의 다른 재시도 계층 아래에 기본으로 깔려 있는 Anthropic SDK max_retries에서 비롯되었습니다. 각 계층은 그 자체로는 합리적으로 보였습니다. 하지만 이들이 결합되면서 곱해졌습니다: 3 × 4 × 3 = 하나의 실패한 호출당 36번의 시도가 발생했습니다. 529 overloaded_error 응답이 짧게 연속으로 오자, 이 곱셈은 작은 문제(blip)를 재시도 폭풍(retry storm)으로 만들었습니다. API가 회복했을 때, 그 폭풍이 저의 자체 레이트 리밋에 부딪히면서 라이브 트래픽을 다운시키고 말았습니다.
요약 (TL;DR)
- Anthropic Python SDK는 연결 오류, 408 Request Timeout, 409 Conflict, 429 Rate Limit 및 5xx(529 포함)에 대해 기본적으로 실패한 요청을 2번 재시도합니다(총 3회 시도).
- 만약
messages.create()를 tenacity로 감싸고 작업 큐(job queue)까지 재시도를 한다면, 시도는 더해지는 것이 아니라 곱해집니다. 제 경우 3 × 4 × 3 = 36이었습니다. - 22분간의 과부하 기간 동안 백그라운드 작업은 정상 요청량 대비 9.3배를 생성했으며, 회복 버스트는 라이브 트랜잭션의 18%에서 429 에러를 유발했습니다.
- 해결책: 모든 재시도 계층의 곱셈 값을 기록하고, SDK가 빠른 HTTP 재시도를 담당하게 하고, 외부 재시도는 느리고 체크포인트(checkpointed) 방식으로 처리하며, 백그라운드 트래픽을 제한하여 라이브 요청이 고갈되는 것을 막아야 합니다.
Anthropic SDK는 기본적으로 무엇을 재시도하는가?
공식 Python SDK(anthropic)는 실패한 요청을 기본적으로 두 번 재시도합니다. 따라서 하나의 client.messages.create() 호출이 3개의 HTTP 요청으로 늘어날 수 있습니다. 이 SDK는 연결 오류, 408 Request Timeout, 409 Conflict, 429 Rate Limit 및 모든 5xx(529 포함)를 재시도합니다. 또한 서버가 제공하는 경우 지터(jitter)를 사용한 지수 백오프(exponential backoff)를 사용하며 retry-after 헤더를 존중합니다.
이는 좋은 기본값입니다. 하지만 눈에 보이지 않습니다. 코드 어디에도
36은 어디서 온 것일까요?
이 시스템은 제가 직접 만들고 운영하는 인터뷰 연습 도구인 Preterview입니다 (솔직히 말씀드리자면, 제가 만든 것입니다). 사용자가 실제 음성 인터뷰를 진행하면, 세션이 끝난 후 백그라운드 작업(background job)이 스크립트를 Claude로 전송하여 점수를 매기고 보고서를 작성합니다. preterview.com/en에서 확인하실 수 있습니다. 라이브 인터뷰 과정과 백그라운드 보고서 작업은 하나의 API 키를 공유했는데, 이것이 나중에 중요했습니다.

대략적인 보고서 처리 경로는 다음과 같았습니다:
import anthropic
from tenacity import retry, stop_after_attempt, wait_fixed
from rq import Retry
...
세 개의 계층, 세 사람의 좋은 의도가 합쳐진 결과였습니다:
| 계층 | 시도 횟수 (Attempts) | 추가한 사람 |
|---|---|---|
| Anthropic SDK | 3 | SDK가 자동으로 (silently) |
| ... |
그리고 build_report에 숨겨진 네 번째 승수(multiplier)가 있었습니다. RQ 재시도(retry)는 전체 함수를 다시 시작합니다. 만약 3단계가 실패했다면, 재시도는 이미 성공했던 1단계와 2단계를 다시 실행했습니다. 그날 저녁 제가 처리한 최악의 작업은 42개의 요청을 발생시켰고 여전히 실패했습니다.
왜 재시도 폭풍(retry storm)이 라이브 인터뷰를 망가뜨렸을까요?
서비스 장애 기간 동안 백그라운드 재시도가 쌓였고, API가 복구되자 한꺼번에 모두 실행되었습니다. 이로 인해 분당 입력 토큰 수(input tokens per minute)가 제 할당량 초과(rate limit)를 넘었습니다. 동일한 키를 사용했던 라이브 인터뷰 과정은 429 에러를 받기 시작했습니다.
로그에서 본 타임라인은 다음과 같습니다:
- 0분부터 22분까지: 간헐적인 529 에러가 발생했습니다. 8개의 리포트 워커는 이 시간 동안 대부분 대기(backoff) 상태로 잠들어 있다가 슬롯을 유지했습니다. tenacity의
wait_fixed(2)에는 지터(jitter)가 없어, 함께 실패한 워커들은 함께 재시도했습니다. - 22분: 과부하가 해소되었습니다. 모든 워커가 깨어났고, 대기 중이던 보고서 큐와 RQ의 재시도가 이미 성공했던 단계를 다시 실행했습니다. 보고서 프롬프트는 전체 인터뷰 스크립트를 담고 있어, 각 요청마다 입력 토큰(input tokens) 사용량이 많았습니다.
- 22분부터 28분까지: 입력 토큰 비율 제한(input-token rate limit)이 초과되었습니다. 실시간 대화(Live turns)는 총 352회 중 **64회(18%)**에서 429 에러를 받았습니다. 음성 레이어는
1. SDK가 빠른 HTTP 재시도(retries) 기능을 자체적으로 보유하고 있습니다. 다른 곳에서는 그렇지 않습니다. 저는 API 호출 주변의 tenacity 라이브러리 사용을 완전히 삭제했습니다. SDK는 이미 지터링된 지수 백오프(jittered exponential backoff)를 가지고 있으며 retry-after 헤더 값을 읽어주는데, 이는 제가 구현한 wait_fixed(2)가 무시하던 부분이었습니다.
2. 트래픽 클래스별로 클라이언트를 분리합니다.
# 실시간 음성 대화(Live voice turns): 지속성보다 지연 시간이 중요합니다. 한 번 재시도하고, 그 다음은 채움말(filler)을 사용합니다.
live = anthropic.Anthropic(max_retries=1, timeout=20.0)
...
3. 외부 재시도는 느리고 체크포인트가 적용됩니다. 이제 작업 재시도 시 몇 초가 아닌 몇 분을 기다리며, 각 단계의 결과는 저장되어 재시작하는 대신 이어서 진행할 수 있습니다.
def step(session_id, name, **kw):
if (hit := store.get(session_id, name)):
return hit
...
4. 529 에러에 대한 회로 차단기(circuit breaker)를 적용했습니다. 지난 20개 백그라운드 호출 중 30% 이상이 529 에러를 받으면, 워커들이 보고서 작업을 가져오는 것을 60초 동안 중단합니다. 보고서는 API에 과부하가 걸리는 대신 대기열에서 기다립니다.
5. 백그라운드 트래픽에 토큰 예산(token budget)을 할당했습니다. 간단한 토큰 버킷(token bucket)이 보고서 워커의 처리량을 분당 입력 토큰 제한의 절반으로 제한합니다. 복구 급증은 여전히 발생할 수 있지만, 실시간 대화가 필요로 하는 절반의 예산을 소모할 수는 없습니다.
개선되었을까요?
일주일 후 또 다른 과부하 구간이 있었는데, 약 15분 동안 지속되었습니다. 보고서 워커는 평소 요청량의 1.3배만 보냈고(9.3배가 아닌), HTTP 대 호출 비율은 최고 2.4를 기록했으며, 실시간 대화에서는 0건의 429 에러가 발생했습니다. 가장 느린 보고서는 11분 늦게 도착했는데, 이는 40초보다 나쁘지만 42개 요청 청구서와 함께 실패한 보고서를 받는 것보다는 훨씬 좋습니다.
솔직히 말씀드리자면: 저는 통제된 실험이 아니라 두 번의 사고를 경험했습니다. 두 번째 정전은 더 짧았고 덜 심각했을 수 있습니다. 제가 확실하게 말할 수 있는 것은 산술적인 부분입니다. 이전 설정은 설계상 실패하는 호출당 최대 36번의 시도를 할 수 있었지만, 새로운 설정은 12회로 제한되었으며 이는 13분에 걸쳐 분산됩니다.
다음 정전 전에 체크해야 할 목록
다음 정전 전에 확인해야 할 목록
- LLM 클라이언트를 건드리는 모든 코드 경로에서
@retry,Retry(,backoff, 및max_retries를 검색하세요. 찾은 것을 곱셈 처리하세요. - SDK를 자체 재시도 로직으로 감싸는 경우, 해당 클라이언트의
max_retries=0으로 설정하세요. 둘 중 하나만 선택하세요. - 공유되는 업스트림(upstreams)에 대해 지터(jitter) 없이 고정된 대기 시간을 절대 사용하지 마세요.
- 다단계 작업(multi-step jobs)을 재시도 가능하게 만들기 전에, 재개 가능하도록 만드세요.
- 실시간 트래픽과 백그라운드 트래픽이 동일한 속도 제한 여유분(rate-limit headroom)을 두고 경쟁하지 않도록 하세요.
그렇다면 Anthropic SDK의 max_retries는 실제로 얼마를 비용으로 발생시키나요?
단독으로는 아주 적습니다. Anthropic SDK는 기본적으로 지터 백오프(jittered backoff)와 함께 각 요청을 2번 재시도하며, 이는 합리적인 설정입니다. 문제는 자체 재시도 데코레이터 및 작업 큐 재시도 로직과 결합될 때 발생하는데, 재시도 계층들이 곱셈으로 작용하기 때문입니다. 저의 3 × 4 × 3 설정은 실패한 호출당 36번의 시도를 허용했고, 필요한 요청 수 420개에 비해 3,904개의 요청을 생성했으며, 복구 버스트(recovery burst)는 실시간 트ิร์น의 18%에서 429 에러를 유발했습니다. SDK가 빠른 HTTP 재시도 처리를 담당하게 하고, 외부 재시도는 느리고 체크포인트 방식으로 처리하며, 재시도 로직을 주석으로 작성하고, 사용자가 기다리는 요청들을 고갈시키지 않도록 백그라운드 트래픽에 예산을 할당하세요.
작성자: 인터뷰 준비 플랫폼인 Preterview의 개발자.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기