Skip to main content

Nano Banana Pro RESOURCE_EXHAUSTED: Fix 429 and Resume Image Jobs

For Nano Banana Pro RESOURCE_EXHAUSTED errors, check the actual project, model, and exhausted limit before retrying. Pause zero allowances, daily limits, and billing blocks; retry transient pressure within a time budget and save confirmed images so a restart does not generate them again.

LaoZhang AI TeamPublishedUpdated 12 min read
On this page
Illustration of Nano Banana Pro resource-limit diagnosis and preserved completed images

To recover a Nano Banana Pro image job that returns 429 RESOURCE_EXHAUSTED, first identify what stopped the actual request. A consumed minute allowance can justify a bounded retry; an explicit zero allowance, daily quota, or billing block needs a different action. Keep completed images, respect any server-requested waiting time, and resume only work whose outcome you can establish.

For the Gemini Developer API, use the failing credential's project and exact image model in AI Studio active rate limits. Google's rate-limit rules apply per project, rather than per key, and cover separate request, input-token, daily, and model-specific image limits. A low request count alone does not establish available image capacity.

The Python and Node.js examples below send GenerateContent REST requests, save decoded image files, and keep a persistent job ledger. They were checked with simulated responses and offline recovery tests, without a live API call or account recovery test. Documentation was checked on October 5, 2026.

Find the boundary before choosing a retry

Save the endpoint, API version, exact model ID, UTC timestamp, HTTP status, and sanitized error body. Also record the quota metric, quota ID, dimensions, retry hint, and response/request ID when provided. Keep keys and private prompts out of public incident reports.

What the response and account checks establishNext action
A positive minute allowance was consumed by a burstReduce concurrent workers and spread arrivals; permit a bounded retry after the waiting floor.
An explicit applied quota limit is zeroPause the image workload. Verify runtime key, project, billing status, model eligibility, and active allowance.
A daily request quota was consumedPause until the applicable reset or an approved capacity change; a two-second loop cannot replenish it.
A credit, billing, monthly-cap, or spend control is blocking useDiagnose that control before requeueing. Waiting does not complete missing billing setup.
A 503 reports temporary unavailabilityUse bounded backoff; preserve unfinished jobs if the retry budget runs out.
A POST timed out, the connection failed, or the worker crashed before recording its resultMark the outcome unconfirmed and reconcile it before resending.

For Developer API RPD, Google's documented reset is midnight Pacific time. Other limits have different windows: applicable spend-based limits use a rolling ten-minute window, while project and account caps have their own semantics. Match the error to the correct control. Rate-limit documentation, billing documentation.

An image model also needs eligible access. The current gemini-3-pro-image pricing entry marks its free-tier input and output as unavailable. A text request succeeding on that key does not prove Pro image access. If you are paid but still receive free_tier_requests with a zero limit, follow the separate paid-project zero-quota checks before enabling repeated image requests.

Decision guide for pausing hard limits, retrying transient pressure and reconciling unknown results

Choose the next action from the actual error and project/model allowance. A hard stop or unknown outcome needs review; a transient condition can justify a bounded retry.

A Cloud 429 can mean unavailable shared capacity

Establish the service before applying AI Studio advice. The code below uses the Developer API at generativelanguage.googleapis.com. A Vertex or other Google Cloud route has a different capacity contract, and a proxy can add its own limits.

Google's Cloud 429 guide explains that pay-as-you-go requests can return 429 when processing capacity is unavailable. It recommends smoothing spikes, gradual traffic ramp-up, and truncated exponential backoff. For eligible deployments, a global endpoint or Provisioned Throughput can be an option, subject to model, region, service, and purchase requirements. Provisioned Throughput overages can be served and billed as pay-as-you-go; some capacity errors below purchased throughput use 5xx under the applicable framework. Therefore, neither “all 429s are my quota” nor “all 503s mean a global outage” is a sound diagnosis.

Read the quota detail and preserve the server's waiting floor

A GenerateContent failure can use this error envelope:

json
{
  "error": {
    "code": 429,
    "status": "RESOURCE_EXHAUSTED",
    "message": "Resource has been exhausted (e.g. check quota)."
  }
}

