Published on

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

Authors

서버에서 OpenAI API를 호출하다 보면 가장 자주 마주치는 운영 이슈 중 하나가 429 Too Many Requests입니다. 문제는 429가 “잠깐만 쉬었다가 다시 해”라는 신호임에도, 애플리케이션이 이를 제대로 해석하지 못하면 재시도 폭풍(retry storm)으로 이어져 성공률은 더 떨어지고 비용/지연은 더 커지는 악순환이 생긴다는 점입니다.

이 글에서는 429를 단순 예외 처리로 끝내지 않고, 재시도 정책(when/how), 백오프 알고리즘, 지터(jitter), 동시성 제어, 큐잉, 관측 지표까지 포함해 “깨지지 않는” 호출 계층을 설계하는 방법을 정리합니다. (401/400 계열은 재시도 대상이 아닌 경우가 많으니, 인증 관련은 Responses API 401인데 키가 맞는 7가지 이유, 입력/출력 스키마 문제는 OpenAI Responses API 400 invalid_output_text 해결 가이드도 함께 참고하면 좋습니다.)

429가 의미하는 것: “요청량”과 “토큰량”은 다르다

OpenAI에서 429는 보통 다음 두 축 중 하나(혹은 둘 다)에서 제한이 걸렸다는 뜻입니다.

  • RPS/RPM(Requests per second/minute): 초/분당 요청 수 제한
  • TPS/TPM(Tokens per second/minute): 초/분당 토큰 처리량 제한

즉, 같은 RPS라도 프롬프트/출력 토큰이 커지면 TPM에 먼저 걸릴 수 있고, 반대로 토큰이 작아도 동시 요청이 많으면 RPM에 걸릴 수 있습니다. 따라서 “429면 몇 초 쉬기” 같은 고정 규칙은 실패하기 쉽고, 서버가 안내하는 힌트(예: Retry-After) + 클라이언트의 부하 제어가 함께 필요합니다.

재시도 대상/비대상 분리: 429는 재시도, 4xx는 대부분 즉시 실패

가장 먼저 해야 할 일은 “재시도해도 되는 오류”를 엄격히 분류하는 것입니다.

재시도 권장

  • 429 Too Many Requests (rate limit)
  • 408 Request Timeout (네트워크/프록시 경유 시)
  • 409/500/502/503/504 (일시적 서버/게이트웨이 오류)
  • 네트워크 단절, DNS, TLS handshake 실패 등 일시적 I/O 오류

재시도 비권장(즉시 실패/수정 필요)

  • 400 계열 중 스키마/파라미터 오류(예: invalid_output_text)
  • 401/403 인증/권한 문제
  • 404 잘못된 엔드포인트
  • 애플리케이션 버그로 인한 요청 본문 생성 실패

이 분리가 안 되면, 절대 성공할 수 없는 요청을 재시도하며 제한을 더 빨리 소진합니다.

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

429 대응의 기본은 지수 백오프(exponential backoff)지만, 운영에서 중요한 건 **지터(jitter)**입니다.

  • 지수 백오프만 쓰면: 여러 워커가 동시에 429를 맞고 동시에 1s, 2s, 4s…로 다시 몰려 “동기화된 재시도”가 발생
  • 지터를 섞으면: 재시도 타이밍이 분산되어 서버와 클라이언트 모두 안정화

권장 형태(대표적으로 AWS가 소개한 패턴과 유사):

  • Full Jitter: sleep = random(0, base * 2^attempt)
  • 또는 Equal Jitter: sleep = base * 2^attempt / 2 + random(0, base * 2^attempt / 2)

그리고 반드시:

  • 최대 대기 시간(cap): 예) 30초
  • 최대 재시도 횟수: 예) 5~8회
  • 총 타임아웃(deadline): 예) 60초 내에 끝내기

Retry-After가 있다면 최우선으로 존중하라

일부 429 응답에는 Retry-After 헤더(초 단위 또는 HTTP-date)가 포함될 수 있습니다. 이 값이 있다면:

  1. Retry-After를 기본 대기 시간으로 사용
  2. 그래도 지터를 소량 섞어 동시 재시도를 방지

즉, “서버가 알려준 최소 대기”를 우선하고, 그 위에 “클라이언트 분산”을 얹는 구조가 가장 안전합니다.

동시성 제한이 백오프보다 먼저다: 세마포어/토큰 버킷

