Published on

Gemini API 429 쿼터·레이트리밋 재시도 설계

Authors

서드파티 LLM API를 운영 환경에 붙이면, 가장 먼저 마주치는 현실적인 에러가 429 입니다. 문제는 429 가 항상 같은 의미가 아니라는 점입니다. 어떤 경우는 초당 요청 수 레이트리밋이고, 어떤 경우는 일/월 단위 쿼터 소진이며, 또 어떤 경우는 특정 프로젝트나 키 단위의 제한입니다. 이 차이를 구분하지 못한 채 “일단 몇 번 재시도”를 넣으면, 트래픽이 몰릴 때 오히려 재시도가 폭주하며 더 큰 장애(지연, 큐 적체, 비용 증가, 사용자 타임아웃)를 만듭니다.

이 글에서는 Gemini API에서 429 를 만났을 때 운영 관점에서 안전하게 복구하는 재시도 설계를 정리합니다. 핵심은 다음 4가지입니다.

  • 429 를 “재시도 가능”과 “재시도해도 소용없는 상태”로 분리
  • 지수 백오프 + 지터로 동시 재시도 폭주 방지
  • 클라이언트 단에서 선제적으로 레이트를 조절(토큰버킷, 동시성 제한)
  • 지속 실패 시 서킷브레이커로 빠르게 실패하고 대체 경로로 전환

참고로, OpenAI의 429 재시도 설계와 비교해보면 사고방식이 훨씬 빨리 잡힙니다. 이 글과 함께 보면 좋습니다: OpenAI 429와 Rate Limit 헤더로 재시도 설계

429를 두 종류로 나눠야 하는 이유

429 는 HTTP 레벨에서 “Too Many Requests”지만, 실제 원인은 크게 두 부류로 나뉩니다.

  1. 레이트리밋(단기 제한)
  • 초당 요청 수(RPS), 분당 토큰, 동시 요청 수 같은 단기 제한
  • 시간이 지나면 자동으로 풀림
  • 적절한 백오프 후 재시도하면 성공 가능
  1. 쿼터 소진(장기 제한)
  • 일/월 단위 사용량 제한, 결제/프로젝트 제한 등
  • 시간이 지나도 안 풀리거나(리셋 시점까지) 운영 조치가 필요
  • 재시도는 비용만 쓰고 실패를 증폭

운영에서 중요한 건, “이 429 가 어느 부류인가”를 빠르게 판별해 재시도 정책을 달리하는 것입니다.

에러 바디 기반 분류 전략

Gemini API의 에러 응답은 SDK/버전/엔드포인트에 따라 다를 수 있지만, 일반적으로 다음 정보를 활용해 분류합니다.

  • HTTP 상태코드: 429
  • 에러 메시지 문자열에 quota 또는 rate limit 류 단서가 있는지
  • Retry-After 같은 힌트가 있는지(있다면 단기 제한 가능성이 큼)

정확한 필드명은 SDK마다 다르므로, 운영 코드에서는 “문자열 매칭 + 안전한 기본값” 전략이 현실적입니다.

재시도 설계의 기본: 지수 백오프 + 지터

재시도는 다음을 동시에 만족해야 합니다.

  • 짧은 순간의 제한에는 회복력 있게 재시도
  • 트래픽이 몰릴 때 재시도가 동기화되어 폭주하지 않게 분산
  • 총 대기 시간 상한을 두어 사용자 요청 타임아웃과 정렬

정석은 exponential backoff with jitter 입니다.

  • 기본 대기: baseMs
  • 시도 횟수: attempt
  • 최대 대기: capMs
  • 지터: 랜덤 요소를 넣어 동시 재시도 쏠림 방지

Node.js 예제: 지터 백오프 재시도 유틸

아래는 Gemini 호출을 감싸는 재시도 래퍼 예제입니다. 핵심은 retryable429 판별과 Retry-After 존중, 그리고 전체 데드라인(요청 전체 타임아웃)을 넘기지 않는 것입니다.

