- Published on
OpenAI 429/RateLimitError 재시도·백오프 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
프로덕션에서 OpenAI API를 붙이면 가장 먼저 마주치는 장애 중 하나가 429 입니다. 에러 메시지는 단순히 “rate limit”처럼 보이지만, 실제 원인은 다양합니다. 초당 요청 수 제한(RPS), 분당 토큰 제한(TPM), 동시 요청 제한, 혹은 내부 보호 로직에 의한 일시적 제한까지 한 덩어리로 나타나기도 합니다.
이 글은 429/RateLimitError를 “그냥 몇 번 더 때리면 되겠지” 수준에서 벗어나, 재시도 정책과 백오프(Backoff), 지터(Jitter), 헤더 기반 대기, 큐잉/동시성 제어를 조합해 안정적으로 처리하는 실전 패턴을 다룹니다.
관련해서 더 넓은 대응(큐잉까지 포함)을 한 번에 보고 싶다면 아래 글도 참고하세요.
429가 “재시도하면 되는 오류”인 이유와 함정
429는 기본적으로 일시적(Transient) 오류로 분류할 수 있어 재시도 대상이 맞습니다. 다만 함정은 다음 두 가지입니다.
즉시 재시도는 한도를 더 초과시켜 실패를 가속합니다.
모든 429가 같은 원인이 아닙니다. 예를 들어 RPS 제한과 TPM 제한은 복구 전략이 다릅니다.
- RPS 제한: 요청 수가 너무 많음. 동시성 제한, 큐잉, 요청 합치기(배치)로 완화
- TPM 제한: 토큰을 너무 많이 씀.
max_output_tokens관리, 프롬프트 압축, 캐시, 모델/요금제 조정
따라서 “재시도”는 필요조건일 뿐, 재시도 방식이 핵심입니다.
기본 원칙: 지수 백오프 + 지터 + 상한
가장 널리 쓰이는 패턴은 다음 조합입니다.
- 지수 백오프(Exponential Backoff): 실패할수록 대기 시간을 2배로 증가
- 지터(Jitter): 여러 인스턴스가 동시에 재시도하는 동기화 현상(Thundering Herd)을 방지
- 상한(Max Delay): 무한정 늘어나지 않도록 최대 대기 시간 제한
권장 형태(개념):
delay = min(maxDelay, baseDelay * 2^attempt)delay = jitter(delay)
지터는 보통 아래 중 하나를 씁니다.
- Full Jitter:
random(0, delay) - Equal Jitter:
delay/2 + random(0, delay/2)
프로덕션에서는 Full Jitter가 충돌을 가장 잘 흩트립니다.
헤더 기반 대기: Retry-After를 최우선으로
서버가 Retry-After 같은 힌트를 제공한다면, 클라이언트는 계산한 백오프보다 헤더 값을 우선하는 게 안전합니다.
실무에서의 우선순위는 보통 다음이 좋습니다.
Retry-After가 있으면 그 값대로 대기- 없으면 지수 백오프 + 지터
- 그래도 계속 실패하면 서킷 브레이커 또는 큐잉 강화
주의할 점은 Retry-After가 초 단위 숫자일 수도, 날짜일 수도 있다는 점입니다. 라이브러리별로 파싱을 잘하는지 확인하세요.
실패를 더 키우는 패턴: 무제한 동시성 + 즉시 재시도
429 폭풍은 대개 아래 조합에서 시작합니다.
- 웹 요청이 늘어남
- 애플리케이션이 OpenAI 호출을 동기적으로 많이 만듦
- 타임아웃 또는 429 발생
- 즉시 재시도
- 동시 호출이 더 늘어나면서 한도 초과가 고착
이건 K8s CrashLoopBackOff가 “원인”이 아니라 “증상”인 것과 비슷합니다. 재시도 로직이 문제를 숨기고 더 키우기도 하죠.
결론은 간단합니다.
- 재시도는 느리게
- 동시성은 제한
- 요청량은 평탄화(큐잉)
패턴 1: 애플리케이션 레벨 재시도 래퍼(파이썬)
아래는 Python에서 OpenAI 호출을 감싸는 재시도 래퍼 예시입니다. 핵심은 429와 네트워크 계열(일부 5xx)을 재시도 대상으로 보고, Retry-After 우선, 없으면 지수 백오프 + Full Jitter를 적용하는 것입니다.
import random
import time
from typing import Callable, Any
# 예: openai 라이브러리의 예외 타입에 맞게 바꾸세요.
# from openai import RateLimitError, APIError, APITimeoutError
class RateLimitError(Exception):
pass
class APITimeoutError(Exception):
pass
class APIError(Exception):
def __init__(self, status_code: int):
self.status_code = status_code
def _full_jitter_sleep(base: float, cap: float, attempt: int) -> float:
exp = min(cap, base * (2 ** attempt))
return random.uniform(0, exp)
def call_with_retry(
fn: Callable[[], Any],
max_attempts: int = 6,
base_delay_s: float = 0.5,
max_delay_s: float = 20.0,
retry_after_s: float | None = None,
) -> Any:
last_err = None
for attempt in range(max_attempts):
try:
return fn()
except RateLimitError as e:
last_err = e
# 실제 구현에서는 응답 헤더에서 `Retry-After`를 읽어오세요.
if retry_after_s is not None:
sleep_s = retry_after_s
else:
sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)
time.sleep(sleep_s)
except APITimeoutError as e:
last_err = e
sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)
time.sleep(sleep_s)
except APIError as e:
last_err = e
# 500~599만 재시도하는 식으로 제한
if 500 <= e.status_code <= 599:
sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)
time.sleep(sleep_s)
else:
raise
raise last_err
포인트
max_attempts를 너무 크게 잡으면 지연이 누적되어 상위 요청 타임아웃과 충돌합니다.base_delay_s는 너무 작으면 효과가 없고, 너무 크면 사용자 경험이 나빠집니다.max_delay_s는 시스템 성격에 맞게 상한을 둡니다.
패턴 2: 동시성 제한(세마포어) + 재시도 결합
재시도가 있어도 동시성이 무제한이면 429는 계속 납니다. 애플리케이션 레벨에서 OpenAI 호출의 동시성을 제한하면 효과가 큽니다.
아래는 asyncio 세마포어로 동시 호출을 제한하는 예시입니다.
import asyncio
sema = asyncio.Semaphore(8) # 워크로드에 맞게 조정
async def call_openai_safely(async_fn):
async with sema:
return await async_fn()
이 패턴은 “최대 동시 호출 수”를 고정함으로써 RPS 초과를 완화합니다. 다만 TPM 제한이 원인이라면 동시성만 줄여서는 부족하고, 토큰 사용량 자체를 줄이거나 큐잉으로 분산해야 합니다.
패턴 3: 요청 평탄화(큐잉)로 스파이크 흡수
트래픽이 스파이크 형태라면 재시도와 동시성 제한만으로는 불안정합니다. 이때는 “지금 당장 처리” 대신 “대기열에 넣고 일정 속도로 처리”하는 큐잉이 정답인 경우가 많습니다.
구현은 상황마다 다르지만 개념은 같습니다.
- API 서버는 요청을 큐에 넣고 빠르게 응답(또는 폴링/웹훅)
- 워커가 큐에서 꺼내 OpenAI 호출
- 워커의 처리 속도와 동시성을 제한해 레이트 리밋을 지킴
이 접근은 Kafka, SQS, RabbitMQ, Redis Streams 등으로 구현할 수 있습니다. 메시지 중복/재처리까지 고려하면 Outbox나 dedup 전략이 필요할 수 있습니다.
패턴 4: 토큰 예산 기반의 “사전 스로틀링”
TPM 제한은 “요청 수”가 아니라 “토큰 소비량”이 핵심입니다. 이때는 호출 직전에 토큰을 대략 추정하고, 예산이 부족하면 잠깐 기다리는 방식이 효과적입니다.
간단한 형태는 다음과 같습니다.
- 최근 60초간 사용 토큰을 슬라이딩 윈도우로 추적
- 새 요청의 예상 토큰(입력+출력)을 더했을 때 한도를 넘으면 대기
정밀한 토큰 계산이 어려우면 보수적으로 잡아도 됩니다. 핵심은 429를 맞고 나서 복구하는 게 아니라, 맞기 전에 속도를 줄이는 것입니다.
운영 관점 체크리스트
1) 재시도 대상 분리
- 재시도 권장:
429, 일부5xx, 타임아웃/일시적 네트워크 오류 - 즉시 실패 권장:
400계열의 입력 오류, 인증/권한 오류(401,403), 잘못된 모델명 등
특히 403은 rate limit처럼 보여도 네트워크 경로나 권한 문제일 수 있습니다. 클라우드 환경에서는 IPv6, DNS, egress 정책 때문에 “간헐적 실패”처럼 보이는 케이스도 있어 분리 진단이 중요합니다.
2) 관측(Observability) 지표
최소한 아래는 찍어야 원인-대응 연결이 됩니다.
- 분당 요청 수, 성공률
429발생률과 재시도 횟수 분포- 평균/상위(p95, p99) 지연
- 분당 토큰 사용량(가능하면 모델/엔드포인트 별)
- 큐 길이(큐잉 사용 시), 워커 처리량
3) 타임아웃과 재시도 총량 예산
상위 요청의 타임아웃이 10초인데, 재시도 정책이 최악의 경우 30초를 기다리게 하면 결국 사용자에게는 실패로 보입니다.
- “재시도는 성공률을 올리되, 사용자 경험을 망치지 않는 범위”에서만
- API 게이트웨이, 프론트엔드, 백엔드 타임아웃을 일관되게 설계
4) 클라이언트 인스턴스 수가 늘어날수록 더 보수적으로
오토스케일로 인스턴스가 늘면, 각 인스턴스가 동일한 재시도 정책을 수행하면서 총 요청량이 폭발할 수 있습니다.
- 인스턴스 로컬 제한만 두지 말고, 가능하면 중앙화된 레이트 리미터(예: Redis 기반 토큰 버킷)를 고려
- 또는 큐잉으로 “처리 속도”를 한 곳에서 제어
Node.js 예시: fetch 기반 재시도 유틸
Node 환경에서도 동일한 원칙을 적용할 수 있습니다.
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fullJitterDelayMs(baseMs, capMs, attempt) {
const exp = Math.min(capMs, baseMs * (2 ** attempt));
return Math.floor(Math.random() * exp);
}
async function fetchWithRetry(url, options, cfg = {}) {
const {
maxAttempts = 6,
baseDelayMs = 500,
maxDelayMs = 20000,
} = cfg;
let lastErr;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url, options);
if (res.status === 429) {
const retryAfter = res.headers.get("retry-after");
const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : null;
const waitMs = Number.isFinite(retryAfterMs)
? retryAfterMs
: fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
await sleep(waitMs);
continue;
}
if (res.status >= 500 && res.status <= 599) {
const waitMs = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
await sleep(waitMs);
continue;
}
return res;
} catch (e) {
lastErr = e;
const waitMs = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
await sleep(waitMs);
}
}
throw lastErr;
}
주의: retry-after가 날짜 형식으로 올 수도 있으니, 실제 운영에서는 파싱 로직을 강화하거나 사용 중인 HTTP 클라이언트의 기능을 활용하세요.
결론: “재시도”는 기능이 아니라 설계다
429/RateLimitError 대응은 단순히 재시도 횟수를 늘리는 문제가 아닙니다. 안정적인 패턴은 보통 아래 순서로 성숙합니다.
- 지수 백오프 + 지터로 재시도 품질 개선
Retry-After등 서버 힌트 기반 대기- 동시성 제한으로 RPS 초과 완화
- 큐잉으로 스파이크 흡수 및 처리 속도 제어
- 토큰 예산 기반 스로틀링으로 TPM 초과 예방
이 조합을 적용하면 429가 “장애”가 아니라 “조절 가능한 이벤트”로 바뀌고, 비용과 지연, 성공률을 함께 최적화할 수 있습니다.