Published on

OpenAI 429·insufficient_quota 재시도와 백오프 설계

Authors

서론

OpenAI API를 운영 환경에 붙이면 가장 흔하게 마주치는 실패가 429 계열입니다. 문제는 429가 항상 “잠깐 기다리면 되는” 상황이 아니라는 점입니다. 같은 429라도 rate limit(일시적), 동시성 제한, 조직/프로젝트 쿼터 소진(사실상 영구적) 등 원인이 다르고, 원인에 따라 재시도 정책이 달라져야 합니다.

이 글에서는 다음을 목표로 합니다.

  • 429insufficient_quota구분하고, 재시도 가능/불가능을 판단한다.
  • 헤더(Retry-After, rate limit 관련 헤더)를 활용해 서버가 원하는 속도로 백오프한다.
  • 지수 백오프 + 지터(jitter) + 회로차단(circuit breaker)로 폭주 재시도를 막는다.
  • 멀티 워커/큐 환경에서 전역 rate limit을 지키는 구현 패턴을 제시한다.

OpenAI 응답 스키마/에러 파싱에 대한 다른 케이스가 필요하다면 OpenAI Responses API 400 invalid_tool_output 해결법도 함께 참고하면 좋습니다.


429의 두 얼굴: throttling vs quota

1) throttling(일시적 제한): 재시도 대상

일반적인 429는 “요청이 너무 많다”는 의미입니다. 보통 아래 중 하나입니다.

  • RPM/TPM 제한: 분당 요청 수(Requests Per Minute), 분당 토큰 수(Tokens Per Minute)
  • 동시성 제한: 동시에 처리 가능한 요청 수 제한
  • 버스트 제한: 짧은 시간에 몰린 트래픽 제한

이 경우는 재시도가 합리적입니다. 단, 즉시 재시도는 더 큰 429 폭풍을 만들기 때문에 백오프가 필수입니다.

2) insufficient_quota(실질적 영구 실패): 재시도 금지

insufficient_quota는 보통 다음을 의미합니다.

  • 결제/크레딧/프로젝트 예산 부족
  • 조직/프로젝트 단위 쿼터 소진
  • 사용량 한도(하드 리밋) 도달

이 경우는 재시도해도 성공할 확률이 낮거나 0에 가깝습니다. 재시도는 비용만 키우고 장애 시간을 늘립니다. 즉시 사용자에게 적절한 메시지를 주거나, 운영 알림을 발생시키고, 대체 경로(다른 모델/다른 공급자/캐시 응답 등)로 전환해야 합니다.


에러를 “문자열”이 아니라 “정책”으로 매핑하기

운영에서 중요한 건 “에러 메시지”가 아니라 “이 에러에 어떤 정책을 적용할 것인가”입니다. 추천하는 분류는 아래처럼 단순합니다.

  • Retryable: 429(일부), 500/502/503/504, 네트워크 타임아웃
  • Retryable but slow down: 429(rate limit) + Retry-After 제공
  • Non-retryable: 400(대부분), 401/403(권한), insufficient_quota

이런 정책 맵을 만들어두면, 클라이언트/워커/큐 어디서든 동일한 규칙으로 동작합니다.


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

왜 지터가 필요한가

여러 워커가 동시에 429를 맞으면, 모두가 “2초 후 재시도” 같은 동일한 패턴으로 움직이며 **동기화된 재폭주(thundering herd)**가 발생합니다. 지터는 이를 깨기 위해 필수입니다.

권장 공식(예시)

  • 기본 지수 백오프: base * 2^attempt
  • 상한: max_delay
  • 지터: full jitter(0~delay 랜덤) 또는 equal jitter

Retry-After와 rate limit 헤더를 최우선으로 사용하기

서버가 Retry-After를 준다면, 로컬에서 계산한 백오프보다 서버 힌트를 우선하는 것이 안전합니다. 일부 환경에서는 추가 rate limit 관련 헤더가 제공될 수 있으니(버전/엔드포인트에 따라 상이), 있다면 적극 활용하세요.

정리하면 우선순위는 다음이 좋습니다.

  1. Retry-After가 있으면 그대로 따른다.
  2. 없으면 지수 백오프 + 지터를 적용한다.
  3. 연속 실패가 길어지면 회로차단으로 잠시 중단한다.

Python 예제: OpenAI 호출 래퍼(재시도/백오프/중단)