That status identifies the failure category; it does not identify a quota dimension. In a structured google.rpc.QuotaFailure, inspect actual violations and fields such as quotaMetric, quotaId, quotaDimensions, and quotaValue when returned. The Google RPC definition defines quota_value as the enforced limit at the time of failure, rather than a used or remaining counter. A missing quotaValue is unknown, not zero. Services and versions may omit fields or use different details, so preserve the original response and adapt parsing to what your service actually sends.

When supplied, an HTTP Retry-After can be a number of seconds or an HTTP date. A Google RPC RetryInfo.retryDelay asks the client to wait at least that duration after receiving the error. The examples take the larger of the recognized provider hints and the local backoff. They pause on an unrecognized hint instead of silently ignoring it. HTTP Retry-After definition, RetryInfo definition.

The critical budget rule is wait plus the next call's timeout must fit the remaining budget. If the server asks for 200 seconds and the job has a 120-second budget, defer the job; do not shorten the wait to 30 seconds just because that is your backoff cap. Two-, four-, or eight-second sleeps are scheduling choices, not promises that capacity will return.

Python: bounded retries with decoded images and a saved ledger

This is a sequential, single-worker text-to-image example for GenerateContent. It uses contents, generationConfig.imageConfig.imageSize, and the returned candidates[].content.parts[].inlineData. The current GenerateContent reference defines those fields; the image guide identifies Nano Banana Pro as gemini-3-pro-image and supports 1K, 2K, and 4K output. This worker requests 1K with text and image modalities.

The image guide also documents the Interactions API, whose request uses input and response_format. Its SDK output_image convenience is not the GenerateContent REST response. Do not change only the URL and keep this parser when migrating APIs.

Use Python 3.11 or later and install the dependencies. The offline checks used Python 3.12.1, HTTPX 0.28.1, and Pillow 12.0.0:

bash
python3 -m pip install httpx==0.28.1 Pillow==12.0.0

Save this input as jobs.json. Give each immutable job a unique ID. Set retry429 to true only after checking that its 429 is plausibly transient and the project/model has usable access; otherwise leave it false. Structured zero/daily/billing signals still stop the worker even when that flag is true.

json
[
  {"id": "product-front", "prompt": "A studio product photograph of a green ceramic cup on a white background", "retry429": false},
  {"id": "product-side", "prompt": "A side-view studio photograph of a green ceramic cup on a white background", "retry429": false}
]

Save the following as recover.py. Supply your already verified credential through GEMINI_API_KEY in the worker's environment. Both examples explicitly select that variable, rather than relying on SDK detection of GOOGLE_API_KEY versus GEMINI_API_KEY.

python
import asyncio, base64, hashlib, io, json, os, random, re, sys, time
from email.utils import parsedate_to_datetime
from pathlib import Path
import httpx
from PIL import Image

MODEL = "gemini-3-pro-image"
CONFIG = {"responseModalities": ["TEXT", "IMAGE"],
          "imageConfig": {"aspectRatio": "1:1", "imageSize": "1K"}}
ATTEMPTS, BUDGET, CALL_SECONDS = 4, 120.0, 30.0
URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent"

def digest(data):
    return hashlib.sha256(data).hexdigest()

def atomic(path, data):
    tmp = path.with_name(path.name + ".tmp")
    tmp.write_bytes(data)
    tmp.replace(path)

def decode_image(blob):
    formats = {"image/png": ("PNG", "png"),
               "image/jpeg": ("JPEG", "jpg"), "image/webp": ("WEBP", "webp")}
    fmt, ext = formats[blob["mimeType"]]
    raw = base64.b64decode(blob["data"], validate=True)
    if not raw or len(raw) > 20_000_000:
        raise ValueError("Image payload is empty or exceeds this worker's 20 MB limit")
    with Image.open(io.BytesIO(raw)) as image:
        if image.format != fmt or image.width * image.height > 50_000_000:
            raise ValueError("Image format or dimensions failed validation")
        image.load()  # Decode pixels, rather than trusting MIME or a file extension.
    return raw, ext

def first_image(body):
    for candidate in body.get("candidates", []):
        for part in candidate.get("content", {}).get("parts", []):
            if not part.get("thought") and "inlineData" in part:
                return decode_image(part["inlineData"])
    raise ValueError("No generated inline image; inspect promptFeedback and finishReason")

