Published on

OpenAI 429와 Rate Limit 헤더로 재시도 설계

Authors

서버에서 OpenAI API를 호출하다 보면 가장 흔하게 마주치는 장애 중 하나가 429 Too Many Requests입니다. 많은 팀이 “지수 백오프 몇 번 넣으면 되겠지”로 시작하지만, 트래픽이 늘거나 배치/스트리밍/에이전트 워크로드가 섞이면 429는 시스템 설계 문제로 확장됩니다. 특히 OpenAI는 응답에 Rate Limit 관련 헤더를 제공하는 경우가 많고, 이를 해석해 재시도 시점을 정밀하게 잡으면 불필요한 재시도 폭풍(retry storm)을 줄이고 비용과 지연을 동시에 낮출 수 있습니다.

이 글에서는 429의 의미를 “재시도하라”가 아니라 “어떤 리소스(요청/토큰/동시성)가 부족한지 파악하고, 헤더 기반으로 다음 동작(대기/큐잉/감속)을 결정하라”로 재정의하고, 실전용 재시도 설계를 제시합니다.

> 참고: 네트워크 타임아웃(408)과 429는 증상이 비슷해 보이지만 대응 전략이 다릅니다. 타임아웃 재현/해결은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 함께 보면 좋습니다.

429를 단순 재시도로 해결하면 안 되는 이유

429는 “서버가 바빠요”가 아니라 대개 아래 중 하나입니다.

  • 요청 수 제한(RPM/RPS): 일정 시간당 요청 횟수 초과
  • 토큰 처리량 제한(TPM): 입력+출력 토큰이 시간당 한도를 초과
  • 동시성 제한: 동시에 처리 가능한 요청 수 초과(특히 스트리밍/장시간 응답)
  • 조직/프로젝트 단위 제한: 여러 서비스가 같은 키/프로젝트를 공유해 합산 초과

여기서 핵심은, 동일한 429라도 “얼마나 기다리면 되는지”가 다르고, 무작정 지수 백오프를 걸면 다음 문제가 발생합니다.

  • 헤더가 알려주는 정확한 리셋 시점을 무시해 불필요한 대기 또는 과도한 재시도
  • 여러 워커가 동시에 실패하고 동시에 재시도하는 thundering herd
  • TPM 초과인데 RPM 기준으로만 감속해 계속 429
  • 스트리밍 요청이 길어 동시성 제한을 치는데, 재시도만 반복해 큐가 폭주

따라서 429 대응은 “백오프”가 아니라 **클라이언트 측 레이트리미터 + 헤더 기반 스케줄링 + 중앙 큐(선택)**로 접근해야 합니다.

OpenAI Rate Limit 헤더: 무엇을 읽어야 하나

OpenAI 응답에는 상황에 따라 다음과 같은 헤더들이 포함될 수 있습니다(이름은 제품/게이트웨이/버전에 따라 달라질 수 있어, 코드는 항상 존재 여부를 체크해야 합니다).

  • Retry-After: 재시도까지 대기해야 하는 시간(초) 또는 HTTP-date
  • x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests
  • x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens
  • (환경에 따라) ratelimit-* 계열, 혹은 x-request-id(추적용)

헤더 해석 우선순위(실전 권장)

  1. Retry-After가 있으면 최우선: 서버가 지정한 대기 시간을 그대로 따르는 것이 가장 안전합니다.
  2. x-ratelimit-reset-*가 있으면 리셋 시각까지 대기: 보통 epoch seconds 혹은 RFC3339/HTTP-date 형태일 수 있어 파싱이 필요합니다.
  3. 나머지 정보(remaining, limit)는 **사전 감속(proactive throttling)**에 사용합니다.
  4. 헤더가 없다면: 지수 백오프 + jitter로 보수적으로 재시도하되, 재시도 횟수는 짧게.

재시도 설계의 목표: “성공률”이 아니라 “안정성”

재시도 로직을 설계할 때 목표를 명확히 하겠습니다.

  • 429 발생 시 즉시 재시도하지 않는다 (동시 실패가 겹치면 폭풍)
  • 헤더 기반으로 정확한 다음 시도 시점을 계산한다
  • 요청/토큰/동시성 중 병목 리소스별로 다른 제어를 한다
  • 재시도는 무한이 아니라 상한을 둔다 (SLO/UX 관점)
  • 멱등성/중복 실행을 고려한다 (특히 “툴 호출/결제/DB write” 포함 시)

구현 1) 헤더 기반 재시도(파이썬) — 최소하지만 실전형

아래 예시는 OpenAI Python SDK가 아니라도 적용 가능한 순수 HTTP 레벨 접근입니다. 중요한 포인트는:

  • 429일 때 Retry-After/x-ratelimit-reset-*를 읽어 대기
  • 헤더가 없으면 지수 백오프 + full jitter
  • 재시도 상한과 전체 타임아웃을 둠
