Published on

OpenAI Responses API 429 레이트리밋 재시도 설계

Authors

서버에서 OpenAI Responses API를 붙이다 보면, 기능은 잘 동작하는데 트래픽이 조금만 늘어도 429가 간헐적으로 튀면서 사용자 체감 품질이 급격히 나빠지는 순간이 옵니다. 특히 스트리밍, 병렬 호출, 배치 작업이 섞이면 requests per minutetokens per minute 제한을 동시에 건드리기 쉬워서, 단순히 “에러 나면 한 번 더 호출” 수준의 재시도 로직으로는 안정화가 어렵습니다.

이 글에서는 Responses API에서 429를 만났을 때의 재시도 전략을, 운영 관점에서 필요한 구성요소(헤더 기반 대기, 지수 백오프, 지터, 아이템포턴시, 동시성 제어, 관측 지표)까지 묶어서 설명합니다.

관련해서 더 넓은 관점의 설계 가이드는 아래 글도 함께 참고하면 좋습니다.

429의 의미를 먼저 분해하기

429 Too Many Requests는 한 가지 원인만을 뜻하지 않습니다. 운영에서 중요한 건 “왜 429가 났는지”에 따라 대응이 달라진다는 점입니다.

1) 레이트리밋 초과

  • 단기간 요청 수가 계정 또는 프로젝트의 분당 제한을 초과
  • 토큰 사용량이 분당 제한을 초과
  • 동시성 폭증으로 순간 버스트가 발생

이 경우는 기다렸다가 재시도가 유효합니다. 단, 무작정 재시도하면 더 큰 버스트를 만들어 429 폭풍을 키울 수 있습니다.

2) 크레딧 또는 쿼터 부족

  • 메시지에 insufficient_quota 류가 포함되는 케이스

이 경우는 재시도해도 해결되지 않습니다. 즉시 실패 처리하고 알림과 차단 로직이 필요합니다. “429니까 무조건 재시도”는 비용만 태우는 실수로 이어집니다.

Responses API 호출에서 429를 줄이는 4가지 레버

재시도는 최후의 안전망이고, 실제로는 아래 4개를 같이 만져야 429 발생량이 줄어듭니다.

  1. 클라이언트 동시성 제한: 워커 수, 요청 파이프라인의 병렬도를 제한
  2. 토큰 예산 관리: max_output_tokens를 합리적으로 제한하고, 프롬프트 길이를 관리
  3. 캐싱: 동일 입력에 대한 결과 캐시 또는 부분 캐시
  4. 재시도 정책 고도화: 헤더 기반 대기, 지수 백오프, 지터, 상한, 서킷 브레이커

이 글은 4번을 중심으로 다루되, 1번과 2번이 없으면 재시도는 근본 해결이 아니라는 점을 전제로 합니다.

재시도 설계의 핵심: 헤더 우선, 그 다음 백오프

Retry-After를 신뢰할 것

OpenAI 계열 API는 레이트리밋 상황에서 Retry-After 헤더를 제공하는 경우가 있습니다. 이 값이 있다면 지수 백오프보다 우선합니다.

  • 헤더가 있으면: Retry-After 만큼 대기 후 재시도
  • 헤더가 없으면: 지수 백오프 + 지터

주의할 점은 Retry-After가 초 단위일 수도, 날짜 형식일 수도 있다는 점입니다. 구현 시 파서가 견고해야 합니다.

지수 백오프와 지터(필수)

지수 백오프만 쓰면 다수의 요청이 비슷한 타이밍에 다시 몰려 동기화된 재폭주가 발생합니다. 이를 막기 위해 지터를 섞습니다.

권장 패턴은 다음 중 하나입니다.

  • Full Jitter: sleep = random(0, base * 2^attempt)
  • Equal Jitter: sleep = base * 2^attempt / 2 + random(0, base * 2^attempt / 2)

운영에서 Full Jitter가 단순하고 효과가 좋아 자주 쓰입니다.

상한과 총 재시도 시간 예산

재시도는 무한정 하면 안 됩니다.

  • 최대 시도 횟수: 예를 들어 5회
  • 최대 대기 상한: 예를 들어 20초
  • 전체 시간 예산: 예를 들어 30초를 넘기면 실패

API 호출이 사용자 요청 경로에 있다면, “재시도 성공”보다 “빠른 실패 후 대체 UX”가 더 낫기도 합니다.

아이템포턴시: 재시도 비용 폭탄을 막는 안전장치

429 상황에서 재시도를 하면, 네트워크 타임아웃이나 중간 장애와 섞여 “서버는 처리했는데 클라이언트는 실패로 오인”하는 상황이 생깁니다. 이때 같은 요청이 중복 실행되면 비용이 2배로 튈 수 있습니다.

