본문으로 건너뛰기

Nano Banana Pro RESOURCE_EXHAUSTED 해결: 429 재시도와 이미지 작업 복구

Nano Banana Pro에서 429가 발생하면 명시적 한도 0·일일 한도·결제 중단은 먼저 처리하고, 일시적 제한만 서버가 요구한 시간 이후 재시도합니다. Python과 Node.js 예제는 총 시도와 누적 시간을 제한하며 실제 이미지와 완료 기록을 저장합니다.

LaoZhang AI Team게시7 분 소요
목차
Nano Banana Pro의 429 응답에서 한도 확인, 대기, 중단을 구분하고 이미지 결과를 보존하는 흐름

Nano Banana Pro의 429 RESOURCE_EXHAUSTED는 무조건 몇 초 기다렸다 다시 보내면 해결되는 오류가 아닙니다. 오류에 적용 한도 0, 일일 한도 소진, 결제 중단이 명시되어 있으면 자동 재시도를 멈추고 해당 상태를 먼저 처리합니다. 분당 제한이나 일시적인 용량 부족으로 판단할 수 있는 응답은 서버의 대기 안내를 지키면서 횟수와 시간을 제한해 재시도합니다. 이미 저장하고 확인한 이미지는 다시 생성하지 않습니다.

아래 예제는 Gemini Developer API의 v1beta GenerateContent와 gemini-3-pro-image를 사용합니다. Python과 Node.js를 각각 독립 실행할 수 있으며, 요청 JSON부터 이미지 저장과 작업 재개까지 연결되어 있습니다. 공식 문서와 요청·응답 필드를 대조하고 가짜 응답으로 오프라인 동작을 검증했습니다. 유료 생성 호출이나 계정 복구를 실제로 수행한 예제는 아닙니다.

재시도 전에 429의 종류를 나눕니다

먼저 요청 주소, 모델 ID, HTTP 상태, 오류의 error.details, Retry-After를 보존합니다. 키 값은 로그에 남기지 않습니다. RESOURCE_EXHAUSTED라는 문자열 하나만으로 프로젝트 한도 소진이나 Google 전체 장애를 확정할 수 없습니다.

확인된 단서지금 할 일자동 재시도
quotaValue: "0" 또는 오류가 명시한 limit: 0같은 프로젝트·모델의 활성 한도와 이용 자격을 확인합니다.중단합니다. 짧은 대기로 이용 자격이 생기지 않습니다.
일일 한도 지표·설명해당 한도의 재설정 시점을 확인하고 작업을 나중으로 예약합니다.분 단위 반복을 중단합니다.
402, 결제·크레딧 문제 또는 서비스 비활성 상태해당 결제·서비스 상태를 먼저 처리합니다.중단합니다.
양수의 시간별 한도 소진 또는 일시적인 429동시 요청을 줄이고 서버의 최소 대기 시간 이후 재시도합니다.전체 예산 안에서만 허용합니다.
503 일시적 가용성 오류동일 경로의 응답을 기록하고 제한된 backoff를 적용합니다.예산 안에서만 허용합니다.
POST timeout, 연결 중단, 실행 도중 종료결과가 생성되었는지 확인할 때까지 보류합니다.즉시 재전송하지 않습니다.

QuotaFailure.quotaValue는 실패 시 적용된 한도 값입니다. 사용한 양이나 남은 양이 아닙니다. 이 필드가 없는 응답을 0으로 채워 넣으면 정상적인 시간별 제한도 “할당량 없음”으로 잘못 분류합니다. QuotaFailure 자체가 없다고 용량 부족이 입증되는 것도 아닙니다. 실제 프로젝트·모델 지표와 응답을 함께 확인해야 합니다. Google RPC 오류 상세 정의

Developer API의 RPM·입력 TPM·RPD 등은 프로젝트 단위이며, 이미지 모델에는 IPM 같은 별도 지표도 적용될 수 있습니다. RPD는 태평양 시간 자정에 재설정됩니다. 모델·등급별 활성 수치는 AI Studio Rate Limit에서 확인하며, 표시된 한도가 처리 용량을 보장하지는 않습니다. 공식 한도 안내

