跳转到主要内容

Nano Banana Pro RESOURCE_EXHAUSTED 429:排查与 Python、Node.js 恢复代码

Nano Banana Pro 的 429 不能一律靠重试解决:零额度、每日上限和计费阻塞应先停止,短时限流才按服务端最低等待时间重试。下面的 Python、Node.js 程序限制次数与请求等待总时长,验证并保存图片,续跑时跳过已确认完成的任务。

LaoZhang AI Team发布于更新于 18 分钟阅读
文章目录
429 RESOURCE_EXHAUSTED 的停止条件与短时限流有限重试分流

Nano Banana Pro 生图返回 429 RESOURCE_EXHAUSTED,先看失败的是哪项限制,再决定重试。错误明确写出适用上限为零、每日额度耗尽或计费阻塞时,停下这批图片任务,修正条件或等待相应窗口;如果是短时限流或可重试的服务错误,再降低并发,尊重服务端等待时间,并给重试设置次数与总时间上限。

本文的 Python 和 Node.js 程序直接调用 Gemini Developer API 的 generateContent,以 gemini-3-pro-image 生成 1K 图片。它们会验证实际返回的图片、保存任务状态,并在再次运行时跳过已确认完成的图片。代码按当前文档编写并做了离线行为测试,没有调用真实生图 API,也没有验证实际账户的恢复率。文档和费用核对日期为 2026 年 10 月 5 日。

先确认调用路线,再读 429 的具体信息

记录请求域名、API 方法、版本、模型 ID、HTTP 状态和脱敏错误正文。不要只截取 RESOURCE_EXHAUSTED 一行:它能帮助识别失败,不能独自证明是哪种配额,也不能证明整个 Pro 服务都在故障。

实际调用路线从哪里判断限制本页代码是否直接适用
generativelanguage.googleapis.com 的 Gemini Developer API同项目、同模型的 AI Studio 有效限制和错误详情适用下方 GenerateContent 示例
Google Cloud / Vertex 路线该路线的配额、按量容量或预留吞吐量规则不直接适用,认证和端点需另行实现
第三方接口提供方的模型映射、响应格式、限制和账单不能只替换 URL 或 Key 就假定兼容
Gemini 网页订阅、Code Assist 或 CLI对应产品自己的额度与错误不能用本页 API 配额解释

Cloud 按量调用的 429 可能与分配容量不可用有关;预留吞吐量也有其容量和超额计费规则。因此,“429 一定是个人 RPM 用完”“付费即可永久消除 429”都不成立。Cloud 官方建议包括截断指数退避、平滑突发流量、逐步增加负载,以及在模型、地区和数据要求允许时考虑全局端点或适用容量方案。这些条件来自 Cloud 429 处理文档,并非 Developer API 的通用开关。

零额度、每日上限和短时限流分别怎么处理

Gemini Developer API 按项目限制,多个 Key 共享同项目配额。请求数、输入 token、图像数和适用支出窗口是不同维度:RPM 尚有空间,不代表其他限制也未用完。当前具体模型的有效值应在 AI Studio 速率限制核对,不把文本模型的固定表套到 Pro 图片模型。Google 速率限制说明

错误中可确认的条件现在采取什么动作什么时候继续
明确 limit: 0,或实际 QuotaFailure 提供 quotaValue: 0停止自动重试,核对 Key 所属项目、目标模型资格和计费状态缺失条件已修正,目标模型有可用额度后
quotaId、quotaMetric 或说明明确指向每日限制暂停当天任务,不每隔几秒重新发对 Developer API 的 RPD,按太平洋时间午夜重置;同时确认正的适用上限
正上限的每分钟请求、输入 token、图像或短时支出窗口耗尽降并发、摊平突发,缩减不必要输入;有限等待相应窗口释放且请求能在预算内完成时
HTTP 402、明确月度支出上限或其他计费阻塞停止,处理对应账务或支出控制账户条件恢复后,由有权限的人决定验证
429 详情不完整,或 503 临时不可用根因保留未知,先小规模有限重试成功返图才算这次任务完成;持续失败则暂停排查

quotaValue 的定义是失败时执行的配额上限,不是已使用量,也不是剩余量。字段缺失时不能默认成零;RetryInfo 也不会为零资格创建额度。这些字段并非每次都返回,须结合当前响应与控制台核对。Google RPC 错误详情定义

