- Published on
OpenAI 429 RateLimit 재시도·백오프 구현 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
OpenAI API를 운영 환경에서 붙이다 보면, 기능 자체보다 더 자주 마주치는 문제가 429입니다. 특히 트래픽이 순간적으로 몰리거나(배치, 크론, 이벤트 폭주), 여러 워커가 동시에 요청을 쏘는 구조라면 RateLimit은 “언젠가”가 아니라 “반드시” 발생합니다.
여기서 중요한 건 단순히 “재시도하면 된다”가 아니라, 재시도가 또 다른 429 폭풍을 만들지 않게 설계하는 것입니다. 재시도 로직이 미숙하면 다음 문제가 연쇄적으로 터집니다.
- 요청 폭주로 더 자주 429가 발생(스톰)
- 큐/스레드가 쌓이며 지연이 기하급수적으로 증가
- 타임아웃 증가로 상위 서비스까지 장애 전파
- 같은 프롬프트가 중복 처리되어 비용 상승
이 글에서는 OpenAI 429에 대해 재시도 기준, 지수 백오프 + 지터, Retry-After 처리, 동시성 제한, 멱등성/중복 방지, **관측(로그·메트릭)**까지 한 번에 정리합니다.
참고로 유사한 429 대응 설계는 다른 LLM에서도 동일하게 적용됩니다. Claude 쪽 레이트리밋 설계는 아래 글도 같이 보면 패턴을 비교하기 좋습니다.
429를 “재시도 가능한 오류”로만 보면 위험한 이유
429는 크게 두 부류가 섞여 들어옵니다.
- 순수 레이트리밋 초과: 잠깐 기다리면 회복됨
- 지속적인 용량 부족/정책 제한: 기다려도 계속 실패(혹은 매우 오래)
따라서 “무조건 N번 재시도”는 비용만 증가시키고, 전체 시스템 지연을 악화시킬 수 있습니다. 재시도는 다음을 반드시 포함해야 합니다.
- 최대 재시도 횟수와 총 대기 상한(cap)
- 지수 백오프(exponential backoff)
- **지터(jitter)**로 동시 재시도 분산
- 가능한 경우 서버 힌트(
Retry-After) 존중 - 서비스 전체를 보호하는 동시성 제한/큐잉
OpenAI 429에서 확인해야 할 신호들
OpenAI SDK/HTTP 응답에는 보통 다음 단서가 있습니다.
- 상태 코드:
429 - 헤더:
Retry-After(제공된다면 최우선) - 응답 바디:
rate_limit류 에러 코드/메시지
운영 팁:
Retry-After가 있다면 백오프 계산보다 우선 적용하세요.429가 짧은 시간에 급증하면, 애플리케이션 레벨에서 동시성 제한이 부족한 경우가 많습니다.
재시도 정책 설계 체크리스트
아래 항목을 정책으로 먼저 “문서화”해두면 구현이 쉬워집니다.
어떤 경우에 재시도할까
- 재시도 대상:
429, 일시적5xx(예:502,503,504), 네트워크 타임아웃 - 재시도 제외:
400(입력 문제),401/403(인증/권한),404(리소스), 명백한 검증 실패
백오프 공식(권장)
- 기본: 지수 백오프
base * 2^attempt - 상한:
maxDelayMs로 캡 - 지터:
full jitter또는equal jitter
예시(Full Jitter):
sleep = random(0, min(maxDelay, base * 2^attempt))
총 대기 시간과 재시도 횟수
- 최대 재시도 횟수: 3~6회 정도로 시작
- 총 대기 상한: 예를 들어 20~60초
동시성 제한(중요)
재시도를 잘 만들어도, 초기 요청이 과도하면 429는 계속 납니다. 다음 중 하나는 꼭 적용하세요.
- 프로세스 단위 세마포어로 동시 호출 제한
- 큐(예: SQS, Redis, Kafka)로 버퍼링
- 사용자/테넌트별 토큰 버킷
Node.js(Typescript) 구현: Retry-After + 지수 백오프 + 지터
아래 예시는 OpenAI 호출을 감싸는 callWithRetry 유틸입니다. 핵심은 다음입니다.
Retry-After가 있으면 그 값을 우선- 없으면 지수 백오프 + full jitter
- 재시도 가능한 에러만 재시도
- 관측을 위해 attempt, sleep, 원인 로그를 남길 수 있게 구성
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
type RetryOptions = {
maxAttempts: number; // 전체 시도 횟수(최초 1회 포함)
baseDelayMs: number; // 예: 250
maxDelayMs: number; // 예: 8000
maxTotalSleepMs: number; // 예: 30000
};
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseRetryAfterMs(err: any): number | null {
// SDK/HTTP 클라이언트에 따라 위치가 달라질 수 있음
const ra = err?.response?.headers?.["retry-after"] ?? err?.headers?.["retry-after"];
if (!ra) return null;
// retry-after는 초 단위 숫자이거나 HTTP-date일 수 있음
const seconds = Number(ra);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const dateMs = Date.parse(ra);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
return null;
}
function isRetryable(err: any): boolean {
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;
}
function computeFullJitterDelayMs(baseDelayMs: number, maxDelayMs: number, attemptIndex: number) {
// attemptIndex: 0부터 시작(첫 재시도는 0)
const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attemptIndex));
return Math.floor(Math.random() * cap);
}
export async function callWithRetry<T>(
fn: () => Promise<T>,
opt: RetryOptions
): Promise<T> {
let totalSleep = 0;
for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
const retryable = isRetryable(err);
const isLast = attempt === opt.maxAttempts;
if (!retryable || isLast) {
throw err;
}
const retryAfterMs = parseRetryAfterMs(err);
const backoffMs = computeFullJitterDelayMs(opt.baseDelayMs, opt.maxDelayMs, attempt - 1);
const sleepMs = retryAfterMs ?? backoffMs;
if (totalSleep + sleepMs > opt.maxTotalSleepMs) {
throw err;
}
totalSleep += sleepMs;
await sleep(sleepMs);
}
}
// 도달 불가
throw new Error("unexpected");
}
// 사용 예시
export async function createChatCompletion(prompt: string) {
return callWithRetry(
async () => {
return client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
});
},
{
maxAttempts: 5,
baseDelayMs: 250,
maxDelayMs: 8000,
maxTotalSleepMs: 30000,
}
);
}
Node.js에서 동시성 제한까지 같이 적용하기
재시도만으로는 부족한 경우가 많습니다. 아래처럼 세마포어를 두면 “초기 폭주” 자체를 줄여 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() {
const next = this.queue.shift();
if (next) {
next();
return;
}
this.available += 1;
}
}
const openAiSemaphore = new Semaphore(10); // 프로세스당 동시 10개 제한
export async function guardedOpenAiCall(prompt: string) {
await openAiSemaphore.acquire();
try {
return await createChatCompletion(prompt);
} finally {
openAiSemaphore.release();
}
}
Spring Boot(Java) 구현: RestClient/WebClient 재시도 + 지터
Spring에서는 Resilience4j를 많이 쓰지만, “429에서 Retry-After를 읽어 대기” 같은 커스텀 로직은 직접 구현하는 편이 명확합니다. 아래는 RestClient 또는 WebClient 호출을 감싸는 방식의 예시입니다.
핵심 포인트:
Retry-After헤더가 있으면 우선- 없으면 지수 백오프 + 지터
- 인터럽트 처리(스레드 인터럽트 존중)
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
public class OpenAiRetryingClient {
private final RestClient restClient;
public OpenAiRetryingClient(RestClient restClient) {
this.restClient = restClient;
}
public String callWithRetry(String url, String bodyJson) {
int maxAttempts = 5;
long baseDelayMs = 250;
long maxDelayMs = 8000;
long maxTotalSleepMs = 30000;
long totalSleep = 0;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return restClient.post()
.uri(url)
.header("Content-Type", "application/json")
.body(bodyJson)
.retrieve()
.body(String.class);
} catch (RestClientResponseException ex) {
int status = ex.getStatusCode().value();
boolean retryable = status == 429 || (status >= 500 && status <= 599);
boolean last = attempt == maxAttempts;
if (!retryable || last) {
throw ex;
}
Long retryAfterMs = parseRetryAfterMs(ex);
long backoffMs = fullJitterBackoff(baseDelayMs, maxDelayMs, attempt - 1);
long sleepMs = retryAfterMs != null ? retryAfterMs : backoffMs;
if (totalSleep + sleepMs > maxTotalSleepMs) {
throw ex;
}
totalSleep += sleepMs;
try {
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
throw new IllegalStateException("unexpected");
}
private long fullJitterBackoff(long baseDelayMs, long maxDelayMs, int attemptIndex) {
long cap = Math.min(maxDelayMs, (long) (baseDelayMs * Math.pow(2, attemptIndex)));
return ThreadLocalRandom.current().nextLong(0, Math.max(1, cap + 1));
}
private Long parseRetryAfterMs(RestClientResponseException ex) {
String ra = ex.getResponseHeaders() != null ? ex.getResponseHeaders().getFirst("Retry-After") : null;
if (ra == null || ra.isBlank()) return null;
// seconds
try {
long seconds = Long.parseLong(ra.trim());
return Math.max(0, seconds * 1000);
} catch (NumberFormatException ignore) {
}
// HTTP-date
try {
ZonedDateTime dt = ZonedDateTime.parse(ra.trim(), DateTimeFormatter.RFC_1123_DATE_TIME);
long diff = Duration.between(ZonedDateTime.now(dt.getZone()), dt).toMillis();
return Math.max(0, diff);
} catch (Exception ignore) {
return null;
}
}
}
Spring에서 “재시도 스레드 폭주”를 막는 방법
Spring MVC(서블릿) 기반이라면, 요청 스레드가 Thread.sleep으로 묶이는 동안 전체 처리량이 떨어질 수 있습니다. 다음 중 하나를 고려하세요.
- 외부 호출은
WebClient로 비동기화하고, 백오프도 논블로킹으로 처리 - 호출 자체를 비동기 워커/큐로 넘기고, API는 폴링/웹훅/콜백 패턴 사용
- 최소한 동시성 제한(세마포어/벌크헤드)을 적용
이런 “스레드가 막히며 연쇄 장애” 패턴은 쿠버네티스 환경에서 CrashLoopBackOff나 지연 폭증으로 이어지기도 합니다. 장애가 확산될 때의 디버깅 관점은 아래 글도 참고할 만합니다.
멱등성·중복 방지: 재시도에서 가장 자주 놓치는 부분
429 재시도는 같은 요청을 여러 번 보낼 수 있습니다. 이때 “같은 작업을 여러 번 수행”하면 곤란한 케이스가 많습니다.
- 결제/주문/포인트 지급 등 사이드이펙트가 있는 작업
- 같은 사용자에게 같은 메시지를 여러 번 발송
- 같은 문서를 여러 번 요약 저장
대응 방법:
- 애플리케이션 레벨에서 멱등 키를 도입(요청 해시, 작업 ID)
- 저장소에
job_id유니크 제약을 걸고 중복 삽입 방지 - 큐 기반이면 메시지
deduplication기능 활용
분산 환경에서 “중복·순서 꼬임”을 다루는 관점은 Saga 설계와도 닿아 있습니다.
운영에서 바로 쓰는 관측 포인트(로그·메트릭)
재시도는 “조용히 성공”하면 원인 파악이 늦습니다. 아래는 최소 권장 항목입니다.
rate_limit_429_count(태그: 모델, 엔드포인트, 테넌트)retry_attempt_count(attempt 번호별 분포)retry_sleep_ms(대기 시간 분포)request_latency_ms(재시도 포함 전체 지연)dropped_due_to_max_total_sleep(총 대기 상한으로 포기한 건수)
로그에는 다음을 남기면 좋습니다.
- 요청 상관관계 ID(트레이스 ID)
- 상태 코드, 에러 코드
Retry-After존재 여부- 백오프 계산 결과(단, PII/프롬프트 원문은 주의)
자주 하는 실수 7가지
- 지수 백오프 없이 고정 간격 재시도
- 지터 없이 모든 워커가 동시에 재시도(재시도 스톰)
Retry-After무시- 재시도 횟수만 두고 총 대기 상한이 없음
- 429를 재시도하면서 동시성 제한이 없음
- 멱등성 없이 사이드이펙트 작업을 재시도
- 관측(메트릭/로그) 없이 “성공했으니 됐다”로 끝냄
결론: 429 대응은 “재시도”가 아니라 “흐름 제어”다
OpenAI 429를 안정적으로 처리하려면, 재시도 함수 하나로 끝나지 않습니다. 백오프·지터·Retry-After, 그리고 무엇보다 동시성 제한/큐잉이 함께 들어가야 운영에서 버팁니다.
정리하면 다음 순서로 적용하는 것이 가장 효과적입니다.
- 동시성 제한으로 429 자체를 줄이고
- 429 및 일시적 오류에 지수 백오프 + 지터로 재시도하며
Retry-After를 우선 존중하고
- 멱등성/중복 방지를 추가하고
- 메트릭으로 재시도 비용과 지연을 가시화
이 조합이 갖춰지면, 트래픽 스파이크나 배치 작업이 있어도 OpenAI 호출이 전체 시스템을 흔드는 일을 크게 줄일 수 있습니다.