Published on

OpenAI Responses API 429 쿼터·레이트리밋 대응

Authors

서버에서 OpenAI Responses API를 붙이다 보면 가장 흔하게 마주치는 장애 중 하나가 429입니다. 겉으로는 “Too Many Requests” 한 줄이지만, 실제로는 레이트리밋(Rate Limit), 쿼터(Quota), 버스트 트래픽, 동시성 폭주, 재시도 폭풍(retry storm) 등 여러 원인이 섞여 나타납니다.

이 글에서는 429를 단순히 “백오프로 재시도”로 끝내지 않고, 원인 분리 → 즉시 완화 → 구조적 개선 → 관측/비용 최적화 순서로 실전 대응책을 정리합니다. (Responses API 기준이지만 Chat Completions 등에도 그대로 적용됩니다.)

1) 429의 두 얼굴: 레이트리밋 vs 쿼터

429는 크게 두 부류로 나뉩니다.

1.1 레이트리밋(순간 트래픽/동시성) 초과

  • 짧은 시간에 요청이 몰리거나(버스트)
  • 워커/스레드가 동시에 호출하거나(동시성 폭주)
  • 실패한 요청을 모든 인스턴스가 동시에 재시도해서(재시도 폭풍)

이 경우는 잠깐 쉬었다가 재시도하면 대부분 회복됩니다.

1.2 쿼터(계정/프로젝트 사용량) 소진

  • 월/일 단위 비용 한도 또는 사용량이 꽉 찬 상태
  • 결제/플랜/프로젝트 제한

이 경우는 재시도해도 계속 429가 납니다. 재시도는 오히려 비용/로그만 늘리고 장애를 길게 만듭니다.

> 포인트: “429면 무조건 재시도”는 위험합니다. 재시도 가능한 429와 **불가능한 429(쿼터)**를 먼저 가르는 게 핵심입니다.

2) 429를 정확히 분류하는 방법(헤더·바디·메트릭)

2.1 응답 헤더의 Retry-After를 최우선으로

OpenAI 계열 API는 상황에 따라 Retry-After(초) 또는 레이트리밋 관련 헤더를 제공합니다. 있다면 이를 가장 신뢰해야 합니다.

  • Retry-After: 2 → 2초 후 재시도

2.2 에러 메시지/코드로 쿼터 vs 레이트리밋 구분

SDK/응답 바디에 “quota exceeded”, “insufficient_quota” 같은 표현이 있으면 재시도 대상이 아닙니다. 반대로 “rate limit” 류면 재시도 대상일 가능성이 큽니다.

실무 팁:

  • 쿼터 소진: 알람을 띄우고 즉시 트래픽을 줄이거나(기능 제한), 결제/한도 조정이 필요
  • 레이트리밋: 백오프+지터, 동시성 제한, 큐잉으로 안정화

2.3 관측 지표로 패턴 확인

  • 특정 시간대에만 429 급증 → 버스트/배치 작업/크론
  • 배포 직후 급증 → 워커 수 증가/콜드스타트 재시도
  • 특정 모델/엔드포인트에서만 급증 → 요청 단가/토큰/모델별 제한

로그 비용이 급증하는 경우도 흔합니다. 429를 대량으로 뿌리면 에러 로그가 폭증하므로, 로그 샘플링/집계 전략도 같이 필요합니다. 관련해서는 CloudWatch Logs 비용 폭증 원인과 절감 10가지도 함께 참고하면 좋습니다.

3) 즉시 적용 가능한 429 완화책 5가지

3.1 지수 백오프 + 지터(필수)

모든 인스턴스가 1초, 2초, 4초… 같은 동일한 타이밍으로 재시도하면 다시 동시에 몰려 429가 반복됩니다. **지터(jitter)**로 재시도 시점을 분산해야 합니다.

아래는 Python httpx 기반의 간단한 예시입니다.

import random
import time
import httpx

MAX_RETRIES = 6
BASE_DELAY = 0.5  # seconds


def backoff_delay(attempt: int, retry_after: float | None = None) -> float:
    # 서버가 Retry-After를 주면 우선 적용
    if retry_after is not None:
        # 약간의 지터를 추가해 동시 재시도 방지
        return retry_after + random.uniform(0, 0.25)

    # 지수 백오프 + 풀 지터(Full Jitter)
    cap = 20.0
    exp = min(cap, BASE_DELAY * (2 ** attempt))
    return random.uniform(0, exp)