截至核对日,gemini-3-pro-image 的 API 免费层输入与输出均标为 Not available。付费项目也仍有有效限制,消费端订阅不能证明 API 模型可用。Pro 图片模型定价与免费层说明

如果已经付费却始终报免费层零限制,继续看按实际 Key、项目和计费恢复 limit 0。它负责账户归属和状态不一致;本页下面解决的是已判定可以重试之后,如何把图片任务执行完整。HTTP 402 要处理适用账户的余额或计费状态;当前 Prepay 余额归零不会自动转回免费层。Google Prepay 说明

按调用路线、项目和图片模型核对当前有效配额

服务端要求等 8 秒,不能按本地 2 秒提前重试

读取 Retry-After 响应头和 google.rpc.RetryInfo。前者可以是整数秒,也可以是 HTTP 日期;后者的 retryDelay 表示至少等待的时长。如果两者都有,再与本地退避相比,取较大的等待值。Retry-After: 5、retryDelay: "8s" 和本地退避 2 秒同时出现时,应等至少 8 秒。HTTP Retry-After 定义、RetryInfo 定义

等待时间超过剩余预算,就把任务留到之后处理;不要为了“总时长不超限”把服务端最低等待截短。下一次请求的期限也必须取“单次上限”和“当前剩余预算”中的较小值,否则最后一次请求仍可能额外占用完整的单次时间。

下面自主选择每个任务最多 4 次请求,即首次加最多 3 次重试;每次请求最多等待 45 秒,一次运行中该任务的请求与退避预算为 120 秒,图片解码和本地文件操作可能增加少量时间。这三个数是示例策略,不是 Google 配额或恢复时间保证。每次退避加入抖动,避免多个任务同步重发。实际应用还应核对框架、HTTP 客户端和 SDK 的重试设置:外层 4 次与内层多次自动重试会让请求次数相乘。Google 重试指南

有限重试后校验保存图片,完成任务跳过,未知结果先检查的流程

Python:生成、校验和保存每张图片

准备 Python 3.10 或以上和 Pillow;本文离线检查使用 Pillow 12.0.0。将下面代码保存为 recover.py。它使用标准库 HTTP 请求,不依赖 Gemini SDK;Pillow 用于实际解码图片,避免把只有 MIME 标签的任意字节保存成成功结果。

bash
python3 -m pip install Pillow

输入 jobs.json 是有稳定、唯一 ID 的任务数组。保留相同 ID 和相同提示词,才能识别已完成工作;更换提示词、模型或规格时使用新 ID,不复用旧结果。

json
[
  {"id": "tea-001", "prompt": "生成一张白底产品照片:一杯透明玻璃杯装的热茶"},
  {"id": "tea-002", "prompt": "生成一张白底产品照片:一杯陶瓷杯装的热茶"}
]
python
import base64, hashlib, io, json, os, queue, random, re, sys, threading, time
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError
from PIL import Image

MODEL = "gemini-3-pro-image"
URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent"

def post(payload, key, timeout):
    # urllib 无 SDK 重试;整个收响应过程有调用方等待期限。
    box = queue.Queue()
    def worker():
        try:
            request = Request(URL, data=json.dumps(payload).encode(), headers={
                "x-goog-api-key": key, "Content-Type": "application/json"})
            try:
                response = urlopen(request, timeout=timeout)
            except HTTPError as error:
                response = error
            with response:
                result = (response.code, dict(response.headers), response.read())
            box.put((True, result))
        except Exception as error:
            box.put((False, error))
    threading.Thread(target=worker, daemon=True).start()
    try:
        ok, value = box.get(timeout=timeout)
    except queue.Empty:
        raise TimeoutError("请求结果未知") from None
    if not ok:
        raise value
    return value

