Published on

Claude API 529 Overloaded 재시도·백오프 설계

Authors

서버가 바쁜 시간대에 Claude API를 붙여두면 가끔 529 overloaded를 만나게 됩니다. 문제는 “그냥 재시도”가 오히려 트래픽을 더 키워 장애를 악화시키거나, 동일 요청이 중복 처리되어 비용/부하가 폭증할 수 있다는 점입니다. 이 글에서는 529를 포함한 과부하 계열 응답을 안전하게 다루는 재시도 전략을, 백오프/지터/동시성 제한/서킷 브레이커/멱등성(idempotency) 관점에서 실전 설계로 정리합니다.

아래 내용은 Claude뿐 아니라 대부분의 LLM API(429/503/504 등)에도 동일하게 적용됩니다. HTTP 클라이언트 레벨의 재시도 패턴은 Python httpx ReadTimeout·ConnectError 재시도 설계 글과 함께 보면 전체 그림을 잡기 좋습니다.

529(overloaded)를 “재시도 가능한 실패”로 다루되, 무조건 재시도는 금물

529의 의미를 운영 관점에서 해석하기

529 overloaded는 대개 다음 중 하나입니다.

  • 일시적 용량 부족: 순간 트래픽 급증, 큐 적체
  • 지역/클러스터 편중: 특정 리전/엔드포인트로 쏠림
  • 클라이언트의 과도한 동시성: 앱/워커가 한 번에 너무 많이 때림

즉, “조금 기다리면 풀릴” 가능성이 높아 재시도 대상이지만, 동시에 클라이언트가 더 때릴수록 회복이 늦어지는 유형입니다. 따라서 재시도는 반드시 속도를 늦추고(백오프), 분산시키고(지터), 상한을 두고(최대 시도/최대 대기), 동시성을 제한해야 합니다.

재시도 대상/비대상 분류(필수)

다음처럼 분류하는 것을 권장합니다.

  • 재시도 권장: 529, 429, 503, 504, 네트워크 타임아웃/연결 오류
  • 조건부 재시도: 408(타임아웃) — 요청이 서버에 도달했는지 애매하므로 멱등성 키가 중요
  • 재시도 금지: 400(잘못된 입력), 401/403(인증/권한), 404(리소스), 대부분의 422

타임아웃(408/ReadTimeout) 계열은 재현과 원인 분리가 중요합니다. 비슷한 접근으로 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 참고할 만합니다.

백오프 설계의 정석: Exponential + Full Jitter + Cap

왜 지터(jitter)가 중요한가

여러 워커가 동시에 529를 맞으면, 모두가 같은 지수 백오프로 같은 타이밍에 재시도하는 “재시도 폭풍(thundering herd)”이 발생합니다. 지터는 이 재시도 타이밍을 랜덤하게 흩어 서버 회복 시간을 확보해 줍니다.

추천 수식

  • 기본 지수 백오프: base * 2^attempt
  • 상한(cap): min(exp, max_delay)
  • Full jitter: sleep = random(0, capped_delay)

권장 파라미터 예시(일반적인 API 호출 기준):

  • base_delay = 0.5s
  • max_delay = 20s
  • max_attempts = 6 (총 대기 기대값이 과도해지지 않게)

멱등성: “재시도해도 같은 요청은 한 번만 처리”

LLM 호출은 보통 “POST + 비용 발생 + 상태 없음” 형태라서 멱등성이 약합니다. 하지만 클라이언트/서버/저장소를 조합하면 사실상 멱등 처리에 가깝게 만들 수 있습니다.

실전 멱등성 패턴 3가지

  1. Idempotency-Key 헤더/필드(지원 시 최우선)
  2. 요청 해시 기반 결과 캐시
    • 입력(모델/시스템/유저 프롬프트/툴 정의)을 정규화하여 해시
    • 동일 해시 요청은 캐시 결과 반환
  3. 작업 큐 + 단일 소비자 키(DEDUP)
    • 예: job_id를 키로 중복 enqueue 방지