2026년 10월 5일 가격표에서 Pro Image의 무료 티어 입력·출력은 Not available입니다. 결제했는데 무료 한도 0이 계속되면 실행 중인 키의 프로젝트와 결제 상태를 확인하는 절차를 따릅니다. Prepay 잔액 소진의 402는 무료 0의 429와 별도입니다. Pro Image 가격표, 결제 상태 안내

Vertex AI를 사용한다면 이 글의 키·AI Studio 한도와 그대로 맞추지 마세요. Cloud의 Pay-as-you-go 429에는 할당 가능한 용량 부족이나 급격한 트래픽 증가도 포함되며, Provisioned Throughput은 별도의 처리량 계약과 오류 규칙을 따릅니다. 가능한 global endpoint, 트래픽 평준화, 증설 여부도 모델·위치·계약 조건에 따라 판단합니다. 예약 처리량을 구매하면 모든 429가 없어진다는 뜻은 아닙니다. 현재 Cloud 429 문서, 제품별 한도 확인 경로

서버가 8초를 요구하면 2초 뒤에 재시도하지 않습니다

서버가 요구한 8초 대기와 남은 6초 예산을 비교하여 조기 재시도를 막고 작업을 보류하는 예시

Retry-After는 응답을 받은 뒤 기다릴 정수 초 또는 HTTP 날짜입니다. RetryInfo.retryDelay는 같은 요청을 다시 보내기 전 적어도 기다려야 하는 시간입니다. 둘 다 있으면 더 긴 시간을 적용하고, 클라이언트 backoff가 더 길면 그 값을 사용합니다. jitter는 이 최소 대기 뒤에 더합니다. HTTP Retry-After 규정, RetryInfo 정의

예를 들어 로컬 backoff가 2초이고 Retry-After: 8이라면 8초 이상 기다립니다. 남은 전체 예산이 6초라면 대기를 6초로 줄이거나 먼저 요청하지 않고 작업을 보류합니다. 아래 코드는 다음 조건을 함께 지킵니다.

  • 총 5회 시도는 첫 요청 1회와 추가 요청 최대 4회를 합한 값입니다.
  • 작업별 누적 요청·대기 예산은 180초, 개별 POST와 본문 수신의 로컬 timeout은 최대 60초입니다. 남은 예산이 더 작으면 timeout도 작아집니다.
  • 서버 대기 시점 notBefore, 누적 시간 used, 총 시도 수 attempts를 저장합니다. 같은 작업을 재개해도 이전 시도 수와 사용한 예산을 다시 0으로 만들지 않습니다.
  • 명시적 0·일일 한도·결제 문제는 기다림보다 중단 판단이 우선합니다.

이 숫자는 예제의 운영 정책이며 Google의 권장 고정값이나 Pro Image의 정상 처리 시간 보장이 아닙니다. 자신의 지연 허용 범위에 맞게 조정하되 서버가 지정한 최소 대기는 줄이지 않습니다. 예제는 SDK의 자동 재시도와 바깥 루프가 겹치지 않도록 직접 REST를 사용합니다. SDK를 쓰려면 설치 버전의 자동 재시도 설정을 확인해야 합니다. 공식 재시도 안내

두 예제가 사용하는 요청과 작업 파일

GenerateContent의 이미지 설정은 generationConfig.imageConfig.aspectRatio와 imageSize입니다. 아래 예제는 Pro에서 지원되는 1K와 16:9를 사용합니다. imageResolution: "1024x1024"나 Interactions의 response_format을 이 요청에 섞지 않습니다. 현재 이미지 가이드의 Interactions API는 별도 프로토콜이므로 주소만 바꿔 같은 JSON을 보낼 수 없습니다. GenerateContent API 정의, 이미지 생성 가이드

두 코드 모두 아래 파일을 jobs.json으로 저장해 사용합니다. id는 영문·숫자·밑줄·하이픈으로만 구성하고 중복하지 않습니다. 같은 id의 프롬프트나 모델·설정을 바꾸면 중단합니다. 다른 작업으로 바꾸려면 새 ID를 사용하되, 결과가 불명확한 기존 작업을 이름만 바꿔 재전송해서는 안 됩니다.

