- Published on
OpenAI 429 Rate Limit 재시도·백오프 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 429 응답을 종종 만납니다. 흔히는 “요청이 너무 많다”로 이해하고 무작정 재시도하지만, 재시도 자체가 트래픽을 더 키워 장애를 악화시키는 경우가 많습니다. 특히 동시성 높은 배치 작업, 웹훅 처리, 큐 워커, 스트리밍 응답을 섞어 쓰는 환경에서는 429가 단발성 에러가 아니라 시스템 설계 문제를 드러내는 신호가 됩니다.
이 글에서는 OpenAI 429의 의미를 정확히 구분하고, 재시도 정책, 지수 백오프와 지터, 동시성 제어, 서킷 브레이커, 관측 지표까지 한 번에 정리합니다. 재시도 폭주 자체가 장애를 만드는 구조는 gRPC 환경에서도 자주 발생하므로, 재시도 스톰 관점은 아래 글도 함께 보면 도움이 됩니다.
429의 두 얼굴: Rate Limit vs Quota/Capacity
429는 HTTP 레벨에서 “Too Many Requests”지만, 실제로는 다음 케이스가 섞여 나타납니다.
- 순수 Rate Limit 초과
- 짧은 시간에 요청이 몰려 분당 요청 수, 분당 토큰 수 같은 제한을 넘는 케이스
- 올바른 백오프를 적용하면 대부분 회복 가능
- 계정/프로젝트 Quota 소진 또는 결제/플랜 이슈
- 재시도해도 계속 실패하는 유형
- 백오프가 아니라 알림, 차단, 라우팅 변경이 필요
- 일시적 용량 문제 혹은 혼잡
- 특정 모델이나 리전에 순간적으로 혼잡
- 백오프와 대체 모델, 큐잉으로 완화 가능
따라서 429를 받으면 “재시도”부터 하기 전에 에러 바디의 메시지, 응답 헤더, 발생 패턴을 근거로 분류하는 게 중요합니다.
재시도 가능 조건 체크리스트
아래 조건이면 재시도 전략이 유효할 가능성이 큽니다.
- 동일 요청이 1회는 성공하는데 피크 시간에만 실패한다
- 트래픽이 특정 작업 배치, 특정 사용자, 특정 엔드포인트에 집중된다
- 실패 직후 수 초에서 수십 초 뒤에는 성공률이 회복된다
반대로 아래라면 재시도는 비용만 키웁니다.
- 모든 요청이 지속적으로
429로 실패한다 - 결제/크레딧/프로젝트 제한 관련 메시지가 반복된다
- 트래픽이 낮아도 계속
429가 난다
재시도 설계의 핵심: “얼마나”가 아니라 “어떻게”
429 대응에서 중요한 것은 재시도 횟수보다 재시도 곡선입니다.
- 고정 간격 재시도: 가장 나쁨. 워커가 동시에 깨어나 다시 몰림
- 지수 백오프: 재시도 간격이 기하급수로 늘어 혼잡을 완화
- 지터(jitter): 여러 클라이언트가 같은 타이밍에 재시도하는 동기화를 깨뜨림
권장 공식에 가까운 형태는 다음입니다.
sleep = random(0, min(cap, base * 2^attempt))
여기서 cap은 최대 대기 시간 상한입니다.
권장 파라미터 예시
base: 200ms ~ 500mscap: 10s ~ 30smaxAttempts: 5 ~ 8- 타임아웃: 요청 자체의 서버 타임아웃과 합쳐서 총 지연이 과도해지지 않게 제한
Node.js 예제: fetch 기반 재시도 유틸
아래 예제는 429와 일부 5xx에 대해 지수 백오프+지터로 재시도합니다. 또한 Retry-After 헤더가 있다면 이를 우선합니다.
// retry.js
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseRetryAfterMs(res) {
const v = res.headers.get('retry-after');
if (!v) return null;
// retry-after: seconds or http-date
const asNum = Number(v);
if (!Number.isNaN(asNum)) return Math.max(0, asNum * 1000);
const asDate = Date.parse(v);
if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function fullJitterDelayMs({ attempt, baseMs, capMs }) {
const exp = Math.min(capMs, baseMs * (2 ** attempt));
return Math.floor(Math.random() * exp);
}
export async function fetchWithRetry(url, options, config = {}) {
const {
maxAttempts = 6,
baseMs = 300,
capMs = 15000,
retryOnStatuses = [429, 500, 502, 503, 504]
} = config;
let lastErr;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url, options);
if (!retryOnStatuses.includes(res.status)) {
return res;
}
const retryAfter = parseRetryAfterMs(res);
const delay = retryAfter ?? fullJitterDelayMs({ attempt, baseMs, capMs });
// 응답 바디는 로깅용으로만 소량 읽고, 스트림은 재사용 불가에 유의
const bodyText = await res.text().catch(() => '');
lastErr = new Error(
`retryable status=${res.status} attempt=${attempt + 1}/${maxAttempts} delayMs=${delay} body=${bodyText.slice(0, 300)}`
);
if (attempt === maxAttempts - 1) break;
await sleep(delay);
continue;
} catch (e) {
lastErr = e;
const delay = fullJitterDelayMs({ attempt, baseMs, capMs });
if (attempt === maxAttempts - 1) break;
await sleep(delay);
}
}
throw lastErr;
}
OpenAI 호출부는 다음처럼 연결합니다.
import { fetchWithRetry } from './retry.js';
export async function callOpenAI({ apiKey, payload }) {
const res = await fetchWithRetry(
'https://api.openai.com/v1/responses',
{
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(payload)
},
{ maxAttempts: 7, baseMs: 250, capMs: 20000 }
);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`openai error status=${res.status} body=${text}`);
}
return res.json();
}
주의: 스트리밍 응답과 재시도
스트리밍 응답은 “중간까지 받다가 끊긴 경우”를 단순 재시도로 복구하기 어렵습니다. 왜냐하면 동일 요청을 다시 보내면 중복 생성이 될 수 있고, 클라이언트가 어디까지 받았는지 서버가 모를 수 있기 때문입니다.
- 스트리밍은 가급적 서버에서만 OpenAI를 호출하고, 클라이언트에는 서버가 재전송 가능한 형태로 전달
- 결과를 저장하고
idempotency key같은 애플리케이션 레벨 키로 중복을 방지
Python 예제: requests 기반 재시도
Python에서도 동일한 원칙을 적용합니다. 아래는 Retry-After를 우선하고, 없으면 full jitter를 쓰는 예시입니다.
import time
import random
import requests
def parse_retry_after_seconds(resp):
v = resp.headers.get("retry-after")
if not v:
return None
try:
return max(0.0, float(v))
except ValueError:
return None
def full_jitter_delay(attempt, base=0.3, cap=15.0):
exp = min(cap, base * (2 ** attempt))
return random.random() * exp
def post_with_retry(url, headers, json_payload, max_attempts=6):
last_exc = None
for attempt in range(max_attempts):
try:
resp = requests.post(url, headers=headers, json=json_payload, timeout=60)
if resp.status_code not in (429, 500, 502, 503, 504):
return resp
ra = parse_retry_after_seconds(resp)
delay = ra if ra is not None else full_jitter_delay(attempt)
last_exc = RuntimeError(
f"retryable status={resp.status_code} attempt={attempt+1}/{max_attempts} delay={delay:.2f} body={resp.text[:300]}"
)
if attempt == max_attempts - 1:
break
time.sleep(delay)
except requests.RequestException as e:
last_exc = e
if attempt == max_attempts - 1:
break
time.sleep(full_jitter_delay(attempt))
raise last_exc
def call_openai(api_key, payload):
resp = post_with_retry(
"https://api.openai.com/v1/responses",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json_payload=payload,
max_attempts=7,
)
if not resp.ok:
raise RuntimeError(f"openai error status={resp.status_code} body={resp.text}")
return resp.json()
백오프만으로 부족할 때: 동시성 제한과 큐잉
429가 자주 난다면, 백오프는 증상 완화일 뿐이고 원인은 동시성 과다인 경우가 많습니다. 특히 다음 패턴은 위험합니다.
- 웹 요청마다 OpenAI를 즉시 호출하고, 타임아웃 나면 프론트가 재시도
- 큐 워커 수를 무작정 늘려 처리량을 올리려다 API 제한을 초과
- 배치 작업이 정각에 몰리며 스파이크 발생
해결은 단순합니다.
- 프로세스 내부에서
semaphore로 동시 호출 수를 제한 - 작업 큐에서 워커 수를 제한하고, 지연 재시도를 큐 레벨에서 처리
- 정각 배치를 랜덤 지연으로 분산
이 문제는 DB 커넥션 고갈과 구조가 유사합니다. “처리량을 올리려 워커를 늘린다”가 오히려 병목을 악화시키는 점에서 아래 글의 사고방식이 그대로 적용됩니다.
서킷 브레이커: 계속 두드리지 말고 잠깐 멈춰라
429가 일정 비율 이상 발생하면, 재시도는 “느린 실패”로 바뀌면서 전체 시스템 지연을 키웁니다. 이때는 서킷 브레이커가 효과적입니다.
- 최근
N개 요청 중429비율이 임계치 초과하면 회로를 열고 - 일정 시간 동안 빠르게 실패시키며
- 이후 제한적으로 반개방 상태에서 성공 여부를 확인
실무에서는 다음 정책이 무난합니다.
- 윈도우
30s - 실패율 임계치
30%이상 - 오픈 유지
10s - 반개방에서 1~3개만 시도
이렇게 하면 장애 시점에 애플리케이션이 스스로 “브레이크”를 걸어, 다운스트림과 자기 자신을 보호합니다.
관측과 디버깅: 로그 한 줄로 끝내지 말기
429는 재현이 어렵고, 트래픽 패턴에 따라 간헐적입니다. 따라서 다음을 반드시 남겨야 원인 분석이 가능합니다.
- 모델명, 엔드포인트, 조직/프로젝트 구분 키(가능한 범위)
- 요청 크기 추정치: 입력 토큰 수, 출력 토큰 상한
- 재시도 횟수, 최종 지연 시간,
Retry-After사용 여부 - 동일 사용자나 동일 작업 키 기준의 발생 빈도
메트릭으로는 아래가 핵심입니다.
429비율과 초당 건수- 평균 및 p95 지연 시간
- 재시도 횟수 분포
- 큐 적체 길이, 워커 동시성
로그가 누락되면 “가끔 429 나요”에서 끝납니다. 반대로 위 지표가 있으면 “정각 배치 스파이크”, “특정 테넌트 폭주”, “토큰 상한 과다로 처리량 감소” 같은 결론을 빠르게 낼 수 있습니다.
흔한 실수 5가지
429를 모든 에러와 동일하게 즉시 재시도
4xx는 보통 재시도 대상이 아닙니다.429만 예외적으로 설계하세요.
- 재시도 간격을 고정값으로 둠
- 동기화된 재시도로 스파이크가 유지됩니다.
- 최대 재시도 횟수만 늘림
- 성공률은 조금 오르지만 지연과 비용이 폭발합니다.
- 클라이언트와 서버가 동시에 재시도
- 프론트, 게이트웨이, 백엔드, 워커가 모두 재시도하면 곱연산으로 폭주합니다.
- 동시성 제한 없이 워커를 늘림
- 처리량을 올리는 게 아니라
429를 더 많이 만들 뿐입니다.
운영 관점의 권장 아키텍처
- API 호출은 서버에서 중앙화하고, 사용자 요청은 큐잉하거나 제한
- 동시성 제한은 애플리케이션 레벨에서 명시적으로 관리
- 재시도는 지수 백오프+지터,
Retry-After우선 - 실패가 반복되면 서킷 브레이커로 빠르게 차단
- 결과 중복을 막기 위해 작업 키를 만들고 저장 후 재전송 가능하게 설계
큐 기반 비동기 처리에서 “재시도 정책이 중복 실행과 유령 작업을 만든다”는 문제는 OpenAI 호출에도 그대로 나타납니다. 워커 재시도, 타임아웃, ack 정책이 충돌하면 동일 프롬프트가 여러 번 실행될 수 있으니 아래 글의 체크리스트도 참고할 만합니다.
마무리
OpenAI 429는 단순한 “잠깐 기다렸다 다시 해”가 아니라, 동시성·트래픽 스파이크·재시도 정책·관측 부재가 합쳐져 나타나는 시스템적 현상인 경우가 많습니다.
- 재시도는 지수 백오프+지터로 설계하고
Retry-After를 존중하며- 동시성 제한과 큐잉으로 스파이크를 눌러야
429를 “가끔 나는 에러”가 아니라 “용량과 안정성의 경계가 드러나는 지표”로 다루면, 비용과 지연을 동시에 줄이면서 성공률을 안정적으로 끌어올릴 수 있습니다.