def parse_retry_after(resp: httpx.Response) -> float | None:
    ra = resp.headers.get("retry-after")
    if not ra:
        return None
    try:
        return float(ra)
    except ValueError:
        return None


def call_openai_with_retry(url: str, headers: dict, payload: dict) -> dict:
    with httpx.Client(timeout=30.0) as client:
        for attempt in range(MAX_RETRIES):
            resp = client.post(url, headers=headers, json=payload)

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

            if resp.status_code == 429:
                retry_after = parse_retry_after(resp)
                delay = backoff_delay(attempt, retry_after)
                time.sleep(delay)
                continue

            # 5xx는 네트워크/게이트웨이 문제일 수 있어 재시도 고려
            if 500 <= resp.status_code < 600:
                time.sleep(backoff_delay(attempt))
                continue

            # 4xx(429 제외)는 대개 재시도해도 소용 없음
            resp.raise_for_status()

        raise RuntimeError("Exceeded max retries")

3.2 동시성 제한(세마포어/워크 큐)

레이트리밋은 “초당 요청 수”뿐 아니라 “동시 요청”에 의해 터지기도 합니다. 특히 웹 서버에서 요청이 몰리면, 내부적으로 OpenAI 호출이 폭주해 429가 연쇄적으로 발생합니다.

import asyncio
import httpx

SEM = asyncio.Semaphore(10)  # 동시 OpenAI 호출 10개로 제한

async def call_openai_async(url, headers, payload):
    async with SEM:
        async with httpx.AsyncClient(timeout=30.0) as client:
            return await client.post(url, headers=headers, json=payload)

현업에서는 이 값을 “모델/프로젝트별”로 다르게 두기도 합니다(예: 고가 모델은 동시성 더 낮게).

3.3 재시도 예산(Retry Budget)과 서킷 브레이커

429가 연속으로 발생하는데 계속 재시도하면, 애플리케이션은 자기 자신을 공격하게 됩니다.

  • 일정 시간 창(window) 내 429 비율이 임계치 초과 → 서킷 오픈
  • 오픈 상태에서는 즉시 실패(fail-fast) 또는 캐시/대체 응답 제공
  • 일정 시간이 지나면 half-open으로 소량만 테스트

이 패턴은 레이트리밋뿐 아니라 502/504 같은 게이트웨이 오류에서도 유효합니다. (Responses API에서 5xx가 섞일 때는 OpenAI Responses API 502 Bad Gateway 원인과 해결도 같이 보면 장애 분리가 쉬워집니다.)

3.4 큐잉(버퍼)으로 버스트 흡수

웹 요청을 받은 즉시 OpenAI를 때리지 말고,

  • 메시지 큐(SQS/RabbitMQ/Kafka)
  • 작업 큐(Celery/RQ)
  • in-memory 큐(단일 인스턴스 한정)

로 버퍼링한 뒤, 워커가 일정 속도로 처리하면 429가 크게 줄어듭니다.

3.5 요청 단위 최적화(토큰/중복 호출 줄이기)

레이트리밋은 “요청 수”뿐 아니라 “토큰 처리량” 제한과 얽히는 경우가 많습니다.

  • 프롬프트에서 불필요한 컨텍스트 제거
  • 동일 입력의 중복 호출 방지(캐시)
  • 배치 가능하면 배치(업무 성격에 따라)
  • 스트리밍을 쓰더라도 호출 수 자체가 늘지 않게 설계

4) 구조적 해결: 429를 ‘안 나게’ 만드는 설계

4.1 멀티 인스턴스 환경에서의 재시도 동기화 문제

쿠버네티스/오토스케일 환경에서는 같은 코드가 여러 파드에서 동시에 재시도합니다. 이때 지터만으로 부족하면 전역 레이트리미터가 필요합니다.

선택지:

  • Redis 기반 토큰 버킷/리키 버킷
  • API Gateway 레벨에서 rate limit
  • 워커 큐에서 처리율 제한

4.2 테넌트/사용자별 공정성(Fairness) 제어

B2B/B2C 서비스에서는 특정 고객이 트래픽을 독점해 전체가 429를 맞는 일이 잦습니다.

  • 사용자별/조직별 할당량(soft/hard limit)
  • 우선순위 큐(유료 고객 우대)
  • 요청당 비용(예상 토큰)을 기반으로 admission control

