Published on

OpenAI 429 rate_limit_exceeded 재시도·백오프 설계

Authors

서버에서 OpenAI API를 붙이다 보면 언젠가 반드시 만나게 되는 에러가 429입니다. 특히 에러 코드가 rate_limit_exceeded로 내려오면 “잠깐만 쉬었다가 다시 호출”이 정답이긴 한데, 구현을 대충하면 오히려 트래픽이 폭주하면서 429가 연쇄적으로 터집니다.

이 글에서는 429가 발생하는 구조, 재시도( retry )와 백오프( backoff )의 올바른 기준, 헤더 기반 대기 시간 계산, 동시성(Concurrency) 제한과 큐잉, idempotency/중복 처리, 그리고 운영에서 필요한 관측 포인트까지 한 번에 정리합니다.

429(rate_limit_exceeded)는 왜 발생하나

429 Too Many Requests는 “요청이 너무 많다”는 HTTP 표준 의미지만, OpenAI에서의 429는 보통 아래 중 하나로 발생합니다.

  • RPM(Requests Per Minute): 분당 요청 수 제한 초과
  • TPM(Tokens Per Minute): 분당 토큰 처리량 제한 초과(프롬프트+출력 합산)
  • 동시 요청 폭증: 짧은 시간에 몰리면 순간적으로 제한에 걸림(버스트)
  • 조직/프로젝트 단위 제한: 여러 서비스가 같은 키/프로젝트를 공유하면 합산되어 초과

핵심은 “요청 수”뿐 아니라 토큰 예산이 함께 제한된다는 점입니다. 즉, 같은 RPM이라도 응답 토큰이 커지면 TPM으로 먼저 막힐 수 있습니다.

재시도 전략의 기본 원칙

429를 만났을 때의 재시도는 단순히 sleep(1)로 끝나지 않습니다. 다음 원칙을 지키면 실패율과 비용을 동시에 낮출 수 있습니다.

1) 우선순위: 서버가 준 힌트(헤더) > 클라이언트 추정

가능하면 응답 헤더에 담긴 재시도 가능 시점을 우선합니다. API/게이트웨이 설계에서 흔히 Retry-After를 보내기도 하고, 일부 플랫폼은 자체 rate limit 헤더를 제공합니다.

  • Retry-After: <seconds> 또는 HTTP-date
  • (플랫폼별) x-ratelimit-remaining, x-ratelimit-reset

헤더가 없거나 신뢰하기 어렵다면 그때 지수 백오프 + 지터로 안전하게 후퇴합니다.

2) 지수 백오프(Exponential Backoff) + 지터(Jitter)

동일한 백오프(예: 1s, 2s, 4s…)만 쓰면 여러 인스턴스가 동시에 다시 깨어나 재폭주(Thundering Herd)를 일으킵니다. 따라서 지터를 섞어 분산시켜야 합니다.

권장 패턴:

  • baseDelay * 2^attempt를 상한(cap)까지 증가
  • 지터는 full jitter 또는 equal jitter 사용

3) 무한 재시도 금지: 시도 횟수/총 대기 시간 상한

429는 일시적이지만, 잘못된 동시성 설정이나 공유 키 폭주 등 구조적 문제면 계속 발생합니다.

  • 최대 재시도 횟수(예: 6~8회)
  • 최대 총 대기 시간(예: 30~60초)
  • 초과 시 상위 레이어로 에러 전파 + 큐/서킷브레이커로 흡수

4) 재시도 대상 선별

429는 재시도 가치가 높지만, 아래는 분리해야 합니다.

  • 400/401/403: 보통 재시도 의미 없음(구성/권한 문제)
  • 408/409/500/502/503/504: 재시도 가치 있음(백오프 적용)
  • 429: 헤더 기반 대기 + 백오프

Node.js/TypeScript: 헤더 우선 + 지터 백오프 구현

아래 예시는 “헤더 기반 대기(있으면 우선)” + “없으면 지수 백오프 + full jitter”를 적용합니다. 또한 재시도 가능한 에러만 재시도합니다.

import OpenAI from "openai";

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

