Published on

OpenAI 429/Rate Limit 재시도·백오프 설계

Authors

서버에서 OpenAI API를 붙이다 보면 가장 자주 마주치는 운영 이슈 중 하나가 429 입니다. 많은 팀이 처음에는 “그냥 몇 번 더 재시도하면 되겠지”로 접근하지만, 429는 대부분 재시도가 곧 부하를 증폭시키는 케이스라서, 제대로 된 백오프와 트래픽 셰이핑이 없으면 장애가 길어집니다.

이 글에서는 OpenAI의 rate limit 상황에서 안전하게 복구하는 재시도 전략을 정책(Policy) 수준으로 설계하고, Node.js/TypeScript 중심으로 구현 예시까지 제공합니다. (Python 예시도 간단히 포함)

관련해서 500/503 계열의 재시도와는 성격이 달라서, 함께 읽으면 좋은 글로는 다음을 권합니다.

429를 “일시 오류”로만 보면 망하는 이유

429 Too Many Requests는 크게 두 부류로 옵니다.

  1. 순수 동시성 폭주: 짧은 시간에 요청이 몰려서 제한을 넘김
  2. 지속적인 처리량 초과: 분당 요청 수(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 대응을 재시도만으로 해결하려고 하면, 재시도 루프가 동시성을 더 키워서 악순환이 납니다.

가장 효과가 큰 순서는 보통 이렇습니다.

  1. 동시성 제한(세마포어, 큐, 워커 풀)
  2. 클라이언트 측 토큰 버짓 관리(추정 토큰 기반)
  3. 백오프 재시도
  4. 폴백(간단 모델, 요약, 캐시, 비동기 처리)

즉, 재시도는 “마지막 안전망”에 가깝고, 앞단에서 트래픽 모양을 바꾸는 것이 핵심입니다.

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_histogram
  • openai_retry_delay_ms_histogram
  • openai_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가지

  1. 429를 5xx처럼 취급하고 즉시 재시도(지터 없음)
  2. Retry-After를 무시함
  3. 재시도 중에도 요청을 계속 받아 동시성이 폭증
  4. 최대 대기 상한이 없어 요청이 “영원히” 붙잡힘
  5. 사용자 요청과 배치 작업이 같은 풀에서 경쟁
  6. 동일 프롬프트 반복 호출을 캐시하지 않음
  7. 429를 로그만 보고 지표/알람이 없음

권장 레퍼런스 아키텍처(요약)

  • API Gateway/Backend
    • 사용자별/테넌트별 rate limit (1차 방어)
    • 동시성 제한(세마포어)
    • 캐시(동일 입력 결과 재사용)
    • 짧은 타임아웃과 폴백
  • Worker
    • 큐 기반 처리
    • 429 재시도는 워커에서 중앙화
    • 지수 백오프 + full jitter + retry-after 존중
  • Observability
    • 429 비율, 재시도 횟수, 대기시간, 큐 길이

마무리

OpenAI 429는 “재시도 몇 번”으로 해결되는 문제가 아니라, 동시성 제어와 트래픽 셰이핑의 문제입니다. Retry-After를 존중하고, 지수 백오프에 지터를 섞고, 상한과 타임아웃으로 사용자 경험을 통제하세요. 그리고 가능하면 큐잉과 캐시로 호출 자체를 줄이는 방향이 가장 강력합니다.

429가 아니라 500/503처럼 일시 장애에 대한 재시도, 폴백, 서킷브레이커까지 포함한 전체 전략은 아래 글에서 이어서 다룹니다.