아래 예제는 (1) 429 중 rate limit은 재시도, (2) insufficient_quota는 즉시 중단, (3) Retry-After 우선, (4) 지수 백오프+지터를 구현합니다.

import random
import time
from typing import Optional

from openai import OpenAI

client = OpenAI()


def _parse_retry_after(headers: dict) -> Optional[float]:
    # headers는 라이브러리/버전에 따라 형태가 다를 수 있음
    # 가능한 경우에만 파싱
    if not headers:
        return None
    ra = headers.get("retry-after") or headers.get("Retry-After")
    if not ra:
        return None
    try:
        return float(ra)
    except ValueError:
        return None


def call_openai_with_retry(prompt: str, *, max_attempts: int = 6,
                           base_delay: float = 0.5, max_delay: float = 20.0):
    last_exc = None

    for attempt in range(max_attempts):
        try:
            return client.responses.create(
                model="gpt-4.1-mini",
                input=prompt,
            )

        except Exception as e:
            last_exc = e

            # openai SDK 예외 구조는 버전에 따라 달라질 수 있어
            # 아래는 "정책" 중심의 방어적 분기 예시
            status = getattr(e, "status_code", None) or getattr(e, "status", None)
            err = getattr(e, "error", None)
            code = None
            msg = str(e)

            # 가능한 경우 code 추출(예: err.code)
            if err is not None:
                code = getattr(err, "code", None) or getattr(err, "type", None)

            # 1) 쿼터 소진: 재시도 금지
            if code == "insufficient_quota" or "insufficient_quota" in msg:
                raise RuntimeError(
                    "OpenAI quota exhausted (insufficient_quota). "
                    "Do not retry; check billing/limits and alert ops."
                ) from e

            # 2) 400/401/403 등은 보통 재시도해도 안 됨
            if status in (400, 401, 403):
                raise

            # 3) 429 및 5xx/네트워크 계열은 재시도
            retryable = (status == 429) or (status in (500, 502, 503, 504)) or (status is None)
            if not retryable:
                raise

            # Retry-After 우선
            headers = getattr(e, "headers", None)
            retry_after = _parse_retry_after(headers or {})

            if attempt == max_attempts - 1:
                break

            if retry_after is not None:
                sleep_s = min(retry_after, max_delay)
            else:
                exp = base_delay * (2 ** attempt)
                capped = min(exp, max_delay)
                # full jitter
                sleep_s = random.uniform(0, capped)

            time.sleep(sleep_s)

    raise RuntimeError(f"OpenAI call failed after {max_attempts} attempts") from last_exc

포인트

  • insufficient_quota는 즉시 예외로 올려서 상위(웹/워커)에서 알림 및 폴백을 실행하게 합니다.
  • 429는 무조건 재시도하지 말고, 가능하면 Retry-After를 따릅니다.
  • 지터를 넣어 다중 워커 환경에서 재시도 동기화를 피합니다.

Node.js 예제: fetch 기반 재시도(지터/Retry-After)

SDK를 쓰더라도 원리는 동일합니다. 아래는 fetch로 호출할 때의 패턴입니다.

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

function parseRetryAfter(res) {
  const ra = res.headers.get('retry-after');
  if (!ra) return null;
  const sec = Number(ra);
  return Number.isFinite(sec) ? sec * 1000 : null;
}

export async function callOpenAIWithRetry({ url, apiKey, body, maxAttempts = 6 }) {
  const baseDelay = 500;
  const maxDelay = 20_000;

  let lastErr;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const res = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'authorization': `Bearer ${apiKey}`,
        },
        body: JSON.stringify(body),
      });

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

      const text = await res.text();

      // insufficient_quota는 보통 JSON 에러 본문에 포함되므로 텍스트 검색도 방어적으로
      if (text.includes('insufficient_quota')) {
        throw new Error('insufficient_quota');
      }

      if ([400, 401, 403].includes(res.status)) {
        throw new Error(`non-retryable ${res.status}: ${text}`);
      }

      const retryable = res.status === 429 || [500, 502, 503, 504].includes(res.status);
      if (!retryable) {
        throw new Error(`unexpected ${res.status}: ${text}`);
      }

      if (attempt === maxAttempts - 1) {
        throw new Error(`failed after retries: ${res.status}: ${text}`);
      }

      const raMs = parseRetryAfter(res);
      let delay;
      if (raMs != null) {
        delay = Math.min(raMs, maxDelay);
      } else {
        const exp = Math.min(baseDelay * (2 ** attempt), maxDelay);
        delay = Math.floor(Math.random() * exp); // full jitter
      }

      await sleep(delay);

    } catch (e) {
      lastErr = e;
      if (String(e.message).includes('insufficient_quota')) {
        // 재시도 금지
        throw e;
      }
      // 네트워크 에러는 여기로 올 수 있으니 재시도 루프 지속
      if (attempt === maxAttempts - 1) throw lastErr;
    }
  }

  throw lastErr;
}