def stop_reason(status, body, allow429):
    error = body.get("error", {})
    violations = [v for d in error.get("details", [])
                  if d.get("@type", "").endswith("QuotaFailure")
                  for v in d.get("violations", [])]
    if any("quotaValue" in v and str(v["quotaValue"]) == "0" for v in violations):
        return "explicit zero quota"
    if re.search(r"\blimit\s*:\s*0\b", error.get("message", ""), re.I):
        return "reported zero limit"
    quota_text = " ".join(str(v.get(k, "")) for v in violations
                          for k in ("quotaMetric", "quotaId", "description"))
    text = quota_text + " " + error.get("message", "")
    if re.search(r"per.?day|daily|requests/day", text, re.I):
        return "daily quota requires review/reset"
    if status == 402 or re.search(r"billing|payment|credit|spend|monthly", text, re.I):
        return "billing or spend control requires review"
    if status == 429 and not allow429:
        return "429 not yet confirmed transient"
    if status not in (429, 503):
        return f"HTTP {status} requires diagnosis"
    return None

def wait_floor(headers, body, received):
    waits = [0.0]
    value = next((v for k, v in headers.items() if k.lower() == "retry-after"), None)
    if value is not None:
        if not re.fullmatch(r"\d+", value) and not re.fullmatch(
                r"[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT", value):
            raise ValueError("Unrecognized Retry-After; review before retry")
        waits.append(float(value) if re.fullmatch(r"\d+", value)
                     else max(0.0, parsedate_to_datetime(value).timestamp() - received))
    for detail in body.get("error", {}).get("details", []):
        if detail.get("@type", "").endswith("RetryInfo"):
            delay = detail.get("retryDelay", "")
            if not isinstance(delay, str) or not re.fullmatch(r"\d+(\.\d+)?s", delay):
                raise ValueError("Unrecognized RetryInfo duration; review before retry")
            waits.append(float(delay[:-1]))
    return max(waits)

async def send(payload, timeout, key):
    # No SDK retry loop, environment proxy detection, or redirect following.
    async with httpx.AsyncClient(trust_env=False, follow_redirects=False) as client:
        async with asyncio.timeout(timeout):
            response = await client.post(URL, json=payload,
                headers={"x-goog-api-key": key}, timeout=timeout)
            return response.status_code, dict(response.headers), response.json()

