Published on

OpenAI 429/RateLimitError 재시도·백오프 패턴

Authors

프로덕션에서 OpenAI API를 붙이면 가장 먼저 마주치는 장애 중 하나가 429 입니다. 에러 메시지는 단순히 “rate limit”처럼 보이지만, 실제 원인은 다양합니다. 초당 요청 수 제한(RPS), 분당 토큰 제한(TPM), 동시 요청 제한, 혹은 내부 보호 로직에 의한 일시적 제한까지 한 덩어리로 나타나기도 합니다.

이 글은 429/RateLimitError를 “그냥 몇 번 더 때리면 되겠지” 수준에서 벗어나, 재시도 정책백오프(Backoff), 지터(Jitter), 헤더 기반 대기, 큐잉/동시성 제어를 조합해 안정적으로 처리하는 실전 패턴을 다룹니다.

관련해서 더 넓은 대응(큐잉까지 포함)을 한 번에 보고 싶다면 아래 글도 참고하세요.

429가 “재시도하면 되는 오류”인 이유와 함정

429는 기본적으로 일시적(Transient) 오류로 분류할 수 있어 재시도 대상이 맞습니다. 다만 함정은 다음 두 가지입니다.

  1. 즉시 재시도는 한도를 더 초과시켜 실패를 가속합니다.

  2. 모든 429가 같은 원인이 아닙니다. 예를 들어 RPS 제한과 TPM 제한은 복구 전략이 다릅니다.

  • RPS 제한: 요청 수가 너무 많음. 동시성 제한, 큐잉, 요청 합치기(배치)로 완화
  • TPM 제한: 토큰을 너무 많이 씀. max_output_tokens 관리, 프롬프트 압축, 캐시, 모델/요금제 조정

따라서 “재시도”는 필요조건일 뿐, 재시도 방식이 핵심입니다.

기본 원칙: 지수 백오프 + 지터 + 상한

가장 널리 쓰이는 패턴은 다음 조합입니다.

  • 지수 백오프(Exponential Backoff): 실패할수록 대기 시간을 2배로 증가
  • 지터(Jitter): 여러 인스턴스가 동시에 재시도하는 동기화 현상(Thundering Herd)을 방지
  • 상한(Max Delay): 무한정 늘어나지 않도록 최대 대기 시간 제한

권장 형태(개념):

  • delay = min(maxDelay, baseDelay * 2^attempt)
  • delay = jitter(delay)

지터는 보통 아래 중 하나를 씁니다.

  • Full Jitter: random(0, delay)
  • Equal Jitter: delay/2 + random(0, delay/2)

프로덕션에서는 Full Jitter가 충돌을 가장 잘 흩트립니다.

헤더 기반 대기: Retry-After를 최우선으로

서버가 Retry-After 같은 힌트를 제공한다면, 클라이언트는 계산한 백오프보다 헤더 값을 우선하는 게 안전합니다.

실무에서의 우선순위는 보통 다음이 좋습니다.

  1. Retry-After가 있으면 그 값대로 대기
  2. 없으면 지수 백오프 + 지터
  3. 그래도 계속 실패하면 서킷 브레이커 또는 큐잉 강화

주의할 점은 Retry-After가 초 단위 숫자일 수도, 날짜일 수도 있다는 점입니다. 라이브러리별로 파싱을 잘하는지 확인하세요.

실패를 더 키우는 패턴: 무제한 동시성 + 즉시 재시도

429 폭풍은 대개 아래 조합에서 시작합니다.

  • 웹 요청이 늘어남
  • 애플리케이션이 OpenAI 호출을 동기적으로 많이 만듦
  • 타임아웃 또는 429 발생
  • 즉시 재시도
  • 동시 호출이 더 늘어나면서 한도 초과가 고착

이건 K8s CrashLoopBackOff가 “원인”이 아니라 “증상”인 것과 비슷합니다. 재시도 로직이 문제를 숨기고 더 키우기도 하죠.

결론은 간단합니다.

  • 재시도는 느리게
  • 동시성은 제한
  • 요청량은 평탄화(큐잉)

패턴 1: 애플리케이션 레벨 재시도 래퍼(파이썬)

