# Nano Banana Pro: ошибка RESOURCE_EXHAUSTED и восстановление генерации

> При RESOURCE_EXHAUSTED остановите повторы для нулевой или исчерпанной суточной квоты. Для временного отказа соблюдайте задержку сервера, ограничивайте время и число попыток, а при продолжении очереди пропускайте уже сохранённые изображения.

- URL: https://blog.laozhang.ai/ru/posts/resource-exhausted-error-nano-banana
- Published: 2026-02-09
- Updated: 2026-10-05
- Author: LaoZhang AI Team (https://blog.laozhang.ai/ru/about)
- Topic: Руководства по API
- Tags: Nano Banana Pro, RESOURCE_EXHAUSTED, Gemini API, Ошибка 429, Python, Node.js

---
Если Nano Banana Pro отвечает `429 RESOURCE_EXHAUSTED`, **сначала проверьте, какой лимит отклонил именно запрос изображения**. При нулевом допустимом лимите или исчерпанной суточной квоте остановите быстрые повторы. При временном ограничении частоты уменьшите параллелизм и повторяйте с растущей паузой, соблюдая минимальное время ожидания сервера. Уже сохранённые результаты оставьте в очереди как завершённые: восстановление должно продолжать оставшиеся задания, а не заново генерировать всё.

Ни одна задержка не гарантирует доступ к модели. Но можно сделать поведение приложения предсказуемым: не повторять заведомо бесполезные запросы, не отправлять их раньше разрешённого времени и не терять изображения при частичном сбое. Ниже есть два самостоятельных примера для **Gemini Developer API, REST `v1beta`, метода `generateContent` и модели `gemini-3-pro-image`**. Они включают запрос, разбор ответа, сохранение файлов и продолжение работы. Поведение проверено локально на искусственных ответах; платные вызовы модели не выполнялись.

## Найдите причину, которую действительно подтверждает ответ

Сохраните HTTP-код, тело ошибки, `Retry-After`, время отказа и точный ID модели. Добавьте конечную точку и версию клиента, но не API-ключ. Сравнивать ошибку с графиком использования нужно для того же проекта, модели и интервала.

В Developer API ошибка может выглядеть так:

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

Этот текст подтверждает отказ, но не называет конкретную квоту. Если есть `google.rpc.QuotaFailure`, изучите `violations`: `quotaMetric`, `quotaId`, `quotaDimensions`, `description` и присутствующее `quotaValue`. Последнее означает применённый предел квоты, а не расход или оставшийся запас. Отсутствующий `quotaValue` нельзя подставлять как ноль. Не все ответы содержат все эти поля. [Определения Google RPC](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto).

| Что подтверждено | Что делать с заданием изображения |
|---|---|
| Применённый лимит явно равен нулю, например `limit: 0` | Остановить повторы и проверить доступ модели в проекте фактического ключа |
| Указана исчерпанная дневная квота | Сохранить задание до сброса или изменения доступной квоты; не запускать минутный цикл |
| Указан ненулевой минутный лимит | Снизить параллелизм, распределить запросы во времени и ограниченно повторить |
| `429` без уточняющих полей | Причина остаётся неизвестной; сопоставить с действующими квотами и допустить только ограниченные повторы |
| `503 UNAVAILABLE` | Рассматривать как возможный временный отказ; ограничить повторы, не назначать гарантированное время восстановления |
| `400`, `402`, `403` или неверная модель | Исправить запрос, оплату или доступ; автоматическая пауза сама по себе задачу не решит |
| Таймаут, разрыв соединения или падение процесса во время POST | Сохранить неопределённый исход; не отправлять тот же платный запрос вслепую |
| `200`, но нет декодируемого изображения | Проверить `promptFeedback`, `finishReason` и части ответа; не считать генерацию завершённой |

В Developer API RPM, входные TPM и RPD проверяются отдельно, а для изображений может действовать IPM. Квоты принадлежат проекту, поэтому несколько ключей одного проекта не создают независимые лимиты. RPD сбрасывается в полночь по тихоокеанскому времени; это не фиксированное местное время для всех читателей. Проверяйте действующие значения в [AI Studio](https://aistudio.google.com/usage), а не по старой таблице квот текстовых моделей. [Правила лимитов Gemini API](https://ai.google.dev/gemini-api/docs/rate-limits).

![Выбор действий при нулевой, суточной и временной квоте после ошибки 429](https://blog.laozhang.ai/posts/ru/resource-exhausted-error-nano-banana/img/retry-branches.webp)

На 5 октября 2026 года официальная страница цен отмечает Free Tier для `gemini-3-pro-image` как недоступный. Платный статус тоже не означает отсутствие ограничений: кроме модельных квот, при соответствующих условиях аккаунта применяются ограничения расходов в скользящем десятиминутном окне. Если оплаченный проект получает `free_tier_requests limit: 0`, сначала выполните [проверку ключа, проекта и оплаты при нулевом Free Tier](https://blog.laozhang.ai/ru/posts/paid-tier-getting-free-tier-requests-limit-0). Это отдельная ветка диагностики, а не повод увеличивать число повторов. [Условия Pro Image](https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image), [ограничения расходов](https://ai.google.dev/gemini-api/docs/rate-limits#spend-based-rate-limits).

### Если запрос идёт через Vertex AI

Не переносите на него таблицу уровней Developer API. В актуальном [руководстве Google Cloud по 429](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429) отказ Pay-as-you-go может означать отсутствие доступной выделенной ёмкости; Provisioned Throughput имеет собственные правила и условия превышения купленной пропускной способности. Полезные меры — сглаживание всплесков и постепенное наращивание нагрузки. Запрос увеличения квоты, глобальная конечная точка или резервирование мощности применимы только при подходящей модели, сервисе и условиях размещения данных. Один код 429 не доказывает, что нужно покупать резерв или менять регион.

## Сколько ждать перед повтором: 2 секунды, 8 секунд или дольше

Ориентируйтесь на **большую из задержек**: собственную паузу с ростом и случайным разбросом, `Retry-After` и `google.rpc.RetryInfo.retryDelay`. Например, приложение выбрало 2 секунды, заголовок указал 5 секунд, а `RetryInfo` — `8s`: повтор допустим не раньше чем через 8 секунд после получения ошибки. Случайный разброс добавляйте к локальной паузе; он не должен уменьшать серверный минимум.

`Retry-After` бывает числом секунд или HTTP-датой. `RetryInfo` требует выждать как минимум указанную длительность с момента получения ошибки. Эти значения задают ожидание, но не обещают, что следующий запрос пройдёт. [RFC 9110 о Retry-After](https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3), [RetryInfo](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto).

Если сервер просит ждать 200 секунд, а осталось 20 секунд общего бюджета, **отложите задание**. Нельзя сократить 200 до 20 и отправить запрос раньше. Ограничение максимальной локальной паузы относится к вашему расписанию, а не к серверному минимуму.

Отдельно ограничивайте время каждого запроса и всего прохода очереди. В примерах ниже один проход имеет бюджет 120 секунд для запросов и ожидания, один запрос — до 30 секунд, одно задание — не больше четырёх отправок с учётом сохранённых попыток. Перед каждой отправкой таймаут уменьшается до остатка общего бюджета. Это выбранные параметры примера, а не рекомендованная Google квота или гарантия скорости Nano Banana Pro.

## Подготовьте задания и каталог результатов

Создайте `jobs.json` с устойчивыми уникальными ID. Не меняйте промпт под старым ID: если нужна новая генерация, создайте новый ID.

```json
[
  {"id": "hero", "prompt": "Создай студийное изображение красного чайника на белом фоне"},
  {"id": "detail", "prompt": "Создай крупный план ручки красного чайника на белом фоне"}
]
```

Выберите **одну** реализацию и отдельный каталог: Python и Node.js используют собственный формат контрольного отпечатка, поэтому их журналы нельзя смешивать. Один каталог должен обслуживать один процесс. Контрольный отпечаток связывает ID с моделью, параметрами и промптом; для каждого подтверждённого файла хранится SHA-256.

Примеры используют `contents[].parts`, `generationConfig.imageConfig.imageSize: "1K"` и `candidates[].content.parts[].inlineData`. В `inlineData` нужны MIME и base64-данные изображения. Поля `image_resolution` и `imageResolution: "1024x1024"` из старых примеров не соответствуют этой конфигурации. Pro Image поддерживает 1K, 2K и 4K; общее значение `512` в типе ImageConfig не доказывает его поддержку этой моделью. [Справочник GenerateContent](https://ai.google.dev/api/generate-content), [возможности моделей изображений](https://ai.google.dev/gemini-api/docs/image-generation).

Текущее руководство изображений также показывает **Interactions API**: у него `input`, `response_format` и другая схема результата. Свойство SDK `output_image` не является полем ответа GenerateContent. Для миграции нужно заменить весь соответствующий запрос и обработчик ответа, а не только URL. Два примера ниже последовательно используют GenerateContent REST.

## Python: запрос, ограниченные повторы и сохранение изображений

Нужны Python 3.11 или новее, `httpx` и `Pillow`. Сохраните код как `recover.py`. Установите зависимости в своём окружении:

```bash
python -m pip install httpx Pillow
```

Задайте `GEMINI_API_KEY` через используемое вами хранилище секретов или окружение процесса. Команда ниже выполняет настоящие платные запросы, когда вы запускаете её со своим ключом; локальная проверка статьи выполнялась без этой команды и без ключа.

```bash
python recover.py jobs.json out-python
```

```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"
URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent"
CONFIG = {"responseModalities": ["TEXT", "IMAGE"],
          "imageConfig": {"aspectRatio": "16:9", "imageSize": "1K"}}

def save_json(path, data):
    temp = path.with_suffix(".tmp")
    temp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
    temp.replace(path)

def decision(status, body, headers, wall):
    err = body.get("error", {})
    details = err.get("details", [])
    violations = [v for d in details
                  if d.get("@type", "").endswith("QuotaFailure")
                  for v in d.get("violations", [])]
    zero = any("quotaValue" in v and str(v["quotaValue"]) == "0"
               for v in violations)
    text = " ".join([str(err.get("message", ""))] +
                    [json.dumps(v) for v in violations]).lower()
    daily = bool(re.search(r"per.?day|daily|requestsperday|суточ", text))
    zero = zero or bool(re.search(r"\blimit\s*[:=]\s*0\b", text))
    if status == 429 and (zero or daily):
        return "stop", 0
    if status not in (429, 503):
        return "stop", 0
    floors = [0.0]
    value = headers.get("retry-after", "").strip()
    if re.fullmatch(r"\d+", value):
        floors.append(float(value))
    elif value:
        try:
            floors.append(max(0, parsedate_to_datetime(value).timestamp() - wall))
        except (ValueError, TypeError, OverflowError):
            pass
    for d in details:
        if d.get("@type", "").endswith("RetryInfo"):
            m = re.fullmatch(r"(\d+(?:\.\d+)?)s", d.get("retryDelay", ""))
            if m:
                floors.append(float(m[1]))
    return "retry", max(floors)

def images(body):
    found = []
    for c in body.get("candidates", []):
        for p in c.get("content", {}).get("parts", []):
            b = p.get("inlineData", {})
            mime = b.get("mimeType")
            if mime not in ("image/png", "image/jpeg", "image/webp"):
                continue
            raw = base64.b64decode(b.get("data", ""), validate=True)
            with Image.open(io.BytesIO(raw)) as image:
                image.verify()
                kind = image.format
            expected = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WEBP"}
            if kind != expected[mime]:
                raise ValueError("MIME не совпадает с файлом")
            found.append((mime.split("/")[1], raw))
    if not found:
        raise ValueError("Нет подтверждённого изображения; проверьте promptFeedback/finishReason")
    return found

async def post(prompt, timeout):
    key = os.environ["GEMINI_API_KEY"]
    async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
        response = await asyncio.wait_for(client.post(
            URL, headers={"x-goog-api-key": key},
            json={"contents": [{"parts": [{"text": prompt}]}],
                  "generationConfig": CONFIG}), timeout)
        return response.status_code, dict(response.headers), response.json()

async def run(jobs, directory, send=post, clock=time.monotonic,
              wall=time.time, sleep=asyncio.sleep, rand=random.random, budget=120):
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    path = directory / "state.json"
    state = json.loads(path.read_text()) if path.exists() else {}
    deadline = clock() + budget
    ids = [j["id"] for j 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("Нужны уникальные безопасные id")
    for job in jobs:
        fingerprint = hashlib.sha256(json.dumps(
            [MODEL, CONFIG, job["prompt"]], sort_keys=True).encode()).hexdigest()
        record = state.setdefault(job["id"], {"fingerprint": fingerprint, "attempts": 0})
        if record["fingerprint"] != fingerprint:
            raise ValueError("Изменённое задание требует нового id")
        if record.get("status") == "done":
            for f in record["files"]:
                raw = (directory / f["name"]).read_bytes()
                if hashlib.sha256(raw).hexdigest() != f["sha256"]:
                    raise ValueError("Подтверждённый файл изменён")
            continue
        if record.get("status") in ("inflight", "unknown", "stop", "review"):
            continue
        while record["attempts"] < 4:
            remaining = deadline - clock()
            wait = max(0, record.get("notBefore", 0) - wall())
            if wait + 1 >= remaining:
                record["status"] = "deferred"
                save_json(path, state)
                return state
            if wait:
                await sleep(wait)
            timeout = min(30, deadline - clock())
            if timeout < 1:
                return state
            record.update(status="inflight", attempts=record["attempts"] + 1)
            save_json(path, state)  # Запись ДО отправки
            try:
                status, headers, body = await send(job["prompt"], timeout)
            except Exception:
                record["status"] = "unknown"
                save_json(path, state)
                return state  # Исход POST неизвестен
            if status == 200:
                try:
                    output = images(body)
                    files = []
                    for i, (ext, raw) in enumerate(output):
                        name = f"{job['id']}-{i}.{ext}"
                        target = directory / name
                        temp = target.with_suffix(".tmp")
                        temp.write_bytes(raw)
                        temp.replace(target)
                        files.append({"name": name, "sha256": hashlib.sha256(raw).hexdigest()})
                    record.update(status="done", files=files)
                except Exception:
                    record["status"] = "review"
                save_json(path, state)
                if record["status"] != "done":
                    return state
                break
            action, floor = decision(status, body, headers, wall())
            record.update(status="stop" if action == "stop" else "deferred", http=status)
            if action == "retry":
                delay = max(floor, min(16, 2 ** record["attempts"]) + rand())
                record["notBefore"] = wall() + delay
            save_json(path, state)
            if action == "stop" or record["attempts"] == 4:
                return state
    return state

if __name__ == "__main__":
    jobs = json.loads(Path(sys.argv[1]).read_text())
    result = asyncio.run(run(jobs, sys.argv[2]))
    print(json.dumps(result, ensure_ascii=False, indent=2))
```

`httpx` здесь не добавляет собственный цикл повторов. `asyncio.wait_for` ограничивает длительность сетевой операции, а журнал записывается до POST. Если операция выбрасывает исключение, статус становится `unknown`, и программа заканчивает проход. При успешном HTTP-ответе `Pillow` проверяет данные изображения перед сохранением; текстовый ответ не помечается как готовое изображение.

## Node.js: тот же результат через fetch и AbortController

Нужны Node.js 22 или новее и `sharp` для декодирования полученных изображений. Сохраните код как `recover.mjs` рядом с `jobs.json` и установите зависимость:

```bash
npm install sharp
```

После настройки собственного `GEMINI_API_KEY` запуск выглядит так:

```bash
node recover.mjs jobs.json out-node
```

Это также платный вызов при реальном запуске. `AbortController` действует до окончания чтения JSON, `sharp` декодирует пиксели, а состояние `done` появляется только после записи файлов. Внешнюю библиотеку повторов поверх этого цикла добавлять не нужно.

```javascript
import fs from 'node:fs/promises';
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 url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
const config = { responseModalities: ['TEXT', 'IMAGE'],
  imageConfig: { aspectRatio: '16:9', imageSize: '1K' } };
const hash = data => createHash('sha256').update(data).digest('hex');
const pause = seconds => new Promise(resolve => setTimeout(resolve, seconds * 1000));
async function save(file, state) {
  await fs.writeFile(file + '.tmp', JSON.stringify(state, null, 2));
  await fs.rename(file + '.tmp', file);
}
export function decision(status, body, headers, now) {
  const error = body.error ?? {}, details = error.details ?? [];
  const violations = details.filter(d => d['@type']?.endsWith('QuotaFailure'))
    .flatMap(d => d.violations ?? []);
  const text = `${error.message ?? ''} ${JSON.stringify(violations)}`;
  const zero = violations.some(v => Object.hasOwn(v, 'quotaValue') && String(v.quotaValue) === '0')
    || /\blimit\s*[:=]\s*0\b/i.test(text);
  if (status === 429 && (zero || /per.?day|daily|requestsperday|суточ/i.test(text)))
    return { action: 'stop', floor: 0 };
  if (![429, 503].includes(status)) return { action: 'stop', floor: 0 };
  const floors = [0], value = (headers['retry-after'] ?? '').trim();
  if (/^\d+$/.test(value)) floors.push(Number(value));
  else if (value && Number.isFinite(Date.parse(value)))
    floors.push(Math.max(0, Date.parse(value) / 1000 - now));
  for (const d of details) {
    if (d['@type']?.endsWith('RetryInfo')) {
      const match = /^(\d+(?:\.\d+)?)s$/.exec(d.retryDelay ?? '');
      if (match) floors.push(Number(match[1]));
    }
  }
  return { action: 'retry', floor: Math.max(...floors) };
}
export async function images(body) {
  const found = [];
  for (const c of body.candidates ?? []) for (const p of c.content?.parts ?? []) {
    const b = p.inlineData ?? {}, formats = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' };
    if (!formats[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');
    const raw = Buffer.from(b.data, 'base64');
    const decoder = sharp(raw), metadata = await decoder.metadata();
    await decoder.raw().toBuffer(); // Декодировать реальные пиксели
    if (metadata.format !== formats[b.mimeType]) throw new Error('MIME не совпадает');
    found.push({ ext: formats[b.mimeType], raw });
  }
  if (!found.length) throw new Error('Нет изображения; проверьте promptFeedback/finishReason');
  return found;
}
async function post(prompt, 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 response = 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({ contents: [{ parts: [{ text: prompt }] }], generationConfig: config }) });
    return [response.status, Object.fromEntries(response.headers), await response.json()];
  } finally { clearTimeout(timer); }
}
export async function run(jobs, directory, opts = {}) {
  const send = opts.send ?? post, clock = opts.clock ?? (() => performance.now() / 1000);
  const wall = opts.wall ?? (() => Date.now() / 1000), sleep = opts.sleep ?? pause;
  const rand = opts.rand ?? Math.random, deadline = clock() + (opts.budget ?? 120);
  await fs.mkdir(directory, { recursive: true });
  const file = path.join(directory, 'state.json');
  let state = {};
  try { state = JSON.parse(await fs.readFile(file, 'utf8')); }
  catch (error) { if (error.code !== 'ENOENT') throw error; }
  const ids = jobs.map(j => j.id);
  if (new Set(ids).size !== ids.length || ids.some(id => !/^[A-Za-z0-9_-]+$/.test(id)))
    throw new Error('Нужны уникальные безопасные id');
  for (const job of jobs) {
    const fingerprint = hash(JSON.stringify([model, config, job.prompt]));
    const record = state[job.id] ??= { fingerprint, attempts: 0 };
    if (record.fingerprint !== fingerprint) throw new Error('Изменённому заданию нужен новый id');
    if (record.status === 'done') {
      for (const f of record.files)
        if (hash(await fs.readFile(path.join(directory, f.name))) !== f.sha256)
          throw new Error('Подтверждённый файл изменён');
      continue;
    }
    if (['inflight', 'unknown', 'stop', 'review'].includes(record.status)) continue;
    while (record.attempts < 4) {
      const wait = Math.max(0, (record.notBefore ?? 0) - wall());
      if (wait + 1 >= deadline - clock()) {
        record.status = 'deferred'; await save(file, state); return state;
      }
      if (wait) await sleep(wait);
      const timeout = Math.min(30, deadline - clock());
      if (timeout < 1) return state;
      record.status = 'inflight'; record.attempts++; await save(file, state);
      let status, headers, body;
      try { [status, headers, body] = await send(job.prompt, timeout); }
      catch { record.status = 'unknown'; await save(file, state); return state; }
      if (status === 200) {
        try {
          const output = await images(body), files = [];
          for (const [i, { ext, raw }] of output.entries()) {
            const name = `${job.id}-${i}.${ext}`, target = path.join(directory, name);
            await fs.writeFile(target + '.tmp', raw); await fs.rename(target + '.tmp', target);
            files.push({ name, sha256: hash(raw) });
          }
          record.status = 'done'; record.files = files;
        } catch { record.status = 'review'; }
        await save(file, state);
        if (record.status !== 'done') return state;
        break;
      }
      const { action, floor } = decision(status, body, headers, wall());
      record.status = action === 'stop' ? 'stop' : 'deferred'; record.http = status;
      if (action === 'retry') record.notBefore = wall() + Math.max(floor, Math.min(16, 2 ** record.attempts) + rand());
      await save(file, state);
      if (action === 'stop' || record.attempts === 4) return state;
    }
  }
  return state;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const jobs = JSON.parse(await fs.readFile(process.argv[2], 'utf8'));
  console.log(JSON.stringify(await run(jobs, process.argv[3]), null, 2));
}
```

## Как продолжить очередь после частичного сбоя

Запустите выбранную программу с тем же `jobs.json` и тем же каталогом результатов. Она прочитает `state.json`, проверит отпечатки завершённых файлов и пропустит задания со статусом `done`. Если первое изображение сохранено, а второе получило временный отказ, первое повторно не отправляется; оставшееся задание учитывает сохранённые попытки и время `notBefore`.

![Продолжение очереди: готовые изображения, отложенные задачи и неопределённый исход POST](https://blog.laozhang.ai/posts/ru/resource-exhausted-error-nano-banana/img/queue-resume.webp)

| Состояние в журнале | Что означает и как продолжать |
|---|---|
| `done` | Изображения проверены и записаны; программа проверяет их SHA-256 и пропускает генерацию |
| `deferred` | Получен явный временный отказ; повтор возможен после `notBefore`, если остались попытки и время |
| `stop` | Получена ошибка доступа, оплаты, нулевая или дневная квота; сначала устраните подтверждённую причину |
| `unknown` | Исключение во время вызова; сервер мог обработать POST, но клиент не получил подтверждения |
| `inflight` при следующем запуске | Процесс прервался после записи намерения отправить запрос; исход тоже требует ручного разбора |
| `review` | HTTP 200 не дал проверяемых изображений либо возникла проблема записи; исследуйте ответ или файлы перед новой генерацией |

`unknown`, оставшийся `inflight`, `stop` и `review` автоматически не отправляются повторно. Для неопределённого исхода проверьте доступные журналу приложения данные и учёт использования, восстановите результат из сохранённого ответа, если он есть. Эти примеры не предоставляют серверную операцию поиска результата по локальному ID. Если подтвердить результат нельзя, решение о новой платной отправке принимает владелец задачи; новый ID явно обозначит эту новую операцию.

Для `stop` после подтверждённого исправления причины остановите процесс и измените только `status` этого задания в `state.json` на `deferred`. Сохраните `fingerprint`, `attempts` и записи остальных заданий, затем запустите программу снова. Это ручное разрешение новой отправки, а не автоматическое исправление квоты. Не удаляйте весь `state.json`, чтобы «начать сначала»: так исчезнут подтверждения уже готовых изображений. После четырёх отправок программа не продолжает это задание автоматически даже при новом запуске. Новая серия попыток требует осознанного решения и собственного бюджета.

Журнал защищает подтверждённые локальные результаты, но не обеспечивает exactly once на сервере. Между завершением POST, записью файла и записью `done` возможен сбой. Поэтому программа оставляет неопределённые задания для разбора. Файловые переименования дают атомарную замену в обычной локальной файловой системе, однако пример не включает блокировки нескольких процессов, транзакционную базу данных или гарантии сохранности при отказе диска. Для сервиса с несколькими работниками это нужно добавить до внедрения.

### Что действительно проверено офлайн

Для каждой реализации выполнено 15 поведенческих проверок на искусственных ответах и локальном PNG. Проверены остановка при явно нулевой и дневной квоте и при 402; приоритет `RetryInfo` над более коротким `Retry-After`; оба формата заголовка; откладывание слишком длинного ожидания; четыре отправки при продолжающемся 503; ограниченное восстановление при отсутствующем `quotaValue`; сохранение изображения и отказ при текстовом или повреждённом результате; отсутствие повторной отправки после таймаута и незавершённого `inflight`; пропуск подтверждённого задания при продолжении очереди; сокращение таймаута до остатка бюджета.

Это проверка локальной логики, не измерение доступности Google, скорости модели, тарифа аккаунта или стоимости неудачных запросов. Перед подключением к приложению адаптируйте хранение, наблюдаемость и бюджет под него. При восстановлении нагрузки начинайте с малого параллелизма: короткая последовательная очередь лучше нового одновременного всплеска.

## Когда выбирать другую модель

Смена модели имеет смысл, если её результат подходит задаче и у проекта действительно есть доступная квота. Она не исправляет предел исходной модели. Сохраните выбранную модель вместе с заданием и согласуйте изменение качества, разрешения, цены и формата ответа до генерации.

По документации на 5 октября 2026 года для новых задач можно оценить Gemini 3.1 Flash Image или Flash Lite Image. Старый автоматический переход на `gemini-2.5-flash-image` использовать не стоит: страница цен указывает отключение 2 октября 2026 года. А текущий image guide сообщает, что Imagen недоступен через Gemini API. Это документированные состояния, а не результаты платных контрольных вызовов. [Выбор модели](https://ai.google.dev/gemini-api/docs/image-generation#model-selection), [график отключения 2.5 Flash Image](https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image).

Если меняете Developer API на Vertex AI или сторонний шлюз, отдельно проверьте аутентификацию, модель, протокол, хранение данных и стоимость. Одинаковое название модели не означает одинаковые квоты и доступность. Без такого сравнения обещание «переключиться и навсегда забыть о 429» не обосновано.

## Часто задаваемые вопросы

### Почему платный Nano Banana Pro всё равно получает RESOURCE_EXHAUSTED?

Платный проект сохраняет модельные ограничения частоты и квоты; при соответствующих условиях действуют и ограничения расходов. `429` сам по себе не показывает, какое ограничение сработало. Сопоставьте детали отказа с квотами конкретного проекта. Если в ответе именно нулевой Free Tier, выполните отдельную проверку проекта и оплаты вместо обычного минутного повтора. [Механика квот](https://ai.google.dev/gemini-api/docs/rate-limits).

### Нужно ли ждать ровно минуту при каждом 429?

Нет. Для временного отказа учитывайте серверный минимум и растущую локальную задержку. При дневном ограничении нужен дневной сброс, а при нулевом допустимом лимите — исправление доступа. Минутная пауза не является универсальным способом восстановления. [Правила лимитов](https://ai.google.dev/gemini-api/docs/rate-limits), [RetryInfo](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto).

### Достаточно ли получить HTTP 200 для подтверждения генерации?

Нет. Для GenerateContent нужны части `inlineData` с подходящим MIME и декодируемыми данными изображения. Ответ может содержать текст или сведения о блокировке. Сохраняйте результат как завершённый только после проверки и записи файлов. [Схема ответа GenerateContent](https://ai.google.dev/api/generate-content#GenerateContentResponse).

### Можно ли повторить запрос после таймаута без риска повторной оплаты?

По одному таймауту это определить нельзя: клиент не знает, успел ли сервер завершить обработку. Локальный ID задания сам по себе не создаёт серверную идемпотентность. Сохраните неопределённое состояние, проверьте доступные результаты и принимайте решение о новой отправке отдельно. Эти примеры не гарантируют отсутствие повторной генерации или повторных затрат.

## Источники

Внешние страницы, на которые ссылается это руководство, в порядке упоминания. Последнее обновление: 2026-10-05.

- [Определения Google RPC](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto) (github.com)
- [AI Studio](https://aistudio.google.com/usage) (aistudio.google.com)
- [Правила лимитов Gemini API](https://ai.google.dev/gemini-api/docs/rate-limits) (ai.google.dev)
- [Условия Pro Image](https://ai.google.dev/gemini-api/docs/pricing) (ai.google.dev)
- [руководстве Google Cloud по 429](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429) (docs.cloud.google.com)
- [RFC 9110 о Retry-After](https://www.rfc-editor.org/rfc/rfc9110) (rfc-editor.org)
- [Справочник GenerateContent](https://ai.google.dev/api/generate-content) (ai.google.dev)
- [возможности моделей изображений](https://ai.google.dev/gemini-api/docs/image-generation) (ai.google.dev)
