Published on

OpenAI 429 Rate Limit 재시도·백오프 실전 가이드

Authors
Binance registration banner

서버에서 OpenAI API를 호출하다 보면 429 응답을 종종 만납니다. 흔히는 “요청이 너무 많다”로 이해하고 무작정 재시도하지만, 재시도 자체가 트래픽을 더 키워 장애를 악화시키는 경우가 많습니다. 특히 동시성 높은 배치 작업, 웹훅 처리, 큐 워커, 스트리밍 응답을 섞어 쓰는 환경에서는 429가 단발성 에러가 아니라 시스템 설계 문제를 드러내는 신호가 됩니다.

이 글에서는 OpenAI 429의 의미를 정확히 구분하고, 재시도 정책, 지수 백오프와 지터, 동시성 제어, 서킷 브레이커, 관측 지표까지 한 번에 정리합니다. 재시도 폭주 자체가 장애를 만드는 구조는 gRPC 환경에서도 자주 발생하므로, 재시도 스톰 관점은 아래 글도 함께 보면 도움이 됩니다.

429의 두 얼굴: Rate Limit vs Quota/Capacity

429는 HTTP 레벨에서 “Too Many Requests”지만, 실제로는 다음 케이스가 섞여 나타납니다.

  1. 순수 Rate Limit 초과
  • 짧은 시간에 요청이 몰려 분당 요청 수, 분당 토큰 수 같은 제한을 넘는 케이스
  • 올바른 백오프를 적용하면 대부분 회복 가능
  1. 계정/프로젝트 Quota 소진 또는 결제/플랜 이슈
  • 재시도해도 계속 실패하는 유형
  • 백오프가 아니라 알림, 차단, 라우팅 변경이 필요
  1. 일시적 용량 문제 혹은 혼잡
  • 특정 모델이나 리전에 순간적으로 혼잡
  • 백오프와 대체 모델, 큐잉으로 완화 가능

따라서 429를 받으면 “재시도”부터 하기 전에 에러 바디의 메시지, 응답 헤더, 발생 패턴을 근거로 분류하는 게 중요합니다.

재시도 가능 조건 체크리스트

아래 조건이면 재시도 전략이 유효할 가능성이 큽니다.

  • 동일 요청이 1회는 성공하는데 피크 시간에만 실패한다
  • 트래픽이 특정 작업 배치, 특정 사용자, 특정 엔드포인트에 집중된다
  • 실패 직후 수 초에서 수십 초 뒤에는 성공률이 회복된다

반대로 아래라면 재시도는 비용만 키웁니다.

  • 모든 요청이 지속적으로 429로 실패한다
  • 결제/크레딧/프로젝트 제한 관련 메시지가 반복된다
  • 트래픽이 낮아도 계속 429가 난다

재시도 설계의 핵심: “얼마나”가 아니라 “어떻게”

429 대응에서 중요한 것은 재시도 횟수보다 재시도 곡선입니다.

  • 고정 간격 재시도: 가장 나쁨. 워커가 동시에 깨어나 다시 몰림
  • 지수 백오프: 재시도 간격이 기하급수로 늘어 혼잡을 완화
  • 지터(jitter): 여러 클라이언트가 같은 타이밍에 재시도하는 동기화를 깨뜨림

권장 공식에 가까운 형태는 다음입니다.

  • sleep = random(0, min(cap, base * 2^attempt))

여기서 cap은 최대 대기 시간 상한입니다.

권장 파라미터 예시

  • base: 200ms ~ 500ms
  • cap: 10s ~ 30s
  • maxAttempts: 5 ~ 8
  • 타임아웃: 요청 자체의 서버 타임아웃과 합쳐서 총 지연이 과도해지지 않게 제한

Node.js 예제: fetch 기반 재시도 유틸

아래 예제는 429와 일부 5xx에 대해 지수 백오프+지터로 재시도합니다. 또한 Retry-After 헤더가 있다면 이를 우선합니다.

// retry.js
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseRetryAfterMs(res) {
  const v = res.headers.get('retry-after');
  if (!v) return null;

  // retry-after: seconds or http-date
  const asNum = Number(v);
  if (!Number.isNaN(asNum)) return Math.max(0, asNum * 1000);

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

  return null;
}

function fullJitterDelayMs({ attempt, baseMs, capMs }) {
  const exp = Math.min(capMs, baseMs * (2 ** attempt));
  return Math.floor(Math.random() * exp);
}