json
[
  {"id": "banner-01", "prompt": "봄 정원의 꽃과 나무를 담은 16:9 수채화 이미지를 생성해 주세요."},
  {"id": "banner-02", "prompt": "밤하늘과 작은 등대를 담은 16:9 수채화 이미지를 생성해 주세요."}
]

두 구현은 서로 다른 결과 디렉터리를 사용합니다. Python과 Node를 같은 작업에 동시에 실행하지 말고 하나를 선택합니다. 예제는 단일 프로세스용이며 다중 worker를 위한 잠금이나 분산 중복 제거를 구현하지 않습니다. GEMINI_API_KEY는 실행 환경에서 직접 지정합니다. 코드는 이 변수 하나만 읽으며 키를 출력하지 않습니다.

응답은 candidates[].content.parts[].inlineData를 순회하고 mimeType과 base64 data를 읽습니다. 200만으로 성공 처리하지 않고 PNG/JPEG/WebP를 실제 디코딩한 뒤 PNG로 저장합니다. 디코더가 읽을 수 있다는 확인이며 프롬프트 충족도나 이미지 품질까지 보장하지는 않습니다. 이미지가 없으면 promptFeedback·finishReason을 확인할 수 있도록 review로 멈춥니다. 응답·Part·Blob 정의

Python: 이미지 확인 후 완료 기록을 저장합니다

Python 3.10 이상에서 별도 가상 환경에 requests와 Pillow를 설치합니다. 아래 전체 코드를 recover.py로 저장합니다.

bash
python -m pip install requests Pillow
python recover.py jobs.json
python
import base64, hashlib, io, json, os, queue, re, sys, threading, time
from email.utils import parsedate_to_datetime
from pathlib import Path
import requests
from PIL import Image

MODEL = 'gemini-3-pro-image'
URL = f'https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent'
MAX_ATTEMPTS, BUDGET, CALL_TIMEOUT = 5, 180.0, 60.0

def payload(prompt):
    return {'contents': [{'role': 'user', 'parts': [{'text': prompt}]}],
            'generationConfig': {'responseModalities': ['TEXT', 'IMAGE'],
                                 'imageConfig': {'aspectRatio': '16:9', 'imageSize': '1K'}}}

def stop_reason(status, body):
    err = body.get('error', {})
    if status not in (429, 503):
        return f'HTTP {status}: 수동 확인'
    for d in err.get('details', []):
        if str(d.get('@type', '')).endswith('QuotaFailure'):
            for v in d.get('violations', []):
                if v.get('serviceDisabled') is True:
                    return '서비스 비활성'
                if 'quotaValue' in v and str(v['quotaValue']) in ('0', '0.0'):
                    return '명시적 한도 0'
                text = ' '.join(str(v.get(k, '')) for k in
                                ('quotaMetric', 'quotaId', 'description'))
                if re.search(r'per.?day|daily|\bRPD\b', text, re.I):
                    return '일일 한도'
    message = str(err.get('message', ''))
    if re.search(r'limit\s*:\s*0(?:\D|$)|daily|per.?day|billing|payment|credits', message, re.I):
        return '한도 또는 결제 상태 확인'
    return None

def server_floor(headers, body, now):
    waits = [0.0]
    value = next((v for k, v in headers.items() if k.lower() == 'retry-after'), '')
    try:
        waits.append(float(value) if str(value).isdigit() else
                     parsedate_to_datetime(value).timestamp() - now)
    except (ValueError, TypeError, OverflowError):
        pass
    for d in body.get('error', {}).get('details', []):
        if str(d.get('@type', '')).endswith('RetryInfo'):
            m = re.fullmatch(r'(\d+(?:\.\d+)?)s', str(d.get('retryDelay', '')))
            if m:
                waits.append(float(m[1]))
    return max(waits)

