Published on

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

Authors

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