import time
import random
import email.utils
from datetime import datetime, timezone

import requests


def _parse_retry_after(value: str) -> float | None:
    """Return seconds to wait."""
    if not value:
        return None
    value = value.strip()

    # 1) delta-seconds
    if value.isdigit():
        return float(value)

    # 2) HTTP-date
    try:
        dt = email.utils.parsedate_to_datetime(value)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        now = datetime.now(timezone.utc)
        return max(0.0, (dt - now).total_seconds())
    except Exception:
        return None


def _parse_reset_header(value: str) -> float | None:
    """Parse reset time header to seconds to wait.

    Vendors sometimes return epoch seconds, milliseconds, or HTTP-date.
    We'll try in that order.
    """
    if not value:
        return None
    v = value.strip()

    # epoch seconds / ms
    if v.isdigit():
        n = int(v)
        # heuristic: treat > 10^12 as ms
        if n > 10**12:
            reset_ts = n / 1000.0
        else:
            reset_ts = float(n)
        now_ts = time.time()
        return max(0.0, reset_ts - now_ts)

    # HTTP-date
    return _parse_retry_after(v)


def request_with_rate_limit_retry(
    method: str,
    url: str,
    headers: dict,
    json: dict | None = None,
    max_retries: int = 6,
    base_backoff: float = 0.5,
    max_backoff: float = 20.0,
    total_timeout: float = 60.0,
):
    start = time.time()

    for attempt in range(max_retries + 1):
        resp = requests.request(method, url, headers=headers, json=json, timeout=30)

        if resp.status_code != 429:
            return resp

        # 1) Prefer Retry-After
        ra = _parse_retry_after(resp.headers.get("Retry-After", ""))

        # 2) Otherwise use ratelimit reset headers (requests/tokens)
        reset_candidates = [
            resp.headers.get("x-ratelimit-reset-requests", ""),
            resp.headers.get("x-ratelimit-reset-tokens", ""),
            resp.headers.get("ratelimit-reset", ""),
        ]
        reset_waits = [w for w in (_parse_reset_header(v) for v in reset_candidates) if w is not None]
        reset_wait = min(reset_waits) if reset_waits else None

        if ra is not None:
            wait = ra
        elif reset_wait is not None:
            # small safety buffer to avoid off-by-a-bit
            wait = reset_wait + 0.25
        else:
            # 3) Exponential backoff + full jitter
            cap = min(max_backoff, base_backoff * (2 ** attempt))
            wait = random.uniform(0, cap)

        # stop if total timeout exceeded
        if time.time() - start + wait > total_timeout:
            return resp  # caller decides what to do

        time.sleep(wait)

    return resp

이 코드의 한계(의도적으로 남겨둔 것)

  • 프로세스/인스턴스가 여러 개면 각자 재시도해서 전역 한도를 맞추기 어렵습니다.
  • TPM/RPM을 사전에 조절하지 못하고, 429가 난 뒤에야 기다립니다.

그래서 다음 단계는 **사전 감속(클라이언트 레이트리미터)**입니다.

구현 2) 요청/토큰 2중 레이트리미터(사전 감속)

실전에서는 “429를 줄이는 것”이 아니라 “429가 거의 안 나게 하는 것”이 목표입니다. 이를 위해 요청 수(RPM) + 토큰(TPM) 두 축을 동시에 제한하는 레이트리미터를 둡니다.

가장 단순한 형태는 토큰 버킷(token bucket) 또는 누적 윈도우(sliding window)입니다. 아래는 이해를 돕기 위한 간단한 토큰 버킷 예시(정교한 분산 환경이면 Redis 기반으로 확장).

import time
import threading

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_per_sec = refill_per_sec
        self.updated_at = time.time()
        self.lock = threading.Lock()

    def _refill(self):
        now = time.time()
        delta = now - self.updated_at
        self.tokens = min(self.capacity, self.tokens + delta * self.refill_per_sec)
        self.updated_at = now

    def consume(self, amount: float):
        while True:
            with self.lock:
                self._refill()
                if self.tokens >= amount:
                    self.tokens -= amount
                    return
                deficit = amount - self.tokens
                wait = deficit / self.refill_per_sec if self.refill_per_sec > 0 else 1.0
            time.sleep(max(0.01, wait))


# Example: 60 requests/min => 1 req/sec
req_bucket = TokenBucket(capacity=10, refill_per_sec=1.0)

# Example: 120k tokens/min => 2000 tokens/sec
tpm_bucket = TokenBucket(capacity=4000, refill_per_sec=2000.0)


def guarded_call(estimated_tokens: int):
    req_bucket.consume(1)
    tpm_bucket.consume(estimated_tokens)
    # then call OpenAI

