API 오류 429 Too Many Requests: 요청 제한, Retry-After 및 안전한 백오프 설정

LLM API의 HTTP 429 오류 해결 완벽 가이드: RPM/TPM 제한 분석, Retry-After 헤더 처리, Full Jitter 지수 백오프 코드 구현.

HTTP 429 Too Many Requests 오류는 클라이언트가 API 제공업체의 요청 빈도 또는 토큰 처리량 한도를 초과했을 때 발생합니다. 무작위적인 즉각 재시도는 문제를 해결하지 못하며, 오히려 재시도 폭풍(Retry Storm)을 유발하여 계정 차단 시간을 연장시킵니다.

애플리케이션의 안정성을 복구하려면 제한 유형(RPM, TPM, 잔액 부족)을 명확히 구분하고, Retry-After 헤더를 올바르게 파싱하며, 랜덤 지터를 포함한 지수 백오프(Full Jitter Exponential Backoff) 알고리즘을 적용해야 합니다.

HTTP 429 오류 발생 원인: 속도 제한의 구조

최신 대규모 언어 모델(LLM) API에서는 주로 다음 세 가지 원인으로 429 오류가 발생합니다:

  1. RPM (Requests Per Minute): 분당 HTTP 요청 수 제한. 큐 없이 여러 워커가 동시에 병렬 요청을 보낼 때 주로 발생합니다.
  2. TPM (Tokens Per Minute): 1분 동안의 입력 및 출력 토큰 총량 제한. 대용량 컨텍스트나 긴 프롬프트를 전송할 때 발생하기 쉽습니다.
  3. 할당량 및 잔액 소진: 계정 잔액이 0이거나 설정된 지출 한도(Spend Limit)에 도달하여 발생하는 차단.
HTTP/1.1 429 Too Many Requests Date: Sun, 23 Aug 2026 03:00:00 GMT Content-Type: application/json Retry-After: 6 x-ratelimit-limit-requests: 500 x-ratelimit-remaining-requests: 0 x-ratelimit-reset-requests: 6s x-ratelimit-limit-tokens: 30000 x-ratelimit-remaining-tokens: 1200 x-ratelimit-reset-tokens: 150ms { "error": { "message": "Rate limit reached for model in organization on tokens per minute (TPM). Please try again in 6s.", "type": "tokens", "param": null, "code": "rate_limit_exceeded" } }

잔액 부족이나 고정 구독 한도가 원인인 경우, 무한 재시도는 네트워크 자원만 낭비할 뿐입니다. 로그를 복잡하게 분석하지 않고 원인을 즉시 파악할 수 있도록 BetterToken 은 투명한 대시보드를 제공합니다. 요청별 HTTP 상태 코드, 입력·출력·캐시 토큰의 정밀한 소비 내역, 종량제(Pay-as-you-go) 잔액을 실시간으로 확인하여 5시간 주기 차단 걱정 없이 안정적으로 운영할 수 있습니다.

429 오류 진단 매트릭스

증상주요 원인응답 확인 항목엔지니어링 해결책
병렬 요청 폭주 시 오류RPM 한도 초과x-ratelimit-remaining-requests: 0세마포어 또는 큐를 통한 동시성 제어
긴 프롬프트 전송 시 오류TPM 한도 초과x-ratelimit-remaining-tokens < 프롬프트 크기컨텍스트 최적화, 프롬프트 캐싱 적용 또는 배치 분할
모든 요청에서 429 발생잔액 / 할당량 소진insufficient_quota 오류 코드재시도를 중단하고 잔액 충전 또는 권한 확인
429 오류의 기하급수적 증가재시도 폭풍Retry-After 헤더 무시대기 시간에 랜덤 지터(Jitter) 추가

Retry-After 헤더 파싱 방법

RFC 6585 규격에 따르면 Retry-After 헤더는 두 가지 형식으로 대기 시간을 전달합니다:

  • 상대적 초 단위 (정수 또는 실수, 예: Retry-After: 12)
  • HTTP-Date 타임스탬프 (예: Retry-After: Sun, 23 Aug 2026 03:05:00 GMT)
