GrepBoard/매뉴얼
51 편 ·

Gemini API 429·503 오류 재시도 코드 만들기

·Ubuntu 24.04 · Python 3.12.3(표준 라이브러리) · 로컬 가짜 서버로 시험 · 오류 분류는 Gemini API 공식 문서 2026-10 기준·7 단계

429 RESOURCE_EXHAUSTED 와 503 UNAVAILABLE 은 대응이 다릅니다. 지수 백오프·지터를 넣은 파이썬 함수를 가짜 서버로 시험하고, 재시도해도 안 풀리는 일일 한도는 바로 포기하게 만듭니다.

429 RESOURCE_EXHAUSTED 와 503 UNAVAILABLE 을 모두 같은 횟수로 재시도하면 일일 한도에 걸린 요청까지 매번 헛되이 반복합니다. 두 오류를 가르고, 기다리면 풀리는 쪽만 지수 백오프로 다시 보내는 함수를 만듭니다. 응답이 SAFETY 로 비는 경우는 빈 응답 점검 에 있습니다.

[1] 공식 문서가 구분하는 오류부터 확인합니다. API 오류 문서 의 표입니다.

HTTP코드문서의 조치
429rate_limit_exceeded · too_many_requestsWait and retry with exponential backoff
429quota_exceeded (일일 한도)Wait until the quota resets or request a quota increase
500api_errorRetry the request
503service_unavailableWait and retry with exponential backoff
504deadline_exceeded클라이언트 deadline 을 늘리거나 제거
400invalid_request · failed_precondition요청·결제 상태 확인 (재시도 대상 아님)

문제 해결 문서 도 「429, 408, 5xx 같은 일시적 오류에서만 재시도」하라고 합니다. 400 은 몇 번을 보내도 같은 400 입니다.

[2] 503 은 쿼터와 무관합니다. 공식 포럼 답변은 「503 errors are unrelated to your quota」, 즉 일시적 과부하라고 설명합니다. 한도를 올려도 줄지 않으니 짧게 여러 번 다시 시도하는 것이 맞습니다.

[3] 429 본문에 RetryInfo 가 있으면 그 값을 씁니다. 포럼에 올라온 사용자 로그에는 "retryDelay": "2970.938289688s" 와 함께 QUOTA_EXHAUSTED 가 있었습니다. 다만 이 필드를 클라이언트가 따라야 한다는 공식 설명은 찾지 못했습니다. 그래서 코드는 「있으면 참고, 없으면 백오프」로 짭니다. 값이 max_wait(60초)보다 길면 기다리지 않고 예외를 그대로 올립니다.

[4] 함수를 만듭니다. 표준 라이브러리만 씁니다. 대기 시간은 0 과 min(cap, base × 2^n) 사이의 난수(full jitter)라서 여러 프로세스가 같은 순간에 다시 몰리지 않습니다. sleep 을 인자로 받게 해 시험에서 실제로 기다리지 않게 했습니다.

retry.py
import json, random, re, time, urllib.request, urllib.error RETRY_STATUS = {429, 500, 503, 504} def retry_delay(body):    """RetryInfo.retryDelay ('2s', '2970.9s') in the error body -> seconds, else None."""    try:        for d in json.loads(body)["error"].get("details", []):            if str(d.get("@type", "")).endswith("RetryInfo"):                m = re.fullmatch(r"([0-9.]+)s", d["retryDelay"])                return float(m.group(1)) if m else None    except (ValueError, KeyError, TypeError, AttributeError):        pass    return None def call(url, payload, *, tries=5, base=1.0, cap=30.0, max_wait=60.0, sleep=time.sleep):    data = json.dumps(payload).encode()    for n in range(tries):        req = urllib.request.Request(url, data, {"Content-Type": "application/json"})        try:            with urllib.request.urlopen(req, timeout=30) as r:                return json.load(r)        except urllib.error.HTTPError as e:            body = e.read().decode("utf-8", "replace")            if e.code not in RETRY_STATUS or n == tries - 1:                raise            hint = retry_delay(body)            if hint is not None and hint > max_wait:                raise  # server says wait longer than we will: daily quota etc.            wait = hint if hint is not None else random.uniform(0, min(cap, base * 2 ** n))            print(f"attempt {n+1}: HTTP {e.code}, sleeping {wait:.2f}s")            sleep(wait)