async def recover(jobs, folder, transport, clock=time.monotonic,
                  wall=time.time, sleep=asyncio.sleep, jitter=random.random):
    folder = Path(folder)
    folder.mkdir(parents=True, exist_ok=True)
    ledger = folder / "state.json"
    records = json.loads(ledger.read_text()) if ledger.exists() else {}
    def checkpoint():
        atomic(ledger, json.dumps(records, indent=2).encode())
    ids = [job["id"] for job in jobs]
    if len(set(ids)) != len(ids) or any(not re.fullmatch(r"[\w-]{1,64}", i, re.ASCII) for i in ids):
        raise ValueError("Use unique ASCII job IDs: letters, digits, underscore or dash")
    for job in jobs:
        if not isinstance(job["prompt"], str) or not job["prompt"].strip():
            raise ValueError("A text prompt is required")
        payload = {"contents": [{"parts": [{"text": job["prompt"]}]}],
                   "generationConfig": CONFIG}
        signature = digest(json.dumps([MODEL, payload], sort_keys=True).encode())
        record = records.setdefault(job["id"], {"signature": signature,
            "status": "pending", "attempts": 0, "spent": 0.0})
        if record["signature"] != signature:
            raise ValueError("Job changed: use a new ID for a new prompt/model/config")
        if record["status"] == "done":
            try:
                name = record["file"]
                if Path(name).name != name:
                    raise ValueError("Unsafe saved path")
                raw = (folder / name).read_bytes()
                if digest(raw) != record["sha256"]:
                    raise ValueError("Saved image changed")
                decode_image({"mimeType": record["mimeType"],
                              "data": base64.b64encode(raw).decode()})
                continue  # Confirmed local image: do not generate it again.
            except Exception as exc:
                record.update(status="review", note=str(exc))
                checkpoint()
                break
        if record["status"] != "pending":
            # A previous inflight/retrying state could be a crash during a POST.
            record["note"] = "Stopped: reconcile this saved state before requeueing"
            checkpoint()
            break
        if record.get("notBefore", 0) > wall():
            record.update(status="deferred", note="Provider waiting floor still active")
            checkpoint()
            break
        started, prior = clock(), record["spent"]
        def elapsed():
            return prior + clock() - started
        while True:
            if record["attempts"] >= ATTEMPTS or BUDGET - elapsed() < CALL_SECONDS:
                record.update(status="deferred", spent=elapsed(), note="Attempt/time budget exhausted")
                break
            record.update(status="inflight", attempts=record["attempts"] + 1, spent=elapsed())
            checkpoint()  # Persist intent BEFORE the paid POST.
            try:
                status, headers, body = await transport(payload, CALL_SECONDS)
                if not isinstance(body, dict):
                    raise ValueError("Response JSON is not an object")
            except Exception as exc:
                record.update(status="unknown", spent=elapsed(), note=type(exc).__name__)
                break  # A timeout/connection failure does not prove no generation occurred.
            record["spent"] = elapsed()
            if status == 200:
                try:
                    raw, ext = first_image(body)
                    name = f"{job['id']}.{ext}"
                    atomic(folder / name, raw)
                    record.update(status="done", file=name, sha256=digest(raw),
                        mimeType={"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}[ext],
                        responseId=body.get("responseId"), spent=elapsed())
                except Exception as exc:
                    record.update(status="review", note=str(exc), response=body, spent=elapsed())
                break
            record["response"] = body
            reason = stop_reason(status, body, job.get("retry429") is True)
            if reason:
                record.update(status="paused", note=reason)
                break
            try:
                minimum = wait_floor(headers, body, wall())
            except Exception as exc:
                record.update(status="paused", note=str(exc))
                break
            delay = max(minimum, min(2 ** record["attempts"], 30) + jitter())
            record["notBefore"] = wall() + delay
            if record["attempts"] >= ATTEMPTS or delay + CALL_SECONDS > BUDGET - elapsed():
                record.update(status="deferred", note="Wait plus next call exceeds budget, or no attempts left")
                break
            record.update(status="retrying", note=f"Waiting {delay:.3f}s")
            checkpoint()
            await sleep(delay)
        checkpoint()
        if record["status"] != "done":
            break  # Stop the queue; other jobs must not bypass the same project boundary.
    return records

if __name__ == "__main__":
    jobs = json.loads(Path(sys.argv[1]).read_text())
    key = os.environ["GEMINI_API_KEY"]  # Explicit choice; do not print this value.
    async def transport(payload, timeout):
        return await send(payload, timeout, key)
    result = asyncio.run(recover(jobs, sys.argv[2], transport))
    print({job_id: record["status"] for job_id, record in result.items()})

Run it with the input file and a private output directory:

bash
python3 recover.py jobs.json out-python

The worker prints job statuses. Successful jobs produce an image and a done entry in out-python/state.json, including the input fingerprint, file hash, MIME type, and response ID when returned. It decodes PNG, JPEG, or WebP pixels before accepting a file. HTTP 200 without a generated image, malformed base64, an undecodable image, or a MIME mismatch produces review, rather than a false success.

Node.js: the same recovery task with fetch and image decoding

Use Node.js 24 and install Sharp. The offline checks used Node.js 24.11.0 and Sharp 0.35.4:

bash
npm install sharp@0.35.4

Save this as recover.mjs beside jobs.json. It uses the same documented GenerateContent request and returned-image fields. Native fetch sends one request per attempt; its abort signal also covers reading the response body.

javascript
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import sharp from "sharp";

const MODEL = "gemini-3-pro-image";
const CONFIG = { responseModalities: ["TEXT", "IMAGE"],
  imageConfig: { aspectRatio: "1:1", imageSize: "1K" } };
const ATTEMPTS = 4, BUDGET = 120, CALL_SECONDS = 30;
const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const atomic = (file, bytes) => {
  fs.writeFileSync(`${file}.tmp`, bytes);
  fs.renameSync(`${file}.tmp`, file);
};

async function decodeImage(blob) {
  const formats = { "image/png": ["png", "png"],
    "image/jpeg": ["jpeg", "jpg"], "image/webp": ["webp", "webp"] };
  const format = formats[blob.mimeType];
  if (!format || typeof blob.data !== "string" || !blob.data.length ||
      !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(blob.data)) {
    throw new Error("Unsupported MIME or invalid base64 image");
  }
  const bytes = Buffer.from(blob.data, "base64");
  if (bytes.length > 20_000_000) throw new Error("Image exceeds this worker's 20 MB limit");
  const image = sharp(bytes, { failOn: "warning", limitInputPixels: 50_000_000 });
  const meta = await image.metadata();
  if (meta.format !== format[0] || !meta.width || !meta.height) {
    throw new Error("Decoded format or dimensions do not match");
  }
  await image.raw().toBuffer(); // Decode pixels; metadata alone is insufficient.
  return { bytes, ext: format[1], mimeType: blob.mimeType };
}

async function firstImage(body) {
  for (const candidate of body.candidates ?? []) {
    for (const part of candidate.content?.parts ?? []) {
      if (!part.thought && part.inlineData) return decodeImage(part.inlineData);
    }
  }
  throw new Error("No generated inline image; inspect promptFeedback and finishReason");
}

function stopReason(status, body, allow429) {
  const error = body.error ?? {};
  const violations = (error.details ?? [])
    .filter((d) => d["@type"]?.endsWith("QuotaFailure"))
    .flatMap((d) => d.violations ?? []);
  if (violations.some((v) => Object.hasOwn(v, "quotaValue") && String(v.quotaValue) === "0")) {
    return "explicit zero quota";
  }
  if (/\blimit\s*:\s*0\b/i.test(error.message ?? "")) return "reported zero limit";
  const text = violations.flatMap((v) => [v.quotaMetric, v.quotaId, v.description])
    .join(" ") + " " + (error.message ?? "");
  if (/per.?day|daily|requests\/day/i.test(text)) return "daily quota requires review/reset";
  if (status === 402 || /billing|payment|credit|spend|monthly/i.test(text)) {
    return "billing or spend control requires review";
  }
  if (status === 429 && !allow429) return "429 not yet confirmed transient";
  if (![429, 503].includes(status)) return `HTTP ${status} requires diagnosis`;
  return null;
}

function waitFloor(headers, body, received) {
  const waits = [0];
  const entry = Object.entries(headers).find(([k]) => k.toLowerCase() === "retry-after");
  if (entry) {
    const value = entry[1];
    if (!/^\d+$/.test(value) && !/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value)) {
      throw new Error("Unrecognized Retry-After; review before retry");
    }
    const seconds = /^\d+$/.test(value) ? Number(value) : Date.parse(value) / 1000 - received;
    if (!Number.isFinite(seconds)) throw new Error("Unrecognized Retry-After; review before retry");
    waits.push(Math.max(0, seconds));
  }
  for (const detail of body.error?.details ?? []) {
    if (detail["@type"]?.endsWith("RetryInfo")) {
      const delay = detail.retryDelay;
      if (typeof delay !== "string" || !/^\d+(\.\d+)?s$/.test(delay)) {
        throw new Error("Unrecognized RetryInfo duration; review before retry");
      }
      waits.push(Number(delay.slice(0, -1)));
    }
  }
  return Math.max(...waits);
}