def decision(status, headers, body, wall=time.time):
    details = body.get("error", {}).get("details", [])
    minimum = 0.0
    header = next((v for k, v in headers.items() if k.lower() == "retry-after"), "")
    if re.fullmatch(r"\d+", header):
        minimum = float(header)
    elif header:
        try:
            minimum = max(0, parsedate_to_datetime(header).timestamp() - wall())
        except (ValueError, TypeError, OverflowError):
            pass
    for detail in details:
        if detail.get("@type", "").endswith("google.rpc.RetryInfo"):
            delay = detail.get("retryDelay", "")
            if re.fullmatch(r"\d+(?:\.\d+)?s", delay):
                minimum = max(minimum, float(delay[:-1]))
    if status == 429:
        message = body.get("error", {}).get("message", "").lower()
        if re.search(r"\blimit\s*:\s*0(?:\D|$)", message):
            return "blocked", minimum
        if "monthly" in message or "spending cap" in message:
            return "blocked", minimum
        for detail in details:
            if not detail.get("@type", "").endswith("google.rpc.QuotaFailure"):
                continue
            for violation in detail.get("violations", []):
                if "quotaValue" in violation and str(violation["quotaValue"]) == "0":
                    return "blocked", minimum
                label = " ".join(str(violation.get(k, "")) for k in
                                 ("quotaMetric", "quotaId", "description")).lower()
                if any(token in label for token in ("perday", "per_day", "daily")):
                    return "daily", minimum
    return ("retry" if status in (429, 503) else "blocked"), minimum

def image_bytes(body):
    for candidate in body.get("candidates", []):
        for part in candidate.get("content", {}).get("parts", []):
            if part.get("thought"):
                continue
            blob = part.get("inlineData", {})
            if blob.get("mimeType") not in ("image/png", "image/jpeg", "image/webp"):
                continue
            raw = base64.b64decode(blob.get("data", ""), validate=True)
            with Image.open(io.BytesIO(raw)) as image:
                image.load()
                actual = Image.MIME.get(image.format)
                if actual != blob["mimeType"]:
                    raise ValueError("图片 MIME 与内容不符")
                output = io.BytesIO()
                image.save(output, format="PNG")
            return output.getvalue()
    raise ValueError("200 响应没有可保存的图片")

def atomic(path, raw):
    temp = path.with_suffix(path.suffix + ".tmp")
    temp.write_bytes(raw)
    temp.replace(path)

def run(jobs, folder, key, send=post, clock=time.monotonic,
        sleep=time.sleep, jitter=random.random, budget=120.0, wall=time.time):
    folder = Path(folder)
    folder.mkdir(parents=True, exist_ok=True)
    ledger = folder / "state.json"
    state = json.loads(ledger.read_text()) if ledger.exists() else {}
    def save():
        atomic(ledger, json.dumps(state, ensure_ascii=False, indent=2).encode())
    ids = [job["id"] for job in jobs]
    if len(ids) != len(set(ids)) or any(not re.fullmatch(r"[a-zA-Z0-9_-]+", x) for x in ids):
        raise ValueError("job id 必须唯一,只用字母、数字、下划线或横线")
    for job in jobs:
        payload = {"contents": [{"parts": [{"text": job["prompt"]}]}],
                   "generationConfig": {"responseModalities": ["IMAGE"],
                   "imageConfig": {"aspectRatio": "1:1", "imageSize": "1K"}}}
        fingerprint = hashlib.sha256(json.dumps([MODEL, payload], sort_keys=True).encode()).hexdigest()
        record = state.setdefault(job["id"], {"fingerprint": fingerprint, "status": "pending"})
        if record["fingerprint"] != fingerprint:
            raise ValueError("同一 id 的请求已改变;请使用新 id")
        output = folder / (job["id"] + ".png")
        if record["status"] == "done":
            if not output.exists() or hashlib.sha256(output.read_bytes()).hexdigest() != record["sha256"]:
                record["status"] = "needs_check"
                save()
            continue
        if record["status"] != "pending" or wall() < record.get("notBefore", 0):
            continue
        deadline = clock() + budget
        for attempt in range(1, 5):
            remaining = deadline - clock()
            if remaining <= 0:
                break
            record["status"] = "unknown"  # 在发送前持久化,崩溃后不盲目重发。
            save()
            try:
                remaining = deadline - clock()
                if remaining <= 0:
                    record["status"] = "pending"
                    save()
                    break
                status, headers, raw = send(payload, key, min(45.0, remaining))
            except Exception:
                break  # 超时/断连,后台可能仍完成,保留 unknown。
            try:
                body = json.loads(raw)
                if not isinstance(body, dict):
                    raise ValueError("响应不是对象")
                if status == 200:
                    image = image_bytes(body)
                    atomic(output, image)
                    record.update(status="done", sha256=hashlib.sha256(image).hexdigest(),
                                  responseId=body.get("responseId"))
                    save()
                    break
                action, minimum = decision(status, headers, body, wall=wall)
            except Exception:
                record["status"] = "needs_check"
                save()
                break
            if action != "retry":
                record.update(status=action, http=status)
                save()
                break
            wait = max(minimum, min(2 ** attempt, 30) + jitter())
            record.update(status="pending", http=status, notBefore=wall() + wait)
            save()
            if attempt == 4:
                break
            if wait >= deadline - clock():
                break  # 放回待处理,不能把服务端 minimum 截短。
            sleep(wait)
    return state

