Published on

OpenAI Responses API 429인데 TPM만 넘는 6가지 원인

Authors

서버에서 OpenAI Responses API를 붙이다 보면, 모니터링상 TPM(Token Per Minute) 은 여유가 있는데도 429 Too Many Requests가 터지는 순간이 있습니다. 이때 흔히 “토큰 계산이 틀렸나?”로만 접근하면 해결이 늦어집니다. 429는 TPM 외에도 여러 형태의 rate limit/제한 조건을 포괄하는 신호이기 때문입니다.

이 글에서는 TPM을 넘지 않았는데도 429가 나는 6가지 대표 원인을 실제 운영 관점에서 분해하고, 각 원인을 어떻게 진단하고 어떤 방식으로 완화할지를 코드와 함께 정리합니다.

> 참고: 인증 문제처럼 보이지만 사실은 다른 원인일 수도 있습니다. 키가 맞는데도 인증/권한처럼 보이는 케이스는 Responses API 401인데 키가 맞는 7가지 이유도 함께 확인해두면 디버깅 시간이 크게 줄어듭니다.

429를 먼저 “종류”로 분류해야 한다

429를 받으면 가장 먼저 해야 할 일은 응답 바디/헤더에서 힌트를 수집해 원인을 좁히는 것입니다.

  • RPM(분당 요청 수) 초과인지
  • 동시성(concurrency) 제한인지
  • 버스트(짧은 시간 폭주) 로 인한 순간 제한인지
  • 조직/프로젝트 단위 공유 리밋인지
  • 재시도 폭주(Thundering herd) 인지
  • 잘못된 토큰 추정(스트리밍/툴콜/멀티턴) 으로 실제 TPM이 튄 것인지

아래는 Python에서 429를 받았을 때 response 헤더 + 에러 메시지를 로깅하는 기본 템플릿입니다.

import json
import time
import requests

API_URL = "https://api.openai.com/v1/responses"

def call_openai(api_key: str, payload: dict):
    r = requests.post(
        API_URL,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        data=json.dumps(payload),
        timeout=60,
    )

    if r.status_code == 429:
        # 서비스마다 헤더 이름은 달라질 수 있으니, 전부 찍어두는 게 좋습니다.
        print("429 headers:", {k: v for k, v in r.headers.items() if "rate" in k.lower() or "retry" in k.lower()})
        try:
            print("429 body:", r.json())
        except Exception:
            print("429 body(raw):", r.text[:1000])

    r.raise_for_status()
    return r.json()

payload = {
    "model": "gpt-4.1-mini",
    "input": "Hello",
}

이 로깅만 제대로 해도, 아래 6가지 중 무엇인지 확률적으로 거의 결정됩니다.

1) TPM은 여유인데 RPM(요청/분) 제한을 넘는 경우

가장 흔한 케이스입니다. 예를 들어 짧은 프롬프트를 초당 여러 번 보내면 TPM은 낮아도 RPM이 먼저 터집니다.

  • 챗봇 UI에서 타이핑할 때마다 자동완성/추천을 붙임
  • 배치 작업이 “짧은 문장”을 대량으로 요청
  • 여러 마이크로서비스가 같은 API 키를 공유

진단 포인트

  • 요청당 토큰이 적은데도 429가 반복
  • 로그를 보면 초당 요청 수(QPS) 가 높음

완화 전략

  • 클라이언트 측 토큰 버킷/리키 버킷으로 RPM 제한
  • 요청을 합쳐 배치/큐잉
  • 사용자 액션 기반이면 디바운스/스로틀

간단한 비동기 세마포어 + 속도 제한(대략적 RPM) 예시:

import asyncio
import time

class RateLimiter:
    def __init__(self, rpm: int):
        self.interval = 60.0 / rpm
        self._lock = asyncio.Lock()
        self._next = 0.0

    async def wait(self):
        async with self._lock:
            now = time.monotonic()
            if now < self._next:
                await asyncio.sleep(self._next - now)
            self._next = max(self._next, now) + self.interval

limiter = RateLimiter(rpm=120)  # 예: 분당 120요청

async def guarded_call(fn, *args, **kwargs):
    await limiter.wait()
    return await fn(*args, **kwargs)

2) 동시성(Concurrency) 제한: 순간적으로 “동시에” 너무 많이 붙는 경우