export async function send(payload, timeout, key) {
  const response = await fetch(URL, { method: "POST", redirect: "error",
    headers: { "Content-Type": "application/json", "x-goog-api-key": key },
    body: JSON.stringify(payload), signal: AbortSignal.timeout(timeout * 1000) });
  return [response.status, Object.fromEntries(response.headers), await response.json()];
}

export async function recover(jobs, folder, transport, options = {}) {
  const clock = options.clock ?? (() => performance.now() / 1000);
  const wall = options.wall ?? (() => Date.now() / 1000);
  const sleep = options.sleep ?? ((s) => new Promise((r) => setTimeout(r, s * 1000)));
  const jitter = options.jitter ?? Math.random;
  fs.mkdirSync(folder, { recursive: true });
  const ledger = path.join(folder, "state.json");
  const records = fs.existsSync(ledger) ? JSON.parse(fs.readFileSync(ledger, "utf8")) : {};
  const checkpoint = () => atomic(ledger, JSON.stringify(records, null, 2));
  const ids = jobs.map((j) => j.id);
  if (new Set(ids).size !== ids.length || ids.some((id) => !/^[A-Za-z0-9_-]{1,64}$/.test(id))) {
    throw new Error("Use unique ASCII job IDs: letters, digits, underscore or dash");
  }
  for (const job of jobs) {
    if (typeof job.prompt !== "string" || !job.prompt.trim()) throw new Error("A text prompt is required");
    const payload = { contents: [{ parts: [{ text: job.prompt }] }], generationConfig: CONFIG };
    const signature = digest(JSON.stringify([MODEL, payload]));
    const record = records[job.id] ??= { signature, status: "pending", attempts: 0, spent: 0 };
    if (record.signature !== signature) throw new Error("Job changed: use a new ID for new inputs");
    if (record.status === "done") {
      try {
        if (path.basename(record.file) !== record.file) throw new Error("Unsafe saved path");
        const bytes = fs.readFileSync(path.join(folder, record.file));
        if (digest(bytes) !== record.sha256) throw new Error("Saved image changed");
        await decodeImage({ mimeType: record.mimeType, data: bytes.toString("base64") });
        continue;
      } catch (error) {
        Object.assign(record, { status: "review", note: error.message });
        checkpoint(); break;
      }
    }
    if (record.status !== "pending") {
      record.note = "Stopped: reconcile this saved state before requeueing";
      checkpoint(); break;
    }
    if ((record.notBefore ?? 0) > wall()) {
      Object.assign(record, { status: "deferred", note: "Provider waiting floor still active" });
      checkpoint(); break;
    }
    const started = clock(), prior = record.spent;
    const elapsed = () => prior + clock() - started;
    while (true) {
      if (record.attempts >= ATTEMPTS || BUDGET - elapsed() < CALL_SECONDS) {
        Object.assign(record, { status: "deferred", spent: elapsed(), note: "Attempt/time budget exhausted" });
        break;
      }
      Object.assign(record, { status: "inflight", attempts: record.attempts + 1, spent: elapsed() });
      checkpoint(); // Persist intent before sending the paid POST.
      let status, headers, body;
      try {
        [status, headers, body] = await transport(payload, CALL_SECONDS);
        if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid response JSON");
      } catch (error) {
        Object.assign(record, { status: "unknown", spent: elapsed(), note: error.name });
        break;
      }
      record.spent = elapsed();
      if (status === 200) {
        try {
          const image = await firstImage(body);
          const file = `${job.id}.${image.ext}`;
          atomic(path.join(folder, file), image.bytes);
          Object.assign(record, { status: "done", file, mimeType: image.mimeType,
            sha256: digest(image.bytes), responseId: body.responseId ?? null, spent: elapsed() });
        } catch (error) {
          Object.assign(record, { status: "review", note: error.message, response: body, spent: elapsed() });
        }
        break;
      }
      record.response = body;
      const reason = stopReason(status, body, job.retry429 === true);
      if (reason) { Object.assign(record, { status: "paused", note: reason }); break; }
      let minimum;
      try { minimum = waitFloor(headers, body, wall()); }
      catch (error) { Object.assign(record, { status: "paused", note: error.message }); break; }
      const delay = Math.max(minimum, Math.min(2 ** record.attempts, 30) + jitter());
      record.notBefore = wall() + delay;
      if (record.attempts >= ATTEMPTS || delay + CALL_SECONDS > BUDGET - elapsed()) {
        Object.assign(record, { status: "deferred", note: "Wait plus next call exceeds budget, or no attempts left" });
        break;
      }
      Object.assign(record, { status: "retrying", note: `Waiting ${delay.toFixed(3)}s` });
      checkpoint(); await sleep(delay);
    }
    checkpoint();
    if (record.status !== "done") break;
  }
  return records;
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  const jobs = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
  const key = process.env.GEMINI_API_KEY; // Explicit choice; never log it.
  if (!key) throw new Error("Set GEMINI_API_KEY in this worker's environment");
  const result = await recover(jobs, process.argv[3], (payload, timeout) => send(payload, timeout, key));
  console.log(Object.fromEntries(Object.entries(result).map(([id, r]) => [id, r.status])));
}