type RetryDecision = {
  retry: boolean;
  reason: string;
  retryAfterMs?: number;
};

function parseRetryAfterMs(headers: Headers): number | undefined {
  const v = headers.get('retry-after');
  if (!v) return;
  const sec = Number(v);
  if (!Number.isFinite(sec)) return;
  return Math.max(0, sec * 1000);
}

function isLikelyQuotaError(message: string): boolean {
  const m = message.toLowerCase();
  return m.includes('quota') || m.includes('exceeded your quota') || m.includes('billing');
}

function isLikelyRateLimitError(message: string): boolean {
  const m = message.toLowerCase();
  return m.includes('rate') || m.includes('too many') || m.includes('requests');
}

function decideRetry429(params: {
  status: number;
  message: string;
  retryAfterMs?: number;
}): RetryDecision {
  const { status, message, retryAfterMs } = params;
  if (status !== 429) return { retry: false, reason: 'not-429' };

  // 쿼터 소진 가능성이 높으면 재시도 금지
  if (isLikelyQuotaError(message)) {
    return { retry: false, reason: 'quota-exceeded' };
  }

  // Retry-After가 있으면 단기 제한일 가능성이 높으므로 존중
  if (typeof retryAfterMs === 'number') {
    return { retry: true, reason: 'rate-limited-retry-after', retryAfterMs };
  }

  // 메시지가 rate limit 류로 보이면 제한적으로 재시도
  if (isLikelyRateLimitError(message)) {
    return { retry: true, reason: 'rate-limited-heuristic' };
  }

  // 애매하면 재시도 횟수를 매우 보수적으로
  return { retry: true, reason: '429-unknown' };
}

function computeBackoffMs(attempt: number, baseMs: number, capMs: number): number {
  const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
  // Full jitter: 0..exp
  return Math.floor(Math.random() * exp);
}

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

async function withRetry<T>(fn: () => Promise<T>, opts: {
  maxAttempts: number;
  baseMs: number;
  capMs: number;
  deadlineMs: number;
  classify: (e: any) => RetryDecision;
}): Promise<T> {
  const started = Date.now();
  let lastErr: any;

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (e: any) {
      lastErr = e;
      const decision = opts.classify(e);
      if (!decision.retry) throw e;

      const elapsed = Date.now() - started;
      const remaining = opts.deadlineMs - elapsed;
      if (remaining <= 0) throw e;

      const waitMs = typeof decision.retryAfterMs === 'number'
        ? decision.retryAfterMs
        : computeBackoffMs(attempt, opts.baseMs, opts.capMs);

      // 남은 시간보다 오래 기다릴 수는 없음
      const boundedWait = Math.min(waitMs, Math.max(0, remaining - 10));
      if (boundedWait <= 0) throw e;

      await sleep(boundedWait);
    }
  }

  throw lastErr;
}

이 유틸은 “재시도 가능 여부”와 “기다릴 시간”을 분리해 설계를 깔끔하게 유지합니다. 특히 Retry-After 가 있으면 백오프 계산보다 우선시해 서버가 의도한 회복 시간을 존중합니다.

Gemini 호출부에 적용: 에러 구조를 안전하게 흡수

실제 Gemini SDK에서 던지는 에러 오브젝트는 버전에 따라 구조가 다를 수 있습니다. 따라서 분류 함수에서는 다음을 지키는 것이 안전합니다.

  • status 를 여러 경로에서 탐색(e.status, e.response.status, e.code 등)
  • 메시지는 e.message 와 바디 문자열을 모두 고려
  • 헤더는 가능하면 응답 객체에서 파싱

아래는 “가능한 만큼 추출하고, 실패하면 보수적으로 처리”하는 예시입니다.

function extractHttpStatus(e: any): number | undefined {
  return e?.status ?? e?.response?.status ?? e?.cause?.status;
}

function extractMessage(e: any): string {
  return String(e?.message ?? e?.response?.data?.error?.message ?? e?.toString?.() ?? '');
}

