- Published on
OpenAI 429·insufficient_quota 재시도와 백오프 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
OpenAI API를 운영 환경에 붙이면 가장 흔하게 마주치는 실패가 429 계열입니다. 문제는 429가 항상 “잠깐 기다리면 되는” 상황이 아니라는 점입니다. 같은 429라도 rate limit(일시적), 동시성 제한, 조직/프로젝트 쿼터 소진(사실상 영구적) 등 원인이 다르고, 원인에 따라 재시도 정책이 달라져야 합니다.
이 글에서는 다음을 목표로 합니다.
429와insufficient_quota를 구분하고, 재시도 가능/불가능을 판단한다.- 헤더(
Retry-After, rate limit 관련 헤더)를 활용해 서버가 원하는 속도로 백오프한다. - 지수 백오프 + 지터(jitter) + 회로차단(circuit breaker)로 폭주 재시도를 막는다.
- 멀티 워커/큐 환경에서 전역 rate limit을 지키는 구현 패턴을 제시한다.
OpenAI 응답 스키마/에러 파싱에 대한 다른 케이스가 필요하다면 OpenAI Responses API 400 invalid_tool_output 해결법도 함께 참고하면 좋습니다.
429의 두 얼굴: throttling vs quota
1) throttling(일시적 제한): 재시도 대상
일반적인 429는 “요청이 너무 많다”는 의미입니다. 보통 아래 중 하나입니다.
- RPM/TPM 제한: 분당 요청 수(Requests Per Minute), 분당 토큰 수(Tokens Per Minute)
- 동시성 제한: 동시에 처리 가능한 요청 수 제한
- 버스트 제한: 짧은 시간에 몰린 트래픽 제한
이 경우는 재시도가 합리적입니다. 단, 즉시 재시도는 더 큰 429 폭풍을 만들기 때문에 백오프가 필수입니다.
2) insufficient_quota(실질적 영구 실패): 재시도 금지
insufficient_quota는 보통 다음을 의미합니다.
- 결제/크레딧/프로젝트 예산 부족
- 조직/프로젝트 단위 쿼터 소진
- 사용량 한도(하드 리밋) 도달
이 경우는 재시도해도 성공할 확률이 낮거나 0에 가깝습니다. 재시도는 비용만 키우고 장애 시간을 늘립니다. 즉시 사용자에게 적절한 메시지를 주거나, 운영 알림을 발생시키고, 대체 경로(다른 모델/다른 공급자/캐시 응답 등)로 전환해야 합니다.
에러를 “문자열”이 아니라 “정책”으로 매핑하기
운영에서 중요한 건 “에러 메시지”가 아니라 “이 에러에 어떤 정책을 적용할 것인가”입니다. 추천하는 분류는 아래처럼 단순합니다.
- Retryable: 429(일부), 500/502/503/504, 네트워크 타임아웃
- Retryable but slow down: 429(rate limit) +
Retry-After제공 - Non-retryable: 400(대부분), 401/403(권한),
insufficient_quota
이런 정책 맵을 만들어두면, 클라이언트/워커/큐 어디서든 동일한 규칙으로 동작합니다.
백오프 설계: 지수 백오프 + 지터 + 상한
왜 지터가 필요한가
여러 워커가 동시에 429를 맞으면, 모두가 “2초 후 재시도” 같은 동일한 패턴으로 움직이며 **동기화된 재폭주(thundering herd)**가 발생합니다. 지터는 이를 깨기 위해 필수입니다.
권장 공식(예시)
- 기본 지수 백오프:
base * 2^attempt - 상한:
max_delay - 지터: full jitter(0~delay 랜덤) 또는 equal jitter
Retry-After와 rate limit 헤더를 최우선으로 사용하기
서버가 Retry-After를 준다면, 로컬에서 계산한 백오프보다 서버 힌트를 우선하는 것이 안전합니다. 일부 환경에서는 추가 rate limit 관련 헤더가 제공될 수 있으니(버전/엔드포인트에 따라 상이), 있다면 적극 활용하세요.
정리하면 우선순위는 다음이 좋습니다.
Retry-After가 있으면 그대로 따른다.- 없으면 지수 백오프 + 지터를 적용한다.
- 연속 실패가 길어지면 회로차단으로 잠시 중단한다.
Python 예제: OpenAI 호출 래퍼(재시도/백오프/중단)
아래 예제는 (1) 429 중 rate limit은 재시도, (2) insufficient_quota는 즉시 중단, (3) Retry-After 우선, (4) 지수 백오프+지터를 구현합니다.
import random
import time
from typing import Optional
from openai import OpenAI
client = OpenAI()
def _parse_retry_after(headers: dict) -> Optional[float]:
# headers는 라이브러리/버전에 따라 형태가 다를 수 있음
# 가능한 경우에만 파싱
if not headers:
return None
ra = headers.get("retry-after") or headers.get("Retry-After")
if not ra:
return None
try:
return float(ra)
except ValueError:
return None
def call_openai_with_retry(prompt: str, *, max_attempts: int = 6,
base_delay: float = 0.5, max_delay: float = 20.0):
last_exc = None
for attempt in range(max_attempts):
try:
return client.responses.create(
model="gpt-4.1-mini",
input=prompt,
)
except Exception as e:
last_exc = e
# openai SDK 예외 구조는 버전에 따라 달라질 수 있어
# 아래는 "정책" 중심의 방어적 분기 예시
status = getattr(e, "status_code", None) or getattr(e, "status", None)
err = getattr(e, "error", None)
code = None
msg = str(e)
# 가능한 경우 code 추출(예: err.code)
if err is not None:
code = getattr(err, "code", None) or getattr(err, "type", None)
# 1) 쿼터 소진: 재시도 금지
if code == "insufficient_quota" or "insufficient_quota" in msg:
raise RuntimeError(
"OpenAI quota exhausted (insufficient_quota). "
"Do not retry; check billing/limits and alert ops."
) from e
# 2) 400/401/403 등은 보통 재시도해도 안 됨
if status in (400, 401, 403):
raise
# 3) 429 및 5xx/네트워크 계열은 재시도
retryable = (status == 429) or (status in (500, 502, 503, 504)) or (status is None)
if not retryable:
raise
# Retry-After 우선
headers = getattr(e, "headers", None)
retry_after = _parse_retry_after(headers or {})
if attempt == max_attempts - 1:
break
if retry_after is not None:
sleep_s = min(retry_after, max_delay)
else:
exp = base_delay * (2 ** attempt)
capped = min(exp, max_delay)
# full jitter
sleep_s = random.uniform(0, capped)
time.sleep(sleep_s)
raise RuntimeError(f"OpenAI call failed after {max_attempts} attempts") from last_exc
포인트
- insufficient_quota는 즉시 예외로 올려서 상위(웹/워커)에서 알림 및 폴백을 실행하게 합니다.
- 429는 무조건 재시도하지 말고, 가능하면 Retry-After를 따릅니다.
- 지터를 넣어 다중 워커 환경에서 재시도 동기화를 피합니다.
Node.js 예제: fetch 기반 재시도(지터/Retry-After)
SDK를 쓰더라도 원리는 동일합니다. 아래는 fetch로 호출할 때의 패턴입니다.
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfter(res) {
const ra = res.headers.get('retry-after');
if (!ra) return null;
const sec = Number(ra);
return Number.isFinite(sec) ? sec * 1000 : null;
}
export async function callOpenAIWithRetry({ url, apiKey, body, maxAttempts = 6 }) {
const baseDelay = 500;
const maxDelay = 20_000;
let lastErr;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (res.ok) return await res.json();
const text = await res.text();
// insufficient_quota는 보통 JSON 에러 본문에 포함되므로 텍스트 검색도 방어적으로
if (text.includes('insufficient_quota')) {
throw new Error('insufficient_quota');
}
if ([400, 401, 403].includes(res.status)) {
throw new Error(`non-retryable ${res.status}: ${text}`);
}
const retryable = res.status === 429 || [500, 502, 503, 504].includes(res.status);
if (!retryable) {
throw new Error(`unexpected ${res.status}: ${text}`);
}
if (attempt === maxAttempts - 1) {
throw new Error(`failed after retries: ${res.status}: ${text}`);
}
const raMs = parseRetryAfter(res);
let delay;
if (raMs != null) {
delay = Math.min(raMs, maxDelay);
} else {
const exp = Math.min(baseDelay * (2 ** attempt), maxDelay);
delay = Math.floor(Math.random() * exp); // full jitter
}
await sleep(delay);
} catch (e) {
lastErr = e;
if (String(e.message).includes('insufficient_quota')) {
// 재시도 금지
throw e;
}
// 네트워크 에러는 여기로 올 수 있으니 재시도 루프 지속
if (attempt === maxAttempts - 1) throw lastErr;
}
}
throw lastErr;
}
운영 설계: “클라이언트 재시도”만으로는 부족하다
1) 큐/워커 환경에서는 전역 rate limit이 필요
웹 서버 여러 대, 워커 여러 개가 각각 재시도하면 합산 트래픽이 제한을 초과합니다. 해결책은 다음 중 하나입니다.
- 중앙 큐에서 토큰 버킷/리키 버킷으로 전역 제한
- Redis 기반 rate limiter(슬라이딩 윈도우)
- 워커 수 자체를 제한(동시성 제한)
2) 회로차단(circuit breaker)로 “장애 증폭” 방지
429가 일정 시간 이상 지속되면, 재시도는 성공 확률이 낮고 비용이 큽니다.
- N회 연속 429 → 30~60초 동안 OpenAI 호출을 빠르게 실패(fail-fast)
- 그동안 캐시 응답/간소화 모델/기능 제한 모드로 degrade
3) 타임아웃과 재시도는 곱해진다
요청 타임아웃 30초에 6회 재시도면 최악의 경우 3분이 넘습니다. API 서버라면 상위 타임아웃(예: ALB idle timeout, Gunicorn worker timeout)과 충돌할 수 있습니다. 웹 레이어 타임아웃 문제를 다룰 때는 Gunicorn Uvicorn Worker timeout 재현과 해결처럼 “상위 타임아웃”도 함께 맞춰야 합니다.
관측(Observability): 429를 ‘장애’로 만들지 않는 지표
재시도 로직을 넣었는데도 문제가 반복된다면, 원인은 대개 “우리가 얼마나 제한을 넘겼는지”를 모른다는 데 있습니다. 아래 지표를 최소로 권장합니다.
- 요청 성공/실패 비율(모델/엔드포인트/프로젝트 태그별)
- 429 발생률과
Retry-After분포 - 재시도 횟수 히스토그램(0~N)
- 토큰 사용량(입력/출력)과 TPS/RPM 추정
- insufficient_quota 발생 시각과 직전 사용량 스파이크
또한 429를 네트워크 문제로 오인하는 경우가 많습니다. 특히 쿠버네티스/EKS 환경에서 egress/ingress, 프록시(Envoy), LB 타임아웃과 섞이면 더 복잡해집니다. 네트워크 계층 트러블슈팅이 필요하면 EKS에서 Pod egress만 502? Envoy/NLB 추적기 같은 접근으로 “애플리케이션 429”와 “인프라 5xx”를 분리하세요.
실전 체크리스트
insufficient_quota는 재시도하지 말고 알림/결제/한도 조치로 전환한다.- 429는
Retry-After가 있으면 그 값을 최우선으로 따른다. - 지수 백오프에는 지터를 반드시 넣는다.
- 다중 워커라면 재시도는 더 위험하다. 전역 rate limit(Redis/큐/토큰버킷)을 둔다.
- 회로차단으로 장기 429 구간에서 fail-fast + degrade를 적용한다.
- 타임아웃(상위 LB/서버)과 재시도 횟수의 곱을 계산해, 사용자 요청이 “끝까지 기다릴 수 있는 시간” 안에 들어오게 설계한다.
결론
OpenAI의 429는 단순히 “조금 기다리면 된다”가 아니라, 원인별로 정책이 달라야 하는 신호입니다. insufficient_quota를 재시도로 해결하려고 하면 장애가 길어지고 비용만 늘어납니다. 반대로 rate limit 429를 재시도 없이 실패 처리하면 사용자 경험이 불필요하게 나빠집니다.
핵심은 세 가지입니다.
- 에러를 원인(정책)으로 분류하고, 2)
Retry-After+ 지수 백오프/지터로 서버 페이스를 맞추며, 3) 전역 제한/회로차단으로 시스템 전체를 보호하는 것.
이 구조를 갖추면 429는 더 이상 “운영 장애”가 아니라, 예측 가능한 “흐름 제어” 이벤트가 됩니다.