- Published on
OpenAI 429/Rate Limit 재시도·백오프 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면 가장 자주 마주치는 운영 이슈 중 하나가 429 입니다. 많은 팀이 처음에는 “그냥 몇 번 더 재시도하면 되겠지”로 접근하지만, 429는 대부분 재시도가 곧 부하를 증폭시키는 케이스라서, 제대로 된 백오프와 트래픽 셰이핑이 없으면 장애가 길어집니다.
이 글에서는 OpenAI의 rate limit 상황에서 안전하게 복구하는 재시도 전략을 정책(Policy) 수준으로 설계하고, Node.js/TypeScript 중심으로 구현 예시까지 제공합니다. (Python 예시도 간단히 포함)
관련해서 500/503 계열의 재시도와는 성격이 달라서, 함께 읽으면 좋은 글로는 다음을 권합니다.
- OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커
- 렌더링/요청 폭주를 “캐시와 재검증”으로 낮추는 관점은 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기도 유사한 문제의식이 있습니다.
429를 “일시 오류”로만 보면 망하는 이유
429 Too Many Requests는 크게 두 부류로 옵니다.
- 순수 동시성 폭주: 짧은 시간에 요청이 몰려서 제한을 넘김
- 지속적인 처리량 초과: 분당 요청 수(RPM)나 토큰 처리량(TPM) 상한을 계속 넘김
1번은 적절한 지수 백오프와 지터로 금방 회복될 수 있습니다. 반면 2번은 “재시도”가 아니라 트래픽 자체를 줄이거나, 큐잉/샤딩/캐시/요약/배치 같은 구조적 대응이 필요합니다.
따라서 429 대응은 다음 질문에 답하는 설계여야 합니다.
- 지금 429는 버스트(burst) 인가, 지속 초과(sustained overload) 인가
- 서버가 재시도 중에 더 큰 동시성을 만들고 있지는 않은가
- 재시도가 사용자 경험을 악화시키는 구간에서 빠른 실패(fail fast) 가 필요한가
- 동일 입력이 반복되는 요청이라면 캐시로 우회할 수 있는가
429에서 확인해야 할 신호: 헤더와 에러 바디
가능하면 서버는 429를 받았을 때 다음을 로그로 남겨야 합니다.
- HTTP 상태 코드:
429 - 응답 헤더의
retry-after존재 여부 - OpenAI 에러 코드(예:
rate_limit_exceeded) 및 메시지 - 요청 단위의 메타데이터: 모델, 토큰 추정치, 엔드포인트, 사용자/테넌트, 요청 크기
Retry-After가 있다면 그 값을 최우선으로 존중하는 것이 기본입니다. 없을 때만 지수 백오프 정책으로 폴백합니다.
재시도 정책의 핵심: “지수 백오프 + 지터 + 상한”
재시도는 보통 아래 3가지를 합쳐야 안전합니다.
- 지수 백오프(exponential backoff):
base * 2^attempt - 지터(jitter): 여러 워커가 동시에 깨어나 재폭주하는 것을 방지
- 상한(cap): 지나치게 긴 대기를 막고, 사용자 경험을 통제
지터는 대표적으로 2가지가 많이 쓰입니다.
- Full jitter:
sleep = random(0, min(cap, base * 2^attempt)) - Equal jitter:
sleep = min(cap, base * 2^attempt) / 2 + random(0, min(cap, base * 2^attempt) / 2)
운영 관점에서는 full jitter가 “동시 깨어남”을 더 잘 흩뜨립니다.
“재시도할 것”과 “바로 실패할 것”을 분리하기
429는 무조건 재시도하면 안 됩니다. 다음 조건을 분리하세요.
- 재시도 대상
429이면서retry-after가 짧거나, 버스트로 판단되는 경우- 동일 요청을 다시 보내도 부작용이 없는 경우(멱등성)
- 즉시 실패(또는 빠른 폴백) 대상
- 사용자 인터랙션에서
p95지연이 치명적인 API - 이미 큐가 길어져서 더 기다리면 의미가 없는 경우
- 지속 초과로 판단되는 경우(최근 N분 429 비율이 높음)
- 사용자 인터랙션에서
여기서 중요한 포인트는, OpenAI 호출을 감싸는 “업스트림 API”가 SLO를 지키기 위한 컷오프를 가져야 한다는 점입니다.
예: “최대 8초까지만 기다리고, 그 이상이면 요약 응답으로 폴백” 같은 정책이 필요합니다.
동시성 제한이 재시도보다 먼저다
429 대응을 재시도만으로 해결하려고 하면, 재시도 루프가 동시성을 더 키워서 악순환이 납니다.
가장 효과가 큰 순서는 보통 이렇습니다.
- 동시성 제한(세마포어, 큐, 워커 풀)
- 클라이언트 측 토큰 버짓 관리(추정 토큰 기반)
- 백오프 재시도
- 폴백(간단 모델, 요약, 캐시, 비동기 처리)
즉, 재시도는 “마지막 안전망”에 가깝고, 앞단에서 트래픽 모양을 바꾸는 것이 핵심입니다.
Node.js/TypeScript: 429 재시도 + Retry-After + 지터 구현
아래 예시는 다음을 포함합니다.
Retry-After헤더가 있으면 우선 적용- 없으면 full jitter 지수 백오프
- 최대 재시도 횟수와 최대 대기 상한
- 429 외에는 그대로 throw (필요 시 확장)
type RetryOptions = {
maxAttempts: number; // 총 시도 횟수(최초 1회 포함)
baseDelayMs: number; // 기본 지연
maxDelayMs: number; // 지연 상한
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(value: string | null): number | null {
if (!value) return null;
// 1) 초 단위 숫자
const asNumber = Number(value);
if (Number.isFinite(asNumber) && asNumber >= 0) {
return Math.round(asNumber * 1000);
}
// 2) HTTP-date
const asDate = Date.parse(value);
if (!Number.isNaN(asDate)) {
const diff = asDate - Date.now();
return diff > 0 ? diff : 0;
}
return null;
}
function fullJitterDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
// attempt: 1부터 시작(첫 실패 후 1)
const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
async function withOpenAIRetry429<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
lastErr = err;
const status = err?.status ?? err?.response?.status;
const headers = err?.headers ?? err?.response?.headers;
if (status !== 429) throw err;
if (attempt === opts.maxAttempts) break;
const retryAfterRaw = headers?.get
? headers.get("retry-after")
: headers?.["retry-after"];
const retryAfterMs = parseRetryAfterMs(retryAfterRaw ?? null);
const delay = retryAfterMs ?? fullJitterDelayMs(attempt, opts.baseDelayMs, opts.maxDelayMs);
await sleep(Math.min(delay, opts.maxDelayMs));
}
}
throw lastErr;
}
OpenAI 호출에 감싸기 예시
OpenAI SDK 버전에 따라 호출 형태가 다를 수 있으니, 핵심은 fn에 “실제 호출”을 넣는 방식입니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
async function generateText(prompt: string) {
return withOpenAIRetry429(
async () => {
// 예시: Responses API
return client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
});
},
{
maxAttempts: 6,
baseDelayMs: 250,
maxDelayMs: 10_000,
}
);
}
“멱등성”과 중복 실행 방지: 요청 키를 설계하라
429 재시도는 같은 요청을 반복합니다. 이때 문제가 되는 건 부작용이 있는 작업입니다.
- 결제 승인, 이메일 발송, DB write 같은 작업과 OpenAI 호출이 묶여 있으면 재시도 시 중복 실행 위험
권장 패턴은 다음입니다.
- OpenAI 호출은 가능한 한 순수 함수처럼 만들기(입력만으로 출력이 결정)
- 부작용 작업은 OpenAI 성공 이후 별도 트랜잭션으로 분리
- 요청에
idempotencyKey(예:sha256(userId + normalizedPrompt + model + toolConfig))를 붙이고, 결과를 캐시/저장해 중복 호출을 막기
캐시를 두면 429를 “재시도”로 해결하기보다 아예 호출을 줄이는 방향으로 전환할 수 있습니다.
토큰/요청 버짓을 모르면 백오프는 땜질이다
429를 근본적으로 줄이려면, 애플리케이션이 대략이라도 다음을 알아야 합니다.
- 요청당 예상 토큰(입력 토큰 + 출력 상한)
- 동시 요청 수
- 분당 처리량 목표(RPM/TPM)
실전에서는 “토큰 추정치 기반 세마포어”가 매우 효과적입니다.
- 가벼운 요청은 더 많이 동시 처리
- 무거운 요청은 동시 처리 수를 줄임
예를 들어 “현재 사용 중인 토큰 버짓”을 전역 카운터로 두고, 요청이 들어오면 예상 토큰만큼 예약한 뒤 실행, 완료 시 반납하는 방식입니다. (정확한 TPM 제어가 아니더라도 폭주 완화에 큰 도움)
큐잉(Queue)로 429를 제품 기능으로 바꾸기
사용자-facing API에서 429가 잦다면, 재시도보다 큐잉이 더 좋은 UX를 만들 수 있습니다.
- 동기 응답: 짧은 시간 내 처리 가능한 요청만
- 비동기 응답: 나머지는 작업 큐에 넣고
jobId반환
이 구조는 다음 장점이 있습니다.
- 서버가 스스로 동시성을 통제
- 재시도가 중앙화되어 폭주가 줄어듦
- 사용자는 진행 상태를 확인할 수 있음
큐 기반 워커는 429를 만나면 워커 내부에서 백오프하고, 큐의 가시성 타임아웃(visibility timeout)과 함께 재시도 횟수를 관리하면 됩니다.
관측(Observability): 429 대응은 로그가 아니라 지표로 운영한다
429는 “몇 번 났다”가 아니라, 비율과 패턴이 중요합니다.
추천 지표
openai_requests_total{status}openai_429_total{model,endpoint}openai_retry_attempts_histogramopenai_retry_delay_ms_histogramopenai_queue_depth(큐 사용 시)openai_request_latency_ms{status}
알람은 단순 429 카운트보다
- 최근 5분 429 비율이 임계치 초과
- 재시도 평균 횟수 급증
- 대기 지연이 상한에 자주 도달
같은 조건이 운영에 더 유용합니다.
Python 예시: requests 기반 429 백오프(간단 버전)
Python에서도 핵심은 동일합니다.
import random
import time
def full_jitter_sleep(attempt: int, base: float, cap: float):
exp = min(cap, base * (2 ** attempt))
time.sleep(random.random() * exp)
def call_with_retry_429(fn, max_attempts=6, base_delay=0.25, cap_delay=10.0):
last = None
for attempt in range(1, max_attempts + 1):
try:
return fn()
except Exception as e:
last = e
status = getattr(e, "status", None)
if status != 429 or attempt == max_attempts:
raise
full_jitter_sleep(attempt, base_delay, cap_delay)
raise last
실제 OpenAI SDK 예외 타입에 맞춰 status 추출 로직만 조정하면 됩니다.
흔한 실수 7가지
- 429를 5xx처럼 취급하고 즉시 재시도(지터 없음)
Retry-After를 무시함- 재시도 중에도 요청을 계속 받아 동시성이 폭증
- 최대 대기 상한이 없어 요청이 “영원히” 붙잡힘
- 사용자 요청과 배치 작업이 같은 풀에서 경쟁
- 동일 프롬프트 반복 호출을 캐시하지 않음
- 429를 로그만 보고 지표/알람이 없음
권장 레퍼런스 아키텍처(요약)
- API Gateway/Backend
- 사용자별/테넌트별 rate limit (1차 방어)
- 동시성 제한(세마포어)
- 캐시(동일 입력 결과 재사용)
- 짧은 타임아웃과 폴백
- Worker
- 큐 기반 처리
- 429 재시도는 워커에서 중앙화
- 지수 백오프 + full jitter +
retry-after존중
- Observability
- 429 비율, 재시도 횟수, 대기시간, 큐 길이
마무리
OpenAI 429는 “재시도 몇 번”으로 해결되는 문제가 아니라, 동시성 제어와 트래픽 셰이핑의 문제입니다. Retry-After를 존중하고, 지수 백오프에 지터를 섞고, 상한과 타임아웃으로 사용자 경험을 통제하세요. 그리고 가능하면 큐잉과 캐시로 호출 자체를 줄이는 방향이 가장 강력합니다.
429가 아니라 500/503처럼 일시 장애에 대한 재시도, 폴백, 서킷브레이커까지 포함한 전체 전략은 아래 글에서 이어서 다룹니다.