type RetryOptions = {
  maxAttempts: number;          // 총 시도 횟수(최초 1회 포함)
  baseDelayMs: number;          // 기본 딜레이
  maxDelayMs: number;           // 딜레이 상한
  maxTotalDelayMs: number;      // 총 대기 상한
};

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

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

  // 1) seconds
  const asNum = Number(ra);
  if (!Number.isNaN(asNum) && asNum >= 0) return Math.ceil(asNum * 1000);

  // 2) HTTP-date
  const dateMs = Date.parse(ra);
  if (!Number.isNaN(dateMs)) {
    const diff = dateMs - Date.now();
    return diff > 0 ? diff : 0;
  }

  return null;
}

function isRetryableStatus(status: number) {
  return status === 429 || status === 408 || status === 409 || (status >= 500 && status <= 504);
}

function fullJitterDelayMs(expDelayMs: number) {
  // full jitter: random(0, expDelay)
  return Math.floor(Math.random() * expDelayMs);
}

async function callWithRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
  const {
    maxAttempts,
    baseDelayMs,
    maxDelayMs,
    maxTotalDelayMs,
  } = opts;

  let totalSlept = 0;
  let lastErr: any;

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

      // OpenAI SDK 에러 형태는 버전에 따라 다를 수 있으므로 방어적으로 처리
      const status = err?.status ?? err?.response?.status;
      const headers: Headers | undefined = err?.headers ?? err?.response?.headers;

      if (!status || !isRetryableStatus(status) || attempt === maxAttempts) {
        throw err;
      }

      // 1) 헤더 기반 대기(가능하면 우선)
      let delayMs: number | null = null;
      if (headers && typeof (headers as any).get === "function") {
        delayMs = parseRetryAfterMs(headers);
      }

      // 2) 없으면 지수 백오프 + 지터
      if (delayMs == null) {
        const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1));
        delayMs = fullJitterDelayMs(exp);
      }

      // 총 대기 상한
      if (totalSlept + delayMs > maxTotalDelayMs) {
        throw err;
      }

      await sleep(delayMs);
      totalSlept += delayMs;
    }
  }

  throw lastErr;
}

// 사용 예
export async function generateText(prompt: string) {
  return callWithRetry(
    async () => {
      const res = await client.responses.create({
        model: "gpt-4.1-mini",
        input: prompt,
      });
      return res;
    },
    {
      maxAttempts: 7,
      baseDelayMs: 250,
      maxDelayMs: 10_000,
      maxTotalDelayMs: 45_000,
    }
  );
}

이 코드의 포인트는 다음과 같습니다.

  • 429가 났다고 즉시 같은 요청을 때리지 않음
  • 서버가 Retry-After를 주면 그걸 따름
  • 없으면 지수 백오프 + full jitter로 분산
  • 재시도 횟수/총 대기 상한으로 “영원히 붙잡히는 요청”을 방지

더 중요한 해법: 재시도보다 동시성 제어(큐/리미터)

운영에서 429를 가장 빨리 줄이는 방법은 “재시도 로직 개선”보다 애초에 초과하지 않게 만드는 것입니다.

1) 동시성 제한(Concurrency Limit)

웹 서버가 초당 수십~수백 요청을 받으면, 백엔드에서 OpenAI 호출이 동시에 터지며 429가 발생합니다. 이때는 프로세스/인스턴스 단위로 동시 호출 수를 제한하세요.

Node라면 p-limit 같은 라이브러리로 간단히 구현할 수 있습니다.

import pLimit from "p-limit";

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

export async function handleRequests(prompts: string[]) {
  const tasks = prompts.map((p) =>
    limit(() => generateText(p))
  );
  return Promise.all(tasks);
}

동시성 제한은 “분당 제한”을 직접 계산하지 않더라도 버스트를 누그러뜨리는 효과가 큽니다.

2) 큐 기반 비동기 처리(특히 배치/대량 작업)

대량 요약/임베딩/분류 같은 작업을 HTTP 요청-응답 사이클에서 처리하면, 순간 트래픽에 취약해집니다.

  • API는 요청을 받으면 작업을 큐(SQS, RabbitMQ, Redis Streams 등)에 넣고 즉시 202 반환
  • 워커가 제한된 동시성으로 소비
  • 워커에서 429는 재시도(백오프)로 흡수

이 패턴은 “429 대응”을 넘어 비용 예측 가능성시스템 안정성을 크게 올립니다.

