Published on

OpenAI 429 RateLimitError 재시도·백오프 구현

Authors

운영 환경에서 OpenAI API를 붙이면, 기능 자체보다 먼저 마주치는 문제가 429 입니다. 트래픽이 갑자기 늘거나 배치 작업이 몰리거나, 여러 워커가 동시에 같은 모델을 두드리면 RateLimitError 또는 HTTP 429 Too Many Requests가 발생합니다.

중요한 점은 429를 “잠깐 기다리면 되는 일시 장애”로만 보면 안 된다는 것입니다. 무작정 재시도하면 더 많은 요청이 쌓여 스로틀링이 악화되고, 결국 전체 처리량이 떨어지거나 다운스트림(큐, DB, 외부 API)까지 연쇄 장애로 번집니다.

이 글에서는 다음을 목표로 합니다.

  • 429 발생 원인을 유형별로 분해
  • Retry-After를 존중하는 재시도 정책
  • 지수 백오프 + 지터(랜덤성)로 동시 재시도 폭주 방지
  • 멱등성(idempotency) 관점에서 “재시도해도 되는 요청”만 선별
  • Node.js/TypeScript 기준으로 바로 복사 가능한 코드 제공

관련해서 요청 파라미터가 잘못되어 400이 나는 경우는 재시도 대상이 아닙니다. 400 디버깅은 아래 글을 함께 참고하세요.

429의 의미: 같은 429라도 대응이 다르다

429는 단일 원인이 아니라 “요청을 더 받기 어렵다”는 결과입니다. 실무에서 자주 보는 케이스는 다음과 같습니다.

1) RPM/TPM 초과

  • RPM: 분당 요청 수 초과
  • TPM: 분당 토큰 처리량 초과

특히 Responses API는 입력 토큰과 출력 토큰이 합쳐져 처리량을 잡아먹기 때문에, “요청 수는 적은데 길게 생성해서” TPM을 먼저 초과하는 경우가 많습니다.

2) 동시성 폭주

서버리스나 큐 컨슈머가 한 번에 스케일 아웃되면, 각 인스턴스는 정상 속도로 호출해도 전체적으로는 순간 동시성이 폭발합니다. 이때는 백오프만으로는 부족하고, 애초에 호출 동시성을 제한해야 합니다.

3) 재시도 폭풍(retry storm)

네트워크 타임아웃이나 일시 오류가 발생했을 때, 모든 워커가 동시에 재시도하면 2차 폭주가 생깁니다. 지터가 필요한 이유입니다.

재시도 원칙: “무조건 재시도”가 아니라 “조건부 재시도”

재시도 로직을 넣을 때는 아래 원칙을 지키는 편이 안전합니다.

  1. 429는 재시도 후보지만, 무한 재시도 금지
  2. Retry-After 헤더가 있으면 최우선으로 존중
  3. 재시도는 지수 백오프 + 지터 적용
  4. 멱등성이 보장되는 요청만 자동 재시도
  5. 재시도 횟수/대기 시간/에러 원인을 로그와 메트릭으로 남김

특히 4번이 중요합니다. “같은 요청을 두 번 보내도 결과가 동일”해야 자동 재시도가 안전합니다. 생성형 API는 결과가 확률적이라 엄밀히 멱등이 아니지만, 업무적으로는 아래 방식으로 리스크를 줄일 수 있습니다.

  • 요청마다 idempotency key를 부여하고, 애플리케이션 레벨에서 “이미 처리한 작업”이면 중복 반영을 막기
  • 결제/발송 같은 사이드 이펙트는 모델 호출 이후 단계에서만 발생시키고, 모델 호출 자체는 재시도 가능하게 설계

백오프 설계: 지수 백오프 + 지터 + 상한

권장 패턴은 다음입니다.

  • 기본 대기: baseDelayMs
  • 재시도 n번째 대기: baseDelayMs * 2^n
  • 지터: 대기 시간에 랜덤을 섞어 동시 재시도 분산
  • 상한: maxDelayMs로 캡

지터는 보통 두 가지 중 하나를 씁니다.

  • Full jitter: random(0, expBackoff)
  • Equal jitter: expBackoff / 2 + random(0, expBackoff / 2)

운영에서는 full jitter가 재시도 폭풍을 더 잘 분산합니다.

구현 1: Node.js/TypeScript 재시도 래퍼

아래 코드는 OpenAI SDK 호출을 감싸는 범용 withRetry 유틸입니다. 포인트는 다음입니다.

  • Retry-After가 있으면 해당 값을 우선
  • 429와 일부 5xx만 재시도
  • 재시도마다 대기 시간을 계산하고 로그 훅 제공