TPM/RPM이 평균적으로는 낮아도, 동시에 열린 요청 수가 제한을 넘으면 429가 날 수 있습니다. 특히 스트리밍 응답은 요청이 오래 열려 동시성이 쉽게 쌓입니다.

  • SSE 스트리밍을 여러 사용자에게 동시에 제공
  • 백엔드에서 fan-out(한 이벤트에 다수 호출)
  • 타임아웃이 길어져 연결이 오래 유지됨

진단 포인트

  • 429가 피크 타임에만 집중
  • 평균 RPM/TPM은 괜찮은데, P95/P99 지연이 늘면서 동시 요청이 누적

완화 전략

  • 서버에서 동시 요청 수를 세마포어로 강제 제한
  • 스트리밍이면 연결 수 제한 + 큐잉
  • 타임아웃/keep-alive 튜닝으로 “불필요한 장기 연결” 제거
import asyncio

sem = asyncio.Semaphore(8)  # 동시에 8개까지만 OpenAI 호출

async def call_with_concurrency_limit(coro):
    async with sem:
        return await coro

> 스트리밍/타임아웃/프록시 튜닝으로 장애가 커지는 패턴은 LLM 서비스 전반에서 자주 나타납니다. 인프라 관점은 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 참고하면 좋습니다.

3) 버스트(Burst) 제한: “분당”은 괜찮은데 “몇 초”에 몰리는 경우

Rate limit은 흔히 분 단위 지표(TPM/RPM) 로 설명되지만, 실제 시스템은 더 짧은 윈도우(예: 1초/10초)에도 보호 장치를 둡니다. 즉, 1분 평균이 안전해도 짧은 순간 폭주로 429가 날 수 있습니다.

예시:

  • 크론이 0초에 맞춰 여러 워커가 동시에 시작
  • 큐가 잠깐 막혔다가 한꺼번에 풀리며 요청이 몰림
  • 오토스케일링으로 인스턴스가 늘며 동시에 재처리

진단 포인트

  • 429가 “특정 시각”에 스파이크처럼 발생
  • 재시도까지 겹치면 몇 초 동안 연쇄적으로 발생

완화 전략

  • 워커 시작/배치 작업에 jitter(랜덤 지연)
  • 재시도 정책에 지수 백오프 + jitter
  • 큐 소비 속도를 점진적으로 올리는 ramp-up
import random
import time

def exponential_backoff_with_jitter(attempt: int, base=0.5, cap=20.0):
    sleep = min(cap, base * (2 ** attempt))
    sleep = sleep * (0.5 + random.random())  # jitter: 0.5x ~ 1.5x
    time.sleep(sleep)

4) 조직/프로젝트/키 공유로 “내 서비스가 안 썼는데”도 429가 나는 경우

OpenAI의 리밋은 보통 계정/조직/프로젝트/키 단위로 적용됩니다. 팀이 같은 프로젝트 키를 공유하거나, 여러 환경(dev/stage/prod)이 같은 키를 쓰면 “내 서비스는 TPM이 낮은데 429”가 가능해집니다.

  • 백오피스 배치가 밤에 대량 실행
  • 다른 팀 서비스가 같은 키를 사용
  • 스테이징에서 실수로 부하 테스트

진단 포인트

  • 내 서비스 지표와 429 발생이 상관이 낮음
  • 특정 프로젝트/키에서만 발생

완화 전략

  • 프로젝트/환경별로 키 분리(prod/stage/dev)
  • 배치/실험 워크로드는 별도 프로젝트로 격리
  • 호출 주체별로 클라이언트 태깅/로깅(request id, service name)

운영에서 특히 많이 하는 실수는 “키는 하나로 통일”입니다. 비용/보안/리밋 관점에서 대개 반대가 맞습니다.

5) 재시도 폭주(Thundering herd): 429를 더 크게 만드는 자동 재시도

SDK/HTTP 클라이언트/프록시 레이어에서 429를 받았을 때 즉시 재시도가 걸려 있으면, 제한이 풀리기도 전에 요청이 더 몰려 악순환이 됩니다.

  • 여러 워커가 동일한 정책으로 동시에 재시도
  • retry-after를 무시
  • 429뿐 아니라 5xx에도 공격적으로 재시도

진단 포인트

  • 429 발생 직후 트래픽이 오히려 증가
  • 동일 요청이 짧은 시간에 여러 번 반복