export async function fetchWithRetry(url, options, config = {}) {
  const {
    maxAttempts = 6,
    baseMs = 300,
    capMs = 15000,
    retryOnStatuses = [429, 500, 502, 503, 504]
  } = config;

  let lastErr;

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

      if (!retryOnStatuses.includes(res.status)) {
        return res;
      }

      const retryAfter = parseRetryAfterMs(res);
      const delay = retryAfter ?? fullJitterDelayMs({ attempt, baseMs, capMs });

      // 응답 바디는 로깅용으로만 소량 읽고, 스트림은 재사용 불가에 유의
      const bodyText = await res.text().catch(() => '');

      lastErr = new Error(
        `retryable status=${res.status} attempt=${attempt + 1}/${maxAttempts} delayMs=${delay} body=${bodyText.slice(0, 300)}`
      );

      if (attempt === maxAttempts - 1) break;
      await sleep(delay);
      continue;
    } catch (e) {
      lastErr = e;
      const delay = fullJitterDelayMs({ attempt, baseMs, capMs });
      if (attempt === maxAttempts - 1) break;
      await sleep(delay);
    }
  }

  throw lastErr;
}

OpenAI 호출부는 다음처럼 연결합니다.

import { fetchWithRetry } from './retry.js';

export async function callOpenAI({ apiKey, payload }) {
  const res = await fetchWithRetry(
    'https://api.openai.com/v1/responses',
    {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'authorization': `Bearer ${apiKey}`
      },
      body: JSON.stringify(payload)
    },
    { maxAttempts: 7, baseMs: 250, capMs: 20000 }
  );

  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`openai error status=${res.status} body=${text}`);
  }

  return res.json();
}

주의: 스트리밍 응답과 재시도

스트리밍 응답은 “중간까지 받다가 끊긴 경우”를 단순 재시도로 복구하기 어렵습니다. 왜냐하면 동일 요청을 다시 보내면 중복 생성이 될 수 있고, 클라이언트가 어디까지 받았는지 서버가 모를 수 있기 때문입니다.

  • 스트리밍은 가급적 서버에서만 OpenAI를 호출하고, 클라이언트에는 서버가 재전송 가능한 형태로 전달
  • 결과를 저장하고 idempotency key 같은 애플리케이션 레벨 키로 중복을 방지

Python 예제: requests 기반 재시도

Python에서도 동일한 원칙을 적용합니다. 아래는 Retry-After를 우선하고, 없으면 full jitter를 쓰는 예시입니다.

import time
import random
import requests


def parse_retry_after_seconds(resp):
    v = resp.headers.get("retry-after")
    if not v:
        return None
    try:
        return max(0.0, float(v))
    except ValueError:
        return None


def full_jitter_delay(attempt, base=0.3, cap=15.0):
    exp = min(cap, base * (2 ** attempt))
    return random.random() * exp


def post_with_retry(url, headers, json_payload, max_attempts=6):
    last_exc = None

    for attempt in range(max_attempts):
        try:
            resp = requests.post(url, headers=headers, json=json_payload, timeout=60)

            if resp.status_code not in (429, 500, 502, 503, 504):
                return resp

            ra = parse_retry_after_seconds(resp)
            delay = ra if ra is not None else full_jitter_delay(attempt)

            last_exc = RuntimeError(
                f"retryable status={resp.status_code} attempt={attempt+1}/{max_attempts} delay={delay:.2f} body={resp.text[:300]}"
            )

            if attempt == max_attempts - 1:
                break
            time.sleep(delay)

        except requests.RequestException as e:
            last_exc = e
            if attempt == max_attempts - 1:
                break
            time.sleep(full_jitter_delay(attempt))

    raise last_exc


def call_openai(api_key, payload):
    resp = post_with_retry(
        "https://api.openai.com/v1/responses",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        json_payload=payload,
        max_attempts=7,
    )

    if not resp.ok:
        raise RuntimeError(f"openai error status={resp.status_code} body={resp.text}")

    return resp.json()

백오프만으로 부족할 때: 동시성 제한과 큐잉

429가 자주 난다면, 백오프는 증상 완화일 뿐이고 원인은 동시성 과다인 경우가 많습니다. 특히 다음 패턴은 위험합니다.

  • 웹 요청마다 OpenAI를 즉시 호출하고, 타임아웃 나면 프론트가 재시도
  • 큐 워커 수를 무작정 늘려 처리량을 올리려다 API 제한을 초과
  • 배치 작업이 정각에 몰리며 스파이크 발생

해결은 단순합니다.

  • 프로세스 내부에서 semaphore로 동시 호출 수를 제한
  • 작업 큐에서 워커 수를 제한하고, 지연 재시도를 큐 레벨에서 처리
  • 정각 배치를 랜덤 지연으로 분산