운영 설계: “클라이언트 재시도”만으로는 부족하다

1) 큐/워커 환경에서는 전역 rate limit이 필요

웹 서버 여러 대, 워커 여러 개가 각각 재시도하면 합산 트래픽이 제한을 초과합니다. 해결책은 다음 중 하나입니다.

  • 중앙 큐에서 토큰 버킷/리키 버킷으로 전역 제한
  • Redis 기반 rate limiter(슬라이딩 윈도우)
  • 워커 수 자체를 제한(동시성 제한)

2) 회로차단(circuit breaker)로 “장애 증폭” 방지

429가 일정 시간 이상 지속되면, 재시도는 성공 확률이 낮고 비용이 큽니다.

  • N회 연속 429 → 30~60초 동안 OpenAI 호출을 빠르게 실패(fail-fast)
  • 그동안 캐시 응답/간소화 모델/기능 제한 모드로 degrade

3) 타임아웃과 재시도는 곱해진다

요청 타임아웃 30초에 6회 재시도면 최악의 경우 3분이 넘습니다. API 서버라면 상위 타임아웃(예: ALB idle timeout, Gunicorn worker timeout)과 충돌할 수 있습니다. 웹 레이어 타임아웃 문제를 다룰 때는 Gunicorn Uvicorn Worker timeout 재현과 해결처럼 “상위 타임아웃”도 함께 맞춰야 합니다.


관측(Observability): 429를 ‘장애’로 만들지 않는 지표

재시도 로직을 넣었는데도 문제가 반복된다면, 원인은 대개 “우리가 얼마나 제한을 넘겼는지”를 모른다는 데 있습니다. 아래 지표를 최소로 권장합니다.

  • 요청 성공/실패 비율(모델/엔드포인트/프로젝트 태그별)
  • 429 발생률과 Retry-After 분포
  • 재시도 횟수 히스토그램(0~N)
  • 토큰 사용량(입력/출력)과 TPS/RPM 추정
  • insufficient_quota 발생 시각과 직전 사용량 스파이크

또한 429를 네트워크 문제로 오인하는 경우가 많습니다. 특히 쿠버네티스/EKS 환경에서 egress/ingress, 프록시(Envoy), LB 타임아웃과 섞이면 더 복잡해집니다. 네트워크 계층 트러블슈팅이 필요하면 EKS에서 Pod egress만 502? Envoy/NLB 추적기 같은 접근으로 “애플리케이션 429”와 “인프라 5xx”를 분리하세요.


실전 체크리스트

  • insufficient_quota재시도하지 말고 알림/결제/한도 조치로 전환한다.
  • 429는 Retry-After가 있으면 그 값을 최우선으로 따른다.
  • 지수 백오프에는 지터를 반드시 넣는다.
  • 다중 워커라면 재시도는 더 위험하다. 전역 rate limit(Redis/큐/토큰버킷)을 둔다.
  • 회로차단으로 장기 429 구간에서 fail-fast + degrade를 적용한다.
  • 타임아웃(상위 LB/서버)과 재시도 횟수의 곱을 계산해, 사용자 요청이 “끝까지 기다릴 수 있는 시간” 안에 들어오게 설계한다.

결론

OpenAI의 429는 단순히 “조금 기다리면 된다”가 아니라, 원인별로 정책이 달라야 하는 신호입니다. insufficient_quota를 재시도로 해결하려고 하면 장애가 길어지고 비용만 늘어납니다. 반대로 rate limit 429를 재시도 없이 실패 처리하면 사용자 경험이 불필요하게 나빠집니다.

핵심은 세 가지입니다.

  1. 에러를 원인(정책)으로 분류하고, 2) Retry-After + 지수 백오프/지터로 서버 페이스를 맞추며, 3) 전역 제한/회로차단으로 시스템 전체를 보호하는 것.

이 구조를 갖추면 429는 더 이상 “운영 장애”가 아니라, 예측 가능한 “흐름 제어” 이벤트가 됩니다.