def image_bytes(body):
    for c in body.get('candidates', []):
        for p in c.get('content', {}).get('parts', []):
            b = p.get('inlineData', {})
            if b.get('mimeType') not in ('image/png', 'image/jpeg', 'image/webp'):
                continue
            raw = base64.b64decode(b.get('data', ''), validate=True)
            with Image.open(io.BytesIO(raw)) as im:
                im.load()
                if im.width < 1 or im.height < 1:
                    raise ValueError('빈 이미지')
                out = io.BytesIO()
                im.convert('RGB').save(out, format='PNG')
                return out.getvalue()
    raise ValueError('이미지 없음: promptFeedback와 finishReason 확인')

def send(body, timeout):
    # 전체 POST/본문 수신에 대한 로컬 마감. 만료된 요청은 재전송하지 않습니다.
    result = queue.Queue()
    def worker():
        try:
            key = os.environ['GEMINI_API_KEY']
            with requests.Session() as session:
                r = session.post(URL, headers={'x-goog-api-key': key}, json=body,
                                 timeout=timeout, allow_redirects=False)
                result.put((r.status_code, dict(r.headers), r.json()))
        except Exception as e:
            result.put(e)
    threading.Thread(target=worker, daemon=True).start()
    try:
        value = result.get(timeout=timeout)
    except queue.Empty:
        raise TimeoutError('POST 결과 불명')
    if isinstance(value, Exception):
        raise value
    return value

def recover(record, body, destination, save, transport=send,
            clock=time.monotonic, wall=time.time, sleep=time.sleep):
    started = clock()
    initial = record.get('used', 0.0)
    def checkpoint(status, **fields):
        record.update(status=status, used=initial + clock() - started, **fields)
        save()
    if record.get('status') == 'inflight':
        checkpoint('unknown', reason='이전 POST 결과 불명')
    if record.get('status') in ('unknown', 'stopped', 'review', 'done'):
        return
    while record.get('attempts', 0) < MAX_ATTEMPTS:
        remaining = BUDGET - initial - (clock() - started)
        delay = max(0.0, record.get('notBefore', 0.0) - wall())
        if remaining <= delay + 0.05:
            checkpoint('deferred', reason='누적 시간 예산 부족')
            return
        if delay:
            sleep(delay)
        remaining = BUDGET - initial - (clock() - started)
        checkpoint('inflight', attempts=record.get('attempts', 0) + 1)
        remaining = BUDGET - initial - (clock() - started)
        if remaining <= 0:
            checkpoint('deferred', reason='호출 전 시간 예산 소진')
            return
        try:
            status, headers, response = transport(body, min(CALL_TIMEOUT, remaining))
        except Exception:
            checkpoint('unknown', reason='POST 또는 응답 수신 결과 불명')
            return
        if status == 200:
            try:
                raw = image_bytes(response)
                temp = destination.with_suffix('.tmp')
                temp.write_bytes(raw)
                os.replace(temp, destination)
                checkpoint('done', sha256=hashlib.sha256(raw).hexdigest(),
                           file=str(destination), responseId=response.get('responseId'))
            except Exception:
                checkpoint('review', reason='응답 또는 이미지 저장 확인 필요',
                           promptFeedback=response.get('promptFeedback'),
                           finishReasons=[c.get('finishReason') for c in response.get('candidates', [])])
            return
        reason = stop_reason(status, response)
        if reason:
            checkpoint('stopped', reason=reason, error=response.get('error'))
            return
        # jitter는 서버 minimum 뒤에 더합니다. minimum을 상한으로 자르지 않습니다.
        delay = max(server_floor(headers, response, wall()),
                    min(16.0, 2 ** (record['attempts'] - 1))) + __import__('random').random()
        checkpoint('pending', notBefore=wall() + delay, reason=f'HTTP {status}')
    checkpoint('stopped', reason='총 시도 횟수 소진')