Run the Node.js worker in its own output directory:

bash
node recover.mjs jobs.json out-node

Do not run the two programs against the same ledger. Their fingerprints and implementations are independent; each directory belongs to one worker. These file ledgers demonstrate recovery for a single process, without concurrent writers or a distributed queue lock.

Resume confirmed unfinished work without losing completed images

Rerun the same program, input, and output directory. A done job is skipped only after its input fingerprint matches and its saved image passes hash and decoding checks. New, unattempted jobs can then run. Changing a prompt, model, or generation setting requires a new ID, so a previous image cannot silently count as the new result.

The worker stops the queue at the first unresolved job. Jobs later in jobs.json remain untouched; they may not yet have ledger entries. This prevents switching to the next prompt from bypassing the same project's waiting floor or hard limit.

Saved statusMeaning and next step
doneImage was decoded and saved. Keep its file and ledger entry; the next run verifies and skips it.
pausedA zero/daily/billing condition, unclassified 429, other HTTP failure, or malformed waiting hint needs diagnosis. Resolve it before requeueing.
deferredAttempts or active-time budget were exhausted, or the provider's waiting floor will not fit. Schedule a reviewed continuation without shortening notBefore.
reviewA successful HTTP response did not yield a valid supported image, or an earlier completed file is missing/changed. Inspect the response or restore the file; do not automatically regenerate it.
unknown, or a surviving inflight/retrying entryA result may have been lost during transport or a crash. Reconcile the request and any local files before deciding to send again.