따라서 가능한 경우 아이템포턴시 키를 사용해 “같은 요청은 같은 결과”가 되도록 만드는 것이 중요합니다.

  • 하나의 사용자 액션에 대해 고유 키를 생성
  • 재시도 시 동일 키로 다시 호출
  • 서버가 같은 키 요청을 중복 처리하지 않도록 보장

SDK나 API가 아이템포턴시를 직접 지원하지 않더라도, 애플리케이션 레벨에서 “요청 키와 결과를 저장”하는 방식으로 유사 아이템포턴시를 구현할 수 있습니다.

Node.js 예제: Responses API 429 재시도 래퍼

아래 코드는 fetch 기반으로, Retry-After 우선 처리 후 Full Jitter 백오프를 적용합니다. 또한 재시도 대상 에러를 제한하고, 총 시간 예산을 둡니다.

<> 문자가 본문에 노출되면 MDX 빌드에서 JSX로 오인될 수 있으니, 코드 블록 안에서만 사용하거나 인라인 코드는 백틱으로 감쌉니다.

type RetryOptions = {
  maxAttempts: number;      // 총 시도 횟수 (최초 1회 포함)
  baseDelayMs: number;      // 백오프 기본 지연
  maxDelayMs: number;       // 백오프 상한
  totalTimeoutMs: number;   // 전체 시간 예산
};

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

function parseRetryAfterMs(value: string | null): number | null {
  if (!value) return null;

  // 1) 초 단위 숫자
  const asNumber = Number(value);
  if (Number.isFinite(asNumber) && asNumber >= 0) return asNumber * 1000;

  // 2) HTTP-date
  const asDate = Date.parse(value);
  if (!Number.isNaN(asDate)) {
    const delta = asDate - Date.now();
    return delta > 0 ? delta : 0;
  }

  return null;
}

function fullJitterDelayMs(baseDelayMs: number, attempt: number, maxDelayMs: number) {
  const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
  return Math.floor(Math.random() * cap);
}

async function callResponsesWithRetry(
  requestInit: RequestInit,
  opts: RetryOptions,
  url = "https://api.openai.com/v1/responses"
) {
  const startedAt = Date.now();
  let lastErr: unknown;

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    const elapsed = Date.now() - startedAt;
    if (elapsed > opts.totalTimeoutMs) {
      throw new Error(`retry budget exceeded: ${elapsed}ms`);
    }

    try {
      const res = await fetch(url, requestInit);

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

      // 재시도 대상: 429, 그리고 일시적 5xx
      const retryable = res.status === 429 || (res.status >= 500 && res.status <= 599);

      if (!retryable) {
        const body = await res.text().catch(() => "");
        throw new Error(`non-retryable status=${res.status} body=${body}`);
      }

      // 429인 경우, 쿼터 부족인지 간단히 판별(운영에서는 에러 바디 구조에 맞춰 정교화)
      const bodyText = await res.text().catch(() => "");
      if (res.status === 429 && bodyText.includes("insufficient_quota")) {
        throw new Error(`quota exhausted: ${bodyText}`);
      }

      // 대기 시간 결정: Retry-After 우선
      const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
      const delayMs = retryAfterMs ?? fullJitterDelayMs(opts.baseDelayMs, attempt, opts.maxDelayMs);

      // 마지막 시도면 실패
      if (attempt === opts.maxAttempts - 1) {
        throw new Error(`retry attempts exhausted status=${res.status} body=${bodyText}`);
      }

      await sleep(delayMs);
      continue;
    } catch (e) {
      lastErr = e;

      // 네트워크 오류는 재시도 대상일 수 있음
      if (attempt === opts.maxAttempts - 1) throw e;

      const delayMs = fullJitterDelayMs(opts.baseDelayMs, attempt, opts.maxDelayMs);
      await sleep(delayMs);
    }
  }

  throw lastErr ?? new Error("unknown error");
}