비슷한 맥락으로 CI/CD나 자동화에서 작업 폭증을 제어하는 접근이 중요한데, 모노레포 환경에서 워크플로우 폭증을 막는 전략도 함께 참고하면 좋습니다: GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기

토큰 예산(TPM) 관점에서의 429 줄이기

429가 RPM이 아니라 TPM 때문에 발생하는 경우가 많습니다. 특히 다음 상황에서 그렇습니다.

  • 프롬프트에 대량 컨텍스트(긴 로그/문서)를 그대로 붙임
  • max_output_tokens를 크게 잡아 출력이 폭증
  • 스트리밍을 켰지만 실제로는 긴 출력이 계속 생성됨

실전 팁:

  • 입력을 요약/청크 후 요청(Chunking)
  • 시스템/지시문을 짧고 재사용 가능한 템플릿으로 정리
  • 출력 상한을 합리적으로 설정(max_output_tokens)
  • 가능하면 작은 모델로 전환하거나, 작업별로 모델을 분리

멱등성(idempotency)과 중복 청구/중복 처리 방지

재시도는 본질적으로 “같은 요청을 여러 번 보낼 수 있음”을 의미합니다. 이때 문제가 되는 건:

  • 동일 작업이 중복 수행(예: 같은 이메일을 두 번 발송)
  • 응답이 중복 저장(예: DB에 같은 결과가 두 번 insert)
  • 비용이 중복 발생(특히 네트워크 타임아웃 후 재시도)

대응:

  • 애플리케이션 레벨에서 requestId를 만들고 결과를 캐시/락으로 보호
  • 작업 큐라면 메시지 키 기반 deduplication(가능한 큐 사용)
  • DB에는 유니크 키로 중복 삽입 방지

예: “프롬프트 해시 + 사용자ID + 날짜”를 키로 결과 저장 후 동일 요청은 캐시 반환.

관측(Observability): 429를 ‘측정’해야 줄일 수 있다

429 대응은 튜닝 싸움입니다. 다음 지표를 반드시 수집하세요.

  • 429 발생률(전체 대비 %), 모델별/엔드포인트별
  • 재시도 횟수 분포(p50/p95)
  • 총 대기 시간(백오프 누적)
  • 성공까지 걸린 시간(사용자 체감 지연)
  • 토큰 사용량(입력/출력), 요청 크기 분포

그리고 로그에는 최소한 아래를 남기면 원인 분석이 빨라집니다.

  • status, error.code (rate_limit_exceeded 등)
  • attempt, delayMs, totalDelayMs
  • 모델명, 입력 토큰 추정치, 출력 상한

운영 중 어떤 문제가 “재시도 로직”이 아니라 “구성/시스템 폭주”에서 오는지 분리하는 것도 중요합니다. 예를 들어 systemd 서비스가 재시작 루프에 빠지면 트래픽이 비정상적으로 재생성되어 외부 API를 두드릴 수 있는데, 이런 유형의 장애 진단 패턴도 유사합니다: systemd 서비스 자동 재시작 무한루프 진단 가이드

실전 체크리스트: 429를 안정적으로 다루는 순서

마지막으로, 현장에서 바로 적용 가능한 우선순위 체크리스트입니다.

  1. 동시성 제한부터 걸기(인스턴스/워커당 N개)
  2. 429/5xx에만 재시도 적용(4xx 일반은 제외)
  3. 가능하면 Retry-After 등 헤더를 우선 준수
  4. 백오프는 지수 증가 + 지터(thundering herd 방지)
  5. 최대 재시도/총 대기 상한으로 사용자 지연 통제
  6. 작업은 가능하면 큐 기반 비동기로 전환
  7. TPM 관점에서 프롬프트/출력 토큰을 줄여 구조적으로 429를 감소
  8. 멱등성 키/중복 방지로 “재시도 부작용” 제거

429는 “잠깐 쉬면 해결되는 일시적 장애”이기도 하지만, 대부분은 시스템 설계(동시성, 큐잉, 토큰 예산) 문제를 드러내는 신호입니다. 재시도·백오프는 최후의 안전망으로 두고, 먼저 초과하지 않는 흐름을 만드는 쪽으로 설계를 잡으면 운영이 훨씬 편해집니다.