529/408에서 재시도할 때 가장 무서운 건 “이미 처리됐는데 타임아웃으로 응답을 못 받아서 또 호출”하는 케이스입니다. 이때 멱등성 키나 결과 캐시가 없으면 비용이 2배, 3배로 늘어납니다.

동시성 제한이 백오프보다 먼저다: 클라이언트 발 과부하 차단

재시도 로직을 잘 짜도, 워커가 과도한 동시성을 유지하면 529가 계속 납니다. 따라서 **전역 동시성 제한(세마포어/토큰 버킷)**을 먼저 두고 그 위에 재시도를 얹는 것이 안정적입니다.

  • 프로세스/파드 단위 동시성 제한: max_in_flight = 5~20부터 시작
  • 전역(여러 인스턴스) 제한: Redis 기반 토큰 버킷, 큐 워커 수 제한, HPA 상한

특히 오토스케일 환경에서는 인스턴스가 늘수록 동시성이 선형 증가하므로, “스케일 아웃 = 더 많은 529”가 되지 않게 전역 레이트 리미터를 고려해야 합니다.

서킷 브레이커: 529가 연속될 때는 잠깐 멈추는 게 이긴다

529가 일정 비율 이상 발생하면, 계속 두드리기보다 **짧게 실패를 빠르게 반환(fail fast)**하고, 일정 시간 후 half-open으로 소량만 테스트하는 서킷 브레이커가 효과적입니다.

  • Open 조건 예시: 최근 30초 동안 529/503 비율 50% 이상 또는 연속 10회 실패
  • Open 상태 대기: 15~30초
  • Half-open: 1~3개 요청만 시험

이 패턴은 사용자 경험에도 좋습니다. “계속 로딩”보다 “잠시 후 다시 시도”를 빠르게 안내하는 편이 낫습니다.

Python 예제: httpx 기반 529 재시도 + 백오프 + 동시성 제한

아래 예시는 핵심만 담은 형태입니다.

  • 529/429/503/504 및 네트워크 오류에 대해 재시도
  • Exponential backoff + full jitter + cap
  • 프로세스 내 동시성 제한(세마포어)
  • (선택) 요청 해시 기반 결과 캐시(간단 예시)
import asyncio
import hashlib
import json
import os
import random
from typing import Any, Dict, Optional

import httpx

CLAUDE_API_URL = "https://api.anthropic.com/v1/messages"  # 예시
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")

# 프로세스 내 동시성 제한
SEM = asyncio.Semaphore(10)

# 매우 단순한 인메모리 캐시(프로덕션에서는 Redis 권장)
CACHE: Dict[str, Dict[str, Any]] = {}

def request_fingerprint(payload: Dict[str, Any]) -> str:
    normalized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()


def backoff_with_full_jitter(attempt: int, base: float = 0.5, cap: float = 20.0) -> float:
    exp = base * (2 ** attempt)
    capped = min(exp, cap)
    return random.uniform(0, capped)


RETRYABLE_STATUS = {429, 503, 504, 529}

async def call_claude(payload: Dict[str, Any], *, max_attempts: int = 6) -> Dict[str, Any]:
    fp = request_fingerprint(payload)
    if fp in CACHE:
        return CACHE[fp]

    headers = {
        "x-api-key": API_KEY,
        "content-type": "application/json",
        # Anthropic/Claude는 버전 헤더가 필요한 경우가 있습니다(환경에 맞게 설정)
        "anthropic-version": "2023-06-01",
        # 지원된다면 멱등성 키를 여기에 포함(사양에 맞춰 적용)
        # "idempotency-key": fp,
    }

    timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)

    async with SEM:
        async with httpx.AsyncClient(timeout=timeout) as client:
            last_exc: Optional[Exception] = None
            for attempt in range(max_attempts):
                try:
                    r = await client.post(CLAUDE_API_URL, headers=headers, json=payload)

                    if r.status_code < 400:
                        data = r.json()
                        CACHE[fp] = data
                        return data

                    # 재시도 가능한 상태코드
                    if r.status_code in RETRYABLE_STATUS:
                        # Retry-After가 있다면 우선 존중
                        ra = r.headers.get("retry-after")
                        if ra is not None:
                            try:
                                delay = min(float(ra), 60.0)
                            except ValueError:
                                delay = backoff_with_full_jitter(attempt)
                        else:
                            delay = backoff_with_full_jitter(attempt)

                        await asyncio.sleep(delay)
                        continue

                    # 그 외 4xx/5xx는 즉시 실패(입력 문제 가능성)
                    r.raise_for_status()

                except (httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError) as e:
                    last_exc = e
                    delay = backoff_with_full_jitter(attempt)
                    await asyncio.sleep(delay)
                    continue

            raise RuntimeError(f"Claude call failed after {max_attempts} attempts") from last_exc