4.3 실패 모드 설계: 기능 저하(Degradation) 전략

429는 “모델 호출 불가” 상태이므로, 제품 관점에서는 다음 중 하나가 필요합니다.

  • 더 싼/가벼운 모델로 폴백
  • 답변 품질을 낮추되 응답은 유지(요약/템플릿)
  • “잠시 후 재시도” 안내 + 비동기 완료(알림/이메일)

5) 운영에서 중요한 체크리스트(알람·로그·런북)

5.1 알람

  • 429 비율(%)
  • 429 절대 건수(특정 모델/엔드포인트 태그 포함)
  • 재시도 횟수 분포(p95)
  • 큐 적체량(큐잉 도입 시)

5.2 로그

  • request_id/correlation id로 사용자 요청과 OpenAI 호출을 연결
  • 429는 샘플링(예: 동일 원인 1분에 1건만 상세 로그)
  • 원인 분류 필드 추가: rate_limit vs quota_exceeded vs unknown

5.3 런북(장애 대응 절차)

  1. 429 급증 확인 → 쿼터/레이트리밋 분류
  2. 레이트리밋이면: 동시성 제한 강화, 워커 수 임시 감축, 큐 처리율 낮춤
  3. 쿼터면: 기능 제한/차단, 결제/한도 확인, 고객 공지
  4. 재발 방지: 캐시/큐/전역 레이트리미터/서킷 브레이커 적용

6) Node.js 예시: p-retry로 429 재시도(지터 포함)

import retry from 'p-retry';
import fetch from 'node-fetch';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

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

async function callResponsesApi({ url, apiKey, payload }) {
  return retry(async (attempt) => {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'authorization': `Bearer ${apiKey}`,
      },
      body: JSON.stringify(payload),
    });

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

    if (res.status === 429) {
      const ra = parseRetryAfter(res);
      if (ra != null) {
        // 서버 지시 우선 + 소량 지터
        await sleep((ra * 1000) + Math.random() * 250);
      }
      throw new retry.AbortError(`429 rate limited (attempt=${attempt})`);
    }

    if (res.status >= 500) {
      throw new Error(`server error: ${res.status}`);
    }

    // 그 외 4xx는 재시도하지 않음
    throw new retry.AbortError(`client error: ${res.status}`);
  }, {
    retries: 5,
    factor: 2,
    minTimeout: 300,
    maxTimeout: 20_000,
    randomize: true, // 지터
  });
}

> 주의: 위 코드는 패턴 예시입니다. 실제로는 429를 모두 Abort 처리하기보다, Retry-After가 없는 429는 백오프로 재시도하고, “quota exceeded”류는 즉시 중단하는 식으로 더 정교하게 분기하는 편이 안전합니다.

7) 자주 하는 실수 7가지

  1. 429를 무한 재시도 → 장애 장기화 + 비용/로그 폭증
  2. 지터 없이 재시도 → 동시 재시도 폭주로 429 반복
  3. 동시성 제한 없이 비동기만 사용 → 순간 QPS 폭발
  4. 쿼터 소진을 레이트리밋으로 오인 → 해결 불가한 재시도 루프
  5. 배치/크론 작업을 피크 타임에 실행 → 서비스 트래픽과 충돌
  6. 캐시 부재로 동일 요청 반복 호출 → 불필요한 사용량 증가
  7. 관측 지표 없이 “감”으로 튜닝 → 재발

8) 정리: 429 대응의 우선순위

  • 1순위: 분류 — 쿼터(재시도 X) vs 레이트리밋(재시도 O)
  • 2순위: 재시도 품질Retry-After 존중, 지수 백오프+지터, 재시도 예산
  • 3순위: 동시성/버스트 제어 — 세마포어, 큐잉, 전역 레이트리미터
  • 4순위: 제품적 대응 — 폴백/기능저하/비동기 처리
  • 5순위: 관측/비용 — 429 지표·알람·로그 샘플링

429는 “API가 불안정하다”의 신호가 아니라, 대부분 내 시스템이 외부 제한을 존중하지 못하는 구조라는 신호입니다. 위의 순서대로 적용하면, 단순히 에러를 숨기는 수준을 넘어 트래픽이 커져도 안정적으로 확장 가능한 호출 구조를 만들 수 있습니다.