- Published on
OpenAI Responses API 429 쿼터·레이트리밋 대응
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI Responses API를 붙이다 보면 가장 흔하게 마주치는 장애 중 하나가 429입니다. 겉으로는 “Too Many Requests” 한 줄이지만, 실제로는 레이트리밋(Rate Limit), 쿼터(Quota), 버스트 트래픽, 동시성 폭주, 재시도 폭풍(retry storm) 등 여러 원인이 섞여 나타납니다.
이 글에서는 429를 단순히 “백오프로 재시도”로 끝내지 않고, 원인 분리 → 즉시 완화 → 구조적 개선 → 관측/비용 최적화 순서로 실전 대응책을 정리합니다. (Responses API 기준이지만 Chat Completions 등에도 그대로 적용됩니다.)
1) 429의 두 얼굴: 레이트리밋 vs 쿼터
429는 크게 두 부류로 나뉩니다.
1.1 레이트리밋(순간 트래픽/동시성) 초과
- 짧은 시간에 요청이 몰리거나(버스트)
- 워커/스레드가 동시에 호출하거나(동시성 폭주)
- 실패한 요청을 모든 인스턴스가 동시에 재시도해서(재시도 폭풍)
이 경우는 잠깐 쉬었다가 재시도하면 대부분 회복됩니다.
1.2 쿼터(계정/프로젝트 사용량) 소진
- 월/일 단위 비용 한도 또는 사용량이 꽉 찬 상태
- 결제/플랜/프로젝트 제한
이 경우는 재시도해도 계속 429가 납니다. 재시도는 오히려 비용/로그만 늘리고 장애를 길게 만듭니다.
> 포인트: “429면 무조건 재시도”는 위험합니다. 재시도 가능한 429와 **불가능한 429(쿼터)**를 먼저 가르는 게 핵심입니다.
2) 429를 정확히 분류하는 방법(헤더·바디·메트릭)
2.1 응답 헤더의 Retry-After를 최우선으로
OpenAI 계열 API는 상황에 따라 Retry-After(초) 또는 레이트리밋 관련 헤더를 제공합니다. 있다면 이를 가장 신뢰해야 합니다.
Retry-After: 2→ 2초 후 재시도
2.2 에러 메시지/코드로 쿼터 vs 레이트리밋 구분
SDK/응답 바디에 “quota exceeded”, “insufficient_quota” 같은 표현이 있으면 재시도 대상이 아닙니다. 반대로 “rate limit” 류면 재시도 대상일 가능성이 큽니다.
실무 팁:
- 쿼터 소진: 알람을 띄우고 즉시 트래픽을 줄이거나(기능 제한), 결제/한도 조정이 필요
- 레이트리밋: 백오프+지터, 동시성 제한, 큐잉으로 안정화
2.3 관측 지표로 패턴 확인
- 특정 시간대에만 429 급증 → 버스트/배치 작업/크론
- 배포 직후 급증 → 워커 수 증가/콜드스타트 재시도
- 특정 모델/엔드포인트에서만 급증 → 요청 단가/토큰/모델별 제한
로그 비용이 급증하는 경우도 흔합니다. 429를 대량으로 뿌리면 에러 로그가 폭증하므로, 로그 샘플링/집계 전략도 같이 필요합니다. 관련해서는 CloudWatch Logs 비용 폭증 원인과 절감 10가지도 함께 참고하면 좋습니다.
3) 즉시 적용 가능한 429 완화책 5가지
3.1 지수 백오프 + 지터(필수)
모든 인스턴스가 1초, 2초, 4초… 같은 동일한 타이밍으로 재시도하면 다시 동시에 몰려 429가 반복됩니다. **지터(jitter)**로 재시도 시점을 분산해야 합니다.
아래는 Python httpx 기반의 간단한 예시입니다.
import random
import time
import httpx
MAX_RETRIES = 6
BASE_DELAY = 0.5 # seconds
def backoff_delay(attempt: int, retry_after: float | None = None) -> float:
# 서버가 Retry-After를 주면 우선 적용
if retry_after is not None:
# 약간의 지터를 추가해 동시 재시도 방지
return retry_after + random.uniform(0, 0.25)
# 지수 백오프 + 풀 지터(Full Jitter)
cap = 20.0
exp = min(cap, BASE_DELAY * (2 ** attempt))
return random.uniform(0, exp)
def parse_retry_after(resp: httpx.Response) -> float | None:
ra = resp.headers.get("retry-after")
if not ra:
return None
try:
return float(ra)
except ValueError:
return None
def call_openai_with_retry(url: str, headers: dict, payload: dict) -> dict:
with httpx.Client(timeout=30.0) as client:
for attempt in range(MAX_RETRIES):
resp = client.post(url, headers=headers, json=payload)
if resp.status_code < 400:
return resp.json()
if resp.status_code == 429:
retry_after = parse_retry_after(resp)
delay = backoff_delay(attempt, retry_after)
time.sleep(delay)
continue
# 5xx는 네트워크/게이트웨이 문제일 수 있어 재시도 고려
if 500 <= resp.status_code < 600:
time.sleep(backoff_delay(attempt))
continue
# 4xx(429 제외)는 대개 재시도해도 소용 없음
resp.raise_for_status()
raise RuntimeError("Exceeded max retries")
3.2 동시성 제한(세마포어/워크 큐)
레이트리밋은 “초당 요청 수”뿐 아니라 “동시 요청”에 의해 터지기도 합니다. 특히 웹 서버에서 요청이 몰리면, 내부적으로 OpenAI 호출이 폭주해 429가 연쇄적으로 발생합니다.
import asyncio
import httpx
SEM = asyncio.Semaphore(10) # 동시 OpenAI 호출 10개로 제한
async def call_openai_async(url, headers, payload):
async with SEM:
async with httpx.AsyncClient(timeout=30.0) as client:
return await client.post(url, headers=headers, json=payload)
현업에서는 이 값을 “모델/프로젝트별”로 다르게 두기도 합니다(예: 고가 모델은 동시성 더 낮게).
3.3 재시도 예산(Retry Budget)과 서킷 브레이커
429가 연속으로 발생하는데 계속 재시도하면, 애플리케이션은 자기 자신을 공격하게 됩니다.
- 일정 시간 창(window) 내 429 비율이 임계치 초과 → 서킷 오픈
- 오픈 상태에서는 즉시 실패(fail-fast) 또는 캐시/대체 응답 제공
- 일정 시간이 지나면 half-open으로 소량만 테스트
이 패턴은 레이트리밋뿐 아니라 502/504 같은 게이트웨이 오류에서도 유효합니다. (Responses API에서 5xx가 섞일 때는 OpenAI Responses API 502 Bad Gateway 원인과 해결도 같이 보면 장애 분리가 쉬워집니다.)
3.4 큐잉(버퍼)으로 버스트 흡수
웹 요청을 받은 즉시 OpenAI를 때리지 말고,
- 메시지 큐(SQS/RabbitMQ/Kafka)
- 작업 큐(Celery/RQ)
- in-memory 큐(단일 인스턴스 한정)
로 버퍼링한 뒤, 워커가 일정 속도로 처리하면 429가 크게 줄어듭니다.
3.5 요청 단위 최적화(토큰/중복 호출 줄이기)
레이트리밋은 “요청 수”뿐 아니라 “토큰 처리량” 제한과 얽히는 경우가 많습니다.
- 프롬프트에서 불필요한 컨텍스트 제거
- 동일 입력의 중복 호출 방지(캐시)
- 배치 가능하면 배치(업무 성격에 따라)
- 스트리밍을 쓰더라도 호출 수 자체가 늘지 않게 설계
4) 구조적 해결: 429를 ‘안 나게’ 만드는 설계
4.1 멀티 인스턴스 환경에서의 재시도 동기화 문제
쿠버네티스/오토스케일 환경에서는 같은 코드가 여러 파드에서 동시에 재시도합니다. 이때 지터만으로 부족하면 전역 레이트리미터가 필요합니다.
선택지:
- Redis 기반 토큰 버킷/리키 버킷
- API Gateway 레벨에서 rate limit
- 워커 큐에서 처리율 제한
4.2 테넌트/사용자별 공정성(Fairness) 제어
B2B/B2C 서비스에서는 특정 고객이 트래픽을 독점해 전체가 429를 맞는 일이 잦습니다.
- 사용자별/조직별 할당량(soft/hard limit)
- 우선순위 큐(유료 고객 우대)
- 요청당 비용(예상 토큰)을 기반으로 admission control
4.3 실패 모드 설계: 기능 저하(Degradation) 전략
429는 “모델 호출 불가” 상태이므로, 제품 관점에서는 다음 중 하나가 필요합니다.
- 더 싼/가벼운 모델로 폴백
- 답변 품질을 낮추되 응답은 유지(요약/템플릿)
- “잠시 후 재시도” 안내 + 비동기 완료(알림/이메일)
5) 운영에서 중요한 체크리스트(알람·로그·런북)
5.1 알람
- 429 비율(%)
- 429 절대 건수(특정 모델/엔드포인트 태그 포함)
- 재시도 횟수 분포(p95)
- 큐 적체량(큐잉 도입 시)
5.2 로그
request_id/correlation id로 사용자 요청과 OpenAI 호출을 연결- 429는 샘플링(예: 동일 원인 1분에 1건만 상세 로그)
- 원인 분류 필드 추가:
rate_limitvsquota_exceededvsunknown
5.3 런북(장애 대응 절차)
- 429 급증 확인 → 쿼터/레이트리밋 분류
- 레이트리밋이면: 동시성 제한 강화, 워커 수 임시 감축, 큐 처리율 낮춤
- 쿼터면: 기능 제한/차단, 결제/한도 확인, 고객 공지
- 재발 방지: 캐시/큐/전역 레이트리미터/서킷 브레이커 적용
6) Node.js 예시: p-retry로 429 재시도(지터 포함)
import retry from 'p-retry';
import fetch from 'node-fetch';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function parseRetryAfter(res) {
const ra = res.headers.get('retry-after');
if (!ra) return null;
const v = Number(ra);
return Number.isFinite(v) ? v : null;
}
async function callResponsesApi({ url, apiKey, payload }) {
return retry(async (attempt) => {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
if (res.status === 429) {
const ra = parseRetryAfter(res);
if (ra != null) {
// 서버 지시 우선 + 소량 지터
await sleep((ra * 1000) + Math.random() * 250);
}
throw new retry.AbortError(`429 rate limited (attempt=${attempt})`);
}
if (res.status >= 500) {
throw new Error(`server error: ${res.status}`);
}
// 그 외 4xx는 재시도하지 않음
throw new retry.AbortError(`client error: ${res.status}`);
}, {
retries: 5,
factor: 2,
minTimeout: 300,
maxTimeout: 20_000,
randomize: true, // 지터
});
}
> 주의: 위 코드는 패턴 예시입니다. 실제로는 429를 모두 Abort 처리하기보다, Retry-After가 없는 429는 백오프로 재시도하고, “quota exceeded”류는 즉시 중단하는 식으로 더 정교하게 분기하는 편이 안전합니다.
7) 자주 하는 실수 7가지
- 429를 무한 재시도 → 장애 장기화 + 비용/로그 폭증
- 지터 없이 재시도 → 동시 재시도 폭주로 429 반복
- 동시성 제한 없이 비동기만 사용 → 순간 QPS 폭발
- 쿼터 소진을 레이트리밋으로 오인 → 해결 불가한 재시도 루프
- 배치/크론 작업을 피크 타임에 실행 → 서비스 트래픽과 충돌
- 캐시 부재로 동일 요청 반복 호출 → 불필요한 사용량 증가
- 관측 지표 없이 “감”으로 튜닝 → 재발
8) 정리: 429 대응의 우선순위
- 1순위: 분류 — 쿼터(재시도 X) vs 레이트리밋(재시도 O)
- 2순위: 재시도 품질 —
Retry-After존중, 지수 백오프+지터, 재시도 예산 - 3순위: 동시성/버스트 제어 — 세마포어, 큐잉, 전역 레이트리미터
- 4순위: 제품적 대응 — 폴백/기능저하/비동기 처리
- 5순위: 관측/비용 — 429 지표·알람·로그 샘플링
429는 “API가 불안정하다”의 신호가 아니라, 대부분 내 시스템이 외부 제한을 존중하지 못하는 구조라는 신호입니다. 위의 순서대로 적용하면, 단순히 에러를 숨기는 수준을 넘어 트래픽이 커져도 안정적으로 확장 가능한 호출 구조를 만들 수 있습니다.