Each job allows four attempts total, including the initial request, with a 120-second cumulative active-time budget covering calls and in-worker waits. Every call reserves a full 30-second local timeout. Attempt counts and recorded time persist across reviewed requeues; merely restarting the process does not reset them. The timeout limits how long the client waits for a response. It cannot cancel already accepted provider work or establish that an image was not billed. Image decoding and local disk operations are outside a strict network timeout, so this example is not a real-time scheduling guarantee.

To continue a paused job after resolving a known rejection, back up the ledger, leave verified done records intact, and change only the reviewed job's status to pending. Keep attempts, spent, and notBefore: the worker refuses to run before that timestamp, or after the remaining budget is exhausted. If you intentionally authorize a new attempt/time budget after reviewing the incident, reset those counters explicitly; do not automate that reset into an endless retry loop.

For an unknown outcome, first inspect saved files, the response/request ID if available, your application's request logs, and provider-visible usage or support evidence. GenerateContent's responseId identifies a response; it does not make this local job ID a provider idempotency key or guarantee retrieval of a lost synchronous response. If reconciliation remains impossible, preserve the unknown record and decide explicitly whether the possible duplicate generation and charge are acceptable. Neither worker promises exactly-once generation.

Image-job ledger statuses and the workflow for verifying and skipping completed files

Verify and skip saved images marked done. Pause, defer or review unresolved jobs; reconcile an unknown result before deciding to submit it again.

What was verified offline

The Python and Node.js workers each passed 31 offline cases using simulated responses, temporary ledgers, and injected clocks. They covered valid PNG/JPEG/WebP output; explicit zero versus missing quota fields; daily, payment, and spend stops; numeric and HTTP-date waiting floors; competing RetryInfo hints; waits longer than the budget; cumulative call-plus-wait time; persisted attempt limits; 200 without an image and invalid image bytes; ambiguous timeout/crash states; and resuming verified completed jobs while generating only new work. The transport adapters were also checked without network access for request assembly and local timeout cancellation.

Those checks establish the example's tested control flow and file recovery behavior. They do not establish live Pro availability, your applied quota, a successful paid request, image quality, or production deployment readiness. The zero/daily classifiers deliberately cover explicit structured fields and recognizable English messages; unknown or changed provider formats require inspection. Keep retry429 false until you can justify the transient branch.

When to add capacity or choose another image model