if __name__ == "__main__":
    key = os.environ.get("GEMINI_API_KEY")
    if not key:
        raise SystemExit("请在运行环境配置 GEMINI_API_KEY")
    jobs = json.loads(Path(sys.argv[1]).read_text())
    result = run(jobs, sys.argv[2], key)
    print(json.dumps({k: v["status"] for k, v in result.items()}, ensure_ascii=False))

在实际运行环境配置 GEMINI_API_KEY 后,执行 python3 recover.py jobs.json output-python。此命令会请求收费的图片模型,运行前应确认项目资格和费用。密钥不要写进脚本或任务文件。程序将可解码的 PNG、JPEG 或 WebP 统一保存为 PNG,并在同一目录写 state.json;它保存的是状态、请求指纹、图片摘要和返回的 responseId,不会保存密钥。

Python 的请求线程以守护线程运行,调用方只等待本次剩余期限。期限过后底层网络操作可能仍在进行,程序不会自动再次发送这个任务;客户端超时不能取消已被服务器接收的生成。这是保留 unknown 的原因。

Node.js:用同一任务输入续跑

准备 Node.js 22 或以上,安装 sharp,把代码保存为 recover.mjs。本文离线检查使用 Node.js 24.11.0 和 sharp 0.35.4。fetch 的超时信号覆盖获取响应和读取响应体,sharp 实际解码后再写 PNG。

bash
npm install sharp
javascript
import fs from 'node:fs';
import path from 'node:path';
import crypto 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 hash = value => crypto.createHash('sha256').update(value).digest('hex');
const atomic = (file, raw) => {
  fs.writeFileSync(file + '.tmp', raw);
  fs.renameSync(file + '.tmp', file);
};

export async function post(payload, key, timeout) {
  const response = await fetch(URL, {
    method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
    body: JSON.stringify(payload), signal: AbortSignal.timeout(Math.max(1, Math.floor(timeout * 1000))),
    redirect: 'error',
  });
  // timeout signal 同时覆盖读取响应体,不只覆盖响应头。
  return [response.status, Object.fromEntries(response.headers), await response.text()];
}

export function decision(status, headers, body, wall = Date.now) {
  const details = body.error?.details ?? [];
  const header = Object.entries(headers).find(([k]) => k.toLowerCase() === 'retry-after')?.[1];
  let minimum = /^\d+$/.test(header ?? '') ? Number(header) :
    Math.max(0, (Date.parse(header ?? '') - wall()) / 1000);
  if (!Number.isFinite(minimum)) minimum = 0;
  for (const detail of details) {
    if (detail['@type']?.endsWith('google.rpc.RetryInfo') && /^\d+(?:\.\d+)?s$/.test(detail.retryDelay ?? '')) {
      minimum = Math.max(minimum, Number(detail.retryDelay.slice(0, -1)));
    }
  }
  if (status === 429) {
    const message = (body.error?.message ?? '').toLowerCase();
    if (/\blimit\s*:\s*0(?:\D|$)/.test(message) || message.includes('monthly') || message.includes('spending cap')) {
      return ['blocked', minimum];
    }
    for (const detail of details) {
      if (!detail['@type']?.endsWith('google.rpc.QuotaFailure')) continue;
      for (const violation of detail.violations ?? []) {
        if (Object.hasOwn(violation, 'quotaValue') && String(violation.quotaValue) === '0') {
          return ['blocked', minimum];
        }
        const label = ['quotaMetric', 'quotaId', 'description'].map(k => violation[k] ?? '').join(' ').toLowerCase();
        if (['perday', 'per_day', 'daily'].some(token => label.includes(token))) return ['daily', minimum];
      }
    }
  }
  return [status === 429 || status === 503 ? 'retry' : 'blocked', minimum];
}

