- Published on
OpenAI 429 Rate Limit 재시도·백오프 구현 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영에서 OpenAI API를 붙이면 가장 먼저 마주치는 장애 중 하나가 429 입니다. 개발 단계에서는 “요청을 좀 줄이면 되겠지”로 끝나지만, 실제 트래픽에서는 배치 작업, 동시 사용자 증가, 큐 리드레인, 워커 재시작 같은 이벤트가 겹치면서 순식간에 요청이 몰립니다. 이때 재시도를 무작정 붙이면 더 큰 폭주를 만들고, 반대로 재시도를 안 하면 사용자 실패율이 급격히 올라갑니다.
이 글에서는 OpenAI 429를 정상적인 시스템 신호로 보고, 재시도와 백오프를 안전하게 구현하는 방법을 설명합니다. 핵심은 다음 4가지입니다.
429는 “잠깐만 쉬어라”라는 의미이므로 즉시 재시도 금지Retry-After같은 서버 힌트를 우선 적용- 지수 백오프에 지터(jitter) 를 넣어 동시 재시도 폭주를 방지
- 재시도는 멱등성과 중복 비용(토큰/요금) 을 고려해 설계
비슷한 주제의 실무 패턴은 아래 글도 참고할 수 있습니다.
429 Rate limit의 의미를 먼저 분해하기
429 Too Many Requests는 대개 아래 중 하나(혹은 복합)입니다.
- 요청 수 제한(RPM, Requests Per Minute)
- 토큰 처리량 제한(TPM, Tokens Per Minute)
- 동시성 제한(Concurrent requests)
- 계정/프로젝트 단위의 순간 버스트 제한
여기서 중요한 점은, 429가 항상 “당신의 코드가 틀렸다”가 아니라는 것입니다. 오히려 “현재 시점에서 더 밀어 넣으면 전체 품질이 나빠지니, 클라이언트가 자율적으로 속도를 조절해달라”는 흐름 제어(backpressure) 신호에 가깝습니다.
따라서 재시도는 단순히 에러를 숨기는 기술이 아니라, 시스템을 안정화하는 제어 로직으로 접근해야 합니다.
재시도 전략의 기본 원칙
1) Retry-After가 있으면 최우선
서버가 Retry-After 헤더를 주는 경우가 있습니다. 이 값은 “몇 초 뒤에 다시 와라”이므로, 클라이언트가 임의로 계산한 백오프보다 우선해야 합니다.
2) 지수 백오프 + 지터는 필수
지수 백오프만 쓰면 여러 워커가 동일한 타이밍에 재시도하면서 다시 429를 유발합니다. 지터는 이 동기화를 깨는 장치입니다.
- 백오프:
base * 2^attempt - 지터: 랜덤 범위를 섞어 재시도 시간을 분산
가장 널리 쓰이는 방식은 Full Jitter 입니다.
sleep = random(0, min(cap, base * 2^attempt))
3) 무한 재시도 금지, 상한을 둔다
재시도는 성공 확률을 올리지만, 실패를 영원히 끌고 가면 큐가 막히고 전체 지연이 커집니다.
- 최대 시도 횟수: 예) 5~8회
- 최대 총 대기 시간: 예) 30초~2분
4) 멱등성 없는 작업은 재시도 비용을 따져라
OpenAI 호출이 “같은 입력이면 같은 출력”이더라도, 서버 관점에서는 매번 비용이 발생합니다. 또한 스트리밍/툴 호출/멀티스텝 체인에서는 “부분 성공 후 실패”가 생길 수 있습니다.
- 재시도 가능한 구간을 명확히 분리
- 중복 호출이 허용되지 않는 작업은 별도 키로 dedupe
Node.js(서버)에서 안전한 재시도 구현
아래 예시는 다음을 포함합니다.
Retry-After우선- Full Jitter 백오프
- 최대 시도 횟수 제한
429및 일시적 네트워크 오류를 재시도 대상으로 분류
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
};
function parseRetryAfterMs(err: any): number | null {
// SDK/런타임에 따라 헤더 접근 방식이 다를 수 있어 방어적으로 처리
const h = err?.response?.headers;
const retryAfter = h?.get?.("retry-after") ?? h?.["retry-after"];
if (!retryAfter) return null;
// `Retry-After`는 초 또는 HTTP date일 수 있음
const asNumber = Number(retryAfter);
if (Number.isFinite(asNumber)) return Math.max(0, asNumber * 1000);
const asDate = Date.parse(retryAfter);
if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function fullJitterDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
function isRetryable(err: any) {
const status = err?.status ?? err?.response?.status;
if (status === 429) return true;
if (status >= 500 && status <= 599) return true;
// 네트워크 계층 오류 예시
const code = err?.code;
if (code === "ETIMEDOUT" || code === "ECONNRESET" || code === "EAI_AGAIN") return true;
return false;
}
async function withRetry<T>(fn: () => Promise<T>, opt: RetryOptions): Promise<T> {
let lastErr: any;
for (let attempt = 0; attempt < opt.maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
lastErr = err;
if (!isRetryable(err)) throw err;
const retryAfterMs = parseRetryAfterMs(err);
const delayMs = retryAfterMs ?? fullJitterDelayMs(attempt, opt.baseDelayMs, opt.maxDelayMs);
// 마지막 시도라면 대기하지 말고 종료
if (attempt === opt.maxAttempts - 1) break;
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw lastErr;
}
export async function generateSummary(prompt: string) {
return withRetry(
async () => {
const res = await client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
});
return res.output_text;
},
{ maxAttempts: 6, baseDelayMs: 250, maxDelayMs: 8000 }
);
}
실무 팁: 재시도는 호출부가 아니라 “클라이언트 래퍼”에 모아라
서비스가 커지면 OpenAI 호출 지점이 늘어납니다. 각 호출부에 재시도가 흩어지면 정책이 달라져서 장애 시 행동이 예측 불가능해집니다.
openaiClient.ts같은 단일 모듈에서 재시도 정책을 제공- 모델별로 비용/지연 허용치가 다르면 옵션만 주입
Python에서 재시도·백오프(동기/비동기) 패턴
동기 호출 예시
import random
import time
from openai import OpenAI
client = OpenAI()
def full_jitter_delay(attempt: int, base: float, cap: float) -> float:
exp = min(cap, base * (2 ** attempt))
return random.random() * exp
def is_retryable(e: Exception) -> bool:
# openai SDK 예외 타입은 버전에 따라 다를 수 있어 status를 방어적으로 접근
status = getattr(e, "status_code", None) or getattr(e, "status", None)
if status == 429:
return True
if isinstance(status, int) and 500 <= status <= 599:
return True
return False
def call_with_retry(fn, max_attempts=6, base_delay=0.25, cap_delay=8.0):
last = None
for attempt in range(max_attempts):
try:
return fn()
except Exception as e:
last = e
if not is_retryable(e):
raise
if attempt == max_attempts - 1:
break
delay = full_jitter_delay(attempt, base_delay, cap_delay)
time.sleep(delay)
raise last
def generate(prompt: str) -> str:
def _do():
res = client.responses.create(
model="gpt-4.1-mini",
input=prompt,
)
return res.output_text
return call_with_retry(_do)
비동기 워커에서 특히 주의할 점
비동기 환경에서 429가 자주 나는 이유는 “재시도”보다 “동시성”이 원인인 경우가 많습니다. 즉, 백오프를 붙여도 동시에 200개 코루틴이 계속 두드리면 계속 429가 납니다.
- 세마포어로 동시 호출 수 제한
- 큐 컨슈머 수를 제한
- 토큰 기반 리미터를 추가
아래는 세마포어로 동시성을 제한하는 예시입니다.
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI()
sema = asyncio.Semaphore(8)
async def guarded_call(prompt: str) -> str:
async with sema:
res = await client.responses.create(
model="gpt-4.1-mini",
input=prompt,
)
return res.output_text
백오프만으로 부족할 때: 클라이언트 측 레이트 리미터
재시도는 “이미 초과했다” 이후의 대응입니다. 지속적으로 429가 난다면, 애초에 초과하지 않도록 레이트 리미터를 둬야 합니다.
1) 토큰 버킷 기반 제한(개념)
- 1분에 N개의 토큰(또는 요청)을 버킷에 채움
- 요청 시 버킷에서 차감
- 부족하면 대기
Node에서는 bottleneck 같은 라이브러리를 쓰거나, Redis 기반 분산 리미터를 두는 방식이 흔합니다.
2) 분산 환경에서는 “프로세스 로컬 리미터”가 깨진다
워커가 10개면, 각 워커가 “분당 60회”를 지키더라도 전체는 분당 600회가 됩니다. 따라서 다음 중 하나가 필요합니다.
- 중앙 큐에서 소비 속도 제한
- Redis 같은 공용 저장소 기반 분산 레이트 리미팅
- API 호출을 단일 게이트웨이로 모아 제한
이 문제는 ALB 뒤에서 워커가 늘어나며 5xx가 터지는 케이스와 비슷한 성격이 있습니다. 트래픽 제어를 어디에서 하느냐가 관건입니다.
운영에서 자주 놓치는 포인트 6가지
1) 재시도는 “전체 요청”이 아니라 “재시도 가능한 단계”에만
예를 들어 “요약 생성 후 DB 저장” 흐름에서 DB 저장이 실패했다고 OpenAI 호출까지 재시도하면 중복 비용이 발생합니다.
- OpenAI 호출 결과를 임시 저장
- 저장 단계만 재시도
2) 스트리밍 응답은 부분 수신 후 실패가 발생한다
스트리밍 중 연결이 끊기면 “이미 일부 토큰을 받았다” 상태입니다. 이때 단순 재시도는 동일 내용을 중복 생성할 수 있습니다.
대응:
- 스트리밍은 사용자 경험용으로만 쓰고, 서버에는 비스트리밍 결과를 별도로 저장
- 또는 chunk 단위로 체크포인트를 두고 후처리
3) 서킷 브레이커를 같이 둬라
429가 계속 발생하는데도 모든 요청이 재시도를 반복하면, 시스템 전체 지연이 누적됩니다.
- 최근 30초 동안
429비율이 일정 수준을 넘으면 즉시 실패(fail fast) - 백그라운드에서만 제한적으로 재시도
4) 관측 가능성: “재시도 횟수”를 지표로 만들기
429 자체보다 중요한 것은 추세입니다.
openai_requests_totalopenai_429_totalopenai_retry_attempts_histogramopenai_retry_exhausted_total- p95 지연 시간
이 지표가 있어야 “백오프를 늘릴지”, “동시성을 줄일지”, “모델을 바꿀지”를 결정할 수 있습니다.
5) 배치 작업은 랜덤 지연으로 시작 시간을 분산
매시 정각에 크론이 동시에 시작하면 순간 버스트로 429가 납니다. 워커 시작 시 0~N초 랜덤 딜레이를 주거나, 큐에 천천히 적재하세요.
크론 기반 작업에서 이런 현상이 자주 보입니다.
6) “재시도 성공”이 항상 좋은 것은 아니다
재시도로 성공률은 올라가도, 지연이 길어져 UX가 나빠질 수 있습니다. 특히 사용자 요청 경로에서는
- 1~2회 짧게 재시도
- 그 이상이면 “대기열로 전환” 또는 “나중에 알림”
같은 제품적 선택이 필요합니다.
권장 레시피(현실적인 기본값)
서비스 유형별로 자주 쓰는 기본값을 정리하면 다음과 같습니다.
- 사용자 인터랙션 API
- 최대 2~3회 재시도
baseDelay200~300mscap2~4s- 총 대기 3~6s 내로 제한
- 백그라운드 워커/배치
- 최대 6~8회 재시도
baseDelay250~500mscap8~20s- 필요 시 서킷 브레이커 + 큐 재적재
그리고 공통으로:
Retry-After우선- Full Jitter 사용
- 동시성 제한(세마포어, 큐 컨슈머 수)
- 분산 환경이면 중앙 리미터 또는 큐 기반 제어
마무리
OpenAI 429는 피할 수 없는 이벤트입니다. 중요한 건 “에러를 없애는 것”이 아니라, 429를 시스템의 리듬에 맞춘 속도 조절 신호로 받아들이고 재시도·백오프·동시성 제한을 함께 설계하는 것입니다.
- 재시도는 짧고 보수적으로
- 백오프는 지수 + 지터
- 지속적
429는 레이트 리미터/동시성 제어로 원인을 제거 - 지표로 재시도 비용과 지연을 관측
이 패턴을 한 번 제대로 잡아두면, 모델을 바꾸거나 트래픽이 늘어도 장애 대응이 훨씬 단단해집니다.