def main():
    jobs = json.loads(Path(sys.argv[1]).read_text())
    root = Path('python-results'); root.mkdir(exist_ok=True)
    statefile = root / 'state.json'
    states = json.loads(statefile.read_text()) if statefile.exists() else {}
    def save():
        temp = root / 'state.tmp'
        temp.write_text(json.dumps(states, ensure_ascii=False, indent=2))
        os.replace(temp, statefile)
    seen = set()
    for job in jobs:
        jid = job['id']
        if not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', jid) or jid in seen:
            raise ValueError('id는 중복 없는 영문·숫자·밑줄·하이픈이어야 합니다')
        seen.add(jid)
        body = payload(job['prompt'])
        digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
        record = states.setdefault(jid, {'status': 'pending', 'request': digest})
        if record['request'] != digest:
            raise ValueError('같은 id의 요청이 바뀌었습니다')
        target = root / f'{jid}.png'
        if record['status'] == 'done':
            if not target.exists() or hashlib.sha256(target.read_bytes()).hexdigest() != record['sha256']:
                record.update(status='review', reason='완료 파일 없음 또는 변경'); save()
        recover(record, body, target, save)
        print(jid, record['status'], record.get('reason', ''))
        if record['status'] in ('unknown', 'stopped'):
            break

if __name__ == '__main__':
    main()

Python은 POST와 JSON 수신을 별도 daemon thread에서 실행하고, 호출자가 전체 timeout까지만 기다립니다. 시간이 지나면 unknown으로 기록하고 재전송을 멈춥니다. 내부 네트워크 요청이 즉시 취소되었다는 뜻은 아니며 서버가 뒤에서 계속 처리할 수 있습니다. requests 자체의 socket timeout만 전체 실행 시간으로 오해하지 않도록 이 경계를 따로 둡니다.

Node.js: fetch의 본문 수신에도 timeout을 적용합니다

Node.js 20 이상에서 새 작업 디렉터리를 만들고 sharp를 설치합니다. 아래 전체 코드를 recover.mjs로 저장합니다. AbortController는 헤더를 받은 뒤 JSON 본문을 읽는 동안에도 유지합니다.

bash
npm init -y
npm install sharp
node recover.mjs jobs.json
javascript
import fs from 'node:fs/promises';
import { createHash, randomInt } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';