export async function imageBytes(body) {
  for (const candidate of body.candidates ?? []) {
    for (const part of candidate.content?.parts ?? []) {
      if (part.thought) continue;
      const blob = part.inlineData ?? {};
      const formats = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' };
      if (!formats[blob.mimeType]) continue;
      const encoded = blob.data ?? '';
      if (!encoded || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
        throw Error('图片 base64 无效');
      }
      const raw = Buffer.from(encoded, 'base64');
      const metadata = await sharp(raw).metadata();
      if (metadata.format !== formats[blob.mimeType]) throw Error('图片 MIME 与内容不符');
      return sharp(raw).png().toBuffer(); // 实际解码,而不是只信 mimeType。
    }
  }
  throw Error('200 响应没有可保存的图片');
}

export async function run(jobs, folder, key, options = {}) {
  const { send = post, clock = () => performance.now() / 1000,
    sleep = seconds => new Promise(resolve => setTimeout(resolve, seconds * 1000)),
    jitter = Math.random, budget = 120, wall = () => Date.now() / 1000 } = options;
  fs.mkdirSync(folder, { recursive: true });
  const ledger = path.join(folder, 'state.json');
  const state = fs.existsSync(ledger) ? JSON.parse(fs.readFileSync(ledger, 'utf8')) : {};
  const save = () => atomic(ledger, JSON.stringify(state, null, 2));
  const ids = jobs.map(job => job.id);
  if (new Set(ids).size !== ids.length || ids.some(id => !/^[A-Za-z0-9_-]+$/.test(id))) {
    throw Error('job id 必须唯一,只用字母、数字、下划线或横线');
  }
  for (const job of jobs) {
    const payload = { contents: [{ parts: [{ text: job.prompt }] }], generationConfig: {
      responseModalities: ['IMAGE'], imageConfig: { aspectRatio: '1:1', imageSize: '1K' },
    } };
    const fingerprint = hash(JSON.stringify([MODEL, payload]));
    const record = state[job.id] ??= { fingerprint, status: 'pending' };
    if (record.fingerprint !== fingerprint) throw Error('同一 id 的请求已改变;请使用新 id');
    const output = path.join(folder, job.id + '.png');
    if (record.status === 'done') {
      if (!fs.existsSync(output) || hash(fs.readFileSync(output)) !== record.sha256) {
        record.status = 'needs_check'; save();
      }
      continue;
    }
    if (record.status !== 'pending' || wall() < (record.notBefore ?? 0)) continue;
    const deadline = clock() + budget;
    for (let attempt = 1; attempt <= 4; attempt++) {
      const remaining = deadline - clock();
      if (remaining <= 0) break;
      record.status = 'unknown'; save();
      let status, headers, raw;
      try {
        const available = deadline - clock();
        if (available <= 0) { record.status = 'pending'; save(); break; }
        [status, headers, raw] = await send(payload, key, Math.min(45, available));
      } catch { break; } // 结果未知,不自动再次发送。
      let action, minimum;
      try {
        const body = JSON.parse(raw);
        if (!body || typeof body !== 'object' || Array.isArray(body)) throw Error('响应不是对象');
        if (status === 200) {
          const image = await imageBytes(body);
          atomic(output, image);
          Object.assign(record, { status: 'done', sha256: hash(image), responseId: body.responseId });
          save(); break;
        }
        [action, minimum] = decision(status, headers, body, () => wall() * 1000);
      } catch { record.status = 'needs_check'; save(); break; }
      if (action !== 'retry') {
        Object.assign(record, { status: action, http: status }); save(); break;
      }
      const wait = Math.max(minimum, Math.min(2 ** attempt, 30) + jitter());
      Object.assign(record, { status: 'pending', http: status, notBefore: wall() + wait }); save();
      if (attempt === 4) break;
      if (wait >= deadline - clock()) break;
      await sleep(wait);
    }
  }
  return state;
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  const key = process.env.GEMINI_API_KEY;
  if (!key) throw Error('请在运行环境配置 GEMINI_API_KEY');
  const jobs = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
  const result = await run(jobs, process.argv[3], key);
  console.log(Object.fromEntries(Object.entries(result).map(([id, value]) => [id, value.status])));
}