function extractRetryAfterMs(e: any): number | undefined {
  const h = e?.response?.headers;
  if (!h) return;

  // fetch Headers
  if (typeof h.get === 'function') {
    return parseRetryAfterMs(h as Headers);
  }

  // axios style plain object
  const v = h['retry-after'] ?? h['Retry-After'];
  const sec = Number(v);
  if (!Number.isFinite(sec)) return;
  return Math.max(0, sec * 1000);
}

function classifyGeminiError(e: any): RetryDecision {
  const status = extractHttpStatus(e) ?? 0;
  const message = extractMessage(e);
  const retryAfterMs = extractRetryAfterMs(e);

  return decideRetry429({ status, message, retryAfterMs });
}

이제 Gemini 호출을 withRetry 로 감싸면 됩니다.

async function callGeminiWithRetry<T>(call: () => Promise<T>) {
  return withRetry(call, {
    maxAttempts: 5,
    baseMs: 200,
    capMs: 5_000,
    deadlineMs: 12_000,
    classify: classifyGeminiError,
  });
}

운영 팁으로, deadlineMs 는 사용자 요청 타임아웃(예: ALB, API Gateway, 프론트엔드 fetch 타임아웃)보다 짧게 잡아야 “우리 서비스가 먼저 정리하고” 의미 있는 에러를 반환할 수 있습니다. 인프라 타임아웃과의 관계는 504 증상으로 이어지기도 하니, 유사한 운영 경험이 있다면 EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결도 같이 점검해보면 좋습니다.

재시도만으로는 부족하다: 클라이언트 레이트 리미터

429 가 자주 발생한다는 건, “서버가 제한을 걸기 전에 이미 우리는 너무 많이 보내고 있다”는 뜻입니다. 재시도는 사후 대응이고, 운영 품질을 올리려면 사전 제어가 필요합니다.

대표적인 방식은 다음 두 가지입니다.

  • 동시성 제한: 한 프로세스에서 동시에 날리는 요청 수를 제한
  • 토큰버킷(또는 리키버킷): 초당 요청 수를 일정하게 유지

간단한 동시성 제한 예제

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

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

  async acquire(): Promise<() => void> {
    if (this.available > 0) {
      this.available -= 1;
      return () => this.release();
    }

    await new Promise<void>((resolve) => this.queue.push(resolve));
    this.available -= 1;
    return () => this.release();
  }

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

const geminiSemaphore = new Semaphore(8);

async function guardedGeminiCall<T>(fn: () => Promise<T>): Promise<T> {
  const release = await geminiSemaphore.acquire();
  try {
    return await fn();
  } finally {
    release();
  }
}

이것만으로도 “동시 폭주로 인한 429” 비율이 꽤 줄어듭니다.

서킷브레이커: 지속 실패 시 빠르게 실패하라

429 가 레이트리밋이든 쿼터든, 일정 시간 동안 계속 실패한다면 재시도는 더 큰 비용을 만들 뿐입니다. 이때는 서킷브레이커로 다음을 달성합니다.

  • 일정 실패율 이상이면 회로를 열고 즉시 실패
  • 일정 시간 후 half-open 상태에서 소수 요청만 시험
  • 회복되면 다시 정상 상태로

간단한 서킷브레이커 예제

type CircuitState = 'closed' | 'open' | 'half-open';

class CircuitBreaker {
  private state: CircuitState = 'closed';
  private openedAt = 0;
  private failures = 0;

  constructor(
    private readonly failureThreshold: number,
    private readonly openMs: number
  ) {}

