- Published on
Claude API 529·429 재시도 전략과 구현 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 Claude API를 붙여 운영하다 보면, 기능 구현보다 더 자주 부딪히는 게 529(Overloaded)와 429(Rate limit)입니다. 둘 다 “잠깐 쉬었다가 다시 시도하면 성공할 확률이 높다”는 공통점이 있지만, 같은 방식으로 재시도하면 오히려 트래픽을 더 증폭시키거나(Thundering herd), 비용과 지연만 늘어나는 경우가 많습니다.
이 글에서는 529와 429를 운영 관점에서 구분하고, 재시도 정책을 코드로 구현하는 방법(백오프, 지터, 타임아웃, 회로 차단, 큐잉, 멱등성)을 한 번에 정리합니다.
529 vs 429: “왜 났는지”부터 다르다
529 (Overloaded)
- 의미: 공급자(Claude) 측이 일시적으로 과부하 상태라 요청을 처리하기 어렵다는 신호
- 특징
- 짧은 시간 내 회복되는 경우가 많음
- 동일한 요청을 곧바로 몰아서 재시도하면 더 오래 지속될 수 있음
- 재시도는 권장되지만, 반드시 백오프와 지터가 필요
429 (Rate limit)
- 의미: 클라이언트가 허용된 속도/쿼터를 초과함
- 특징
- “클라이언트가 조절해야” 해결됨
- 헤더에
Retry-After(또는 유사한 rate limit 힌트)가 있다면 그 값을 최우선으로 존중해야 함 - 재시도만으로 해결되지 않고, 동시성 제한/토큰 버킷/큐잉이 필요
정리하면:
529는 공급자 측 혼잡이므로 “기다리면 풀릴 가능성”이 높고429는 내가 과속한 것이므로 “속도 제한 장치”가 필요합니다.
재시도 설계의 기본 원칙 6가지
1) 무조건 재시도는 금물: 상태코드별 정책 분리
- 재시도 후보
429,529,503,502, 네트워크 타임아웃 등
- 즉시 실패 권장
400(요청 자체가 잘못됨),401/403(인증/권한),404(엔드포인트/리소스),422(검증 실패)
2) Exponential Backoff + Full Jitter가 사실상 표준
- 지터 없이
1s, 2s, 4s...로만 재시도하면 모든 인스턴스가 같은 타이밍에 다시 몰립니다. - 권장 패턴(Full Jitter)
sleep = random(0, base * 2^attempt)
3) Retry-After가 있으면 최우선
429의 경우, 서버가 “이만큼 쉬고 와라”를 알려주는 경우가 있습니다.- 이 값은 백오프보다 우선합니다.
4) 재시도 예산(Budget)과 데드라인(Deadline)을 둔다
- 요청당 최대 재시도 횟수만 정하면 “최악의 경우” 지연이 끝없이 늘어납니다.
- 권장
- 전체 데드라인(예: 20초)을 두고, 그 안에서만 재시도
- 혹은 “최대 지연 합”을 제한
5) 멱등성(Idempotency) 확보: 중복 과금/중복 작업 방지
- LLM 호출은 결과가 비결정적일 수 있고, 재시도는 “같은 작업을 여러 번 수행”하게 만듭니다.
- 서버에서 “같은 요청”을 식별할 키를 만들고(요청 해시), 캐시/저장소로 중복 처리를 막아야 합니다.
6) 동시성 제한 + 큐잉이 429의 정공법
- 트래픽이 몰릴 때는 재시도보다 “동시 요청 수를 줄이는 것”이 먼저입니다.
- API 호출을 워커 큐로 흘리고, 워커 동시성만 제한하면
429가 급감합니다.
재시도는 실패 후 대응이고, 동시성 제한은 실패를 예방합니다.
Node.js/TypeScript: 529·429 재시도 유틸리티 구현
아래 예시는 다음을 포함합니다.
429는Retry-After를 우선529는 백오프+지터로 완화- 전체 데드라인 기반 중단
- 재시도 가능한 오류만 선별
주의: 본문에 부등호 문자가 노출되면 MDX 빌드 에러가 날 수 있어, 비교 연산 등은 코드 블록 안에서만 사용합니다.
type RetryOptions = {
maxAttempts: number; // 예: 6
baseDelayMs: number; // 예: 300
maxDelayMs: number; // 예: 8_000
deadlineMs: number; // 예: 20_000
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
function parseRetryAfterMs(headers: Headers): number | null {
const v = headers.get("retry-after");
if (!v) return null;
// Retry-After can be seconds or HTTP date
const asSeconds = Number(v);
if (!Number.isNaN(asSeconds)) return Math.max(0, asSeconds * 1000);
const asDate = Date.parse(v);
if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function isRetryableStatus(status: number) {
return status === 429 || status === 529 || status === 502 || status === 503 || status === 504;
}
function computeFullJitterDelayMs(baseDelayMs: number, attempt: number, maxDelayMs: number) {
const cap = clamp(baseDelayMs * Math.pow(2, attempt), 0, maxDelayMs);
return Math.floor(Math.random() * cap);
}
export async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
const start = Date.now();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
const elapsed = Date.now() - start;
if (elapsed > opts.deadlineMs) {
throw new Error(`Retry deadline exceeded after ${elapsed}ms`);
}
try {
return await fn();
} catch (e: any) {
lastErr = e;
// If this is a fetch Response error wrapper you use, adapt here.
const status: number | undefined = e?.status;
const headers: Headers | undefined = e?.headers;
if (typeof status === "number" && !isRetryableStatus(status)) {
throw e;
}
// Last attempt: rethrow
if (attempt === opts.maxAttempts - 1) {
throw e;
}
// 429: honor Retry-After if present
let delayMs: number;
if (status === 429 && headers) {
const ra = parseRetryAfterMs(headers);
if (ra !== null) {
delayMs = clamp(ra, 0, opts.maxDelayMs);
} else {
delayMs = computeFullJitterDelayMs(opts.baseDelayMs, attempt, opts.maxDelayMs);
}
} else {
// 529 and others: exponential backoff + full jitter
delayMs = computeFullJitterDelayMs(opts.baseDelayMs, attempt, opts.maxDelayMs);
}
// Keep some budget for next tries
const remaining = opts.deadlineMs - (Date.now() - start);
if (remaining <= 0) throw e;
await sleep(Math.min(delayMs, remaining));
}
}
throw lastErr;
}
위 유틸리티를 Claude 호출에 붙이는 방식은 간단합니다. 핵심은 “에러 객체에서 상태코드와 헤더를 꺼낼 수 있게” 래핑하는 것입니다.
type HttpError = Error & { status?: number; headers?: Headers };
async function callClaude(payload: any) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.CLAUDE_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const err: HttpError = new Error(`Claude API error: ${res.status}`);
err.status = res.status;
err.headers = res.headers;
throw err;
}
return res.json();
}
export async function callClaudeWithRetry(payload: any) {
return withRetry(() => callClaude(payload), {
maxAttempts: 6,
baseDelayMs: 300,
maxDelayMs: 8000,
deadlineMs: 20000,
});
}
429를 줄이는 운영 패턴: 동시성 제한(세마포어)
429는 재시도만으로는 한계가 있습니다. 특히 여러 요청을 병렬로 날리는 배치/크론/대화형 스트리밍에서 빈번합니다. 아래는 프로세스 내부에서 동시 호출 수를 제한하는 최소 구현입니다.
class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(max: number) {
this.available = max;
}
async acquire() {
if (this.available > 0) {
this.available -= 1;
return;
}
await new Promise<void>((resolve) => this.queue.push(resolve));
this.available -= 1;
}
release() {
this.available += 1;
const next = this.queue.shift();
if (next) next();
}
}
const claudeSem = new Semaphore(3); // 예: 동시 3개로 제한
export async function callClaudeLimited(payload: any) {
await claudeSem.acquire();
try {
return await callClaudeWithRetry(payload);
} finally {
claudeSem.release();
}
}
이 방식은 단일 인스턴스에만 적용됩니다. 서버가 여러 대라면 Redis 기반 분산 세마포어, 혹은 작업 큐(예: SQS, BullMQ, Cloud Tasks)로 확장하는 게 안전합니다.
멱등성 키로 “재시도 중복 실행”을 제어하기
재시도는 같은 요청을 여러 번 수행할 수 있습니다. 특히 다음 케이스가 위험합니다.
- 타임아웃으로 “응답을 못 받았는데 실제로는 처리됨”
- 네트워크 오류로 결과를 유실
- 클라이언트 재시도와 서버 재시도가 겹침
권장 접근은 “요청을 대표하는 키”를 만들고, 결과를 저장한 뒤 재사용하는 것입니다.
- 키 구성 예
userId+ 프롬프트 해시 + 모델 + 온도 + 시스템 프롬프트 버전
- 저장소
- Redis(짧은 TTL 캐시)
- DB(감사/추적까지 필요하면)
import crypto from "crypto";
function stableHash(input: any) {
const s = JSON.stringify(input);
return crypto.createHash("sha256").update(s).digest("hex");
}
// pseudo: get/set from Redis
async function getCached(key: string): Promise<any | null> { return null; }
async function setCached(key: string, value: any, ttlSec: number): Promise<void> { }
export async function callClaudeIdempotent(payload: any) {
const key = `claude:msg:${stableHash(payload)}`;
const cached = await getCached(key);
if (cached) return cached;
const result = await callClaudeLimited(payload);
await setCached(key, result, 60);
return result;
}
주의할 점은 “같은 입력이면 같은 출력”이 아니라도, 운영적으로는 “같은 요청을 중복 처리하지 않는다”는 목표가 더 중요할 때가 많다는 것입니다. 특히 비용이 큰 생성 요청에서 효과가 큽니다.
재시도 폭주를 막는 회로 차단(Circuit Breaker) 아이디어
529가 장기간 지속되거나, 특정 시간대에 과부하가 반복되면 재시도는 전체 시스템을 더 느리게 만들 수 있습니다. 이때는 짧은 시간 “즉시 실패”로 전환해 상위 계층이 빠르게 폴백하도록 만드는 게 낫습니다.
- 예
- 최근 30초 동안
529비율이 일정 임계치를 넘으면 - 10초 동안 호출을 차단하고(오픈)
- 이후 일부만 시험 호출(하프 오픈)
- 최근 30초 동안
코드까지 모두 싣기엔 길지만, 핵심은 “에러율 기반으로 자동 차단”을 넣는 것입니다.
관측(Observability): 재시도는 반드시 계측해야 한다
재시도 로직은 넣는 순간부터 “성공률이 올라간 대신 지연이 늘었는지”를 봐야 합니다.
권장 메트릭
- 상태코드별 카운트:
429_count,529_count - 시도 횟수 분포:
attempts_histogram - 최종 지연:
end_to_end_latency - 재시도 후 성공률:
retry_success_rate Retry-After수용률: 헤더가 있을 때 실제로 준수했는지
로그에는 최소한 아래를 남기면 트러블슈팅이 빨라집니다.
- 요청 ID(내부 correlation ID)
- 모델, 입력 토큰 추정치(가능하면)
- 최종 상태코드와 시도 횟수
- 백오프 지연값
실전 체크리스트
529대응- Exponential backoff + Full jitter
- 최대 지연 캡(
maxDelayMs) 설정 - 데드라인으로 무한 대기 방지
- 장기 과부하 시 회로 차단 고려
429대응Retry-After우선- 동시성 제한(프로세스/분산)
- 큐잉으로 평탄화
- 요청 합치기(배치), 캐시로 중복 제거
공통
- 멱등성 키로 중복 실행 방지
- 재시도 횟수/지연/성공률 계측
장애 복구 관점에서의 “재시도”는 설계의 일부다
재시도는 단순한 네트워크 편의 기능이 아니라, 분산 시스템에서 실패를 흡수하는 핵심 메커니즘입니다. 특히 여러 서비스가 연쇄 호출하는 구조에서는 재시도 한 번이 다른 계층의 타임아웃과 맞물려 장애를 키울 수 있습니다.
이 관점은 사가(Saga)나 보상 트랜잭션에서도 그대로 적용됩니다. 재시도는 “어디서, 얼마나, 어떤 조건으로” 해야 하는지 정책이 필요합니다. 더 넓은 실패 복구 설계가 궁금하다면 아래 글도 함께 보는 걸 권합니다.
마무리
Claude API의 529와 429는 “가끔 나는 예외”가 아니라, 트래픽이 있는 서비스라면 언젠가 반드시 마주치는 정상적인 운영 이벤트에 가깝습니다.
529는 기다리되, 지터와 데드라인으로 안전하게429는 재시도보다 먼저 동시성 제한과 큐잉으로 구조적으로- 둘 다 멱등성과 계측 없이는 비용/지연/중복 실행 리스크가 커짐
위 패턴을 적용하면 응답 성공률을 올리면서도, 재시도가 시스템을 망치지 않게(폭주하지 않게) 제어할 수 있습니다.