Published on

OpenAI 429 RateLimit 재시도·백오프 구현 가이드

Authors

OpenAI API를 운영 환경에서 붙이다 보면, 기능 자체보다 더 자주 마주치는 문제가 429입니다. 특히 트래픽이 순간적으로 몰리거나(배치, 크론, 이벤트 폭주), 여러 워커가 동시에 요청을 쏘는 구조라면 RateLimit은 “언젠가”가 아니라 “반드시” 발생합니다.

여기서 중요한 건 단순히 “재시도하면 된다”가 아니라, 재시도가 또 다른 429 폭풍을 만들지 않게 설계하는 것입니다. 재시도 로직이 미숙하면 다음 문제가 연쇄적으로 터집니다.

  • 요청 폭주로 더 자주 429가 발생(스톰)
  • 큐/스레드가 쌓이며 지연이 기하급수적으로 증가
  • 타임아웃 증가로 상위 서비스까지 장애 전파
  • 같은 프롬프트가 중복 처리되어 비용 상승

이 글에서는 OpenAI 429에 대해 재시도 기준, 지수 백오프 + 지터, Retry-After 처리, 동시성 제한, 멱등성/중복 방지, **관측(로그·메트릭)**까지 한 번에 정리합니다.

참고로 유사한 429 대응 설계는 다른 LLM에서도 동일하게 적용됩니다. Claude 쪽 레이트리밋 설계는 아래 글도 같이 보면 패턴을 비교하기 좋습니다.

429를 “재시도 가능한 오류”로만 보면 위험한 이유

429는 크게 두 부류가 섞여 들어옵니다.

  1. 순수 레이트리밋 초과: 잠깐 기다리면 회복됨
  2. 지속적인 용량 부족/정책 제한: 기다려도 계속 실패(혹은 매우 오래)

따라서 “무조건 N번 재시도”는 비용만 증가시키고, 전체 시스템 지연을 악화시킬 수 있습니다. 재시도는 다음을 반드시 포함해야 합니다.

  • 최대 재시도 횟수총 대기 상한(cap)
  • 지수 백오프(exponential backoff)
  • **지터(jitter)**로 동시 재시도 분산
  • 가능한 경우 서버 힌트(Retry-After) 존중
  • 서비스 전체를 보호하는 동시성 제한/큐잉

OpenAI 429에서 확인해야 할 신호들

OpenAI SDK/HTTP 응답에는 보통 다음 단서가 있습니다.

  • 상태 코드: 429
  • 헤더: Retry-After (제공된다면 최우선)
  • 응답 바디: rate_limit 류 에러 코드/메시지

운영 팁:

  • Retry-After가 있다면 백오프 계산보다 우선 적용하세요.
  • 429가 짧은 시간에 급증하면, 애플리케이션 레벨에서 동시성 제한이 부족한 경우가 많습니다.

재시도 정책 설계 체크리스트

아래 항목을 정책으로 먼저 “문서화”해두면 구현이 쉬워집니다.

어떤 경우에 재시도할까

  • 재시도 대상: 429, 일시적 5xx(예: 502, 503, 504), 네트워크 타임아웃
  • 재시도 제외: 400(입력 문제), 401/403(인증/권한), 404(리소스), 명백한 검증 실패

백오프 공식(권장)

  • 기본: 지수 백오프 base * 2^attempt
  • 상한: maxDelayMs로 캡
  • 지터: full jitter 또는 equal jitter

예시(Full Jitter):

  • sleep = random(0, min(maxDelay, base * 2^attempt))

총 대기 시간과 재시도 횟수

  • 최대 재시도 횟수: 3~6회 정도로 시작
  • 총 대기 상한: 예를 들어 20~60초

동시성 제한(중요)

재시도를 잘 만들어도, 초기 요청이 과도하면 429는 계속 납니다. 다음 중 하나는 꼭 적용하세요.

  • 프로세스 단위 세마포어로 동시 호출 제한
  • 큐(예: SQS, Redis, Kafka)로 버퍼링
  • 사용자/테넌트별 토큰 버킷

Node.js(Typescript) 구현: Retry-After + 지수 백오프 + 지터

아래 예시는 OpenAI 호출을 감싸는 callWithRetry 유틸입니다. 핵심은 다음입니다.

  • Retry-After가 있으면 그 값을 우선
  • 없으면 지수 백오프 + full jitter
  • 재시도 가능한 에러만 재시도
  • 관측을 위해 attempt, sleep, 원인 로그를 남길 수 있게 구성
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

type RetryOptions = {
  maxAttempts: number;        // 전체 시도 횟수(최초 1회 포함)
  baseDelayMs: number;        // 예: 250
  maxDelayMs: number;         // 예: 8000
  maxTotalSleepMs: number;    // 예: 30000
};

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

function parseRetryAfterMs(err: any): number | null {
  // SDK/HTTP 클라이언트에 따라 위치가 달라질 수 있음
  const ra = err?.response?.headers?.["retry-after"] ?? err?.headers?.["retry-after"];
  if (!ra) return null;

  // retry-after는 초 단위 숫자이거나 HTTP-date일 수 있음
  const seconds = Number(ra);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);

  const dateMs = Date.parse(ra);
  if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());

  return null;
}

