Published on

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

Authors

서버에서 OpenAI API를 호출하다 보면 어느 순간 429를 만나게 됩니다. 특히 트래픽이 순간적으로 몰리거나, 배치 작업이 동시에 실행되거나, 스트리밍 응답을 다수 유지하는 상황에서 rate_limit_exceeded는 “언젠가 한 번”이 아니라 “반드시 다시” 발생합니다.

문제는 많은 구현이 sleep(1); retry; 같은 단순 재시도로 끝난다는 점입니다. 이렇게 하면 다음과 같은 부작용이 쉽게 생깁니다.

  • 동시에 여러 워커가 같은 타이밍에 재시도하며 더 큰 스파이크를 만든다(재시도 폭주, retry storm)
  • 지연이 누적되어 사용자 요청 타임아웃이나 큐 적체로 이어진다
  • 같은 요청을 중복 실행하면서 비용이 증가한다
  • 재시도 정책이 불명확해 장애 분석이 어렵다

이 글에서는 OpenAI API 429를 “예외 처리”가 아니라 “트래픽 제어” 관점에서 다루고, 실전에서 안전한 재시도·백오프 설계를 어떻게 구성하는지 단계별로 정리합니다.

관련해서 408 타임아웃 재시도 전략도 함께 고민해야 하는데, 네트워크/서버 지연과 레이트 리밋은 성격이 달라서 정책을 분리하는 것이 좋습니다. 필요하면 다음 글도 같이 보세요.

429의 의미를 정확히 분해하기

429 Too Many Requests는 “요청이 너무 많다”는 신호지만, 실제 원인은 여러 층으로 나뉩니다.

  • 짧은 시간 단위에서의 요청 수 초과(RPM 계열)
  • 토큰 처리량 초과(TPM 계열)
  • 동시성 제한(동시에 처리 가능한 요청 수 제한)
  • 조직/프로젝트 단위의 쿼터/버짓 제한(하드 리밋)

재시도 설계 관점에서 중요한 구분은 다음 두 가지입니다.

  1. 시간이 지나면 풀리는 제한(soft limit): 백오프로 회복 가능
  2. 시간이 지나도 안 풀리는 제한(hard limit): 재시도가 아니라 즉시 실패 처리 또는 대체 경로가 필요

실무에서는 응답 바디의 에러 코드/메시지, 그리고(제공되는 경우) Retry-After 같은 힌트를 함께 보고 soft/hard를 판별해야 합니다.

재시도 설계의 목표: “성공률”이 아니라 “시스템 안정성”

좋은 재시도는 성공률을 올리지만, 더 중요한 목적은 시스템 전체를 안정적으로 유지하는 것입니다.

  • 재시도는 무조건 많이 하는 게 아니라, 예산(budget) 안에서 한다
  • 동시에 재시도하지 않도록 지터(jitter) 를 섞는다
  • 호출 자체를 줄이기 위해 클라이언트 레이트 리미터와 함께 쓴다
  • 실패 시 사용자 경험을 위해 폴백(fallback) 을 준비한다

이 관점이 없으면, 429 상황에서 “재시도 코드가 장애를 증폭”시키는 일이 흔합니다.

백오프 기본형: 지수 백오프 + 지터

가장 널리 쓰이는 형태는 지수 백오프입니다.

  • baseDelayMs에서 시작해 재시도마다 지연을 2배로 증가
  • 최대 지연 maxDelayMs로 상한 설정
  • 여기에 지터를 섞어 워커들이 같은 순간에 몰리지 않게 함

지터에는 대표적으로 3가지가 있습니다.

  • Full jitter: random(0, cap)
  • Equal jitter: cap/2 + random(0, cap/2)
  • Decorrelated jitter: 이전 딜레이를 기반으로 랜덤하게 흔듦

실무에서는 full jitter가 단순하고 효과가 좋아 기본값으로 추천됩니다.

TypeScript 예제: full jitter 지수 백오프

아래 코드는 429나 일시적 네트워크 에러에 대해 재시도하되, Retry-After가 있으면 우선하는 형태입니다.

type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
};

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseRetryAfterMs(headers: Headers): number | null {
  const v = headers.get("retry-after");
  if (!v) return null;

  // Retry-After는 초 단위 숫자거나 HTTP date일 수 있음
  const asNumber = Number(v);
  if (!Number.isNaN(asNumber)) return Math.max(0, asNumber * 1000);

  const asDate = Date.parse(v);
  if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());

  return null;
}

function computeFullJitterDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
  const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
  return Math.floor(Math.random() * cap);
}