type RetryOptions = {
  maxAttempts: number; // 전체 시도 횟수(최초 1회 포함)
  baseDelayMs: number;
  maxDelayMs: number;
  jitter: "full" | "equal" | "none";
  retryOnStatuses: number[];
  onRetry?: (info: {
    attempt: number;
    delayMs: number;
    status?: number;
    message: string;
  }) => void;
};

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

function clamp(n: number, min: number, max: number) {
  return Math.max(min, Math.min(max, n));
}

function computeBackoffMs(attemptIndex: number, opt: RetryOptions) {
  // attemptIndex: 0부터 시작(첫 재시도는 0)
  const exp = opt.baseDelayMs * Math.pow(2, attemptIndex);
  const capped = clamp(exp, 0, opt.maxDelayMs);

  if (opt.jitter === "none") return capped;

  if (opt.jitter === "full") {
    return Math.floor(Math.random() * capped);
  }

  // equal jitter
  const half = capped / 2;
  return Math.floor(half + Math.random() * half);
}

function parseRetryAfterMs(retryAfter: string | null): number | null {
  if (!retryAfter) return null;

  // 1) 초 단위 숫자
  const seconds = Number(retryAfter);
  if (!Number.isNaN(seconds) && seconds >= 0) {
    return Math.floor(seconds * 1000);
  }

  // 2) HTTP date
  const date = new Date(retryAfter);
  const diff = date.getTime() - Date.now();
  if (!Number.isNaN(date.getTime()) && diff > 0) {
    return diff;
  }

  return null;
}

export async function withRetry<T>(
  fn: () => Promise<T>,
  opt: RetryOptions
): Promise<T> {
  let lastErr: unknown;

  for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;

      const status: number | undefined =
        err?.status ?? err?.response?.status ?? err?.cause?.status;

      const message =
        err?.message ?? err?.error?.message ?? "request failed";

      const shouldRetry =
        typeof status === "number" && opt.retryOnStatuses.includes(status);

      const isLast = attempt === opt.maxAttempts;
      if (!shouldRetry || isLast) {
        throw err;
      }

      const retryAfterHeader: string | null =
        err?.response?.headers?.get?.("retry-after") ??
        err?.headers?.get?.("retry-after") ??
        null;

      const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
      const backoffMs = computeBackoffMs(attempt - 1, opt);
      const delayMs = retryAfterMs ?? backoffMs;

      opt.onRetry?.({
        attempt,
        delayMs,
        status,
        message,
      });

      await sleep(delayMs);
    }
  }

  throw lastErr;
}

사용 예시: OpenAI Responses API 호출 감싸기

아래는 OpenAI SDK의 호출을 withRetry로 감싼 예시입니다. SDK 버전에 따라 에러 객체 형태가 조금 다를 수 있으니, 위 코드처럼 status를 여러 경로에서 추출하도록 방어적으로 작성하는 것이 좋습니다.

import OpenAI from "openai";
import { withRetry } from "./withRetry";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function generateSummary(input: string) {
  return withRetry(
    async () => {
      const res = await client.responses.create({
        model: "gpt-4.1-mini",
        input,
      });
      return res;
    },
    {
      maxAttempts: 6,
      baseDelayMs: 250,
      maxDelayMs: 10_000,
      jitter: "full",
      retryOnStatuses: [429, 500, 502, 503, 504],
      onRetry: ({ attempt, delayMs, status, message }) => {
        console.warn(
          JSON.stringify({
            msg: "openai retry",
            attempt,
            delayMs,
            status,
            message,
          })
        );
      },
    }
  );
}

운영에서는 retryOnStatuses408(timeout)도 넣고 싶을 수 있는데, 이 경우 네트워크 계층 타임아웃과 애플리케이션 타임아웃을 분리해서 관찰 가능하게 만드는 편이 좋습니다.

구현 2: 동시성 제한까지 포함한 “안전한 호출기”

429가 자주 난다면 백오프만으로는 부족하고, 호출 자체를 일정 동시성으로 제한해야 합니다. Node.js에서는 p-limit 같은 라이브러리를 많이 씁니다.

아래 예시는 p-limit로 동시성을 N으로 제한하고, 각 호출은 withRetry로 감싸는 패턴입니다.

import pLimit from "p-limit";
import OpenAI from "openai";
import { withRetry } from "./withRetry";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const limit = pLimit(5); // 동시에 5개만 OpenAI 호출