백오프는 “터졌을 때”의 대응이고, 동시성 제한은 “터지지 않게” 하는 예방입니다.

  • 단일 프로세스: Semaphore로 동시 요청 수 제한
  • 다중 인스턴스: Redis 기반 분산 세마포어/레이트리미터(토큰 버킷)
  • 대규모: 큐(예: SQS/Kafka)로 흡수하고 워커가 일정 속도로 처리

특히 429가 반복된다면 “재시도 로직이 부족해서”가 아니라, 대부분 요청 생성 속도가 제한을 초과하고 있기 때문입니다. 이때는 재시도만 늘리면 평균 지연만 커지고 성공률은 크게 개선되지 않습니다.

실전 Python 예제: httpx + 지수 백오프(지터) + Retry-After

아래 예제는 다음을 포함합니다.

  • 429/5xx에만 재시도
  • Retry-After 우선
  • Full jitter
  • per-request deadline
  • 동시성 제한(semaphore)
import asyncio
import random
import time
from typing import Optional

import httpx

RETRYABLE_STATUS = {429, 500, 502, 503, 504}


def parse_retry_after(headers: httpx.Headers) -> Optional[float]:
    ra = headers.get("Retry-After")
    if not ra:
        return None
    # Retry-After: seconds (most common)
    try:
        return float(ra)
    except ValueError:
        return None


def full_jitter_sleep(base: float, attempt: int, cap: float) -> float:
    # random(0, min(cap, base * 2^attempt))
    return random.uniform(0, min(cap, base * (2 ** attempt)))


async def call_openai_with_retry(
    client: httpx.AsyncClient,
    url: str,
    json_body: dict,
    *,
    max_attempts: int = 6,
    base_backoff: float = 0.5,
    cap_backoff: float = 20.0,
    deadline_sec: float = 60.0,
) -> dict:
    start = time.monotonic()
    last_exc = None

    for attempt in range(max_attempts):
        # deadline 체크
        if time.monotonic() - start > deadline_sec:
            raise TimeoutError("deadline exceeded") from last_exc

        try:
            resp = await client.post(url, json=json_body, timeout=30.0)
        except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
            last_exc = e
            sleep = full_jitter_sleep(base_backoff, attempt, cap_backoff)
            await asyncio.sleep(sleep)
            continue

        if resp.status_code < 400:
            return resp.json()

        # 재시도 비대상 4xx는 즉시 실패
        if 400 <= resp.status_code < 500 and resp.status_code != 429:
            resp.raise_for_status()

        if resp.status_code in RETRYABLE_STATUS:
            ra = parse_retry_after(resp.headers)
            if ra is not None:
                # 서버 권장 대기 + 소량 지터(최대 20%)
                sleep = ra * random.uniform(1.0, 1.2)
            else:
                sleep = full_jitter_sleep(base_backoff, attempt, cap_backoff)
            await asyncio.sleep(sleep)
            continue

        # 그 외는 기본적으로 실패
        resp.raise_for_status()

    raise RuntimeError("max attempts exceeded") from last_exc


async def main():
    semaphore = asyncio.Semaphore(8)  # 동시성 제한

    async with httpx.AsyncClient(headers={
        "Authorization": f"Bearer {"YOUR_API_KEY"}",
        "Content-Type": "application/json",
    }) as client:

        async def guarded_call(payload: dict):
            async with semaphore:
                return await call_openai_with_retry(
                    client,
                    url="https://api.openai.com/v1/responses",
                    json_body=payload,
                )

        payload = {
            "model": "gpt-4.1-mini",
            "input": "Explain exponential backoff with jitter in one paragraph."
        }

        results = await asyncio.gather(*(guarded_call(payload) for _ in range(20)))
        print(results[0])


if __name__ == "__main__":
    asyncio.run(main())

포인트

  • Semaphore(8)처럼 동시성 상한을 먼저 두면 429 빈도가 크게 줄어듭니다.
  • deadline_sec가 없으면, 장애 상황에서 재시도가 누적되어 요청이 “영원히” 매달릴 수 있습니다.
  • 429 외 4xx를 재시도하지 않게 해야 제한 소모를 막습니다.

Node.js(Typescript) 예제: p-retry + AbortController + 지터

Node 환경에서는 재시도 라이브러리를 쓰되, “기본 설정” 그대로 쓰지 말고 지터/헤더 존중/재시도 조건을 명확히 하세요.

import pRetry from "p-retry";

type FetchLike = typeof fetch;

function parseRetryAfter(headers: Headers): number | null {
  const ra = headers.get("retry-after");
  if (!ra) return null;
  const sec = Number(ra);
  return Number.isFinite(sec) ? sec : null;
}

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