async function fetchWithRetry(url: string, init: RequestInit, opt: RetryOptions) {
  let lastErr: unknown;

  for (let attempt = 0; attempt < opt.maxAttempts; attempt++) {
    try {
      const res = await fetch(url, init);

      if (res.status !== 429) {
        if (!res.ok) {
          // 4xx 중 재시도하면 안 되는 케이스는 여기서 분기
          // 예: 400, 401, 403 등
          throw new Error(`HTTP ${res.status}`);
        }
        return res;
      }

      // 429: retry-after 우선
      const retryAfterMs = parseRetryAfterMs(res.headers);
      const backoffMs = computeFullJitterDelayMs(attempt, opt.baseDelayMs, opt.maxDelayMs);
      const delayMs = retryAfterMs ?? backoffMs;

      await sleep(delayMs);
      continue;
    } catch (e) {
      lastErr = e;

      // 네트워크 에러 등도 재시도 대상일 수 있음
      const backoffMs = computeFullJitterDelayMs(attempt, opt.baseDelayMs, opt.maxDelayMs);
      await sleep(backoffMs);
    }
  }

  throw lastErr ?? new Error("retry exhausted");
}

핵심은 다음입니다.

  • 429는 “서버가 지금은 못 받는다”이므로, Retry-After가 있으면 존중
  • 지수 백오프만 쓰면 워커들이 동일한 패턴으로 움직일 수 있으니 지터 필수
  • maxAttemptsmaxDelayMs로 재시도 상한을 명확히

반드시 넣어야 하는 3가지 가드레일

백오프만으로는 부족합니다. 재시도 정책이 안전하려면 최소한 아래 3가지는 들어가야 합니다.

1) 재시도 예산(budget): 요청 단위 타임아웃과 결합

사용자 요청은 보통 “얼마 안에 응답해야 한다”는 SLA가 있습니다. 예를 들어 API 게이트웨이가 10초, 프론트가 15초에서 타임아웃이 날 수 있습니다.

따라서 재시도는 maxAttempts가 아니라 총 경과 시간 기준으로도 끊어야 합니다.

  • 예: 총 6초를 넘기면 더 재시도하지 않고 폴백 응답

2) 동시성 제어: 클라이언트 측 레이트 리미터

429가 발생한 뒤에야 백오프하는 것은 사후 대응입니다. 더 좋은 설계는 “애초에 넘지 않도록” 하는 것입니다.

  • 프로세스 내부: 토큰 버킷(token bucket) 또는 리키 버킷(leaky bucket)
  • 멀티 인스턴스: Redis 기반 분산 레이트 리미터

이를 통해 429를 “가끔”으로 줄이고, 백오프는 최후의 안전장치로 남겨둘 수 있습니다.

3) 큐/배치 분리: 사용자 트래픽을 배치가 잠식하지 않게

배치(요약/임베딩 생성/재색인)가 사용자 요청과 같은 레이트 리밋 풀을 공유하면, 배치가 시작되는 순간 사용자 경험이 무너질 수 있습니다.

  • 배치 전용 워커 풀 분리
  • 배치에는 낮은 우선순위 큐 적용
  • 배치에는 더 보수적인 레이트 리미터 적용

“재시도 가능한 429”와 “즉시 실패해야 하는 429”

실제로는 429여도 재시도하면 안 되는 경우가 있습니다.

  • 계정/프로젝트 예산 소진 등 하드 리밋
  • 잘못된 키/조직 설정으로 인해 지속적으로 거절되는 경우

이때는 백오프가 아니라 아래가 필요합니다.

  • 즉시 에러 반환(사용자에게 명확한 메시지)
  • 다른 모델/프로젝트로 라우팅(가능한 경우)
  • 요청을 큐에 적재하고 나중에 처리(비동기 처리)

판별 기준은 “몇 번을 재시도해도 같은 에러가 반복되는가”입니다. 예를 들어 2~3회 내에 회복되지 않으면 hard limit로 간주하고 빠르게 전환하는 정책이 실무적으로 유용합니다.

멱등성(idempotency)과 중복 비용 방지

재시도는 곧 “중복 호출 가능성”을 의미합니다. 네트워크가 끊겼을 때 서버는 요청을 처리했는데, 클라이언트는 실패로 간주하고 재시도할 수 있습니다.

따라서 다음을 고려해야 합니다.

  • 같은 입력에 대해 결과를 캐시(특히 임베딩/정적 프롬프트)
  • 작업 ID를 부여하고 중복 실행을 방지(서버 측 dedup)
  • 가능하면 idempotency key 패턴을 적용

특히 “결과는 한 번만 생성돼야 하는” 워크플로(예: 결제/발송/티켓 발급)에 LLM 호출이 끼어 있다면, 재시도는 더 조심해야 합니다.

관측 가능성: 429를 ‘로그 한 줄’로 끝내지 않기

429 대응은 관측이 없으면 튜닝이 불가능합니다. 최소한 아래 지표를 분리해서 봐야 합니다.

  • 429 발생률(전체 요청 대비)
  • 재시도 횟수 분포(0회, 1회, 2회…)
  • 재시도 후 성공률
  • 평균 추가 지연(백오프로 인해 늘어난 latency)
  • 엔드포인트/모델별 429 비율

그리고 로그에는 다음을 남기는 것이 좋습니다.

  • 요청 종류(사용자/배치)
  • 모델명
  • 시도 횟수
  • 적용된 딜레이(ms)
  • Retry-After 사용 여부

이렇게 해야 “백오프가 너무 공격적이다/너무 보수적이다”를 근거로 조정할 수 있습니다.

Node.js에서 OpenAI 호출 래퍼로 묶기(실전 패턴)