export function limitedResponseCreate(params: any) {
  return limit(() =>
    withRetry(
      () => client.responses.create(params),
      {
        maxAttempts: 6,
        baseDelayMs: 250,
        maxDelayMs: 10_000,
        jitter: "full",
        retryOnStatuses: [429, 500, 502, 503, 504],
      }
    )
  );
}

// 예: 배치 처리
export async function runBatch(inputs: string[]) {
  const tasks = inputs.map((input) =>
    limitedResponseCreate({ model: "gpt-4.1-mini", input })
  );
  return Promise.all(tasks);
}

이 방식의 장점은 “재시도는 하되, 동시에 몰리지 않게” 만든다는 점입니다. 서버가 여러 대면 각 인스턴스가 5로 제한해도 전체 동시성은 인스턴스 수 * 5가 됩니다. 따라서 큐 컨슈머라면 파티션 수, 컨슈머 수, 오토스케일 정책까지 같이 튜닝해야 합니다.

Retry-After를 무시하면 생기는 문제

일부 API는 Retry-After를 명시적으로 내려줍니다. 이 값을 무시하고 자체 백오프만 적용하면 다음 문제가 생깁니다.

  • 서버가 “이 시간 이후에 와라”라고 했는데 더 빨리 와서 계속 429
  • 불필요한 요청 증가로 비용과 로그가 증가
  • 재시도 큐가 길어지고, 사용자 응답 지연이 늘어남

그래서 구현 우선순위는 다음이 좋습니다.

  1. Retry-After가 있으면 그 값을 사용
  2. 없으면 지수 백오프 사용

재시도하면 안 되는 케이스 체크리스트

다음은 “재시도해도 성공 확률이 낮거나, 오히려 문제를 키우는” 케이스입니다.

  • 400 계열(특히 400, 401, 403, 404)은 대부분 재시도 무의미
  • 입력이 너무 길어 토큰 제한에 걸린 경우(요청을 줄여야 함)
  • 모델/프로젝트 설정 문제(권한, 키, 조직 설정)

즉, 재시도 정책은 상태 코드 기반으로 단순화하되, 400을 재시도 대상으로 넣지 않는 것이 기본입니다.

관측 가능성: 로그만 남기지 말고 메트릭으로 본다

운영에서 중요한 것은 “재시도가 얼마나 자주, 얼마나 오래 발생하는가”입니다. 아래 항목은 최소로 계측하는 것을 권합니다.

  • openai_requests_total
  • openai_retries_total (라벨: status)
  • openai_retry_delay_ms (히스토그램)
  • openai_errors_total (라벨: status)

재시도 횟수가 급증하면, 애플리케이션의 동시성/큐 적체/스케일 정책이 함께 흔들리고 있을 가능성이 큽니다. 이런 연쇄 병목은 DB 커넥션 풀 고갈 같은 형태로도 나타납니다. 비슷한 관점의 튜닝 글로 아래도 참고할 만합니다.

운영 팁: 백오프만큼 중요한 것은 “부하를 줄이는 설계”

재시도는 방어막일 뿐, 근본적으로는 요청량과 토큰량을 줄여야 429가 줄어듭니다.

  • 프롬프트 템플릿을 짧게 유지하고 불필요한 컨텍스트 제거
  • 배치 작업은 시간 분산(스케줄 랜덤 지연) 적용
  • 캐시 적용(동일 입력에 동일 출력이 허용되는 업무라면)
  • 스트리밍을 쓰더라도 TPM은 소비하므로, 출력 길이 제한을 함께 설정

또한 쿠버네티스에서 워커가 OOM으로 재시작되면 동일 작업을 다시 처리하면서 OpenAI 호출이 중복되어 429를 더 악화시키는 경우가 있습니다. 워커 안정성까지 같이 보면 문제 해결이 빨라집니다.

정리: 실전에서 통하는 429 대응 조합

429/RateLimitError 대응을 “재시도 한 줄”로 끝내면, 트래픽이 커지는 순간 다시 무너집니다. 아래 조합이 실전에서 가장 안정적으로 동작합니다.

  • 상태 코드 기반 재시도(주로 429, 일부 5xx)
  • Retry-After 우선 적용
  • 지수 백오프 + full jitter
  • 최대 시도 횟수와 최대 지연 상한
  • 호출 동시성 제한
  • 멱등성/중복 처리 방지 설계
  • 재시도 메트릭으로 추세 관찰

위 패턴대로 구현하면 “가끔 터지는 429”가 아니라, “예측 가능한 지연”으로 바뀌고 시스템 전체 안정성이 올라갑니다.