function isRetryable(err: any): boolean {
  const status = err?.status ?? err?.response?.status;
  if (status === 429) return true;
  if (status >= 500 && status <= 599) return true;

  // 네트워크 계층 오류(라이브러리별 상이)
  const code = err?.code;
  if (code === "ETIMEDOUT" || code === "ECONNRESET" || code === "EAI_AGAIN") return true;

  return false;
}

function computeFullJitterDelayMs(baseDelayMs: number, maxDelayMs: number, attemptIndex: number) {
  // attemptIndex: 0부터 시작(첫 재시도는 0)
  const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attemptIndex));
  return Math.floor(Math.random() * cap);
}

export async function callWithRetry<T>(
  fn: () => Promise<T>,
  opt: RetryOptions
): Promise<T> {
  let totalSleep = 0;

  for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const retryable = isRetryable(err);
      const isLast = attempt === opt.maxAttempts;

      if (!retryable || isLast) {
        throw err;
      }

      const retryAfterMs = parseRetryAfterMs(err);
      const backoffMs = computeFullJitterDelayMs(opt.baseDelayMs, opt.maxDelayMs, attempt - 1);
      const sleepMs = retryAfterMs ?? backoffMs;

      if (totalSleep + sleepMs > opt.maxTotalSleepMs) {
        throw err;
      }

      totalSleep += sleepMs;
      await sleep(sleepMs);
    }
  }

  // 도달 불가
  throw new Error("unexpected");
}

// 사용 예시
export async function createChatCompletion(prompt: string) {
  return callWithRetry(
    async () => {
      return client.responses.create({
        model: "gpt-4.1-mini",
        input: prompt,
      });
    },
    {
      maxAttempts: 5,
      baseDelayMs: 250,
      maxDelayMs: 8000,
      maxTotalSleepMs: 30000,
    }
  );
}

Node.js에서 동시성 제한까지 같이 적용하기

재시도만으로는 부족한 경우가 많습니다. 아래처럼 세마포어를 두면 “초기 폭주” 자체를 줄여 429를 체감상 크게 낮출 수 있습니다.

class Semaphore {
  private available: number;
  private queue: Array<() => void> = [];

  constructor(count: number) {
    this.available = count;
  }

  async acquire() {
    if (this.available > 0) {
      this.available -= 1;
      return;
    }
    await new Promise<void>((resolve) => this.queue.push(resolve));
  }

  release() {
    const next = this.queue.shift();
    if (next) {
      next();
      return;
    }
    this.available += 1;
  }
}

const openAiSemaphore = new Semaphore(10); // 프로세스당 동시 10개 제한

export async function guardedOpenAiCall(prompt: string) {
  await openAiSemaphore.acquire();
  try {
    return await createChatCompletion(prompt);
  } finally {
    openAiSemaphore.release();
  }
}

Spring Boot(Java) 구현: RestClient/WebClient 재시도 + 지터

Spring에서는 Resilience4j를 많이 쓰지만, “429에서 Retry-After를 읽어 대기” 같은 커스텀 로직은 직접 구현하는 편이 명확합니다. 아래는 RestClient 또는 WebClient 호출을 감싸는 방식의 예시입니다.

핵심 포인트:

  • Retry-After 헤더가 있으면 우선
  • 없으면 지수 백오프 + 지터
  • 인터럽트 처리(스레드 인터럽트 존중)
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;

public class OpenAiRetryingClient {

  private final RestClient restClient;

  public OpenAiRetryingClient(RestClient restClient) {
    this.restClient = restClient;
  }

  public String callWithRetry(String url, String bodyJson) {
    int maxAttempts = 5;
    long baseDelayMs = 250;
    long maxDelayMs = 8000;
    long maxTotalSleepMs = 30000;

    long totalSleep = 0;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        return restClient.post()
            .uri(url)
            .header("Content-Type", "application/json")
            .body(bodyJson)
            .retrieve()
            .body(String.class);
      } catch (RestClientResponseException ex) {
        int status = ex.getStatusCode().value();
        boolean retryable = status == 429 || (status >= 500 && status <= 599);
        boolean last = attempt == maxAttempts;

        if (!retryable || last) {
          throw ex;
        }

        Long retryAfterMs = parseRetryAfterMs(ex);
        long backoffMs = fullJitterBackoff(baseDelayMs, maxDelayMs, attempt - 1);
        long sleepMs = retryAfterMs != null ? retryAfterMs : backoffMs;

        if (totalSleep + sleepMs > maxTotalSleepMs) {
          throw ex;
        }

        totalSleep += sleepMs;
        try {
          Thread.sleep(sleepMs);
        } catch (InterruptedException ie) {
          Thread.currentThread().interrupt();
          throw new RuntimeException(ie);
        }
      }
    }

    throw new IllegalStateException("unexpected");
  }

  private long fullJitterBackoff(long baseDelayMs, long maxDelayMs, int attemptIndex) {
    long cap = Math.min(maxDelayMs, (long) (baseDelayMs * Math.pow(2, attemptIndex)));
    return ThreadLocalRandom.current().nextLong(0, Math.max(1, cap + 1));
  }

  private Long parseRetryAfterMs(RestClientResponseException ex) {
    String ra = ex.getResponseHeaders() != null ? ex.getResponseHeaders().getFirst("Retry-After") : null;
    if (ra == null || ra.isBlank()) return null;

    // seconds
    try {
      long seconds = Long.parseLong(ra.trim());
      return Math.max(0, seconds * 1000);
    } catch (NumberFormatException ignore) {
    }

    // HTTP-date
    try {
      ZonedDateTime dt = ZonedDateTime.parse(ra.trim(), DateTimeFormatter.RFC_1123_DATE_TIME);
      long diff = Duration.between(ZonedDateTime.now(dt.getZone()), dt).toMillis();
      return Math.max(0, diff);
    } catch (Exception ignore) {
      return null;
    }
  }
}

