Gemini API 429·503 오류 재시도 코드 만들기
429 RESOURCE_EXHAUSTED 와 503 UNAVAILABLE 은 대응이 다릅니다. 지수 백오프·지터를 넣은 파이썬 함수를 가짜 서버로 시험하고, 재시도해도 안 풀리는 일일 한도는 바로 포기하게 만듭니다.
429 RESOURCE_EXHAUSTED 와 503 UNAVAILABLE 을 모두 같은 횟수로 재시도하면 일일 한도에 걸린 요청까지 매번 헛되이 반복합니다. 두 오류를 가르고, 기다리면 풀리는 쪽만 지수 백오프로 다시 보내는 함수를 만듭니다. 응답이 SAFETY 로 비는 경우는 빈 응답 점검 에 있습니다.
[1] 공식 문서가 구분하는 오류부터 확인합니다. API 오류 문서 의 표입니다.
| HTTP | 코드 | 문서의 조치 |
|---|---|---|
| 429 | rate_limit_exceeded · too_many_requests | Wait and retry with exponential backoff |
| 429 | quota_exceeded (일일 한도) | Wait until the quota resets or request a quota increase |
| 500 | api_error | Retry the request |
| 503 | service_unavailable | Wait and retry with exponential backoff |
| 504 | deadline_exceeded | 클라이언트 deadline 을 늘리거나 제거 |
| 400 | invalid_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 을 인자로 받게 해 시험에서 실제로 기다리지 않게 했습니다.
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)의 응답을 순서대로 내보냅니다. 응답 본문의 모양은 위 포럼 로그를 따라 만든 것이고, 실제 서버를 호출하지는 않습니다.
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 대신 목록에 모으는 함수를 넘겼기 때문에 출력의 시간은 「잤을 시간」입니다.
$ 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: 2503 두 번 뒤 성공, 429 는 서버가 준 2초를 따르고, 2970초는 포기하고, 400 은 한 번만 보내고, 계속 실패하면 tries 에서 멈춥니다. sleeping 뒤의 대기 시간은 난수가 섞여 실행마다 다릅니다.
[7] 실제 호출에 붙일 때는 url 과 payload 를 바꾸고, API 키 헤더는 공식 문서대로 Request 에 추가합니다. 공식 SDK 를 쓴다면 포럼 답변이 SDK 의 재시도 설정을 쓰라고 하니, 직접 만들기 전에 그쪽을 먼저 봅니다. 즉시 응답이 필요 없는 대량 작업은 같은 답변이 Batch API 를 권합니다.