  canRequest(): boolean {
    if (this.state === 'closed') return true;

    const now = Date.now();
    if (this.state === 'open' && now - this.openedAt >= this.openMs) {
      this.state = 'half-open';
      return true;
    }

    return this.state === 'half-open';
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  onFailure() {
    this.failures += 1;
    if (this.failures >= this.failureThreshold) {
      this.state = 'open';
      this.openedAt = Date.now();
    }
  }
}

const geminiCircuit = new CircuitBreaker(10, 30_000);

async function callWithCircuit<T>(fn: () => Promise<T>): Promise<T> {
  if (!geminiCircuit.canRequest()) {
    throw new Error('Gemini circuit is open');
  }

  try {
    const res = await fn();
    geminiCircuit.onSuccess();
    return res;
  } catch (e) {
    geminiCircuit.onFailure();
    throw e;
  }
}

서킷이 열려 있는 동안은 “대체 응답”을 주는 전략이 중요합니다.

  • 캐시된 결과 반환
  • 더 작은 모델/더 짧은 컨텍스트로 degrade
  • 사용자에게 “잠시 후 재시도” 안내 + 비동기 작업 큐로 전환

쿼터 소진(장기 제한) 대응: 재시도 대신 운영 플로우

쿼터 소진이 의심되면, 코드는 재시도를 줄이고 운영 플로우로 전환해야 합니다.

  • 알림: quota-exceeded 분류 시 즉시 PagerDuty, Slack 알림
  • 보호: 해당 API 키/프로젝트로 가는 트래픽 셧오프(서킷 오픈)
  • 폴백: 다른 프로젝트 키, 다른 리전, 다른 모델, 또는 기능 제한
  • 사용자 경험: 동기 요청을 비동기 작업으로 전환하고 완료 시 알림

여기서 중요한 건 “자동 전환이 비용 폭탄을 만들지 않게” 폴백에도 레이트 제한을 걸어야 한다는 점입니다.

관측(Observability): 재시도는 반드시 지표로 남겨라

재시도 로직은 조용히 숨어 있으면 나중에 장애 원인 분석이 어려워집니다. 최소한 아래 지표는 남기는 것을 권장합니다.

  • gemini_requests_total (status별)
  • gemini_429_total (reason별: quota-exceeded, rate-limited-retry-after, rate-limited-heuristic, 429-unknown)
  • gemini_retries_total (attempt별)
  • gemini_retry_wait_ms (histogram)
  • gemini_circuit_state (gauge)

로그에는 다음 필드를 구조적으로 남기면 좋습니다.

  • requestId (우리 서비스의 correlation id)
  • upstream (gemini)
  • status
  • reason
  • attempt
  • waitMs
  • deadlineRemainingMs

이렇게 해두면 “레이트리밋이 늘었는지”, “특정 배포 이후 동시성이 늘었는지”, “특정 엔드포인트만 터지는지”가 빠르게 보입니다.

실전 체크리스트

운영 환경에서 429 를 안정적으로 다루기 위한 체크리스트입니다.

  • 429 를 쿼터/레이트리밋으로 분류하고, 쿼터 의심이면 재시도 금지
  • Retry-After 가 있으면 반드시 존중
  • 지수 백오프 + 지터 적용(동시 재시도 동기화 방지)
  • 전체 데드라인을 두고, 사용자 타임아웃보다 먼저 포기
  • 동시성 제한 또는 토큰버킷으로 사전 레이트 제어
  • 서킷브레이커로 지속 실패 시 빠른 실패 + 폴백
  • 재시도/대기/분류 사유를 메트릭과 로그로 남김

마무리

Gemini API의 429 는 “조금 기다리면 되는 일”일 때도 있지만, “기다려도 소용없는 쿼터 소진”일 때도 있습니다. 따라서 재시도는 단순한 루프가 아니라, 분류 기반 정책과 사전 레이트 제어, 그리고 서킷브레이커까지 포함한 복구 설계로 접근해야 합니다.

이미 OpenAI 쪽에서 429 재시도 설계를 경험했다면, 동일한 원칙을 Gemini에도 적용하되 “쿼터 소진을 빠르게 감지해 재시도를 멈추는 것”에 더 집중해보세요. 이는 비용과 장애 시간을 동시에 줄이는 가장 확실한 방법입니다.