Spring에서 “재시도 스레드 폭주”를 막는 방법

Spring MVC(서블릿) 기반이라면, 요청 스레드가 Thread.sleep으로 묶이는 동안 전체 처리량이 떨어질 수 있습니다. 다음 중 하나를 고려하세요.

  • 외부 호출은 WebClient로 비동기화하고, 백오프도 논블로킹으로 처리
  • 호출 자체를 비동기 워커/큐로 넘기고, API는 폴링/웹훅/콜백 패턴 사용
  • 최소한 동시성 제한(세마포어/벌크헤드)을 적용

이런 “스레드가 막히며 연쇄 장애” 패턴은 쿠버네티스 환경에서 CrashLoopBackOff나 지연 폭증으로 이어지기도 합니다. 장애가 확산될 때의 디버깅 관점은 아래 글도 참고할 만합니다.

멱등성·중복 방지: 재시도에서 가장 자주 놓치는 부분

429 재시도는 같은 요청을 여러 번 보낼 수 있습니다. 이때 “같은 작업을 여러 번 수행”하면 곤란한 케이스가 많습니다.

  • 결제/주문/포인트 지급 등 사이드이펙트가 있는 작업
  • 같은 사용자에게 같은 메시지를 여러 번 발송
  • 같은 문서를 여러 번 요약 저장

대응 방법:

  • 애플리케이션 레벨에서 멱등 키를 도입(요청 해시, 작업 ID)
  • 저장소에 job_id 유니크 제약을 걸고 중복 삽입 방지
  • 큐 기반이면 메시지 deduplication 기능 활용

분산 환경에서 “중복·순서 꼬임”을 다루는 관점은 Saga 설계와도 닿아 있습니다.

운영에서 바로 쓰는 관측 포인트(로그·메트릭)

재시도는 “조용히 성공”하면 원인 파악이 늦습니다. 아래는 최소 권장 항목입니다.

  • rate_limit_429_count (태그: 모델, 엔드포인트, 테넌트)
  • retry_attempt_count (attempt 번호별 분포)
  • retry_sleep_ms (대기 시간 분포)
  • request_latency_ms (재시도 포함 전체 지연)
  • dropped_due_to_max_total_sleep (총 대기 상한으로 포기한 건수)

로그에는 다음을 남기면 좋습니다.

  • 요청 상관관계 ID(트레이스 ID)
  • 상태 코드, 에러 코드
  • Retry-After 존재 여부
  • 백오프 계산 결과(단, PII/프롬프트 원문은 주의)

자주 하는 실수 7가지

  1. 지수 백오프 없이 고정 간격 재시도
  2. 지터 없이 모든 워커가 동시에 재시도(재시도 스톰)
  3. Retry-After 무시
  4. 재시도 횟수만 두고 총 대기 상한이 없음
  5. 429를 재시도하면서 동시성 제한이 없음
  6. 멱등성 없이 사이드이펙트 작업을 재시도
  7. 관측(메트릭/로그) 없이 “성공했으니 됐다”로 끝냄

결론: 429 대응은 “재시도”가 아니라 “흐름 제어”다

OpenAI 429를 안정적으로 처리하려면, 재시도 함수 하나로 끝나지 않습니다. 백오프·지터·Retry-After, 그리고 무엇보다 동시성 제한/큐잉이 함께 들어가야 운영에서 버팁니다.

정리하면 다음 순서로 적용하는 것이 가장 효과적입니다.

    1. 동시성 제한으로 429 자체를 줄이고
    1. 429 및 일시적 오류에 지수 백오프 + 지터로 재시도하며
    1. Retry-After를 우선 존중하고
    1. 멱등성/중복 방지를 추가하고
    1. 메트릭으로 재시도 비용과 지연을 가시화

이 조합이 갖춰지면, 트래픽 스파이크나 배치 작업이 있어도 OpenAI 호출이 전체 시스템을 흔드는 일을 크게 줄일 수 있습니다.