- Published on
OpenAI 429 Rate Limit 재시도·백오프 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 429(Rate Limit) 응답을 만나는 건 흔합니다. 문제는 여기서 “그냥 몇 번 더 재시도”를 넣는 순간, 트래픽이 몰리는 시간대에 재시도 폭풍이 발생하고, 오히려 성공률이 더 떨어지거나 전체 서비스 지연이 커지는 패턴으로 이어진다는 점입니다.
이 글은 429를 정상적인 운영 이벤트로 보고, 재시도·백오프를 “성공률을 올리는 장치”가 아니라 “시스템을 보호하는 제어장치”로 설계하는 방법을 다룹니다. 특히 다음을 목표로 합니다.
- 재시도해도 되는 실패와 하면 안 되는 실패를 구분
Retry-After등 힌트를 최대한 활용- 지수 백오프에 지터(jitter)를 섞어 동시 재시도 폭발 방지
- 요청 단위가 아니라 프로세스/서비스 단위로 동시성·속도 제한
- 멱등성(idempotency)과 중복 과금/중복 처리 위험 최소화
429가 의미하는 것: “잠깐 쉬었다가 와”
429는 보통 다음 중 하나를 의미합니다.
- RPM/TPM 제한: 분당 요청 수(Requests Per Minute) 또는 분당 토큰 수(Tokens Per Minute) 초과
- 버스트 제한: 짧은 시간에 몰린 급격한 요청량으로 임계치 초과
- 조직/프로젝트 제한: 계정 단위의 제한에 걸림
여기서 중요한 건 429가 “영구 실패”가 아니라 일시적 실패인 경우가 많다는 점입니다. 하지만 “일시적”이라고 해서 “즉시 재시도”가 정답은 아닙니다. 즉시 재시도는 같은 제한에 다시 걸릴 확률이 높고, 동시 요청이 많을수록 재시도들이 서로를 방해하며 실패율을 더 올립니다.
재시도 정책의 핵심: 무엇을, 언제, 얼마나 재시도할까
1) 재시도 대상 에러 분류
실무에서 추천하는 기본 분류는 아래와 같습니다.
재시도 권장
429Rate limit408Request timeout409(상황에 따라) 일시적 충돌5xx(특히502,503,504) 업스트림/게이트웨이 문제- 네트워크 단절, DNS 일시 실패, TLS 핸드셰이크 타임아웃 등
재시도 비권장(즉시 실패 처리)
400잘못된 요청(스키마/파라미터 오류)401/403인증/권한 문제404리소스 없음(요청 자체가 잘못된 경우)- 응답이 “정책/검증 실패”로 확정적인 경우
즉, 429는 재시도 후보지만, 재시도 방식이 핵심입니다.
2) 서버 힌트 우선: Retry-After
API가 Retry-After 헤더를 내려주면, 클라이언트 임의의 백오프보다 우선해야 합니다.
Retry-After가 초(second) 단위 숫자일 수도 있고- 특정 시각(HTTP-date)일 수도 있습니다.
힌트가 있다면 그대로 따르고, 없다면 지수 백오프를 적용합니다.
3) 최대 재시도 횟수보다 중요한 것: 최대 총 대기 시간
재시도 횟수만 제한하면, 백오프가 길어질수록 사용자 대기 시간이 예측 불가해집니다. 따라서 다음을 함께 두는 것이 안전합니다.
maxAttempts: 최대 시도 횟수maxElapsedMs: 최초 시도부터 최종 실패까지 허용하는 최대 시간
예: maxAttempts=6, maxElapsedMs=20_000 같은 식으로 “사용자 경험”을 상한으로 둡니다.
백오프 설계: 지수 + 지터가 기본
왜 지터가 필수인가
동시에 많은 요청이 429를 맞으면 모두가 같은 백오프(예: 1초)로 재시도하고, 1초 뒤에 다시 동시에 몰려 또 429를 맞습니다. 이를 thundering herd라고 합니다.
지터는 각 요청의 대기 시간을 조금씩 흩어 재시도 타이밍을 분산시킵니다.
추천 백오프 공식
- 기본 지수 백오프:
baseMs * 2^attempt - 상한:
capMs로 제한 - 지터: Full jitter 또는 Equal jitter
가장 널리 추천되는 패턴 중 하나는 Full jitter입니다.
sleep = random(0, min(cap, base * 2^attempt))
이 방식은 분산 효과가 좋고 구현도 단순합니다.
Node.js(Typescript) 예제: 429 재시도 + Retry-After + Full Jitter
아래 예시는 OpenAI 호출을 감싸는 범용 재시도 유틸입니다. fetch를 사용하며, Retry-After가 있으면 이를 우선합니다.
type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
capDelayMs: number;
maxElapsedMs: number;
};
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
// case 1) seconds
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.floor(seconds * 1000);
}
// case 2) HTTP date
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) {
const diff = dateMs - Date.now();
return diff > 0 ? diff : 0;
}
return null;
}
function fullJitterDelayMs(attempt: number, baseDelayMs: number, capDelayMs: number): number {
const exp = Math.min(capDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
function isRetryableStatus(status: number): boolean {
if (status === 429) return true;
if (status === 408) return true;
if (status >= 500 && status <= 599) return true;
return false;
}
export async function withRetry<T>(
fn: () => Promise<{ ok: boolean; status: number; headers: Headers; json: () => Promise<T> }>,
opts: RetryOptions
): Promise<T> {
const startedAt = Date.now();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
const elapsed = Date.now() - startedAt;
if (elapsed > opts.maxElapsedMs) {
throw new Error(`retry budget exceeded: elapsed=${elapsed}ms`);
}
try {
const res = await fn();
if (res.ok) {
return await res.json();
}
if (!isRetryableStatus(res.status)) {
// non-retryable
const body = await res.json().catch(() => null);
throw new Error(`non-retryable status=${res.status} body=${JSON.stringify(body)}`);
}
// retryable
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
const delayMs = retryAfterMs ?? fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);
// attempt 0: immediate failure -> delay small; later attempts -> delay grows
await new Promise((r) => setTimeout(r, delayMs));
continue;
} catch (e) {
lastErr = e;
// network errors: treat as retryable within budget
const delayMs = fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);
await new Promise((r) => setTimeout(r, delayMs));
continue;
}
}
throw lastErr ?? new Error("retry failed");
}
이 유틸을 OpenAI 호출에 적용하면 다음처럼 사용할 수 있습니다.
type OpenAIResponse = {
id: string;
output_text?: string;
};
async function callOpenAI(): Promise<{ ok: boolean; status: number; headers: Headers; json: () => Promise<OpenAIResponse> }> {
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4.1-mini",
input: "Summarize rate limit handling.",
}),
});
return {
ok: res.ok,
status: res.status,
headers: res.headers,
json: () => res.json(),
};
}
const data = await withRetry(callOpenAI, {
maxAttempts: 6,
baseDelayMs: 200,
capDelayMs: 5_000,
maxElapsedMs: 20_000,
});
console.log(data.id);
“재시도”만으로는 부족하다: 동시성 제한과 큐잉
재시도를 잘 설계해도, 애초에 동시에 너무 많이 쏘면 429는 계속 납니다. 따라서 서비스 레벨에서 다음 중 최소 하나는 필요합니다.
- 동시성 제한(concurrency limit)
- 토큰 버킷/리키 버킷 기반 속도 제한(rate limiter)
- 작업 큐(Queue)로 흡수(예: BullMQ, SQS, RabbitMQ)
간단한 동시성 제한 예시(p-limit)
Node 환경에서 가장 쉬운 접근은 동시 실행 개수를 제한하는 것입니다.
import pLimit from "p-limit";
const limit = pLimit(5); // 동시에 5개만 OpenAI 호출
const tasks = inputs.map((input) =>
limit(() => withRetry(() => callOpenAIForInput(input), {
maxAttempts: 6,
baseDelayMs: 200,
capDelayMs: 5_000,
maxElapsedMs: 20_000,
}))
);
const results = await Promise.all(tasks);
이렇게만 해도 429 빈도가 눈에 띄게 줄어드는 경우가 많습니다. 특히 웹 요청 핸들러에서 사용자 트래픽이 순간적으로 튀는 서비스라면, 동시성 제한은 사실상 필수입니다.
멱등성: 재시도가 “중복 작업”을 만들지 않게
재시도는 같은 요청을 여러 번 보낼 수 있습니다. 이때 문제가 되는 건 다음입니다.
- 과금이 중복으로 발생할 수 있는가
- 같은 작업이 두 번 처리되어 데이터가 꼬일 수 있는가
해결책은 보통 아래 조합입니다.
- 요청에
idempotency key를 부여(가능한 경우) - 애플리케이션 레벨에서 작업 키를 만들고, 결과를 캐시/저장
- “이미 처리됨”을 빠르게 판별할 저장소(예: Redis setnx, DB unique key)
이 주제는 캐시 무효화/일관성과도 맞닿아 있습니다. 재시도와 캐시가 결합되면 “한 번만 처리되어야 할 작업이 두 번 처리되는” 형태로 데이터가 꼬일 수 있으니, 캐시 전략을 함께 점검하는 것이 좋습니다.
관측 가능성: 429를 “장애”가 아니라 “지표”로 만들기
429 대응이 어려운 이유는, 재시도를 넣으면 겉보기 성공률이 올라가면서 문제가 가려지기 때문입니다. 반드시 아래를 지표로 남겨야 합니다.
rate_limit_hit_count(태그: 모델, 엔드포인트, 프로젝트)- 재시도 횟수 분포(평균이 아니라 p95, p99)
- 최종 실패율(재시도 후에도 실패한 비율)
- 백오프 대기 시간 총합
- 큐 대기 시간(큐를 쓴다면)
또한 로그에는 최소한 다음을 포함하세요.
status=429retry_after_ms(있으면)attemptrequest_id(가능하면)- 작업 키(멱등성 키)
실전 체크리스트
1) 재시도 조건
429,408,5xx만 재시도400류는 재시도 금지(입력 검증으로 차단)
2) 대기 전략
Retry-After우선- 없으면 지수 백오프 + Full jitter
capDelayMs로 상한maxElapsedMs로 사용자 경험 상한
3) 트래픽 제어
- 호출부에 동시성 제한
- 대량 배치라면 큐로 흡수하고 워커 수로 조절
4) 멱등성
- 작업 키를 만들고 중복 실행 방지
- 결과 캐시/저장으로 재시도 비용 절감
5) 운영
429를 KPI로 모니터링- 특정 시간대 급증 시 동시성/큐/요금제/모델 선택을 재검토
마무리: “재시도”는 기능이 아니라 시스템 설계다
OpenAI 429는 단순히 “잠깐 쉬었다가 다시 해”가 아니라, 현재 시스템이 감당할 수 있는 처리량을 넘어섰다는 신호입니다. 따라서 정답은 재시도 코드 한 줄이 아니라,
- 재시도 정책(대상/예산)
- 백오프(지수+지터)
- 동시성 제어(큐/리미터)
- 멱등성(중복 방지)
- 관측(지표/로그)
이 다섯 가지가 함께 맞물린 형태여야 합니다. 이런 구조로 바꾸면 429를 “불안정한 외부 API 문제”가 아니라 “예측 가능한 리소스 제약”으로 다룰 수 있고, 트래픽이 늘어도 시스템이 부드럽게 버티는 방향으로 진화합니다.