配置好实际运行环境的 GEMINI_API_KEY 后,执行 node recover.mjs jobs.json output-node。输入格式与 Python 示例相同;两个实现使用各自输出目录,不共享状态文件。代码使用 GenerateContent 的 generationConfig.imageConfig.imageSize、aspectRatio 和 candidates[].content.parts[].inlineData,这些字段可对照 GenerateContent 类型文档。请求使用 Pro 的 1K 规格,不能从通用类型列出 512 就推导 Pro 支持该规格。

当前图片生成指南也提供 Interactions 示例。它使用 input、response_format 等接口字段;SDK 的 output_image 便利属性也不是这里的 REST 读取路径。迁移时要按完整接口修改,不能把 Interactions 的参数和输出属性混入上面的 GenerateContent 程序。

哪些任务能续跑,哪些要先确认结果

再次执行相同命令,会读取既有 state.json。可重试拒绝会保存 notBefore(Unix 秒);尚未到这个时间,重启也会跳过任务,不用重启缩短服务端最低等待。请保持系统时钟准确。只让一个进程处理同一输出目录;这是单进程恢复示例,没有跨进程锁或分布式幂等保证。

状态说明再次运行如何处理
done收到、解码并保存了图片,图片摘要已写入状态跳过;文件丢失或内容改变则转为 needs_check,不自动重生
pending收到了明确可重试的拒绝,但次数或等待预算用完本次不再请求;问题缓解且状态中 notBefore 最早重试时间已到后重新运行,使用新的本次预算
daily返回详情明确指向每日上限跳过;确认窗口重置且额度可用后,手动改为 pending
blocked零上限、402、客户端错误或其他不在本例重试范围的状态跳过;处理具体原因后再决定是否改为 pending
unknown已准备发送,却没拿到可确认的完整结果,包括超时、断连或发送后的崩溃跳过;先查看结果、日志或提供方记录,再决定是否重新提交
needs_check200 无图片、响应或图片无效、完成文件不完整跳过;检查原始响应、内容阻断信息和本地结果

pending 表示本例收到明确的临时错误响应,并不保证该失败完全没有计费。unknown 更不能当作“没生成”:服务器可能已经完成,客户端却没收到。需要新请求时,应由应用负责人接受可能重复生成和费用的风险,保留旧记录后再作决定。响应 ID 可用于关联记录,不能凭它承诺 GenerateContent 支持幂等重放。

程序先保存发送标记,再调用 API,图片成功保存后才写 done。如果恰好在图片落盘与状态完成之间崩溃,重启会留下 unknown;人工确认文件后可以恢复完成状态,而不是整批重做。文件原子替换降低了部分写入风险,但没有实现跨图片与状态文件的事务,也没有保证断电时的持久性。

本文两份完整程序各通过 17 个离线用例:明确零上限与日限只请求一次;缺 quotaValue 不判零;正配额 429 和 503 可在模拟响应下继续;同时有 5 秒响应头和 8 秒 RetryInfo 时等待 8 秒;最低等待超过 120 秒时不提前请求,立即重启也会跳过尚未到最早重试时间的任务;5 秒预算中首次请求耗时 2 秒、退避 2 秒后,下一次期限降到 1 秒;持续 429 最多 4 次;402 停止;200 文本或无效图片不能算成功;超时续跑不重发;已完成图片续跑不重生;HTTP 日期可解析;完成文件改变后转人工检查。测试使用本地生成的 2×2 PNG、模拟时钟和注入响应,没有真实密钥、网络请求或付费生成。文件落盘和图片解码确实执行了,但这不证明实际模型能恢复或真实账户有额度。

持续失败时,先控制队列,再选择可用替代

让新任务进入队列,减少瞬时并发,按实际项目与模型配额出队;发生持续拒绝时暂停出队。多个进程共享一个项目时,要在共享层控制总请求,而不是每个客户端都自认为“每秒一张”就足够。单机计数器也不能证明提供方所有窗口的剩余额度。

如果业务允许改变模型,可以评估当前 gemini-3.1-flash-image 或 gemini-3.1-flash-lite-image;先确认团队已验证该路线的访问资格、提示词与图片效果,记录实际使用的模型,不把它当作 Pro 的无损替代。新模型同样可能受项目、账务或容量条件影响,切换不能保证立即成功。当前图片模型选择