[5] 가짜 서버로 시험합니다. http.server 로 구글 오류 본문과 같은 모양(error.status·details[].retryDelay)의 응답을 순서대로 내보냅니다. 응답 본문의 모양은 위 포럼 로그를 따라 만든 것이고, 실제 서버를 호출하지는 않습니다.

test.py
import json, threading, urllib.errorfrom http.server import BaseHTTPRequestHandler, HTTPServerimport retrySCRIPT = []class H(BaseHTTPRequestHandler):    def do_POST(self):        self.rfile.read(int(self.headers["Content-Length"]))        code, details = SCRIPT.pop(0)        if code == 200:            body = {"candidates": [{"content": {"parts": [{"text": "ok"}]}}]}        else:            body = {"error": {"code": code, "message": "fake", "status": {429: "RESOURCE_EXHAUSTED", 503: "UNAVAILABLE", 400: "INVALID_ARGUMENT"}[code], "details": details}}        b = json.dumps(body).encode()        self.send_response(code); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)    def log_message(self, *a): passsrv = HTTPServer(("127.0.0.1", 18429), H); threading.Thread(target=srv.serve_forever, daemon=True).start()url = "http://127.0.0.1:18429/v1beta/models/m:generateContent"ri = lambda s: [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": s}]def run(name, script, **kw):    SCRIPT[:] = script; sleeps = []    print("==", name)    try:        r = retry.call(url, {}, sleep=sleeps.append, **kw); print("result:", r["candidates"][0]["content"]["parts"][0]["text"])    except urllib.error.HTTPError as e: print("raised HTTP", e.code)    print("calls left unused:", len(SCRIPT))run("503 x2 then 200", [(503, []), (503, []), (200, [])])run("429 with retryDelay 2s then 200", [(429, ri("2s")), (200, [])])run("429 retryDelay 2970.9s (give up)", [(429, ri("2970.938289688s")), (200, [])])run("400 not retried", [(400, []), (200, [])])run("503 forever, tries=3", [(503, [])]*5, tries=3)

[6] 실행합니다. sleep 대신 목록에 모으는 함수를 넘겼기 때문에 출력의 시간은 「잤을 시간」입니다.

console
$ python3 test.py== 503 x2 then 200attempt 1: HTTP 503, sleeping 0.94sattempt 2: HTTP 503, sleeping 0.84sresult: okcalls left unused: 0== 429 with retryDelay 2s then 200attempt 1: HTTP 429, sleeping 2.00sresult: okcalls left unused: 0== 429 retryDelay 2970.9s (give up)raised HTTP 429calls left unused: 1== 400 not retriedraised HTTP 400calls left unused: 1== 503 forever, tries=3attempt 1: HTTP 503, sleeping 0.67sattempt 2: HTTP 503, sleeping 1.39sraised HTTP 503calls left unused: 2

503 두 번 뒤 성공, 429 는 서버가 준 2초를 따르고, 2970초는 포기하고, 400 은 한 번만 보내고, 계속 실패하면 tries 에서 멈춥니다. sleeping 뒤의 대기 시간은 난수가 섞여 실행마다 다릅니다.

[7] 실제 호출에 붙일 때는 url 과 payload 를 바꾸고, API 키 헤더는 공식 문서대로 Request 에 추가합니다. 공식 SDK 를 쓴다면 포럼 답변이 SDK 의 재시도 설정을 쓰라고 하니, 직접 만들기 전에 그쪽을 먼저 봅니다. 즉시 응답이 필요 없는 대량 작업은 같은 답변이 Batch API 를 권합니다.

#gemini#api#retry#rate-limit