If bursts are the problem, first put a project-wide limit on concurrent requests and smooth the queue. One serial worker reduces its own concurrency, but other services using the same project still consume quota. Measure the aggregate workload; do not infer the provider's remaining quota from this worker's local counter. If a positive daily allowance is consistently inadequate, compare the active model limit with the required throughput and request an eligible increase. Google does not guarantee rate-limit increases.

For flexible output requirements, test a currently supported alternative such as gemini-3.1-flash-image, which Google's guide positions for general image generation, or gemini-3.1-flash-lite-image for efficiency. Verify paid eligibility, request schema, output size, editing needs, and acceptable results before routing real jobs. A model switch does not ensure an independent quota pool, available capacity, or identical images; the examples deliberately do not silently switch models.

As checked on October 5, standard Developer API image-output pricing is $0.134 for Pro at 1K/2K and $0.24 at 4K. Flash Image lists $0.067 at 1K and $0.101 at 2K; Flash Lite Image lists $0.0336 at 1K. These are documented output components, not total usable-image costs: input, text/thinking, grounding, rejected or duplicated work, and the selected serving mode can affect the bill. Pro pricing, Flash Image pricing, Flash Lite Image pricing.

Do not copy the older fallback chain from this page's original version. The current image guide says Imagen is no longer available through the Gemini API. The gemini-2.5-flash-image pricing entry lists an October 2, 2026 shutdown. A different Cloud product's model availability does not restore those Developer API paths, and changing a model name does not convert an Imagen request into GenerateContent.

If you consider another API provider, evaluate its actual credential ownership, model mapping, data handling, price, limits, and recovery semantics. An OpenAI-compatible endpoint or a similar model label does not make this GenerateContent parser interchangeable, or establish unlimited throughput. Keep any routing change separate from reconciling the original unknown request.

FAQ

Why does a paid Nano Banana Pro project still return RESOURCE_EXHAUSTED?

Payment does not remove all limits. A project can consume a request, token, image, daily, or applicable spend allowance, and the failing runtime can also be using a different project's key. Check the same project's exact model and active limits, then compare the full error. A reported zero free-tier allowance belongs in the paid-project zero-quota workflow. Google's rate-limit documentation also warns that specified limits are not guaranteed capacity; a Cloud route may instead involve shared processing capacity.

Will waiting two or eight seconds fix the error?

Only if the underlying condition can clear in that time. Use at least the supplied waiting hint and stay within a finite retry budget. Waiting cannot create a zero model allowance or complete billing setup; a daily Developer API request allowance resets at midnight Pacific, rather than on a short backoff timer. Waiting-hint semantics, daily quota rules.

Can I retry immediately after an image-generation timeout?

First reconcile the outcome. The provider may have accepted the request even though the client never received its image. Preserve the job, response/request identifiers and any files or logs; do not mark it definitely failed solely because the client timed out. The examples stop that job as unconfirmed, and a new run skips images already confirmed locally. If an unknown request cannot be reconciled, a new submission is an explicit decision to accept possible duplication, not a guaranteed safe retry.

Sources9

External pages this guide links to, in the order they appear. Last updated Oct 5, 2026.

  1. 1.AI Studio active rate limitsaistudio.google.com/rate-limit
  2. 2.rate-limit rulesai.google.dev/gemini-api/docs/rate-limits
  3. 3.billing documentationai.google.dev/gemini-api/docs/billing
  4. 4.gemini-3-pro-image pricing entryai.google.dev/gemini-api/docs/pricing
  5. 5.Cloud 429 guidedocs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429
  6. 6.Google RPC definitiongithub.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
  7. 7.HTTP Retry-After definitionrfc-editor.org/rfc/rfc9110
  8. 8.GenerateContent referenceai.google.dev/api/generate-content
  9. 9.image guideai.google.dev/gemini-api/docs/image-generation
More in Troubleshooting
Match a Gemini zero free-tier allowance error with the failing app’s project, billing state, and model quota
Troubleshooting

Gemini API Paid Tier Getting free_tier_requests Limit 0: What to Fix

A paid Gemini project can still return free_tier_requests limit 0 if the running app uses another project's key, billing setup is incomplete, or the requested model has no usable allowance. Match the request to AI Studio's project, billing status, and active model limits before retrying or replacing keys.

12 min