- Published on
Claude API 529 Overloaded 재시도·큐잉 패턴 정리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 바쁜 시간대에 Claude API를 호출하다 보면 529 Overloaded를 종종 마주칩니다. 이 코드는 “요청이 잘못됐다”가 아니라 “지금은 처리 여력이 없다”에 가깝습니다. 따라서 해결책도 파라미터 수정이 아니라 트래픽 형태를 바꾸는 것(재시도·완충·제어) 입니다.
이번 글에서는 529를 단순 재시도로 덮는 방식의 함정, 그리고 운영에서 효과가 좋았던 재시도(Backoff+Jitter) + 큐잉(Queue) + 서킷브레이커(Circuit Breaker) + 동시성 제한(Concurrency Limit) 패턴을 한 세트로 정리합니다. (유사한 장애 대응 철학은 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커에서도 비슷하게 적용됩니다.)
529 Overloaded의 의미를 운영 관점에서 해석하기
529는 “일시적 과부하” 신호다
- 429가 “쿼터/레이트리밋 초과”에 가까운 의미라면,
- 529는 “서버가 지금은 못 받는다(일시적)”에 가깝습니다.
즉, 529가 뜨는 순간 같은 요청을 즉시 재전송하면 성공 확률이 낮고, 오히려 동시 요청이 더 늘어 과부하를 증폭시킬 수 있습니다(Thundering Herd).
재시도만으로는 해결되지 않는 이유
- 즉시 재시도는 서버 혼잡을 더 키웁니다.
- 동시성 제한이 없으면 재시도 요청이 기존 요청 위에 쌓입니다.
- 긴 응답 생성(LLM) 특성상 한 요청이 점유하는 시간이 길어, 순간 부하가 빠르게 회복되지 않습니다.
따라서 529 대응은 “몇 번 더 던져보기”가 아니라, 요청을 천천히 보내고(백오프), 한 번에 보내는 양을 줄이고(동시성 제한), 넘치는 요청은 큐에 넣어(완충), 계속 실패하면 잠시 멈추는(서킷브레이커) 방향이 정석입니다.
기본 원칙: 재시도는 ‘지연’과 ‘분산’이 핵심
1) 지수 백오프(Exponential Backoff)
- 1s → 2s → 4s → 8s … 처럼 대기 시간을 늘려 서버가 숨을 돌릴 시간을 줍니다.
2) 지터(Jitter)
- 모든 클라이언트가 동일한 규칙으로 재시도하면 같은 타이밍에 다시 몰립니다.
- 대기 시간에 난수를 섞어 재시도 타이밍을 분산합니다.
3) 재시도 예산(Retry Budget)
- “최대 5회” 같은 단순 횟수 제한도 좋지만,
- 더 운영 친화적인 방식은 “전체 트래픽 대비 재시도 트래픽 비중”을 제한하는 것입니다.
패턴 1: 클라이언트 단 재시도(Backoff + Jitter + 타임아웃)
아래는 Python httpx 기반 예시입니다. 핵심은 다음입니다.
- 짧은 connect timeout + 적절한 read timeout
- 529/503/502 등 재시도 대상 분리
- 지수 백오프 + Full Jitter
- 최대 대기 시간 cap
import asyncio
import random
import httpx
RETRYABLE_STATUS = {529, 502, 503, 504}
async def call_claude(payload: dict, api_key: str) -> dict:
url = "https://api.anthropic.com/v1/messages"
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
async with httpx.AsyncClient(timeout=timeout) as client:
return await _retry(async_fn=lambda: client.post(url, headers=headers, json=payload))
async def _retry(async_fn, max_attempts: int = 6, base_delay: float = 0.5, max_delay: float = 12.0):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
resp = await async_fn()
if resp.status_code in RETRYABLE_STATUS:
# 서버 과부하/게이트웨이 문제는 재시도 후보
delay = _full_jitter_delay(attempt, base_delay, max_delay)
await asyncio.sleep(delay)
continue
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.NetworkError) as e:
# 네트워크 단절도 재시도 가능하지만, 무한 재시도 금지
last_exc = e
delay = _full_jitter_delay(attempt, base_delay, max_delay)
await asyncio.sleep(delay)
raise RuntimeError(f"Request failed after retries: {last_exc}")
def _full_jitter_delay(attempt: int, base_delay: float, max_delay: float) -> float:
# Exponential backoff capped, then random(0, cap)
cap = min(max_delay, base_delay * (2 ** (attempt - 1)))
return random.uniform(0, cap)
운영 팁
- 529가 잦다면
read timeout을 과도하게 늘리는 것보다 동시성 제한+큐잉이 더 효과적입니다. httpx에서 서버가 연결을 끊는 형태의 오류가 동반되면 원인 분석이 필요합니다. 이 경우는 Python httpx RemoteProtocolError 서버 끊김 원인과 해결 같은 네트워크/프록시 계층 점검도 함께 진행하세요.
패턴 2: 동시성 제한(Concurrency Limit)으로 “한 번에 덜 보내기”
재시도를 잘해도, 한 순간에 요청을 200개씩 던지면 529는 계속 납니다. LLM 호출은 CPU/메모리/스케줄링을 오래 점유하므로 동시성 상한이 사실상 필수입니다.
간단한 Semaphore 기반 제한
import asyncio
class ClaudeClient:
def __init__(self, api_key: str, max_in_flight: int = 10):
self.api_key = api_key
self.sem = asyncio.Semaphore(max_in_flight)
async def generate(self, payload: dict) -> dict:
async with self.sem:
return await call_claude(payload, self.api_key)
max_in_flight는 “우리 서비스가 감당 가능한 지연”과 “529 비율”을 보면서 튜닝합니다.- 보통은 낮게(예: 5~20) 시작해 점진적으로 올리는 것이 안전합니다.
패턴 3: 큐잉(Queueing)으로 피크를 흡수하기
동시성 제한만 걸면, 피크 트래픽에서 요청이 그냥 실패하거나 타임아웃으로 떨어질 수 있습니다. 이때 필요한 것이 큐(버퍼) 입니다.
큐잉의 목표
- 사용자 요청을 즉시 처리하지 못해도 “접수”는 하고,
- 백그라운드 워커가 일정 속도로 Claude API를 호출합니다.
인메모리 큐(단일 프로세스) 예시
import asyncio
from dataclasses import dataclass
@dataclass
class Job:
job_id: str
payload: dict
async def worker(name: str, queue: asyncio.Queue, client: ClaudeClient):
while True:
job = await queue.get()
try:
result = await client.generate(job.payload)
# TODO: 결과 저장(DB/캐시) 및 job_id로 조회 가능하게
print(name, job.job_id, "done")
except Exception as e:
# TODO: DLQ(Dead Letter Queue) 또는 재처리 정책
print(name, job.job_id, "failed", e)
finally:
queue.task_done()
async def main():
queue = asyncio.Queue(maxsize=1000) # 큐가 꽉 차면 backpressure
client = ClaudeClient(api_key="...", max_in_flight=10)
workers = [asyncio.create_task(worker(f"w{i}", queue, client)) for i in range(4)]
# enqueue
await queue.put(Job(job_id="1", payload={"model": "claude-3-5-sonnet", "messages": []}))
await queue.join()
for w in workers:
w.cancel()
asyncio.run(main())
큐잉을 도입할 때의 설계 포인트
- Backpressure: 큐가 가득 차면 더 받지 말고 503 또는 “잠시 후 재시도” 응답을 주는 편이 시스템을 살립니다.
- 우선순위: 유료 사용자/관리자 요청을 우선 처리하려면 Priority Queue를 고려합니다.
- 지연 허용: 큐잉은 “성공률”을 높이는 대신 “대기 시간”을 늘립니다. UX 요구사항과 조율이 필요합니다.
패턴 4: 서킷브레이커로 과부하 구간을 ‘빨리’ 감지하고 멈추기
529가 연속으로 발생하면 “지금은 보내봤자 안 된다”는 신호입니다. 이때는 재시도보다 일시 중단이 더 이득입니다.
서킷브레이커 상태
- Closed: 정상 호출
- Open: 일정 시간 호출 차단(즉시 실패 처리)
- Half-Open: 소량만 시험 호출하여 회복 여부 확인
Python에서 직접 구현할 수도 있지만, 운영에서는 관측/알림 연동이 쉬운 라이브러리 또는 미들웨어 형태가 관리가 편합니다. 서킷브레이커 전반의 사고방식은 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커 글의 패턴을 그대로 가져와도 됩니다(상태 머신과 실패율 기반 차단).
패턴 5: 멱등성(Idempotency)과 중복 실행 방지
재시도/큐잉을 넣으면 같은 작업이 두 번 실행될 수 있습니다.
- 클라이언트 타임아웃 후 재요청
- 워커 재시작
- 메시지 브로커 redelivery
따라서 “생성 작업”에는 다음 중 하나가 필요합니다.
- 요청 키(request_id) 를 만들어 서버에서 결과 캐시/락으로 중복 처리 방지
- “이미 처리됨”을 빠르게 반환
간단한 예:
-- job_results 테이블에 job_id UNIQUE
CREATE TABLE job_results (
job_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
result_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
# 의사코드
# 1) INSERT 시도 -> 성공이면 처리 시작
# 2) 이미 존재하면 기존 결과/상태 반환
관측(Observability): 529 대응은 지표 없으면 튜닝이 불가능하다
최소한 아래는 메트릭으로 뽑아야 합니다.
status_code별 비율(특히 529)- 재시도 횟수 분포(p50/p95)
- 큐 길이(Queue depth) 및 대기 시간
- in-flight 요청 수
- 타임아웃/네트워크 오류율
그리고 529가 늘어날 때 함께 확인할 네트워크 계층 이슈도 있습니다. 예를 들어 프록시/인그레스의 타임아웃이 너무 짧으면 응답이 오기 전에 끊겨 재시도가 폭증합니다. 쿠버네티스 환경이라면 EKS ALB Ingress에서 504 Idle timeout만 반복될 때처럼 게이트웨이 타임아웃 설정도 같이 점검하세요.
실전 권장 조합(레시피)
서비스에서 “529가 간헐적으로 발생”하는 정도라면 아래 조합이 가장 무난합니다.
- 동시성 제한: 워커/프로세스 단위로
max_in_flight설정 - 재시도: 529/503/502/504에 한해
Exponential Backoff + Full Jitter, 최대 5~7회 - 큐잉: 피크 흡수(Queue depth 제한 + backpressure)
- 서킷브레이커: 1~2분 단위로 Open, Half-open에서 소량 테스트
- 멱등성 키: job_id/request_id로 중복 실행 방지
이 조합의 핵심은 “성공률을 올리되, 실패 구간에서는 더 조용히 행동한다”입니다. 529는 클라이언트가 공격적으로 굴수록 더 오래 지속되는 경향이 있어, 느리게·적게·줄 세워서 보내는 전략이 가장 효과적입니다.
마무리
Claude API의 529 Overloaded는 예외가 아니라, LLM 기반 서비스에서 충분히 예상 가능한 정상 범주의 운영 이벤트입니다. 중요한 것은 529를 “에러 처리”로만 보지 않고, 트래픽 제어 문제로 보고 설계를 바꾸는 것입니다.
- 재시도는 하되, 반드시 백오프+지터를 넣고
- 요청은 동시성 제한으로 조절하며
- 피크는 큐잉으로 흡수하고
- 연속 실패는 서킷브레이커로 빠르게 차단하고
- 전체 과정은 멱등성과 관측으로 안정화하세요.
이렇게 구성하면 529가 떠도 “장애”가 아니라 “지연”으로 흡수되는 구조를 만들 수 있습니다.