- Published on
OpenAI 429/Rate Limit 재시도·백오프 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 가장 자주 마주치는 장애 중 하나가 429 입니다. 겉으로는 단순히 “요청이 너무 많다”처럼 보이지만, 실제 운영에서는 다음이 한꺼번에 얽힙니다.
- 사용자 트래픽 스파이크로 순간 동시 요청이 폭증
- 워커나 크론이 같은 시점에 몰리며 버스트 발생
- 재시도 로직이 오히려 폭주를 키워 더 많은
429를 유발 - 스트리밍 응답 중간 끊김, 타임아웃과 섞여 관측이 어려움
이 글은 429를 “그냥 sleep 후 재시도”로 끝내지 않고, 백오프 설계, 지터(jitter), 동시성 제한, 재시도 가능한 오류만 선별, 그리고 중복 실행 방지까지 포함해 실전에서 안전하게 굴리는 방법을 다룹니다.
운영 관점에서의 장애 재현과 타임아웃 디버깅은 네트워크/스트림 계층과도 맞닿아 있습니다. 업로드나 스트림이 멈춘 듯 보일 때의 관측 포인트는 Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController도 함께 참고하면 좋습니다.
429의 의미를 먼저 분해하기
429는 크게 두 부류로 나타납니다.
- Rate Limit 초과: 초당 요청 수(RPS), 분당 요청 수(RPM), 토큰 처리량(TPM) 등 제한을 넘음
- 동시 처리 제한: 특정 모델/프로젝트에 허용된 동시 요청 수를 초과
중요한 점은, 두 경우 모두 “조금 기다리면 풀리는” 성격이 강하지만, 기다리는 방식이 잘못되면 더 오래 막힙니다.
- 모든 워커가 동일한 고정 지연(예: 1초)으로 재시도하면, 1초 뒤에 다시 동시에 몰려서 또
429 - 재시도 횟수만 늘리면, 시스템이 스스로 트래픽 증폭기를 만듦
따라서 핵심은 다음 3가지입니다.
- 지수 백오프(Exponential Backoff)
- 지터(Jitter) 로 재시도 타이밍 분산
- 동시성 제한(Concurrency Limit) 으로 버스트를 애초에 줄임
재시도 정책: 무엇을, 언제, 얼마나 재시도할까
재시도 대상 에러 선별
운영에서 흔히 쓰는 기준은 아래와 같습니다.
- 재시도 권장
429(rate limit)408(request timeout)409(일시적 충돌로 재시도 가능할 때)500,502,503,504(일시적 서버/게이트웨이 문제)- 네트워크 단절, DNS 일시 실패 같은 transient error
- 재시도 비권장
400(파라미터/스키마 오류)401,403(인증/권한)404(엔드포인트/리소스)
여기서 429는 “반드시 재시도”가 아니라 “재시도하되 시스템을 보호하는 방식으로”가 정답입니다.
Retry-After를 존중하되, 없으면 백오프 적용
일부 응답은 Retry-After 힌트를 줄 수 있습니다. 있다면 최우선으로 따르되, 항상 존재한다고 가정하면 안 됩니다.
Retry-After존재: 해당 시간 이상 대기- 없거나 파싱 실패: 지수 백오프 + 지터
백오프 수식 추천: Full Jitter
AWS에서도 널리 쓰는 패턴으로, Full Jitter가 재시도 폭주를 막는 데 효과적입니다.
- 기본 지수 증가:
base * 2^attempt - 실제 대기:
random(0, cap)
cap은 상한(예: 10초, 30초)을 둬서 사용자 체감과 워커 점유를 통제합니다.
Node.js 실전 예제: fetch + 재시도 + AbortController
아래 예제는 다음을 포함합니다.
429및 일시 오류만 재시도Retry-After처리- Full Jitter 백오프
- 요청 타임아웃을
AbortController로 강제 - 로깅에 attempt, 대기시간 포함
// retryFetch.ts
type RetryOptions = {
maxAttempts: number; // 총 시도 횟수 (최초 1회 포함)
baseDelayMs: number; // 예: 200
maxDelayMs: number; // 예: 10_000
timeoutMs: number; // 예: 30_000
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(res: Response): number | null {
const v = res.headers.get("retry-after");
if (!v) return null;
// Retry-After는 초 단위 숫자 또는 HTTP-date일 수 있음
const asNum = Number(v);
if (!Number.isNaN(asNum)) return Math.max(0, asNum * 1000);
const asDate = Date.parse(v);
if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());
return null;
}
function isRetryableStatus(status: number) {
return status === 429 || status === 408 || status === 409 || (status >= 500 && status <= 504);
}
function computeFullJitterDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
// attempt: 1,2,3... (재시도 횟수 기준)
const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * cap);
}
export async function retryFetch(
input: RequestInfo | URL,
init: RequestInit,
opt: RetryOptions
) {
let lastErr: unknown;
for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), opt.timeoutMs);
try {
const res = await fetch(input, { ...init, signal: ac.signal });
clearTimeout(t);
if (res.ok) return res;
if (!isRetryableStatus(res.status) || attempt === opt.maxAttempts) {
// 재시도 비대상 또는 마지막 시도
return res;
}
const ra = parseRetryAfterMs(res);
const backoff = computeFullJitterDelayMs(attempt, opt.baseDelayMs, opt.maxDelayMs);
const delay = ra !== null ? Math.max(ra, backoff) : backoff;
// 필요하다면 여기서 res.text()를 읽어 에러 바디를 로그로 남기되,
// 민감정보가 포함될 수 있으니 필터링하세요.
console.warn("retryable status", {
status: res.status,
attempt,
delayMs: delay,
});
await sleep(delay);
continue;
} catch (err) {
clearTimeout(t);
lastErr = err;
// 네트워크 에러/Abort는 일시적일 수 있어 재시도
if (attempt === opt.maxAttempts) throw err;
const delay = computeFullJitterDelayMs(attempt, opt.baseDelayMs, opt.maxDelayMs);
console.warn("fetch error, will retry", { attempt, delayMs: delay, err: String(err) });
await sleep(delay);
}
}
throw lastErr;
}
호출 예시는 다음처럼 구성합니다.
import { retryFetch } from "./retryFetch";
const res = await retryFetch(
"https://api.openai.com/v1/responses",
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4.1-mini",
input: "Summarize this text...",
}),
},
{
maxAttempts: 6,
baseDelayMs: 200,
maxDelayMs: 10_000,
timeoutMs: 30_000,
}
);
if (!res.ok) {
const body = await res.text();
throw new Error(`OpenAI error status=${res.status} body=${body}`);
}
const data = await res.json();
console.log(data);
더 중요한 해법: 재시도보다 먼저 동시성 제한
재시도는 “터졌을 때 회복”이고, 동시성 제한은 “터지지 않게 예방”입니다. 429가 자주 난다면, 재시도 횟수를 늘리기 전에 동시 요청 수를 강제로 제한하는 게 효과가 큽니다.
간단한 세마포어로 동시성 제한
class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(count: number) {
this.available = count;
}
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 openaiSem = new Semaphore(5); // 예: 동시에 5개만
export async function withOpenAIConcurrencyLimit<T>(fn: () => Promise<T>) {
await openaiSem.acquire();
try {
return await fn();
} finally {
openaiSem.release();
}
}
사용:
import { withOpenAIConcurrencyLimit } from "./sem";
import { retryFetch } from "./retryFetch";
const res = await withOpenAIConcurrencyLimit(() =>
retryFetch(
"https://api.openai.com/v1/responses",
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "gpt-4.1-mini", input: "..." }),
},
{ maxAttempts: 6, baseDelayMs: 200, maxDelayMs: 10_000, timeoutMs: 30_000 }
)
);
이 패턴은 워커 수가 늘어나도 “OpenAI로 나가는 동시 호출”을 일정하게 유지해 429를 크게 줄입니다.
스트리밍 응답에서의 429와 재시도 주의점
스트리밍은 “요청이 성공했는지”가 단번에 결정되지 않습니다.
- 연결은 열렸지만, 중간에 끊길 수 있음
- 일부 토큰을 받은 뒤 끊기면, 재시도 시 결과가 중복될 수 있음
실전 팁:
- 스트리밍은 요청 단위 아이템포턴시를 더 강하게 설계
- 사용자에게는 “재시도 중”을 표시하되, 중복 출력 방지(예: 마지막 커서 이후만 append)
- 서버 내부적으로는 “한 번 응답을 사용자에게 내보내기 시작했으면” 재시도 대신 실패 처리로 전환하는 정책도 고려
특히 네트워크 계층에서 멈춤처럼 보이는 현상은 디버깅 포인트가 많습니다. 스트림, 타임아웃, 취소 전파는 Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController에서 다룬 방식이 그대로 응용됩니다.
아이템포턴시: 재시도는 “중복 실행”을 만든다
429에서 재시도는 필수에 가깝지만, 재시도는 같은 요청을 여러 번 실행할 수 있습니다. 특히 다음 상황에서 비용/데이터 일관성 문제가 생깁니다.
- DB에 결과를 저장하는 작업이 요청마다 insert를 수행
- 외부 시스템에 웹훅/메일/SMS를 발송
- 결제/포인트 차감 같은 부작용(side effect)
대응:
- 요청마다
idempotencyKey를 생성해 DB에 “처리됨” 기록을 먼저 잡고, 중복이면 기존 결과를 반환 - 큐 기반 처리에서 job id를 고정해 중복 enqueue 방지
간단한 예:
import crypto from "crypto";
function makeIdempotencyKey(userId: string, promptHash: string) {
return crypto.createHash("sha256").update(`${userId}:${promptHash}`).digest("hex");
}
// DB에 (idempotency_key unique)로 저장해 중복 실행 방지
운영 관측: 로그에 반드시 남길 것들
429를 줄이는 작업은 “감”이 아니라 “측정”입니다. 최소한 아래는 구조화 로그로 남기는 것을 권합니다.
- 모델명, 엔드포인트
- 응답 상태 코드
- 재시도 attempt 번호
- 계산된 delay(ms),
Retry-After유무 - 요청 크기(대략): 입력 토큰 수 추정치, 출력 max 토큰
- 동시성 제한 큐 길이(대기 중인 요청 수)
이런 지표가 있어야,
- 동시성 제한 값을 5에서 10으로 올릴지
maxAttempts를 6에서 4로 줄일지maxDelayMs를 10초에서 30초로 늘릴지
같은 튜닝이 근거를 갖습니다.
흔한 실수 7가지
429인데 고정 1초 sleep만 적용- 모든 에러를 무조건 재시도(
400까지 재시도하는 경우) - 재시도 횟수만 늘리고 동시성 제한은 없음
- 타임아웃이 없어 hung 상태로 워커가 쌓임
- 스트리밍 중간 실패를 그대로 재시도해 중복 출력
- 재시도 시 입력이 달라지는 비결정성(예: timestamp 포함)으로 캐시/아이템포턴시가 깨짐
- 로그에 attempt, delay가 없어 원인 분석이 불가
실전 권장 조합(템플릿)
- 1차 방어: 동시성 제한(프로세스 단위, 가능하면 분산 락/큐로 확장)
- 2차 방어:
429포함 transient error만 재시도 - 3차 방어: Full Jitter 지수 백오프 +
Retry-After존중 - 안전장치: 요청 타임아웃 + 취소 전파
- 데이터 안전: 아이템포턴시 키로 중복 실행 방지
여기까지 적용하면 429는 “장애”라기보다 “제한을 존중하며 안정적으로 흡수되는 이벤트”가 됩니다.
추가로, 운영에서 특정 시점에만 장애가 몰리는 패턴(예: 배포 직후, 캐시 재검증 폭주)은 애플리케이션 캐시/재검증과도 관련이 있을 수 있습니다. 비슷한 유형의 트래픽 스파이크와 원인 분해 방식은 Next.js App Router 캐시 꼬임·재검증 버그 해결 글의 접근법도 참고할 만합니다.
마무리
OpenAI 429 대응의 핵심은 “재시도는 하되, 재시도가 트래픽을 증폭시키지 않게 만드는 것”입니다. 지수 백오프만으로는 부족하고, 지터와 동시성 제한, 타임아웃, 아이템포턴시까지 한 세트로 묶어야 운영에서 조용해집니다.
다음 단계로는,
- 프로세스 내부 세마포어를 Redis 기반 분산 세마포어로 확장
- 요청을 큐로 넘기고 워커가 일정 속도로 drain
- 모델별로 별도 제한(heavy 모델은 더 낮은 동시성)
같은 방식으로 더 큰 규모에서도 안정적으로 확장할 수 있습니다.