아래는 Python에서 OpenAI 호출을 감싸는 재시도 래퍼 예시입니다. 핵심은 429와 네트워크 계열(일부 5xx)을 재시도 대상으로 보고, Retry-After 우선, 없으면 지수 백오프 + Full Jitter를 적용하는 것입니다.

import random
import time
from typing import Callable, Any

# 예: openai 라이브러리의 예외 타입에 맞게 바꾸세요.
# from openai import RateLimitError, APIError, APITimeoutError

class RateLimitError(Exception):
    pass

class APITimeoutError(Exception):
    pass

class APIError(Exception):
    def __init__(self, status_code: int):
        self.status_code = status_code


def _full_jitter_sleep(base: float, cap: float, attempt: int) -> float:
    exp = min(cap, base * (2 ** attempt))
    return random.uniform(0, exp)


def call_with_retry(
    fn: Callable[[], Any],
    max_attempts: int = 6,
    base_delay_s: float = 0.5,
    max_delay_s: float = 20.0,
    retry_after_s: float | None = None,
) -> Any:
    last_err = None

    for attempt in range(max_attempts):
        try:
            return fn()
        except RateLimitError as e:
            last_err = e

            # 실제 구현에서는 응답 헤더에서 `Retry-After`를 읽어오세요.
            if retry_after_s is not None:
                sleep_s = retry_after_s
            else:
                sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)

            time.sleep(sleep_s)
        except APITimeoutError as e:
            last_err = e
            sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)
            time.sleep(sleep_s)
        except APIError as e:
            last_err = e
            # 500~599만 재시도하는 식으로 제한
            if 500 <= e.status_code <= 599:
                sleep_s = _full_jitter_sleep(base_delay_s, max_delay_s, attempt)
                time.sleep(sleep_s)
            else:
                raise

    raise last_err

포인트

  • max_attempts를 너무 크게 잡으면 지연이 누적되어 상위 요청 타임아웃과 충돌합니다.
  • base_delay_s는 너무 작으면 효과가 없고, 너무 크면 사용자 경험이 나빠집니다.
  • max_delay_s는 시스템 성격에 맞게 상한을 둡니다.

패턴 2: 동시성 제한(세마포어) + 재시도 결합

재시도가 있어도 동시성이 무제한이면 429는 계속 납니다. 애플리케이션 레벨에서 OpenAI 호출의 동시성을 제한하면 효과가 큽니다.

아래는 asyncio 세마포어로 동시 호출을 제한하는 예시입니다.

import asyncio

sema = asyncio.Semaphore(8)  # 워크로드에 맞게 조정

async def call_openai_safely(async_fn):
    async with sema:
        return await async_fn()

이 패턴은 “최대 동시 호출 수”를 고정함으로써 RPS 초과를 완화합니다. 다만 TPM 제한이 원인이라면 동시성만 줄여서는 부족하고, 토큰 사용량 자체를 줄이거나 큐잉으로 분산해야 합니다.

패턴 3: 요청 평탄화(큐잉)로 스파이크 흡수

트래픽이 스파이크 형태라면 재시도와 동시성 제한만으로는 불안정합니다. 이때는 “지금 당장 처리” 대신 “대기열에 넣고 일정 속도로 처리”하는 큐잉이 정답인 경우가 많습니다.

구현은 상황마다 다르지만 개념은 같습니다.

  • API 서버는 요청을 큐에 넣고 빠르게 응답(또는 폴링/웹훅)
  • 워커가 큐에서 꺼내 OpenAI 호출
  • 워커의 처리 속도와 동시성을 제한해 레이트 리밋을 지킴

이 접근은 Kafka, SQS, RabbitMQ, Redis Streams 등으로 구현할 수 있습니다. 메시지 중복/재처리까지 고려하면 Outbox나 dedup 전략이 필요할 수 있습니다.

패턴 4: 토큰 예산 기반의 “사전 스로틀링”

TPM 제한은 “요청 수”가 아니라 “토큰 소비량”이 핵심입니다. 이때는 호출 직전에 토큰을 대략 추정하고, 예산이 부족하면 잠깐 기다리는 방식이 효과적입니다.

