- Published on
OpenAI 429 RateLimitError 재시도·백오프 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면, 한 번쯤은 429 응답과 함께 RateLimitError를 마주칩니다. 문제는 여기서 “그냥 몇 번 더 재시도”를 넣는 순간, 트래픽이 몰리는 시간대에는 재시도가 재시도를 부르고, 결국 더 큰 폭주와 더 긴 장애로 이어진다는 점입니다.
이 글에서는 429를 단순 예외가 아니라 용량(capacity) 신호로 보고, 재시도·백오프를 어떻게 설계해야 안정적으로 처리량을 유지하는지 다룹니다. 특히 다음을 목표로 합니다.
- 재시도 폭주(thundering herd) 방지
- 헤더 기반 대기와 지수 백오프의 결합
- 동시성 제한과 큐잉으로 “재시도 자체”를 줄이기
- 재시도 가능한 오류와 불가능한 오류를 명확히 분리
추가로 5xx까지 포함한 재시도 전략은 아래 글과 함께 보면 전체 그림이 깔끔해집니다.
429가 의미하는 것: “잠깐 쉬어라”가 아니라 “용량을 넘었다”
429 Too Many Requests는 흔히 “잠깐 쉬면 된다”로 받아들이지만, 운영 관점에서는 다음 두 가지 중 하나입니다.
- 순간적인 버스트로 인한 초과: 짧게만 기다리면 정상화
- 지속적인 초과 상태: 기다려도 계속
429가 나며, 재시도는 오히려 상황을 악화
따라서 재시도 로직은 “몇 초 쉬고 다시 던지기”가 아니라, 현재 시스템이 낼 수 있는 요청률을 자동으로 낮추는 제어 장치여야 합니다.
재시도 설계의 핵심 원칙 6가지
1) 재시도 대상 오류를 엄격히 제한
모든 실패를 재시도하면 비용과 지연만 늘어납니다. 최소한 다음처럼 나누는 게 안전합니다.
- 재시도 후보
429(rate limit)408(timeout)409(일부 상황에서 경합)5xx(일시적 서버 오류)- 네트워크 오류, DNS 일시 장애
- 재시도 금지
400(잘못된 요청)401(인증)403(권한)404(엔드포인트 등)422(유효성)
특히 400 계열은 “고쳐야 할 요청”인 경우가 많아 재시도해도 성공 확률이 거의 없습니다.
2) 지수 백오프는 기본, 지터는 필수
지수 백오프만 쓰면 클라이언트들이 동일한 간격으로 다시 몰려 동기화된 폭주가 생깁니다. 그래서 지터(jitter)를 섞어야 합니다.
- 권장:
base * 2^attempt에 랜덤 지터를 더하거나, “Full Jitter” 방식 사용 - 상한(cap)을 둬서 무한정 늘어나지 않게 제한
3) 서버가 알려주는 대기 힌트를 우선
가능하다면 응답 헤더의 Retry-After 같은 힌트를 우선 적용합니다. 서비스가 “이만큼 기다리면 된다”를 알려주는 경우가 있기 때문입니다.
Retry-After가 있으면 그 값을 최우선- 없으면 지수 백오프 계산값 사용
4) 재시도 횟수보다 “총 대기 시간”을 제한
재시도를 10번까지 허용해도, 각 대기가 길어지면 사용자 체감은 더 나빠집니다.
max_attempts와 함께max_elapsed_ms를 둬서 총 지연 상한을 관리
5) 동시성 제한이 재시도보다 먼저다
429는 대개 “너무 많이 동시에 때렸다”의 결과입니다. 재시도만 잘해도 해결될 것 같지만, 근본적으로는 동시성(Concurrency) 제한이 먼저입니다.
- 프로세스 내 세마포어로 동시 요청 수 제한
- 워커 큐를 둬서 초과 요청은 대기열로 보내기
6) 관측 가능성: 재시도는 반드시 지표로 남겨라
다음 지표가 없으면 백오프가 “잘 동작하는지” 판단이 어렵습니다.
429발생률- 재시도 횟수 분포(시도 1,2,3,4…)
- 백오프 대기 시간 분포
- 요청 성공까지의 총 지연
- 토큰 사용량과 요청량의 상관
백오프 계산: Full Jitter 예시
아래는 Full Jitter 방식의 대표적인 계산입니다.
cap_ms까지 지수적으로 증가한 뒤0부터 그 값 사이에서 랜덤 선택
// TypeScript
function fullJitterBackoffMs(attempt: number, baseMs = 200, capMs = 10_000) {
const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
이 방식은 클라이언트들이 같은 시점에 실패하더라도, 재시도 시점이 자연스럽게 분산되어 폭주를 줄입니다.
헤더 기반 대기와 결합하기
Retry-After가 있다면 우선 적용하고, 없다면 지터 백오프를 적용합니다.
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
// 1) 초 단위 숫자
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
// 2) HTTP-date
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - Date.now());
}
return null;
}
Node.js에서 OpenAI 429 재시도 래퍼 구현
아래 예시는 OpenAI SDK 호출을 감싸서 429 및 일부 일시 장애에 대해 재시도하는 패턴입니다.
포인트는 다음입니다.
maxAttempts와maxElapsedMs를 동시에 제한Retry-After우선- 그 외에는 Full Jitter 백오프
- 재시도 금지 오류는 즉시 throw
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type RetryOptions = {
maxAttempts: number;
maxElapsedMs: number;
baseBackoffMs: number;
capBackoffMs: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function fullJitterBackoffMs(attempt: number, baseMs: number, capMs: number) {
const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
return null;
}
function isRetryableStatus(status: number) {
return status === 429 || status === 408 || (status >= 500 && status <= 599);
}
export async function callWithRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
const startedAt = Date.now();
let lastErr: unknown;
for (let attempt = 0; 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 || !isRetryableStatus(status)) {
throw err;
}
const elapsed = Date.now() - startedAt;
if (elapsed >= opts.maxElapsedMs) {
throw err;
}
const retryAfterHeader = headers?.["retry-after"] ?? headers?.get?.("retry-after") ?? null;
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
const backoffMs =
retryAfterMs ??
fullJitterBackoffMs(attempt, opts.baseBackoffMs, opts.capBackoffMs);
const nextElapsed = Date.now() - startedAt + backoffMs;
if (nextElapsed > opts.maxElapsedMs) {
throw err;
}
await sleep(backoffMs);
}
}
throw lastErr;
}
// 사용 예시
export async function createResponse(prompt: string) {
return callWithRetry(
() =>
client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
}),
{
maxAttempts: 6,
maxElapsedMs: 20_000,
baseBackoffMs: 200,
capBackoffMs: 8_000,
}
);
}
위 래퍼만으로도 “즉시 재시도 폭주”는 많이 줄어듭니다. 하지만 트래픽이 커질수록 동시성 제한이 없으면 결국 다시 429가 반복됩니다.
동시성 제한: 세마포어로 429를 구조적으로 줄이기
프로세스 내에서 동시에 OpenAI 요청을 몇 개까지 허용할지 제한합니다.
class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(size: number) {
this.available = size;
}
async acquire() {
if (this.available > 0) {
this.available -= 1;
return;
}
await new Promise<void>((resolve) => this.queue.push(resolve));
}
release() {
this.available += 1;
const next = this.queue.shift();
if (next) {
this.available -= 1;
next();
}
}
}
const sem = new Semaphore(5);
export async function createResponseWithConcurrencyLimit(prompt: string) {
await sem.acquire();
try {
return await createResponse(prompt);
} finally {
sem.release();
}
}
- 동시성 제한은 “재시도 횟수”를 직접 줄입니다.
- 특히 배치 작업, 웹훅 처리, 큐 컨슈머처럼 순간적으로 이벤트가 몰리는 시스템에서 효과가 큽니다.
백오프만으로 부족할 때: 클라이언트 측 레이트 리미터
동시성 제한이 “동시에 몇 개”라면, 레이트 리미터는 “초당 몇 개”를 제한합니다. 둘을 같이 쓰면 429가 눈에 띄게 줄어듭니다.
간단한 토큰 버킷을 직접 구현할 수도 있지만, 운영에서는 검증된 라이브러리를 쓰는 편이 안전합니다. 예를 들어 Node.js에서는 bottleneck 같은 라이브러리를 자주 사용합니다.
import Bottleneck from "bottleneck";
const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 120, // 요청 간 최소 간격(ms). 대략 초당 8.3회
});
export async function limitedCreateResponse(prompt: string) {
return limiter.schedule(() => createResponse(prompt));
}
여기서 중요한 건 숫자를 “감”으로 잡지 말고, 실제 429 비율과 지연, 처리량을 보면서 조정하는 것입니다.
재시도 정책을 더 안전하게 만드는 디테일
요청 단위 타임아웃을 반드시 둬라
재시도는 “실패를 빠르게 감지”할수록 유리합니다. 요청이 길게 물려 있으면 동시성 제한이 사실상 무력화됩니다.
- SDK 타임아웃 또는 HTTP 클라이언트 타임아웃 설정
- 서버 전체 타임아웃과 “요청 단위 타임아웃”을 분리
Circuit Breaker로 지속 초과 상태를 차단
지속적으로 429가 나는 상태에서 계속 재시도하면, 시스템은 계속 비용을 지불하며 느려집니다. 일정 비율 이상 실패하면 잠시 요청을 차단하고 빠르게 실패시키는 Circuit Breaker가 도움이 됩니다.
- 최근 N초 동안
429비율이 임계치를 넘으면 오픈 - 오픈 상태에서는 즉시 실패 또는 대체 경로로 라우팅
- 쿨다운 후 half-open으로 일부만 시도
멱등성(idempotency) 고려
재시도는 같은 요청을 여러 번 보낼 수 있습니다. 결제나 과금, 저장 같은 부작용이 있는 작업은 멱등성을 갖추지 않으면 장애 때 중복 처리가 발생합니다.
- 가능한 경우 idempotency 키를 사용
- “같은 입력이면 같은 결과”를 보장하기 어렵다면 저장 계층에서 중복 방지
이 주제는 아래 글에서 더 깊게 다룹니다.
운영 체크리스트: 429를 줄이는 순서
- 동시성 제한부터 적용(세마포어, 워커 수 조절)
- 레이트 리미터로 초당 요청 수를 안정화
Retry-After우선 + Full Jitter 지수 백오프 적용maxElapsedMs로 사용자 체감 지연 상한 설정- Circuit Breaker로 지속 초과 상태 차단
- 지표로 재시도 비용을 가시화하고 수치 튜닝
마무리
429 RateLimitError는 “에러 처리”라기보다 트래픽 제어 문제입니다. 재시도·백오프를 잘 설계하면 순간적인 스파이크는 부드럽게 흡수하면서도, 지속적인 초과 상태에서는 시스템이 스스로 요청률을 낮추도록 만들 수 있습니다.
핵심은 세 가지입니다.
- 지수 백오프에 지터를 섞어 재시도 동기화를 깨기
Retry-After같은 힌트를 우선 반영하기- 재시도 이전에 동시성·레이트를 구조적으로 제한하기
이 3가지만 제대로 잡아도 429는 “가끔 보이는 경고등” 수준으로 내려가고, 장애 대응 난이도도 크게 떨어집니다.