토큰 추정이 왜 중요한가

TPM 제한은 “요청 횟수”가 아니라 “토큰 양”이므로, 요청 전 estimated_tokens를 대략이라도 잡아야 합니다.

  • 입력 토큰: 프롬프트 길이로 대략 추정 가능
  • 출력 토큰: max_output_tokens(또는 상한)를 기준으로 잡는 것이 안전

즉, 보수적으로 추정하면 429는 줄지만 처리량이 떨어지고, 낙관적으로 추정하면 429가 늘어납니다. 보통은 입력 토큰 + max_output_tokens로 시작해, 운영 데이터로 보정합니다.

헤더를 “재시도”뿐 아니라 “동적 조정”에 쓰는 법

429가 나지 않더라도, 응답 헤더의 remaining이 급격히 줄면 곧 429가 날 신호입니다. 이를 이용해 동적으로 감속할 수 있습니다.

  • x-ratelimit-remaining-requests가 낮으면: 동시성(워커 수) 줄이기
  • x-ratelimit-remaining-tokens가 낮으면: max_output_tokens 낮추기, 배치 크기 줄이기
  • reset-*가 멀면: 큐에 쌓아두고 리셋 이후에 배출

운영에서는 이런 조정이 “재시도 횟수 감소 → 지연 감소 → 비용 감소(불필요 호출 감소)”로 연결됩니다.

분산 환경(EKS/멀티 워커)에서의 정답: 중앙 큐 + 전역 레이트리미팅

파드/프로세스가 여러 개일 때 각자 로컬 레이트리미터를 돌리면 합산이 튀기 쉽습니다. 특히 배치 잡/크론/웹 트래픽이 같은 키를 공유하면 더 심해집니다.

권장 아키텍처는 다음 중 하나입니다.

  • **API 호출을 전담하는 내부 게이트웨이(단일 서비스)**를 두고, 그 앞에서 전역 레이트리미팅
  • 또는 Redis 기반 분산 레이트리미터로 RPM/TPM을 전역으로 관리
  • 429 발생 시에는 작업을 실패시키지 말고 **Delay Queue(지연 큐)**로 재투입

여기서 네트워크 계층 이슈(예: Pod는 DNS 되는데 외부 HTTPS 실패, NAT/WAF로 403 등)와 429를 혼동하면 재시도만 늘어날 수 있습니다. 클러스터에서 외부 호출이 불안정하다면 아래 글도 함께 점검하세요.

멱등성: 재시도 설계에서 가장 자주 터지는 함정

429 재시도 자체는 안전해 보이지만, 다음 상황에서는 중복 실행이 사고로 이어집니다.

  • 툴 호출이 결제/주문/메일 발송/DB 업데이트 같은 **부작용(side effect)**을 일으킴
  • 스트리밍 중간에 클라이언트가 끊겨 “실패”로 판단했지만 서버에서는 이미 처리됨

대응:

  • 요청에 idempotency key(가능한 경우) 또는 내부 트랜잭션 키를 부여
  • “모델 호출”과 “부작용 실행”을 분리하고, 부작용은 정확히 한 번 처리되도록 설계
  • 재시도는 모델 호출까지만, 부작용은 별도 워크플로/사가(saga)로 관리

권장 정책 템플릿(운영 기본값)

  • 429:
    • Retry-After 우선
    • 없으면 reset-* + safety buffer(200~500ms)
    • 그래도 없으면 exponential backoff + full jitter
    • 최대 재시도 36회, 전체 타임아웃 3090초(제품 요구에 맞게)
  • 5xx:
    • 짧은 백오프 + jitter, 재시도 횟수 제한
  • 408/네트워크 타임아웃:
    • 재시도하되 요청 중복서버 처리 여부 불명확성을 고려(관측/추적 필수)
  • 관측:
    • x-request-id 로깅
    • 429 발생률, 평균 대기 시간, 헤더 존재율, reset 파싱 실패율을 대시보드화

마무리: 429는 “대기”가 아니라 “제어”의 문제

정리하면, OpenAI 429 대응은 단순 재시도 코드 몇 줄로 끝나지 않습니다.

  • 헤더(Retry-After, x-ratelimit-reset-*)를 읽어 정확히 언제 재시도할지 결정하고
  • RPM/TPM/동시성 병목을 분리해 사전 감속하며
  • 분산 환경에서는 전역 레이트리미팅/큐잉으로 재시도 폭풍을 구조적으로 차단해야 합니다.

이렇게 설계하면 429를 “가끔 발생하는 에러”가 아니라 “예측 가능한 흐름 제어 신호”로 바꿀 수 있고, 결과적으로 성공률·지연·비용을 동시에 개선할 수 있습니다.