API 오류 429 Too Many Requests: 요청 제한, Retry-After 및 안전한 백오프 설정
LLM API의 HTTP 429 오류 해결 완벽 가이드: RPM/TPM 제한 분석, Retry-After 헤더 처리, Full Jitter 지수 백오프 코드 구현.
목차
API 오류 429 Too Many Requests: 요청 제한, Retry-After 및 안전한 백오프 설정
HTTP 429 Too Many Requests 오류는 클라이언트가 API 제공업체의 요청 빈도 또는 토큰 처리량 한도를 초과했을 때 발생합니다. 무작위적인 즉각 재시도는 문제를 해결하지 못하며, 오히려 재시도 폭풍(Retry Storm)을 유발하여 계정 차단 시간을 연장시킵니다.
애플리케이션의 안정성을 복구하려면 제한 유형(RPM, TPM, 잔액 부족)을 명확히 구분하고, Retry-After 헤더를 올바르게 파싱하며, 랜덤 지터를 포함한 지수 백오프(Full Jitter Exponential Backoff) 알고리즘을 적용해야 합니다.
HTTP 429 오류 발생 원인: 속도 제한의 구조
최신 대규모 언어 모델(LLM) API에서는 주로 다음 세 가지 원인으로 429 오류가 발생합니다:
- RPM (Requests Per Minute): 분당 HTTP 요청 수 제한. 큐 없이 여러 워커가 동시에 병렬 요청을 보낼 때 주로 발생합니다.
- TPM (Tokens Per Minute): 1분 동안의 입력 및 출력 토큰 총량 제한. 대용량 컨텍스트나 긴 프롬프트를 전송할 때 발생하기 쉽습니다.
- 할당량 및 잔액 소진: 계정 잔액이 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 헤더가 없는 경우, 랜덤 지터가 포함된 지수 백오프를 사용하는 것이 표준입니다. 시도 횟수 $i$ 에 대한 대기 시간 계산 공식은 다음과 같습니다:
$$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 작업의 경우:
- 에이전트 작업 중복 실행 방지: 네트워크 타임아웃 발생 시, 무작정 재전송하기 전에 토큰이 차감되었거나 작업이 실행 중인지 확인하십시오.
- 클라이언트 요청 ID 사용:
X-Request-ID헤더를 전송하여 서버 로그에서 각 호출을 추적하십시오. - 401/403 오류를 429로 처리하지 말 것: 인증 오류는 대기로 해결되지 않으며 API 키를 수정해야 합니다.
복구 검증 및 점진적 재개
전체 트래픽을 재개하기 전에:
- 최소 토큰(
max_tokens: 5)으로 단일 프로브 요청을 전송합니다. - HTTP 200 수신 및
x-ratelimit-remaining-requests헤더를 확인합니다. - 지표 대시보드에서 429 오류율을 모니터링하며 동시성을 단계적으로 높입니다.
엄격한 분당 제한으로 인한 갑작스러운 429 오류를 방지하고 요청 상태를 투명하게 관리하려면 BetterToken API 로 전환하여 전용 API 키를 생성하고 대시보드에서 실시간 토큰 소비량을 모니터링하십시오.