不要继续使用旧文的 2.5 Flash Image → Imagen 自动回退链:Google 对 gemini-2.5-flash-image 公布的停止服务日期为 2026 年 10 月 2 日,而当前图片指南明确 Imagen 已不再通过 Gemini API 提供。这是文档中的服务计划和可用性说明,本文没有真实调用旧模型确认错误。2.5 Flash Image 服务计划、Imagen 可用性说明

比较替代成本时要对齐调用路线和规格。截至 2026 年 10 月 5 日,Developer API Standard 图片输出费用如下;它不含输入、文字或思考输出、适用搜索费用,也不等于可用成品的最终平均成本。

模型和输出规格官方图片输出费 / 张1,000 张对应输出费
Pro Image,1K 或 2K0.134 美元134 美元
Pro Image,4K0.24 美元240 美元
3.1 Flash Image,1K0.067 美元67 美元
3.1 Flash Image,2K0.101 美元101 美元
3.1 Flash Lite Image,1K0.0336 美元33.60 美元

表中千张费用仅为单张输出费乘以 1,000,不代表购买该预算就能按时得到 1,000 张可用图片。价格依据分别为 Pro Image、Flash Image 和 Flash Lite Image。第三方路线需要另看费用、协议、数据处理与可用性条款,不能拿统一售价推导其无限额度或可靠性。

常见问题

已经付费,为什么 Nano Banana Pro 仍报 429?

付费不等于无限。核对失败进程实际 Key 的项目、同一图片模型的有效限制,以及错误指向的分钟、每日、图像或支出窗口;持续零上限则先处理归属和资格。Cloud 路线还可能有容量条件,不能把所有 429 归为 Developer API 的某个固定 RPM。Google 速率限制

报错让等 2 秒,等了还失败,要加到多久?

至少尊重响应给出的等待,后续可在总预算内增加退避并降低负载。等待只是允许再次尝试,不保证恢复。零额度和日限应先停止;最低等待超过剩余预算时,延后整个任务,不能提前请求。上方代码会在这种情况下保留 pending。

收到 HTTP 200,为什么没有保存图片?

200 只证明收到了成功 HTTP 响应。本例还要求存在非思考部分的 inlineData、支持的图片 MIME、有效 base64 和可解码图片。只返回文本或被阻断时,检查 promptFeedback、候选的 finishReason 与安全信息,不把该响应计为完成,也不无限重试同一提示词。GenerateContent 响应定义

重试用完后重启,能确保不会重复收费吗?

不能。程序会跳过有有效已存图片的 done,并保留超时或崩溃产生的 unknown,减少盲目重复生成;它无法证明服务器是否对未知请求完成了生成,也无法替提供方决定费用。只有确认任务结果后,才决定继续哪些工作。

错误变成 403,还要继续退避吗?

停止。403 需要按认证、权限、模型或失败动作核对,等待不能补齐授权。记录脱敏信息后,可按Gemini API 403 的失败位置排查继续;本例将这类响应记为 blocked。Google 客户端错误与重试说明

参考来源10

本文引用的外部页面,按正文出现顺序排列。最后更新于 2026年10月5日。

  1. 1.Cloud 429 处理文档docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429
  2. 2.AI Studio 速率限制aistudio.google.com/rate-limit
  3. 3.Google 速率限制说明ai.google.dev/gemini-api/docs/rate-limits
  4. 4.Google RPC 错误详情定义github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
  5. 5.Pro 图片模型定价与免费层说明ai.google.dev/gemini-api/docs/pricing
  6. 6.Google Prepay 说明ai.google.dev/gemini-api/docs/billing
  7. 7.HTTP Retry-After 定义rfc-editor.org/rfc/rfc9110
  8. 8.Google 重试指南ai.google.dev/gemini-api/docs/troubleshooting
  9. 9.GenerateContent 类型文档ai.google.dev/api/generate-content
  10. 10.图片生成指南ai.google.dev/gemini-api/docs/image-generation
核对 503 响应来源、在次数和等待预算内恢复,并检查实际图片结果的主题示意。
故障排查

Nano Banana Pro 503 模型过载:Deadline expired 怎么处理?

503 UNAVAILABLE 和 Deadline expired 可以同时出现。先确认是谁返回了错误,再在原接口上有限重试;达到次数或等待预算就停止并排队,只有拿到满足原要求的可用图片才算恢复。

20 分钟