import datetime import email.utils import time def parse_retry_after(header_value: str | None, default_delay: float = 1.0) -> float: if not header_value: return default_delay header_value = header_value.strip() try: return max(0.0, float(header_value)) except ValueError: pass try: parsed_date = email.utils.parsedate_to_datetime(header_value) now = datetime.datetime.now(datetime.timezone.utc) delay = (parsed_date - now).total_seconds() return max(0.0, delay) except Exception: return default_delay

Full Jitter 지수 백오프 구현

Retry-After 헤더가 없는 경우, 랜덤 지터가 포함된 지수 백오프를 사용하는 것이 표준입니다. 시도 횟수 ii 에 대한 대기 시간 계산 공식은 다음과 같습니다:

Textwait=extrandom(0,min(Textmax,Textbaseimes2i))T_{ ext{wait}} = ext{random}(0, \min(T_{ ext{max}}, T_{ ext{base}} imes 2^i))

import asyncio import json import random import httpx class RateLimitRetryClient: def __init__( self, base_url: str = "https://www.bettertoken.ai/v1", api_key: str = "", max_retries: int = 4, base_delay: float = 1.0, max_delay: float = 32.0, ): self.base_url = base_url self.api_key = api_key self.max_retries = max_retries self.base_delay = base_delay self.max_delay = max_delay self.client = httpx.AsyncClient( base_url=self.base_url, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60.0, ) async def send_chat_completion(self, payload: dict) -> dict: for attempt in range(self.max_retries + 1): try: response = await self.client.post("/chat/completions", json=payload) if response.status_code == 200: return response.json() if response.status_code == 429: error_data = response.json().get("error", {}) error_code = error_data.get("code") if error_code in ("insufficient_quota", "billing_not_active"): raise RuntimeError(f"결제 오류: {error_data.get('message')}") if attempt == self.max_retries: raise RuntimeError(f"재시도 횟수 초과 (429): {response.text}") retry_after = response.headers.get("Retry-After") if retry_after: wait_time = parse_retry_after(retry_after) + random.uniform(0.1, 0.5) else: backoff_cap = min(self.max_delay, self.base_delay * (2 ** attempt)) wait_time = random.uniform(0, backoff_cap) await asyncio.sleep(wait_time) continue response.raise_for_status() except httpx.RequestError as exc: if attempt == self.max_retries: raise wait_time = min(self.max_delay, self.base_delay * (2 ** attempt)) await asyncio.sleep(wait_time) raise RuntimeError("재시도 예산을 모두 소진하여 요청 처리에 실패했습니다")

멱등성 및 부작용 방지

단순 읽기 작업(GET)은 재시도해도 안전합니다. 그러나 LLM 추론과 같은 POST 작업의 경우:

  1. 에이전트 작업 중복 실행 방지: 네트워크 타임아웃 발생 시, 무작정 재전송하기 전에 토큰이 차감되었거나 작업이 실행 중인지 확인하십시오.
  2. 클라이언트 요청 ID 사용: X-Request-ID 헤더를 전송하여 서버 로그에서 각 호출을 추적하십시오.
  3. 401/403 오류를 429로 처리하지 말 것: 인증 오류는 대기로 해결되지 않으며 API 키를 수정해야 합니다.

복구 검증 및 점진적 재개

전체 트래픽을 재개하기 전에:

  • 최소 토큰(max_tokens: 5)으로 단일 프로브 요청을 전송합니다.
  • HTTP 200 수신 및 x-ratelimit-remaining-requests 헤더를 확인합니다.
  • 지표 대시보드에서 429 오류율을 모니터링하며 동시성을 단계적으로 높입니다.

엄격한 분당 제한으로 인한 갑작스러운 429 오류를 방지하고 요청 상태를 투명하게 관리하려면 BetterToken API 로 전환하여 전용 API 키를 생성하고 대시보드에서 실시간 토큰 소비량을 모니터링하십시오.

LLM 워크플로를 최적화할 준비가 되셨나요?

하나의 API로 모델을 연결하고 키와 AI 비용을 관리하세요.