const MODEL = 'gemini-3-pro-image';
const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
const MAX_ATTEMPTS = 5, BUDGET = 180, CALL_TIMEOUT = 60;
const hash = data => createHash('sha256').update(data).digest('hex');
export const payload = prompt => ({
  contents: [{ role: 'user', parts: [{ text: prompt }] }],
  generationConfig: { responseModalities: ['TEXT', 'IMAGE'],
    imageConfig: { aspectRatio: '16:9', imageSize: '1K' } }
});
export function stopReason(status, body) {
  if (![429, 503].includes(status)) return `HTTP ${status}: 수동 확인`;
  for (const d of body.error?.details ?? []) {
    if (!String(d['@type']).endsWith('QuotaFailure')) continue;
    for (const v of d.violations ?? []) {
      if (v.serviceDisabled === true) return '서비스 비활성';
      if (Object.hasOwn(v, 'quotaValue') && ['0', '0.0'].includes(String(v.quotaValue)))
        return '명시적 한도 0';
      const text = [v.quotaMetric, v.quotaId, v.description].join(' ');
      if (/per.?day|daily|\bRPD\b/i.test(text)) return '일일 한도';
    }
  }
  if (/limit\s*:\s*0(?:\D|$)|daily|per.?day|billing|payment|credits/i.test(body.error?.message ?? ''))
    return '한도 또는 결제 상태 확인';
  return null;
}
export function serverFloor(headers, body, now) {
  const value = Object.entries(headers).find(([k]) => k.toLowerCase() === 'retry-after')?.[1];
  let floor = 0;
  if (value !== undefined) {
    const seconds = /^\d+$/.test(String(value)) ? Number(value) : Date.parse(value) / 1000 - now;
    if (Number.isFinite(seconds)) floor = Math.max(floor, seconds);
  }
  for (const d of body.error?.details ?? []) {
    if (!String(d['@type']).endsWith('RetryInfo')) continue;
    const m = /^(\d+(?:\.\d+)?)s$/.exec(d.retryDelay ?? '');
    if (m) floor = Math.max(floor, Number(m[1]));
  }
  return floor;
}
export async function imageBytes(body) {
  for (const c of body.candidates ?? []) {
    for (const p of c.content?.parts ?? []) {
      const b = p.inlineData;
      if (!b || !['image/png', 'image/jpeg', 'image/webp'].includes(b.mimeType)) continue;
      if (typeof b.data !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(b.data))
        throw new Error('유효하지 않은 base64');
      return await sharp(Buffer.from(b.data, 'base64'), { failOn: 'warning' }).png().toBuffer();
    }
  }
  throw new Error('이미지 없음: promptFeedback와 finishReason 확인');
}
async function send(body, timeout) {
  if (!process.env.GEMINI_API_KEY) throw new Error('GEMINI_API_KEY 필요');
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout * 1000);
  try {
    const r = await fetch(URL, { method: 'POST', redirect: 'error', signal: controller.signal,
      headers: { 'Content-Type': 'application/json', 'x-goog-api-key': process.env.GEMINI_API_KEY },
      body: JSON.stringify(body) });
    const response = await r.json(); // 본문 수신까지 timeout이 적용됩니다.
    return [r.status, Object.fromEntries(r.headers), response];
  } finally { clearTimeout(timer); }
}
export async function recover(record, body, destination, save, deps = {}) {
  const clock = deps.clock ?? (() => performance.now() / 1000);
  const wall = deps.wall ?? (() => Date.now() / 1000);
  const sleep = deps.sleep ?? (s => new Promise(resolve => setTimeout(resolve, s * 1000)));
  const transport = deps.transport ?? send;
  const started = clock(), initial = record.used ?? 0;
  const checkpoint = async (status, fields = {}) => {
    Object.assign(record, fields, { status, used: initial + clock() - started });
    await save();
  };
  if (record.status === 'inflight') await checkpoint('unknown', { reason: '이전 POST 결과 불명' });
  if (['unknown', 'stopped', 'review', 'done'].includes(record.status)) return;
  while ((record.attempts ?? 0) < MAX_ATTEMPTS) {
    let remaining = BUDGET - initial - (clock() - started);
    const delay = Math.max(0, (record.notBefore ?? 0) - wall());
    if (remaining <= delay + 0.05) {
      await checkpoint('deferred', { reason: '누적 시간 예산 부족' }); return;
    }
    if (delay) await sleep(delay);
    remaining = BUDGET - initial - (clock() - started);
    await checkpoint('inflight', { attempts: (record.attempts ?? 0) + 1 });
    remaining = BUDGET - initial - (clock() - started);
    if (remaining <= 0) {
      await checkpoint('deferred', { reason: '호출 전 시간 예산 소진' }); return;
    }
    let status, headers, response;
    try { [status, headers, response] = await transport(body, Math.min(CALL_TIMEOUT, remaining)); }
    catch { await checkpoint('unknown', { reason: 'POST 또는 응답 수신 결과 불명' }); return; }
    if (status === 200) {
      try {
        const bytes = await imageBytes(response);
        await fs.writeFile(`${destination}.tmp`, bytes);
        await fs.rename(`${destination}.tmp`, destination);
        await checkpoint('done', { file: destination, sha256: hash(bytes), responseId: response.responseId });
      } catch { await checkpoint('review', { reason: '응답 또는 이미지 저장 확인 필요',
        promptFeedback: response.promptFeedback,
        finishReasons: (response.candidates ?? []).map(c => c.finishReason) }); }
      return;
    }
    const reason = stopReason(status, response);
    if (reason) { await checkpoint('stopped', { reason, error: response.error }); return; }
    const wait = Math.max(serverFloor(headers, response, wall()),
      Math.min(16, 2 ** (record.attempts - 1))) + randomInt(0, 1000) / 1000;
    await checkpoint('pending', { notBefore: wall() + wait, reason: `HTTP ${status}` });
  }
  await checkpoint('stopped', { reason: '총 시도 횟수 소진' });
}
async function main() {
  const jobs = JSON.parse(await fs.readFile(process.argv[2], 'utf8'));
  const root = 'node-results', statefile = `${root}/state.json`;
  await fs.mkdir(root, { recursive: true });
  let states = {};
  try { states = JSON.parse(await fs.readFile(statefile, 'utf8')); }
  catch (e) { if (e.code !== 'ENOENT') throw e; }
  const save = async () => {
    await fs.writeFile(`${statefile}.tmp`, JSON.stringify(states, null, 2));
    await fs.rename(`${statefile}.tmp`, statefile);
  };
  const seen = new Set();
  for (const job of jobs) {
    const jid = job.id;
    if (!/^[A-Za-z0-9_-]{1,64}$/.test(jid) || seen.has(jid)) throw new Error('잘못된 id 또는 중복 id');
    seen.add(jid);
    const body = payload(job.prompt), digest = hash(JSON.stringify(body));
    const record = states[jid] ??= { status: 'pending', request: digest };
    if (record.request !== digest) throw new Error('같은 id의 요청이 바뀌었습니다');
    const target = `${root}/${jid}.png`;
    if (record.status === 'done') {
      try { if (hash(await fs.readFile(target)) !== record.sha256) throw new Error('변경된 파일'); }
      catch { Object.assign(record, { status: 'review', reason: '완료 파일 없음 또는 변경' }); await save(); }
    }
    await recover(record, body, target, save);
    console.log(jid, record.status, record.reason ?? '');
    if (['unknown', 'stopped'].includes(record.status)) break;
  }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();

중단된 작업을 이어 갈 때 확인할 상태

완료 이미지와 SHA-256 기록은 보존하고 대기 중 작업과 결과가 불명확한 POST를 따로 처리하는 작업 기록 예시

실행 결과는 python-results 또는 node-results의 이미지와 state.json에 저장됩니다. review에는 promptFeedback와 candidate의 finishReasons, 한도·결제 중단에는 원래 error도 남기므로 상태 파일에서 확인합니다. 키는 기록하지 않습니다. 다시 실행하면 같은 요청의 done 상태와 파일의 SHA-256을 대조하고, 확인된 완료 작업을 건너뜁니다. 완료 파일이 없거나 바뀌었으면 review가 되어 자동 재생성을 하지 않습니다. 상태 파일은 임시 파일을 쓴 뒤 rename으로 교체하므로 같은 프로세스의 부분 기록을 줄일 수 있습니다.

상태재실행 동작사람이 확인할 내용
done파일이 기록된 해시와 같으면 건너뜁니다.저장된 이미지를 사용할 수 있는지 확인합니다.
pending저장된 최소 대기 시점 이후 남은 예산·횟수 안에서 진행합니다.원래 작업과 설정을 유지합니다.
deferred서버 대기와 남은 예산을 다시 비교하며, 예산이 부족하면 계속 보류합니다.후속 실행 계획과 새 예산 승인이 필요합니다. 자동으로 예산을 초기화하지 않습니다.
stopped해당 작업을 다시 호출하지 않습니다.한도·결제·서비스 문제 또는 총 시도 소진의 원인을 처리합니다.
review해당 작업을 다시 호출하지 않습니다.이미지 없음, 저장 실패, 완료 파일 유실·변경을 조사합니다.
unknown 또는 이전 inflight재전송하지 않습니다.POST 결과와 중복 생성·비용 가능성을 조사합니다.

unknown과 stopped가 나오면 뒤에 남은 작업도 해당 실행에서는 멈춥니다. review는 다음 작업으로 진행하므로 해당 작업을 따로 확인하세요. 문제를 해결한 뒤에는 원래 결과·상태를 백업하고 재개할 대상과 예산을 사람이 결정합니다. 완료 기록을 지우는 방식으로 배치를 처음부터 다시 돌리지 않습니다.

이 코드가 exactly-once 생성이나 중복 과금 방지를 보장하지는 않습니다. POST가 서버에 도착한 뒤 응답을 받지 못했거나, 이미지를 쓴 직후 완료 상태를 쓰기 전에 프로세스가 종료되면 처리 결과가 불명확해집니다. 저장된 inflight는 재실행 시 unknown으로 전환하여 자동 재전송을 막습니다. 파일 rename은 로컬 기록의 일관성을 돕지만, 서버 생성과 로컬 기록을 하나의 트랜잭션으로 만들거나 전원 장애에 대한 디스크 내구성을 보장하지 않습니다.

예제의 검증에서는 실제 생성 서버를 호출하지 않고, 정상 이미지 바이트가 들어 있는 가짜 응답을 사용했습니다. Python과 Node 모두 명시적 0·일일 한도·402 중단, 누락된 quotaValue의 비영 처리, Retry-After 초·날짜와 RetryInfo의 긴 대기 적용, 예산 초과 보류, 503 최대 시도 소진, 이미지 없는 200과 손상된 데이터의 중단, 불명확한 timeout·이전 실행 중 상태의 보류, 확인된 완료 작업의 건너뛰기를 확인했습니다. 실제 계정의 할당량, 생성 지연, 비용과 회복률을 측정한 결과는 아닙니다.

자주 묻는 질문

429에 RetryInfo가 있으면 기다리기만 하면 되나요?

아닙니다. 명시적 한도 0이나 일일 한도, 결제·서비스 상태 문제라면 먼저 그 조건을 처리해야 합니다. 짧은 retryDelay가 함께 있어도 이용 자격이나 새 일일 한도가 생긴다는 보장이 아닙니다. 원인이 일시적인 제한이고 재시도가 가능할 때만 서버의 최소 대기를 적용합니다. Google RPC 오류 상세

같은 프로젝트에서 키를 더 만들면 처리량이 늘어나나요?

늘어나지 않습니다. Developer API 한도는 프로젝트 단위로 적용되므로 같은 프로젝트의 여러 키가 같은 한도를 공유합니다. 키가 잘못된 프로젝트에 속한 경우에는 설정을 바로잡아야 하지만, 새 키를 만드는 행위 자체가 한도를 늘리지는 않습니다. 공식 프로젝트 한도

200인데 파일이 없으면 재시도해도 되나요?

먼저 모든 candidate의 image part, promptFeedback, finishReason, 디코더와 저장 경로를 확인합니다. 이미지가 없는 성공 응답이나 저장 실패를 429처럼 자동 재시도하면 유효한 결과를 놓치거나 새 생성 비용을 만들 수 있습니다. 위 예제는 이 경우 review로 멈춥니다.

계속 실패하면 다른 이미지 모델로 자동 전환해도 되나요?

사용 가능한 모델·프로젝트 한도, 이미지 크기, 비용과 결과 요구를 확인한 뒤 별도 작업으로 결정해야 합니다. 예제는 임의 fallback을 실행하지 않습니다. 2026년 10월 5일 기준 가격표에는 gemini-2.5-flash-image의 10월 2일 종료 일정이 있으며, 현재 이미지 가이드는 Gemini API의 Imagen이 종료되었다고 설명합니다. 오래된 대체 모델 코드를 복사해 현재 복구 경로로 사용하지 마세요. 모델 종료 안내, 이미지 모델 선택

참고 자료10

이 글이 링크한 외부 페이지를 본문에 나온 순서대로 정리했습니다. 마지막 업데이트: 2026년 10월 5일.

  1. 1.Google RPC 오류 상세 정의github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
  2. 2.AI Studio Rate Limitaistudio.google.com/rate-limit
  3. 3.공식 한도 안내ai.google.dev/gemini-api/docs/rate-limits
  4. 4.Pro Image 가격표ai.google.dev/gemini-api/docs/pricing
  5. 5.결제 상태 안내ai.google.dev/gemini-api/docs/billing
  6. 6.현재 Cloud 429 문서docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429
  7. 7.HTTP Retry-After 규정rfc-editor.org/rfc/rfc9110
  8. 8.공식 재시도 안내ai.google.dev/gemini-api/docs/troubleshooting
  9. 9.GenerateContent API 정의ai.google.dev/api/generate-content
  10. 10.이미지 생성 가이드ai.google.dev/gemini-api/docs/image-generation