- Published on
Claude 3 API 529/503 과부하 재시도·백오프 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 바쁜 시간대에 Claude 3 API를 호출하면 529 또는 503을 만나기 쉽습니다. 이 둘은 대개 “요청이 잘못됐다”가 아니라 “지금은 처리 여력이 없다”에 가깝기 때문에, 클라이언트가 재시도 전략을 제대로 갖추면 성공률과 지연 시간을 동시에 개선할 수 있습니다.
문제는 많은 구현이 다음 두 가지 실수를 한다는 점입니다.
- 모든 실패를 동일하게 재시도한다
- 즉시 재시도하거나, 고정 간격으로 재시도해서 트래픽을 더 악화시킨다
이 글에서는 529/503 과부하를 중심으로, 지수 백오프 + 지터, Retry-After 준수, 동시성 제한, 서킷 브레이커, 관측 지표 설계까지 한 번에 정리합니다.
529와 503을 “같은 재시도”로 보면 안 되는 이유
Claude 3 API에서 관측되는 과부하 계열 응답은 크게 두 축으로 나뉩니다.
503 Service Unavailable: 일시적인 장애, 유지보수, 업스트림 문제 등 “서비스가 현재 요청을 처리할 수 없음”529(Overloaded 계열로 사용되는 경우가 많음): 용량 초과, 순간적인 폭주, 내부 큐 적체 등 “지금은 너무 바쁨”
둘 다 재시도 대상인 경우가 많지만, 운영 관점에서 다음을 분리하는 게 좋습니다.
- 즉시 실패로 처리할 케이스:
400계열 스키마 오류, 인증 실패, 툴 스키마 오류 등 - 재시도 후보:
429(레이트리밋),503,529, 네트워크 타임아웃
특히 툴 사용을 붙인 상태에서 400이 나면 재시도해도 계속 실패합니다. 이런 경우는 먼저 스키마를 고쳐야 합니다. 관련해서는 Claude Tool Use 400 invalid_tool_schema 해결 가이드도 같이 참고하면 좋습니다.
재시도 설계의 핵심: “재시도는 트래픽을 늘린다”
재시도는 성공률을 올려주지만, 동시에 추가 트래픽입니다. 과부하 상태에서 무분별한 재시도는 다음 현상을 만들 수 있습니다.
- 서버 큐가 더 길어짐
- 동일 요청이 여러 번 실행되어 비용 증가
- 지연 시간이 폭증하고 타임아웃이 연쇄적으로 발생
따라서 재시도는 “몇 번 더 던져보자”가 아니라, 서버가 회복할 시간을 주면서, 동시에 클라이언트도 버티는 형태여야 합니다.
권장 정책 요약
아래 정책 조합이 실무에서 가장 안정적입니다.
503/529/429및 네트워크 오류만 재시도Retry-After헤더가 있으면 최우선으로 존중- 지수 백오프(Exponential Backoff) + 지터(Jitter)
- 최대 재시도 횟수 + 최대 대기 시간 상한
- 요청 단위 타임아웃(예: 60초)과 재시도 전체 예산(예: 2분)을 분리
- 동시성 제한(큐잉)과 서킷 브레이커로 폭주 차단
- 멱등성 키 또는 요청 해시로 중복 실행을 방지(가능한 범위에서)
Node.js/TypeScript 재시도·백오프 구현 예시
아래 코드는 다음 요구를 만족합니다.
Retry-After가 있으면 그대로 대기- 없으면 지수 백오프 + Full Jitter
- 재시도 가능한 상태 코드만 재시도
- 전체 재시도 예산을 초과하면 중단
주의: MDX에서 부등호 문자가 노출되면 빌드 에러가 날 수 있으니, 코드 블록 밖에서는
<>또는 인라인 코드로 처리합니다.
type RetryableStatus = 429 | 503 | 529;
function isRetryableStatus(status: number): status is RetryableStatus {
return status === 429 || status === 503 || status === 529;
}
function parseRetryAfterMs(value: string | null): number | null {
if (!value) return null;
// Retry-After는 초 또는 HTTP-date일 수 있음
const asSeconds = Number(value);
if (Number.isFinite(asSeconds)) return Math.max(0, asSeconds * 1000);
const asDate = Date.parse(value);
if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function fullJitterDelayMs(baseMs: number, capMs: number, attempt: number) {
const exp = Math.min(capMs, baseMs * 2 ** attempt);
return Math.floor(Math.random() * exp);
}
type FetchLikeResponse = {
ok: boolean;
status: number;
headers: { get(name: string): string | null };
text(): Promise<string>;
json(): Promise<unknown>;
};
type FetchLike = (input: string, init?: Record<string, unknown>) => Promise<FetchLikeResponse>;
export async function callWithRetry<T>(
fetchFn: FetchLike,
url: string,
init: Record<string, unknown>,
parse: (res: FetchLikeResponse) => Promise<T>,
opts?: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
totalBudgetMs?: number;
}
): Promise<T> {
const maxAttempts = opts?.maxAttempts ?? 6;
const baseDelayMs = opts?.baseDelayMs ?? 400;
const maxDelayMs = opts?.maxDelayMs ?? 10_000;
const totalBudgetMs = opts?.totalBudgetMs ?? 60_000;
const startedAt = Date.now();
let lastErr: unknown;
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
const elapsed = Date.now() - startedAt;
if (elapsed > totalBudgetMs) {
throw new Error(`retry budget exceeded after ${elapsed}ms`);
}
try {
const res = await fetchFn(url, init);
if (res.ok) return await parse(res);
if (!isRetryableStatus(res.status)) {
const body = await res.text().catch(() => "");
throw new Error(`non-retryable status=${res.status} body=${body}`);
}
// retryable
const retryAfter = parseRetryAfterMs(res.headers.get("retry-after"));
const delay = retryAfter ?? fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
// 마지막 시도면 종료
if (attempt === maxAttempts) {
const body = await res.text().catch(() => "");
throw new Error(`retry exhausted status=${res.status} body=${body}`);
}
await sleep(delay);
continue;
} catch (e) {
lastErr = e;
// 네트워크 오류도 재시도하되, 마지막이면 throw
if (attempt === maxAttempts) throw e;
const delay = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
await sleep(delay);
}
}
throw lastErr instanceof Error ? lastErr : new Error("unknown error");
}
왜 Full Jitter가 좋은가
지수 백오프만 쓰면 많은 클라이언트가 비슷한 타이밍에 함께 재시도해서 “재폭주”가 생깁니다. Full Jitter는 대기 시간을 0부터 상한까지 랜덤으로 분산시켜 동시 재시도 파도를 줄입니다.
동시성 제한: 재시도보다 먼저 해야 할 일
과부하 상황에서 가장 먼저 해야 할 것은 “재시도”가 아니라 동시 요청 수를 줄이는 것입니다.
- 한 프로세스에서 Claude 호출을 무제한으로 날리면,
529가 늘고 비용도 튑니다. - 제한된 워커 수로 큐잉하면, 성공률이 오르고 P95 지연이 안정됩니다.
간단한 패턴은 p-limit 같은 라이브러리로 동시성을 제한하는 겁니다.
import pLimit from "p-limit";
const limit = pLimit(5); // 동시에 5개만 Claude 호출
async function runBatch(inputs: string[]) {
return await Promise.all(
inputs.map((x) =>
limit(async () => {
// callWithRetry로 감싼 Claude 호출
return await doClaudeRequest(x);
})
)
);
}
운영에서는 이 “동시성 5” 같은 숫자를 고정값으로 박지 말고, 다음 신호에 따라 조절하는 게 좋습니다.
529비율이 증가하면 동시성 감소- 평균 지연이 낮고
529가 거의 없으면 동시성 증가
서킷 브레이커: 실패가 일정 수준을 넘으면 잠깐 멈추기
과부하가 심할 때는 “계속 두드리기”보다 “잠깐 멈추고 회복을 기다리기”가 전체 성공률을 올립니다.
서킷 브레이커의 기본 규칙은 간단합니다.
- 최근 N건 중 실패율이 임계치 이상이면 Open
- Open 상태에서는 즉시 실패(또는 빠른 폴백)
- 일정 시간이 지나면 Half-Open으로 소량만 통과
- 통과 성공률이 회복되면 Close
Node.js에서는 opossum 같은 구현체를 쓰거나, 간단한 인메모리 상태로도 시작할 수 있습니다.
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
class SimpleCircuitBreaker {
private state: CircuitState = "CLOSED";
private openedAt = 0;
private failCount = 0;
private successCount = 0;
constructor(
private readonly openAfterFails: number,
private readonly coolDownMs: number
) {}
canPass() {
if (this.state === "CLOSED") return true;
if (this.state === "OPEN") {
if (Date.now() - this.openedAt >= this.coolDownMs) {
this.state = "HALF_OPEN";
this.failCount = 0;
this.successCount = 0;
return true;
}
return false;
}
// HALF_OPEN: 1~소량만 허용하는 정책이 이상적이지만, 여기선 단순화
return true;
}
onSuccess() {
if (this.state === "HALF_OPEN") {
this.successCount++;
if (this.successCount >= 3) this.state = "CLOSED";
}
}
onFailure() {
this.failCount++;
if (this.state === "HALF_OPEN" || this.failCount >= this.openAfterFails) {
this.state = "OPEN";
this.openedAt = Date.now();
}
}
}
이걸 Claude 호출 앞단에 붙이면, 과부하가 길게 이어질 때도 시스템 전체가 “천천히” 실패하고, 다운스트림(큐, DB, 웹 서버)이 같이 무너지는 걸 막을 수 있습니다.
타임아웃과 재시도 예산: 사용자 지연을 통제하기
재시도를 넣으면 사용자 관점 지연이 늘어납니다. 그래서 다음 두 개를 분리해야 합니다.
- 요청 1회의 타임아웃: 예를 들어 60초
- 재시도 전체 예산: 예를 들어 2분
이렇게 하면 “한 번이 너무 오래 걸려서” 재시도 기회 자체가 사라지는 문제를 줄이고, 동시에 “재시도 때문에 무한 대기”하는 UX도 막습니다.
또한 스트리밍 응답을 쓰는 경우, 첫 토큰이 늦어지는 상황과 네트워크 끊김을 구분해야 합니다. 첫 토큰 타임아웃(예: 10초) 같은 별도 기준을 두면 운영이 쉬워집니다.
멱등성: 재시도로 인한 중복 실행을 줄이는 방법
LLM 호출은 전통적인 결제 API처럼 “완전한 멱등성”을 보장하기 어렵지만, 그래도 다음을 적용하면 피해를 크게 줄일 수 있습니다.
- 동일 입력에 대해
requestId를 부여하고, 결과를 캐시 - 백엔드에서 “같은 requestId면 같은 결과를 반환”하도록 저장
- 작업 큐를 쓴다면
deduplication key를 사용
특히 “LLM 결과를 기반으로 결제/주문/권한 변경” 같은 부작용이 있는 작업을 한다면, LLM 호출 실패보다 더 무서운 게 중복 실행입니다. 이런 종류의 중복 방지는 Outbox 패턴과도 연결됩니다. 결제 같은 영역을 다룬다면 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기 관점으로 같이 설계를 점검하는 걸 권합니다.
관측(Observability): 529/503을 “수치”로 다루기
재시도 로직이 제대로 동작하는지 확인하려면 지표가 필요합니다. 최소한 아래는 찍어야 합니다.
- 상태 코드별 비율:
status=200/429/503/529카운트 - 재시도 횟수 분포: attempt
0..N - 최종 성공률: “재시도 포함 성공”과 “1회차 성공”을 분리
- 지연: P50/P95/P99, 그리고 “첫 토큰까지 시간”(스트리밍일 때)
- 큐 길이 및 동시성(세마포어) 사용률
추가로, 쿠버네티스에서 워커가 과부하로 죽었다 살아나면 재시도 폭풍이 더 커질 수 있습니다. 파드가 재시작 루프를 타는 상황이라면 애플리케이션 레벨 재시도 이전에 인프라 상태부터 확인해야 합니다. Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트가 도움이 됩니다.
운영 체크리스트
마지막으로 529/503 대응을 배포 전에 점검할 수 있는 체크리스트입니다.
- 재시도 대상이
503/529/429및 네트워크 오류로 제한되어 있는가 Retry-After를 우선 적용하는가- 지수 백오프 + 지터가 적용되어 재시도 타이밍이 분산되는가
- 최대 재시도 횟수와 전체 예산이 존재하는가
- 동시성 제한이 있는가(무제한
Promise.all금지) - 서킷 브레이커 또는 폴백 경로가 있는가
- 멱등성 키 또는 결과 캐시로 중복 실행을 줄였는가
- 상태 코드, 재시도 횟수, 지연 지표가 대시보드에 있는가
정리
Claude 3 API의 529/503은 “클라이언트가 더 똑똑해져야 하는 신호”입니다. 핵심은 재시도를 하되, 서버가 회복할 시간을 주고(백오프), 재시도 트래픽을 분산시키고(지터), 애초에 폭주를 만들지 않는 것(동시성 제한), 그리고 장기 장애에서 빠르게 포기하는 것(서킷 브레이커)입니다.
이 4가지를 갖추면 과부하 시간대에도 성공률이 유의미하게 개선되고, 비용과 지연이 함께 안정화됩니다.