포인트 정리

  • retry-after 헤더가 있으면 가급적 준수합니다(서버가 권장 대기 시간을 알려주는 경우).
  • 재시도 횟수는 무작정 늘리기보다, 업무 SLA에 맞춰 “최대 총 대기 시간” 관점으로 제한합니다.
  • 동시성 제한(세마포어)은 생각보다 효과가 큽니다. “재시도”보다 먼저 적용하세요.

운영에서 꼭 넣어야 하는 관측(Observability) 지표

재시도 로직이 들어가면, 성공률이 올라가는 대신 “조용히 느려지는” 문제가 생길 수 있습니다. 아래 지표를 반드시 수집하세요.

  • 상태코드별 비율: 2xx/4xx/529/503/timeout
  • 재시도 횟수 분포: attempt=0~N
  • 백오프 누적 대기 시간(요청당)
  • 동시 in-flight 요청 수
  • 큐 적체량(비동기 처리 시)
  • 비용 지표(토큰/요금)와 재시도 상관관계

경험적으로 529가 늘어나는 시점은 애플리케이션 레벨 문제(동시성 폭증)일 수도 있고, 네트워크/DNS 지연이 누적되어 타임아웃이 늘어난 결과일 수도 있습니다. 쿠버네티스 환경이라면 DNS 지연 튜닝도 병행 점검할 가치가 있습니다: EKS에서 Pod DNS만 느릴 때 ndots·search 튜닝

권장 아키텍처: 동기 호출을 줄이고, 큐 기반으로 완충하기

트래픽이 큰 서비스라면 “사용자 요청 → 즉시 Claude 호출” 구조는 529에 취약합니다. 다음 구조가 더 안정적입니다.

  1. 사용자 요청 수신
  2. 작업 큐(SQS/RabbitMQ/Kafka)에 enqueue (dedup 키 포함)
  3. 워커가 고정 동시성으로 소비
  4. 결과 저장(캐시/DB)
  5. 사용자에게는 폴링/웹훅/스트리밍으로 전달

이렇게 하면 순간 트래픽이 폭증해도 큐가 완충 역할을 해 529가 크게 줄고, 재시도도 워커에서 통제할 수 있습니다.

체크리스트: 529 대응을 “설계”로 끝내기

  • 529/429/503/504 + 네트워크 오류만 재시도 대상으로 분리했는가?
  • Exponential backoff + full jitter + cap을 적용했는가?
  • Retry-After가 있으면 우선 존중하는가?
  • 최대 시도 횟수뿐 아니라 최대 총 대기 시간도 제한했는가?
  • 멱등성 키/요청 해시 캐시/큐 dedup 중 최소 1개를 적용했는가?
  • 동시성 제한(프로세스/전역)을 적용했는가?
  • 서킷 브레이커로 연속 과부하 시 fail fast가 가능한가?
  • 재시도 횟수/대기 시간/상태코드 분포를 모니터링하는가?

마무리

Claude API의 529(overloaded)는 “가끔 생기는 에러”가 아니라, 트래픽이 늘수록 반드시 마주칠 용량/동시성의 경계 신호입니다. 해결의 핵심은 재시도를 하되 더 공격적으로 때리는 게 아니라, 속도를 늦추고(백오프), 타이밍을 흩고(지터), 요청 수를 제한하고(동시성), 중복을 막는(멱등성) 방향으로 설계하는 것입니다. 이 4가지만 제대로 잡아도 529는 대부분 “장애”가 아니라 “일시적 지연” 수준으로 흡수할 수 있습니다.