async function postWithRetry(fetchFn: FetchLike, url: string, body: unknown) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);

  try {
    return await pRetry(
      async (attempt) => {
        const res = await fetchFn(url, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify(body),
          signal: controller.signal,
        });

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

        // 4xx 중 429가 아니면 즉시 실패
        if (res.status >= 400 && res.status < 500 && res.status !== 429) {
          throw new pRetry.AbortError(await res.text());
        }

        // 429/5xx는 재시도
        const retryAfter = parseRetryAfter(res.headers);
        if (retryAfter != null) {
          // 서버 권장 대기 + 0~200ms 지터
          await sleep(retryAfter * 1000 + Math.floor(Math.random() * 200));
        }

        throw new Error(`retryable status: ${res.status} (attempt ${attempt})`);
      },
      {
        retries: 5,
        factor: 2,
        minTimeout: 300,
        maxTimeout: 10_000,
        randomize: true, // 기본 지터(완전하진 않지만 없는 것보단 낫다)
      }
    );
  } finally {
    clearTimeout(timeout);
  }
}

(async () => {
  const result = await postWithRetry(fetch, "https://api.openai.com/v1/responses", {
    model: "gpt-4.1-mini",
    input: "Give me a checklist for handling 429 rate limits.",
  });
  console.log(result);
})();

재시도 폭풍을 막는 아키텍처 패턴 4가지

코드 레벨 재시도만으로는 한계가 있습니다. 트래픽이 커질수록 아래 패턴이 효과적입니다.

1) 클라이언트 측 요청 병합/디바운스

동일 사용자/동일 프롬프트가 짧은 시간에 반복되면:

  • 요청을 병합하거나
  • 캐시(결과/임베딩)를 적용

2) 큐 기반 비동기 처리

사용자에게는 “접수됨”을 먼저 응답하고, 백엔드 워커가 제한 속도에 맞춰 처리합니다.

  • 장점: 429를 구조적으로 줄임
  • 단점: 실시간성이 떨어짐

3) 우선순위 큐

무료/체험 사용자의 작업이 폭주할 때 유료/내부 업무까지 같이 429를 맞으면 치명적입니다.

  • priority queue로 중요한 요청을 먼저 처리

4) 서킷 브레이커 + 벌크헤드

429가 일정 비율 이상이면 잠깐 “회로를 열어” 더 이상의 호출을 줄이고, 시스템 전체를 보호합니다.

  • 벌크헤드(격리)로 기능별 동시성 풀을 분리(예: 챗/요약/임베딩)

관측(Observability): 로그 한 줄로는 부족하다

429 대응은 “정책”이므로, 정책이 잘 작동하는지 지표가 필요합니다.

추천 지표:

  • rate_limit_429_count (모델/엔드포인트/테넌트 태그)
  • retry_attempts_histogram (평균 재시도 횟수)
  • request_latency_p95/p99 (재시도로 인해 늘어나는 꼬리 지연)
  • tokens_in/tokens_out 추정치와 tpm_utilization(가능하면)
  • 큐 사용 시 queue_depth, age_of_oldest_message

그리고 429 응답 본문/헤더 일부를 샘플링해서 남기면(PII 제거 필수) “RPM인지 TPM인지”, “특정 모델에만 집중되는지”를 빠르게 파악할 수 있습니다.

체크리스트: 운영에서 바로 쓰는 429 대응 규칙

  • 429/5xx만 재시도하고, 나머지 4xx는 즉시 실패
  • Retry-After가 있으면 최우선 적용
  • 지수 백오프 + 지터(Full/Equal jitter) 적용
  • max attempts + overall deadline 설정
  • 동시성 제한(세마포어)과, 필요 시 분산 레이트리미터 도입
  • 큐/우선순위/서킷 브레이커로 재시도 폭풍 차단
  • 429 비율/재시도 횟수/지연 p99 관측

마무리

429는 “일시적 오류”이지만, 대응 방식이 잘못되면 “상시 장애”로 바뀝니다. 좋은 설계는 재시도 로직만이 아니라 **부하를 제한하고 분산시키는 구조(동시성 제한·큐잉·관측)**까지 포함합니다.

추가로, 429가 아니라 401/400이 섞여 들어온다면(특히 배포 직후) 재시도보다 원인 분리가 먼저입니다. 인증 이슈는 Responses API 401인데 키가 맞는 7가지 이유, 요청/출력 검증 문제는 OpenAI Responses API 400 invalid_output_text 해결 가이드를 함께 점검해 “재시도할 가치가 있는 실패”만 남기는 것이 전체 성공률을 가장 빠르게 끌어올립니다.