간단한 형태는 다음과 같습니다.

  • 최근 60초간 사용 토큰을 슬라이딩 윈도우로 추적
  • 새 요청의 예상 토큰(입력+출력)을 더했을 때 한도를 넘으면 대기

정밀한 토큰 계산이 어려우면 보수적으로 잡아도 됩니다. 핵심은 429를 맞고 나서 복구하는 게 아니라, 맞기 전에 속도를 줄이는 것입니다.

운영 관점 체크리스트

1) 재시도 대상 분리

  • 재시도 권장: 429, 일부 5xx, 타임아웃/일시적 네트워크 오류
  • 즉시 실패 권장: 400 계열의 입력 오류, 인증/권한 오류(401, 403), 잘못된 모델명 등

특히 403은 rate limit처럼 보여도 네트워크 경로나 권한 문제일 수 있습니다. 클라우드 환경에서는 IPv6, DNS, egress 정책 때문에 “간헐적 실패”처럼 보이는 케이스도 있어 분리 진단이 중요합니다.

2) 관측(Observability) 지표

최소한 아래는 찍어야 원인-대응 연결이 됩니다.

  • 분당 요청 수, 성공률
  • 429 발생률과 재시도 횟수 분포
  • 평균/상위(p95, p99) 지연
  • 분당 토큰 사용량(가능하면 모델/엔드포인트 별)
  • 큐 길이(큐잉 사용 시), 워커 처리량

3) 타임아웃과 재시도 총량 예산

상위 요청의 타임아웃이 10초인데, 재시도 정책이 최악의 경우 30초를 기다리게 하면 결국 사용자에게는 실패로 보입니다.

  • “재시도는 성공률을 올리되, 사용자 경험을 망치지 않는 범위”에서만
  • API 게이트웨이, 프론트엔드, 백엔드 타임아웃을 일관되게 설계

4) 클라이언트 인스턴스 수가 늘어날수록 더 보수적으로

오토스케일로 인스턴스가 늘면, 각 인스턴스가 동일한 재시도 정책을 수행하면서 총 요청량이 폭발할 수 있습니다.

  • 인스턴스 로컬 제한만 두지 말고, 가능하면 중앙화된 레이트 리미터(예: Redis 기반 토큰 버킷)를 고려
  • 또는 큐잉으로 “처리 속도”를 한 곳에서 제어

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

Node 환경에서도 동일한 원칙을 적용할 수 있습니다.

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

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

async function fetchWithRetry(url, options, cfg = {}) {
  const {
    maxAttempts = 6,
    baseDelayMs = 500,
    maxDelayMs = 20000,
  } = cfg;

  let lastErr;

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

      if (res.status === 429) {
        const retryAfter = res.headers.get("retry-after");
        const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : null;
        const waitMs = Number.isFinite(retryAfterMs)
          ? retryAfterMs
          : fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);

        await sleep(waitMs);
        continue;
      }

      if (res.status >= 500 && res.status <= 599) {
        const waitMs = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
        await sleep(waitMs);
        continue;
      }

      return res;
    } catch (e) {
      lastErr = e;
      const waitMs = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
      await sleep(waitMs);
    }
  }

  throw lastErr;
}

주의: retry-after가 날짜 형식으로 올 수도 있으니, 실제 운영에서는 파싱 로직을 강화하거나 사용 중인 HTTP 클라이언트의 기능을 활용하세요.

결론: “재시도”는 기능이 아니라 설계다

429/RateLimitError 대응은 단순히 재시도 횟수를 늘리는 문제가 아닙니다. 안정적인 패턴은 보통 아래 순서로 성숙합니다.

  1. 지수 백오프 + 지터로 재시도 품질 개선
  2. Retry-After 등 서버 힌트 기반 대기
  3. 동시성 제한으로 RPS 초과 완화
  4. 큐잉으로 스파이크 흡수 및 처리 속도 제어
  5. 토큰 예산 기반 스로틀링으로 TPM 초과 예방

이 조합을 적용하면 429가 “장애”가 아니라 “조절 가능한 이벤트”로 바뀌고, 비용과 지연, 성공률을 함께 최적화할 수 있습니다.