실무에서는 fetchWithRetry 같은 범용 함수보다, OpenAI 호출을 한 곳으로 모아 정책을 강제하는 편이 유지보수에 유리합니다.

아래는 OpenAI Responses API 호출을 감싸는 형태의 예시입니다. 실제 SDK를 쓰더라도 구조는 동일합니다.

type OpenAIResponse = unknown;

type CallOptions = {
  timeoutMs: number;
  retry: {
    maxAttempts: number;
    baseDelayMs: number;
    maxDelayMs: number;
    maxTotalDelayMs: number;
  };
};

async function callOpenAIWithBackoff(
  url: string,
  init: RequestInit,
  opt: CallOptions
): Promise<OpenAIResponse> {
  const startedAt = Date.now();

  let attempt = 0;
  while (attempt < opt.retry.maxAttempts) {
    attempt++;

    const elapsed = Date.now() - startedAt;
    if (elapsed > opt.timeoutMs) {
      throw new Error("timeout budget exceeded");
    }

    const controller = new AbortController();
    const t = setTimeout(() => controller.abort(), Math.max(1, opt.timeoutMs - elapsed));

    try {
      const res = await fetch(url, { ...init, signal: controller.signal });

      if (res.ok) {
        return await res.json();
      }

      if (res.status === 429) {
        const retryAfterMs = parseRetryAfterMs(res.headers);
        const backoffMs = computeFullJitterDelayMs(attempt - 1, opt.retry.baseDelayMs, opt.retry.maxDelayMs);
        const delayMs = retryAfterMs ?? backoffMs;

        const totalDelay = Date.now() - startedAt;
        if (totalDelay + delayMs > opt.retry.maxTotalDelayMs) {
          throw new Error("retry delay budget exceeded");
        }

        clearTimeout(t);
        await sleep(delayMs);
        continue;
      }

      // 400 계열은 대개 재시도 무의미, 500 계열은 제한적으로 재시도 고려
      throw new Error(`HTTP ${res.status}`);
    } catch (e) {
      // AbortError, 네트워크 에러 등
      if (attempt >= opt.retry.maxAttempts) throw e;

      const backoffMs = computeFullJitterDelayMs(attempt - 1, opt.retry.baseDelayMs, opt.retry.maxDelayMs);
      clearTimeout(t);
      await sleep(backoffMs);
    } finally {
      clearTimeout(t);
    }
  }

  throw new Error("retry exhausted");
}

포인트는 두 가지 예산을 분리한 것입니다.

  • timeoutMs: 사용자 요청 전체 예산(총 경과 시간)
  • maxTotalDelayMs: 백오프로 소비할 수 있는 지연 예산(재시도에 얼마까지 쓸지)

이렇게 하면 “기다리다 끝나는” 요청을 줄이고, 실패 시 빠르게 폴백으로 전환할 수 있습니다.

폴백 전략: 실패를 숨기지 말고, 제품적으로 처리하기

429는 종종 “일시적”이지만, 사용자 입장에서는 “그냥 안 됨”입니다. 따라서 제품 레벨에서 폴백을 준비하는 것이 좋습니다.

  • 응답 품질을 낮춘 모델로 라우팅(가능한 경우)
  • 캐시된 마지막 결과 제공
  • 비동기 처리로 전환하고 완료 시 알림
  • 요약/추천 같은 비핵심 기능은 일시적으로 비활성화

중요한 점은 “429를 무한 재시도로 덮지 말라”는 것입니다. 실패는 실패로 기록하고, 사용자 경험을 설계로 보완해야 합니다.

실전 튜닝 가이드(권장 기본값)

환경마다 다르지만, 초기값으로 시작하기 좋은 범위는 다음과 같습니다.

  • baseDelayMs: 200~500ms
  • maxDelayMs: 5,000~10,000ms
  • maxAttempts: 4~6회
  • 지터: full jitter
  • Retry-After: 있으면 반드시 우선

그리고 운영에서 지표를 보며 조정합니다.

  • 429가 자주 나오면: 레이트 리미터 강화, 배치 분리, 동시성 제한
  • 재시도 후 성공률이 낮으면: hard limit 가능성 점검, 프로젝트/모델 쿼터 확인
  • 지연이 과도하면: maxTotalDelayMs를 줄이고 폴백을 강화

마무리: 429 대응은 “재시도 코드”가 아니라 “트래픽 제어 설계”

정리하면, OpenAI API의 429는 백오프로 해결되는 경우가 많지만, 단순 재시도는 장애를 키울 수 있습니다. 안정적인 설계를 위해서는 다음을 함께 가져가야 합니다.

  • 지수 백오프 + 지터(동시 재시도 폭주 방지)
  • Retry-After 존중
  • 총 시간/지연 예산 기반 중단
  • 클라이언트 레이트 리미터로 사전 제어
  • 배치와 사용자 트래픽 분리
  • 멱등성/중복 비용 방지
  • 지표 기반 튜닝

이 조합이 갖춰지면 429는 “서비스가 불안정해지는 신호”가 아니라, “정상적인 흐름 제어 이벤트”로 다룰 수 있습니다.