// 사용 예시
(async () => {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) throw new Error("OPENAI_API_KEY is required");

  const payload = {
    model: "gpt-4.1-mini",
    input: "429가 날 때 안전하게 재시도하는 방법을 요약해줘",
    // 토큰 예산을 제한하면 레이트리밋과 비용 모두에 도움
    max_output_tokens: 300
  };

  const json = await callResponsesWithRetry(
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${apiKey}`,
        // 아이템포턴시를 직접 지원하지 않더라도, 내부적으로 요청 키를 만들어 로그에 남기는 것부터 시작
        "x-request-id": `req_${Date.now()}`
      },
      body: JSON.stringify(payload)
    },
    {
      maxAttempts: 5,
      baseDelayMs: 250,
      maxDelayMs: 10_000,
      totalTimeoutMs: 30_000
    }
  );

  console.log(JSON.stringify(json, null, 2));
})();

실무에서 자주 빠지는 함정 6가지

1) 재시도 로직이 병렬 호출을 더 폭주시킨다

워커 50개가 동시에 429를 맞고 동시에 백오프 후 동시에 재시도하면, 실제로는 “429 증폭기”가 됩니다.

  • 워커 풀의 전역 동시성 제한
  • 큐 기반 처리
  • 테넌트별, 사용자별 레이트리밋

을 함께 둬야 합니다.

2) 토큰 기반 제한을 무시한다

분당 요청 수는 여유인데도 429가 나는 경우가 있습니다. 프롬프트가 길거나 출력이 길면 토큰 분당 제한을 먼저 칩니다.

  • max_output_tokens를 보수적으로
  • 시스템 프롬프트를 템플릿화하고 불필요한 반복 제거
  • RAG 사용 시 컨텍스트 문서 수를 제한

3) 스트리밍은 성공처럼 보이지만 중간에 끊길 수 있다

스트리밍은 시작은 200인데 네트워크나 프록시에서 중간에 끊길 수 있습니다. 이때 “부분 응답”을 어떻게 처리할지 정책이 필요합니다.

  • 부분 응답을 사용자에게 노출할지
  • 끊기면 전체를 재시도할지
  • 끊긴 지점부터 이어받기 같은 기능은 보통 어렵기 때문에, UX 설계를 같이 해야 합니다.

4) 429와 503을 같은 방식으로 다룬다

둘 다 재시도 가능하지만 의미가 다릅니다.

  • 429: 클라이언트가 너무 빠름, 동시성 제어와 토큰 예산이 핵심
  • 503: 서버 또는 인프라 일시 장애, 백오프가 더 중요

5) 재시도 때문에 사용자 요청이 타임아웃 난다

프론트엔드나 API Gateway 타임아웃이 10초인데 백엔드에서 30초 재시도를 하면, 사용자는 실패를 보고 백엔드는 뒤늦게 성공하는 이상한 상황이 됩니다.

  • 사용자 경로는 짧은 예산(예: 3초에서 8초)
  • 백그라운드 작업은 긴 예산(예: 1분)

처럼 경로별로 분리하세요.

6) 관측이 없다

429 대응에서 가장 위험한 건 “재시도는 되는데 비용과 지연이 늘고 있는 상황”을 못 보는 것입니다.

권장 지표는 아래와 같습니다.

  • responses_api_requests_total (라벨: status)
  • responses_api_retries_total (라벨: reason)
  • responses_api_latency_ms (p50, p95, p99)
  • responses_api_tokens_in responses_api_tokens_out
  • rate_limit_retry_after_ms 분포

아키텍처 레벨의 권장 패턴

전역 토큰 버킷 또는 리키 버킷

서비스 전체에서 OpenAI 호출을 하는 지점이 여러 군데라면, 각 모듈이 제각각 재시도를 구현하는 순간 제한을 예측할 수 없게 됩니다.

  • 중앙화된 “LLM Gateway”를 두고
  • 그 안에서 레이트리밋, 재시도, 캐시, 모델 라우팅을 통합

하면 운영 난이도가 급격히 내려갑니다.

서킷 브레이커로 사용자 경험을 보호

429가 일정 비율 이상 발생하면, 당분간은 모델 호출을 차단하고

  • 캐시된 답변
  • 축약 모드(짧은 출력)
  • 대체 모델

로 전환하는 것이 전체 시스템을 살리는 경우가 많습니다.

이 패턴은 DB 데드락이나 분산 락 문제를 다룰 때와도 유사합니다. 병목이 생긴 컴포넌트를 계속 두드리면 더 악화됩니다. 비슷한 관점의 장애 대응 글로 PostgreSQL 데드락(40P01) 원인·해결 9단계도 참고할 만합니다.

체크리스트: 운영 투입 전 최소 요건

  • Retry-After 헤더 처리
  • 지수 백오프 + Full Jitter
  • 최대 시도 횟수, 최대 대기, 총 시간 예산
  • 429 중 insufficient_quota는 즉시 실패
  • 전역 동시성 제한(워커 풀, 큐)
  • max_output_tokens 기본값 보수적으로
  • 재시도/429/지연/토큰 지표 수집

마무리

Responses API에서 429는 “가끔 나는 에러”가 아니라, 트래픽이 성장하면 반드시 마주치는 정상적인 신호입니다. 중요한 건 재시도를 단순한 예외 처리로 두지 말고, 헤더 기반 대기, 지터 백오프, 아이템포턴시, 동시성 제어, 관측을 한 세트로 설계하는 것입니다.

이렇게 구성하면 429를 완전히 없애지는 못해도, 사용자 경험과 비용을 동시에 안정화할 수 있습니다.