- Published on
Anthropic Claude 429 레이트리밋 재시도 설계법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM 호출을 운영하다 보면 가장 흔하게 마주치는 장애가 429 Too Many Requests입니다. 특히 트래픽이 스파이크치거나 배치 작업이 겹치는 순간, “재시도 몇 번 하면 되겠지”라는 접근은 오히려 더 큰 폭주를 만들고, 지연(latency)과 비용(cost)을 동시에 악화시킵니다.
이 글에서는 Anthropic Claude에서 429를 만났을 때 재시도 로직을 어떻게 ‘제품 수준’으로 설계할지 다룹니다. 핵심은 다음 3가지입니다.
- 재시도는 마지막 수단: 먼저 동시성/큐/버짓(예산)으로 ‘들어오는 양’을 통제
- 백오프는 지터(jitter) 포함: 동시 재시도로 인한 thundering herd 방지
- 관측 가능성: 429 자체보다 “왜 429가 났는지”를 추적 가능하게
> 참고: LLM 호출은 네트워크/인증 이슈도 자주 섞입니다. 429 대응을 하다가 SSL 검증 실패 같은 문제를 함께 만나면 원인 분리가 어려워집니다. 관련해서는 Python SSL CERTIFICATE_VERIFY_FAILED 10분 해결도 같이 점검해두면 운영이 편해집니다.
429는 ‘에러’가 아니라 ‘흐름 제어 신호’다
429는 “요청이 잘못됐다”가 아니라 “현재 이 속도로는 처리 못 한다”는 신호입니다. 따라서 클라이언트는 다음을 해야 합니다.
- 즉시 실패 처리할 요청과 기다렸다가 재시도할 요청을 구분
- 재시도를 하더라도 동시성/속도를 낮춰 시스템이 회복할 시간을 제공
- 재시도 중에도 사용자 경험을 보호(타임아웃, 부분 결과, 비동기 전환)
실무에서 429는 보통 아래 원인으로 발생합니다.
- 짧은 시간에 요청이 몰림(버스트)
- 워커 수/동시성이 과도함(예: K8s HPA가 급격히 scale-out)
- 동일 사용자/테넌트가 과도하게 사용
- 긴 응답(토큰 사용량↑)으로 처리 시간이 늘어 실질 처리량이 감소
Kubernetes에서 워커가 늘어나는 상황은 429를 더 쉽게 유발합니다. HPA가 빠르게 확장되면 외부 API로의 동시 호출이 폭증하기 때문입니다. HPA/종료 윈도우 등 운영 관점은 Kubernetes HPA가 0으로 안 줄 때 - PDB·윈도우·종료처럼 “스케일 정책이 실제 트래픽을 어떻게 증폭시키는지”를 함께 보는 것이 좋습니다.
재시도 설계의 목표: 성공률이 아니라 ‘안정성’
많은 팀이 재시도 목표를 “성공률 99.9%”로 잡습니다. 하지만 429에서는 성공률만 올리려는 재시도가 다음 문제를 만듭니다.
- 요청 폭주: 실패한 요청이 다시 몰리며 더 큰 429 유발
- 꼬리 지연 증가: 일부 요청이 수십 초~수분까지 늘어남
- 비용 증가: 중복 요청, 타임아웃 후 재시도 등으로 토큰/요금 낭비
따라서 목표를 이렇게 바꾸는 게 좋습니다.
- 시스템이 과부하일 때 자동으로 스스로 속도를 줄이는가
- 사용자에게는 예측 가능한 응답 시간(상한)이 있는가
- 테넌트/사용자 간 공정성(fairness)이 있는가
429 대응의 기본 구성요소 6가지
1) 요청 단위의 Idempotency(멱등성)
재시도는 “같은 요청을 다시 보내는 것”입니다. 서버 측에서 멱등 키를 지원하면 가장 좋고, 클라이언트에서도 최소한 중복 실행을 감지해야 합니다.
- 내부적으로
request_id를 생성해 로그/트레이싱 키로 사용 - 동일 입력/동일 목적의 요청이 중복 실행되면 캐시/결과 재사용 고려
2) 타임아웃 예산(Deadline Budget)
재시도는 무한정 하면 안 됩니다. “총 예산”을 정해두고 그 안에서만 재시도합니다.
- 전체 deadline: 예) 8초
- 1차 시도: 2초
- 재시도 포함 총합이 deadline을 넘으면 즉시 실패/비동기로 전환
3) 백오프(Exponential Backoff) + 지터(Jitter)
지터 없는 백오프는 동시 요청이 같은 리듬으로 재시도하게 만들어 동조화된 폭주를 유발합니다.
권장 패턴:
- base delay: 200~500ms
- factor: 2.0
- max delay: 10~30s
- jitter: full jitter 또는 decorrelated jitter
4) Retry-After/레이트리밋 힌트 존중
응답 헤더/바디에 “언제 다시 시도하라”는 힌트가 있다면 그것이 최우선입니다. 없다면 클라이언트 정책으로 결정합니다.
5) 동시성 제한(Concurrency Limiter)
재시도는 “요청 수를 늘리는 행위”입니다. 그래서 재시도 설계에서 가장 중요한 것은 사실 동시성 제한입니다.
- 프로세스/파드 단위 세마포어
- 테넌트별 동시성 제한
- 엔드포인트(모델/작업 유형)별 동시성 제한
6) 서킷 브레이커(Circuit Breaker)
429가 일정 비율 이상이면 “지금은 보내봤자 또 429”일 가능성이 큽니다. 일정 시간 호출 자체를 멈추고(오픈), 천천히 재개(하프오픈)합니다.
Python 예제: 429 재시도 + 지터 + 동시성 제한
아래 예시는 (1) 동시성 제한, (2) deadline 예산, (3) 429/5xx만 재시도, (4) full jitter 백오프를 포함합니다.
> SDK 버전/메서드명은 환경에 따라 다를 수 있으니, 핵심은 “정책”을 코드로 고정하는 것입니다.
import os
import time
import random
import threading
from dataclasses import dataclass
# 예: anthropic SDK를 쓴다고 가정
# pip install anthropic
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
# 프로세스 내 동시성 제한 (파드/프로세스 단위)
CLAUDE_MAX_CONCURRENCY = int(os.getenv("CLAUDE_MAX_CONCURRENCY", "8"))
_sema = threading.Semaphore(CLAUDE_MAX_CONCURRENCY)
@dataclass
class RetryPolicy:
max_attempts: int = 6
base_delay_s: float = 0.3
max_delay_s: float = 12.0
factor: float = 2.0
total_deadline_s: float = 8.0
class RateLimitError(Exception):
pass
class RetryBudgetExceeded(Exception):
pass
def _full_jitter_delay(cap: float) -> float:
# AWS architecture blog에서 자주 권장하는 full jitter
return random.uniform(0, cap)
def call_claude_with_retry(prompt: str, policy: RetryPolicy = RetryPolicy()):
start = time.monotonic()
attempt = 0
cap = policy.base_delay_s
while True:
attempt += 1
elapsed = time.monotonic() - start
if elapsed > policy.total_deadline_s:
raise RetryBudgetExceeded(f"deadline exceeded after {elapsed:.2f}s")
with _sema:
try:
# 예시: Messages API 형태(개념)
resp = client.messages.create(
model="claude-3-5-sonnet-latest",
max_tokens=512,
temperature=0.2,
messages=[{"role": "user", "content": prompt}],
)
return resp
except Exception as e:
# 실제로는 SDK가 제공하는 예외 타입(예: RateLimitError 등)로 분기 권장
msg = str(e).lower()
is_429 = "429" in msg or "rate limit" in msg
is_retryable_5xx = any(code in msg for code in ["500", "502", "503", "504"])
if not (is_429 or is_retryable_5xx):
raise
if attempt >= policy.max_attempts:
raise RateLimitError(f"max attempts reached: {attempt}") from e
# 세마포어 밖에서 대기(중요: 대기 중에 동시성 슬롯을 점유하지 않음)
cap = min(policy.max_delay_s, cap * policy.factor)
sleep_s = _full_jitter_delay(cap)
# deadline을 넘기지 않도록 마지막에 클램프
remaining = policy.total_deadline_s - (time.monotonic() - start)
if remaining <= 0:
raise RetryBudgetExceeded("no remaining budget")
time.sleep(min(sleep_s, remaining))
포인트 해설
- 대기는 세마포어 밖에서: 동시성 슬롯을 쥔 채로 잠들면 전체 처리량이 급락합니다.
- deadline 기반: “몇 번 재시도”보다 “총 시간 예산”이 사용자 경험에 더 직결됩니다.
- 재시도 대상 최소화: 429/5xx만 재시도하고, 4xx(권한/요청 오류)는 즉시 실패.
Node.js/TypeScript 예제: 큐 기반(버스트 흡수) + 재시도
서버가 동시에 많은 요청을 받는다면, 애플리케이션 레벨에서 작업 큐(Queue)로 버스트를 흡수하는 게 효과적입니다. 아래는 간단한 인메모리 큐 형태의 예시입니다(프로덕션은 Redis/BullMQ/SQS 등을 권장).
type Task<T> = () => Promise<T>;
class ConcurrencyQueue {
private running = 0;
private queue: Array<() => void> = [];
constructor(private readonly concurrency: number) {}
async run<T>(task: Task<T>): Promise<T> {
if (this.running >= this.concurrency) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.running++;
try {
return await task();
} finally {
this.running--;
const next = this.queue.shift();
if (next) next();
}
}
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function fullJitterDelay(capMs: number) {
return Math.floor(Math.random() * capMs);
}
async function withRetry<T>(fn: () => Promise<T>) {
const maxAttempts = 6;
const deadlineMs = 8000;
const baseMs = 300;
const maxMs = 12000;
let capMs = baseMs;
const start = Date.now();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
const msg = String(err?.message ?? err).toLowerCase();
const is429 = msg.includes("429") || msg.includes("rate limit");
const is5xx = ["500", "502", "503", "504"].some((c) => msg.includes(c));
if (!(is429 || is5xx)) throw err;
if (attempt === maxAttempts) throw err;
capMs = Math.min(maxMs, capMs * 2);
const delay = fullJitterDelay(capMs);
const remaining = deadlineMs - (Date.now() - start);
if (remaining <= 0) throw err;
await sleep(Math.min(delay, remaining));
}
}
throw new Error("unreachable");
}
// 사용 예: Claude 호출을 큐로 감싸기
const q = new ConcurrencyQueue(Number(process.env.CLAUDE_MAX_CONCURRENCY ?? 8));
async function callClaude(prompt: string) {
return q.run(() =>
withRetry(async () => {
// 여기에 실제 Claude SDK 호출을 넣는다.
// 예: await anthropic.messages.create(...)
return { ok: true, prompt };
})
);
}
이 패턴의 장점은 단순합니다.
- 큐가 버스트를 흡수해서 외부 API에 전달되는 순간 동시성이 제한됨
- 재시도는 하되, 전체 트래픽을 더 키우지 않음
재시도보다 먼저 해야 할 것: 토큰/요청량 자체를 줄이기
429를 ‘재시도’로만 풀면 근본 원인이 남습니다. 아래는 429를 줄이는 실전 체크리스트입니다.
프롬프트/출력 토큰 최적화
- 불필요한 시스템 프롬프트 반복 제거(템플릿화)
- 출력
max_tokens를 보수적으로 설정 - 대화 히스토리 요약/압축
RAG를 쓰는 경우, “무조건 많이 넣기”는 비용과 레이트리밋을 동시에 악화시킵니다. 구조화 출력과 검증을 통해 재질문/재시도를 줄이는 전략은 RAG 환각을 줄이는 JSON Schema 강제 출력법처럼 출력 품질을 올려 재호출을 줄이는 방향이 장기적으로 효과가 큽니다.
요청 병합/중복 제거
- 동일 사용자 행동으로 여러 번 호출되는지(프론트 더블 클릭, 재전송)
- 동일 문서 요약을 여러 워커가 동시에 수행하는지(락/캐시 필요)
우선순위 큐(Priority Queue)
모든 요청이 똑같이 중요하지 않습니다.
- 실시간 사용자 요청: 높은 우선순위, 짧은 deadline
- 배치/오프라인 작업: 낮은 우선순위, 긴 deadline
429 상황에서는 배치를 자동으로 늦추는 것만으로도 사용자 경험을 지킬 수 있습니다.
운영 관측(Observability): 429를 ‘수치’로 관리하기
재시도 설계는 지표 없이는 최적화할 수 없습니다. 최소한 아래를 수집하세요.
rate_limit.count(429 발생 수)rate_limit.retry_count(재시도 횟수)request.latency_p50/p95/p99queue.depth(큐 적재량)token.in/out(입출력 토큰)- 테넌트/사용자별 상위 N개 사용량
또한 로그에는 다음 필드를 강제하는 것이 좋습니다.
request_id,user_id/tenant_idattempt,backoff_ms,deadline_remaining_ms- 모델명, max_tokens, 입력 크기(대략)
실전 권장 아키텍처: “즉시 재시도”가 아니라 “조절된 재처리”
정리하면, Claude 429 대응의 이상적인 구조는 다음 흐름입니다.
- API Gateway/Backend에서 사용자별/테넌트별 쿼터 적용
- 앱 내부에서 동시성 제한 + 큐로 외부 호출량을 평탄화
- 429 발생 시 짧은 백오프 + 지터로 제한된 횟수만 재시도
- 그래도 실패하면
- 사용자 요청: 빠르게 실패/대체 응답(“잠시 후 다시 시도”)
- 배치 작업: 큐에 재적재(지연 실행)
- 지표 기반으로 동시성/토큰/프롬프트를 조정
이렇게 하면 “429가 떠도 서비스가 무너지지 않는” 방향으로 수렴합니다. 재시도는 그중 일부일 뿐이고, 핵심은 트래픽을 통제하고, 재시도를 동조화시키지 않고, 예산 내에서만 수행하는 것입니다.
체크리스트(바로 적용용)
- 429/5xx만 재시도하고 나머지 4xx는 즉시 실패
- 지터 포함 백오프 적용(full jitter 권장)
- 총 deadline 예산 기반으로 재시도 제한
- 세마포어/큐로 동시성 제한(파드 수가 늘어도 상한 유지)
- 사용자/테넌트별 공정성(별도 큐 또는 토큰 버킷)
- 429 비율이 높으면 서킷 브레이커로 호출 자체를 감속
- 토큰/프롬프트 최적화로 호출량 자체를 줄이기
이 체크리스트를 기준으로 현재 시스템의 429를 “재시도로 덮는” 단계에서 “흐름 제어로 안정화”하는 단계로 끌어올릴 수 있습니다.