완화 전략

  • 429에서는 반드시 Retry-After(혹은 유사 헤더)를 존중
  • 재시도는 지수 백오프 + jitter
  • idempotency key 도입(중복 호출 방지)
import json
import time
import uuid
import requests

API_URL = "https://api.openai.com/v1/responses"

def post_with_retry(api_key: str, payload: dict, max_attempts=6):
    # 같은 작업 재시도 시 중복 처리 방지(서버가 지원하는 경우 유효)
    idem = str(uuid.uuid4())

    for attempt in range(max_attempts):
        r = requests.post(
            API_URL,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": idem,
            },
            data=json.dumps(payload),
            timeout=60,
        )

        if r.status_code != 429:
            r.raise_for_status()
            return r.json()

        retry_after = r.headers.get("Retry-After")
        if retry_after is not None:
            time.sleep(float(retry_after))
        else:
            # 헤더가 없다면 지수 백오프 + jitter
            import random
            sleep = min(20.0, 0.5 * (2 ** attempt))
            sleep *= (0.5 + random.random())
            time.sleep(sleep)

    raise RuntimeError("Too many 429s; giving up")

6) “TPM 계산 착시”: 스트리밍/툴콜/멀티턴으로 실제 토큰이 튀는 경우

마지막은 가장 억울한 케이스입니다. 대시보드나 자체 추정 로직에서 “TPM 여유”로 보이지만, 실제로는 아래 이유로 토큰 사용이 급증할 수 있습니다.

  • 스트리밍: 응답이 길어지면 completion 토큰이 급증
  • 툴 호출(tool calls): 모델이 툴 인자(JSON)를 길게 생성하거나, 툴 결과를 다시 모델에 넣으면서 토큰이 연쇄 증가
  • 멀티턴 컨텍스트 누적: 대화 히스토리를 매번 전부 보내며 입력 토큰이 선형 증가
  • 시스템 프롬프트/정책 프롬프트가 과도하게 김: 사용자 입력은 짧아도 실제 input 토큰은 큼

진단 포인트

  • 같은 RPM인데도 어떤 요청은 429, 어떤 요청은 정상(요청별 토큰 편차)
  • 특정 기능(툴/에이전트/검색)이 켜질 때만 429 증가

완화 전략

  • 요청별로 실제 사용 토큰(usage) 을 로깅해 분포를 본다
  • 히스토리는 요약/슬라이딩 윈도우 적용
  • 툴 결과는 필요한 필드만 모델에 전달(원본 JSON 통째로 금지)

Responses API 응답에서 usage를 저장하는 예시(필드명은 모델/버전에 따라 달라질 수 있어 방어적으로 처리):

def extract_usage(resp: dict) -> dict:
    usage = resp.get("usage") or {}
    return {
        "input_tokens": usage.get("input_tokens"),
        "output_tokens": usage.get("output_tokens"),
        "total_tokens": usage.get("total_tokens"),
    }

# resp = call_openai(...)
# print(extract_usage(resp))

운영 체크리스트: 10분 안에 원인 좁히기

  1. 429 로그에 헤더/바디를 남긴다(rate/retry 관련 헤더 포함).
  2. 같은 시각의 QPS/RPM, 동시 요청 수, P95 지연을 함께 본다.
  3. 스트리밍/툴콜/히스토리 누적 여부를 확인하고 요청별 usage를 샘플링한다.
  4. 키/프로젝트 공유 여부를 점검하고, 환경별 키를 분리한다.
  5. 재시도 정책이 429에서 공격적으로 동작하는지 확인한다(특히 여러 워커).

마무리

429는 “TPM 초과”의 동의어가 아닙니다. 실제 현장에서는 RPM, 동시성, 버스트, 공유 리밋, 재시도 폭주, 토큰 추정 착시가 더 자주 원인으로 등장합니다.

핵심은 429를 받았을 때 곧바로 “토큰”만 보지 말고, 요청 수/동시성/시간축(초 단위) 스파이크/키 공유/재시도까지 함께 관측하는 것입니다. 이 6가지를 체계적으로 분류하면, 같은 429라도 해결책이 완전히 달라지는 지점을 빠르게 잡아낼 수 있습니다.

> 추가로, 요청 충돌/취소처럼 429와 함께 운영에서 자주 섞여 보이는 오류도 있습니다. 필요하면 OpenAI Responses API 409 499 충돌 취소 오류 해결도 함께 보면서 에러 코드를 분리해두면 트러블슈팅이 쉬워집니다.