이 문제는 DB 커넥션 고갈과 구조가 유사합니다. “처리량을 올리려 워커를 늘린다”가 오히려 병목을 악화시키는 점에서 아래 글의 사고방식이 그대로 적용됩니다.

서킷 브레이커: 계속 두드리지 말고 잠깐 멈춰라

429가 일정 비율 이상 발생하면, 재시도는 “느린 실패”로 바뀌면서 전체 시스템 지연을 키웁니다. 이때는 서킷 브레이커가 효과적입니다.

  • 최근 N개 요청 중 429 비율이 임계치 초과하면 회로를 열고
  • 일정 시간 동안 빠르게 실패시키며
  • 이후 제한적으로 반개방 상태에서 성공 여부를 확인

실무에서는 다음 정책이 무난합니다.

  • 윈도우 30s
  • 실패율 임계치 30% 이상
  • 오픈 유지 10s
  • 반개방에서 1~3개만 시도

이렇게 하면 장애 시점에 애플리케이션이 스스로 “브레이크”를 걸어, 다운스트림과 자기 자신을 보호합니다.

관측과 디버깅: 로그 한 줄로 끝내지 말기

429는 재현이 어렵고, 트래픽 패턴에 따라 간헐적입니다. 따라서 다음을 반드시 남겨야 원인 분석이 가능합니다.

  • 모델명, 엔드포인트, 조직/프로젝트 구분 키(가능한 범위)
  • 요청 크기 추정치: 입력 토큰 수, 출력 토큰 상한
  • 재시도 횟수, 최종 지연 시간, Retry-After 사용 여부
  • 동일 사용자나 동일 작업 키 기준의 발생 빈도

메트릭으로는 아래가 핵심입니다.

  • 429 비율과 초당 건수
  • 평균 및 p95 지연 시간
  • 재시도 횟수 분포
  • 큐 적체 길이, 워커 동시성

로그가 누락되면 “가끔 429 나요”에서 끝납니다. 반대로 위 지표가 있으면 “정각 배치 스파이크”, “특정 테넌트 폭주”, “토큰 상한 과다로 처리량 감소” 같은 결론을 빠르게 낼 수 있습니다.

흔한 실수 5가지

  1. 429를 모든 에러와 동일하게 즉시 재시도
  • 4xx는 보통 재시도 대상이 아닙니다. 429만 예외적으로 설계하세요.
  1. 재시도 간격을 고정값으로 둠
  • 동기화된 재시도로 스파이크가 유지됩니다.
  1. 최대 재시도 횟수만 늘림
  • 성공률은 조금 오르지만 지연과 비용이 폭발합니다.
  1. 클라이언트와 서버가 동시에 재시도
  • 프론트, 게이트웨이, 백엔드, 워커가 모두 재시도하면 곱연산으로 폭주합니다.
  1. 동시성 제한 없이 워커를 늘림
  • 처리량을 올리는 게 아니라 429를 더 많이 만들 뿐입니다.

운영 관점의 권장 아키텍처

  • API 호출은 서버에서 중앙화하고, 사용자 요청은 큐잉하거나 제한
  • 동시성 제한은 애플리케이션 레벨에서 명시적으로 관리
  • 재시도는 지수 백오프+지터, Retry-After 우선
  • 실패가 반복되면 서킷 브레이커로 빠르게 차단
  • 결과 중복을 막기 위해 작업 키를 만들고 저장 후 재전송 가능하게 설계

큐 기반 비동기 처리에서 “재시도 정책이 중복 실행과 유령 작업을 만든다”는 문제는 OpenAI 호출에도 그대로 나타납니다. 워커 재시도, 타임아웃, ack 정책이 충돌하면 동일 프롬프트가 여러 번 실행될 수 있으니 아래 글의 체크리스트도 참고할 만합니다.

마무리

OpenAI 429는 단순한 “잠깐 기다렸다 다시 해”가 아니라, 동시성·트래픽 스파이크·재시도 정책·관측 부재가 합쳐져 나타나는 시스템적 현상인 경우가 많습니다.

  • 재시도는 지수 백오프+지터로 설계하고
  • Retry-After를 존중하며
  • 동시성 제한과 큐잉으로 스파이크를 눌러야

429를 “가끔 나는 에러”가 아니라 “용량과 안정성의 경계가 드러나는 지표”로 다루면, 비용과 지연을 동시에 줄이면서 성공률을 안정적으로 끌어올릴 수 있습니다.