# Nano Banana Pro RESOURCE_EXHAUSTED: cuándo reintentar y cómo recuperar las imágenes

> Reintenta con límites solo si el fallo puede ser transitorio. Un límite explícito de 0, una cuota diaria agotada o un problema de pago requieren parar; un timeout exige conservar el resultado como desconocido.

- URL: https://blog.laozhang.ai/es/posts/resource-exhausted-error-nano-banana
- Published: 2026-10-05
- Updated: 2026-10-05
- Author: LaoZhang AI Team (https://blog.laozhang.ai/es/about)
- Category: Guías de API
- Tags: Nano Banana Pro, Gemini API, RESOURCE_EXHAUSTED, Python, Node.js

---
**Si Nano Banana Pro devuelve `429 RESOURCE_EXHAUSTED`, conserva la respuesta completa antes de volver a enviar la imagen.** Un límite por minuto o un pico de capacidad puede admitir un reintento con espera; un límite explícito de cero, una cuota diaria agotada o una condición de facturación pendiente necesitan otra acción. Si la conexión termina sin una respuesta completa, no sabes si el servidor generó la imagen: guarda el trabajo como resultado desconocido y evita reenviarlo automáticamente.

La recuperación consiste en identificar el servicio, decidir si el error admite espera y continuar únicamente los trabajos que siguen pendientes. Los programas completos de Python y Node de esta guía hacen esas tres cosas para **Gemini Developer API, REST `v1beta` y `gemini-3-pro-image`**. Sus decisiones y archivos se han comprobado con respuestas simuladas; no se han realizado llamadas de generación ni pruebas de facturación. La documentación citada se consultó el **5 de octubre de 2026**.

## Qué hacer según el error que has recibido

`RESOURCE_EXHAUSTED` describe el rechazo, pero por sí solo no identifica la causa. Guarda HTTP, cuerpo JSON, cabecera `Retry-After` si existe, fecha, modelo, método, versión de API e identificador de respuesta o petición. Revisa los logs sin publicar la clave ni los datos privados del trabajo.

| Señal concreta | Acción inmediata | Cuándo recuperar la cola |
| --- | --- | --- |
| `quotaValue: "0"` o un mensaje explícito `limit: 0` | Detener reintentos; comprobar proyecto, facturación y acceso al modelo | Cuando se haya corregido la condición y una imagen del mismo modelo funcione |
| Una infracción identifica peticiones por día, `PerDay` o RPD | Pausar esa cola | Tras el reinicio de esa cuota o una ampliación confirmada |
| Cuota por minuto o 429 sin causa permanente identificada | Reducir carga y permitir pocos reintentos con espera | Si llega una imagen válida, aumentando la carga gradualmente |
| HTTP 503 con respuesta completa | Permitir recuperación acotada por indisponibilidad temporal | Si vuelve el servicio y el trabajo produce una imagen |
| HTTP 402, 400, 401, 403 o modelo/ruta inexistente | Corregir pago, credencial, permisos o petición | Tras resolver el requisito concreto |
| Timeout, desconexión o caída durante el envío | Marcar resultado desconocido | Tras conciliar el resultado; nunca mediante reenvío ciego |
| HTTP 200 sin imagen final utilizable | Conservar respuesta y diagnosticar salida o parser | Tras resolver el motivo; 200 solo no confirma éxito |

La [guía de solución de problemas de Gemini](https://ai.google.dev/gemini-api/docs/troubleshooting) recomienda espera exponencial, variación aleatoria y un máximo de reintentos para fallos transitorios; excluye los errores de cliente como 400, 402 y 403. Aquí adoptamos una política más conservadora ante timeouts: al tratarse de una generación que puede tener coste, la ausencia de respuesta no basta para ordenar otro POST.

### Un campo ausente no significa cuota cero

En los detalles `google.rpc.QuotaFailure`, cuando llegan, `quotaMetric`, `quotaId` y `quotaDimensions` ayudan a relacionar el rechazo con el modelo y la cuota. **`quotaValue` es el límite aplicado en el momento del fallo, no el consumo ni lo que queda disponible.** Si no aparece, no lo rellenes con cero. Estas definiciones proceden del [protocolo de errores de Google](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto); tu servicio puede omitir campos o devolver un formato distinto.

Para una asignación cero, usa la [comprobación de clave, proyecto y facturación ante `free_tier_requests limit 0`](https://blog.laozhang.ai/es/posts/paid-tier-getting-free-tier-requests-limit-0). Esa comprobación evita confundir un recibo de pago con la credencial que realmente utiliza el proceso. Los límites de Developer API se aplican por proyecto, no por clave; crear varias claves del mismo proyecto no multiplica la capacidad. [Reglas de cuota de Gemini](https://ai.google.dev/gemini-api/docs/rate-limits).

Si se ha agotado una asignación diaria positiva de RPD, el reinicio documentado ocurre a medianoche del Pacífico. No lo traduzcas a una hora fija española durante todo el año: hay cambios de horario. Tampoco sirve para conceder cuota a un modelo sin acceso. A la fecha de consulta, `gemini-3-pro-image` figura sin Free Tier en la [tabla oficial de precios](https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image).

## Confirma la API antes de cambiar el código

Los ejemplos llaman a `generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image:generateContent`. Si utilizas Google Cloud, Vertex AI o un intermediario, sus credenciales y reglas de capacidad necesitan su propio diagnóstico. Una cuota de AI Studio no acredita la capacidad de otro servicio.

En la ruta de Cloud, un 429 de pago por uso puede indicar que no hay capacidad asignada para procesar la petición; con Provisioned Throughput puede indicar exceso sobre el rendimiento contratado. Google recomienda reducir picos y aplicar retroceso exponencial, y contempla el endpoint global cuando es aplicable o capacidad reservada para modelos admitidos. Son opciones sujetas a servicio, modelo, región y coste, no un arreglo universal para Developer API. [Tratamiento de 429 en Google Cloud](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429).

También hay dos contratos de API distintos dentro de Gemini:

| Petición utilizada | Configuración de imagen | Dónde se busca la salida |
| --- | --- | --- |
| `generateContent` de estos programas | `generationConfig.imageConfig.imageSize` y `aspectRatio`; `responseModalities` | `candidates[].content.parts[].inlineData`, excluyendo `thought: true` |
| Interactions API de la guía actual de imágenes | `response_format` con `type: "image"`, `image_size` y `aspect_ratio` | Según el contrato de Interactions; el SDK muestra `output_image` en su ejemplo |

No copies `response_format` a `generationConfig`, ni presupongas que la propiedad de conveniencia del SDK describe el JSON REST de otro método. La [referencia de GenerateContent](https://ai.google.dev/api/generate-content) y la [guía de generación de imágenes](https://ai.google.dev/gemini-api/docs/image-generation) documentan esos contratos por separado. Los programas solicitan texto e imagen a 1K y relación 1:1, y recorren las partes de salida para encontrar imágenes finales decodificables.

Evita rescatar código antiguo con `imageResolution: "1024x1024"`: el campo verificado aquí es `imageSize: "1K"`. Pro admite los tamaños 1K, 2K y 4K descritos en sus documentos; que el tipo general enumere 512 no demuestra que Pro lo admita. Cambiar a `gemini-2.5-flash-image` tampoco es una alternativa actual: la [página de precios](https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image) fijó su retirada para el 2 de octubre de 2026. La guía actual indica además que Imagen ya no está disponible a través de Gemini API.

## Cuánto esperar: respeta el mínimo del servidor

No hay una espera fija de dos u ocho segundos que resuelva todos los 429. La cabecera `Retry-After` puede contener segundos enteros o una fecha HTTP, según [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3). El detalle `RetryInfo.retryDelay`, si aparece, exige esperar al menos ese intervalo desde la recepción del error. [Definición de RetryInfo](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto).

Los programas calculan la espera como el mayor valor entre los mínimos válidos del servidor y la espera exponencial local con variación aleatoria. Por ejemplo, con `Retry-After: 2` y `retryDelay: "10s"`, la siguiente petición no sale antes de diez segundos. Si quedan cinco segundos de presupuesto, el programa conserva la tarea aplazada; no recorta el mínimo a cinco segundos para forzar otro intento.

![Ejemplo de espera: se respeta el mayor retraso del servidor y se aplaza la tarea si no queda tiempo suficiente.](https://blog.laozhang.ai/posts/es/resource-exhausted-error-nano-banana/img/server-wait-floor.webp)

Los límites elegidos para el ejemplo son **cuatro envíos como máximo por trabajo, 90 segundos acumulados de llamadas y esperas, y 30 segundos como máximo por llamada**. Se persisten el número de envíos y el tiempo gastado, de modo que ejecutar otra vez el programa no reinicia esos límites. El timeout de cada nueva llamada se reduce al presupuesto que queda. Son decisiones de implementación, no cuotas ni recomendaciones numéricas oficiales; puedes adaptarlas al tiempo que admite tu aplicación.

La documentación advierte de reintentos automáticos en los SDK. Estos ejemplos usan REST sin esa capa: el contador representa los POST que controla el programa. Si los trasladas a un framework, revisa sus reintentos antes de añadir otro bucle.

## Prepara los trabajos y la carpeta de recuperación

Elige **uno** de los programas. Ambos leen un archivo `trabajos.json` con IDs únicos y estables:

```json
[
  {"id": "faro-01", "prompt": "Genera una imagen de un faro en la costa cantábrica al amanecer, estilo acuarela."},
  {"id": "faro-02", "prompt": "Genera una imagen del mismo tipo de faro de noche, con el haz de luz sobre el mar."}
]
```

Cada ID utiliza una carpeta propia. El programa vincula ese ID al modelo y a la petición mediante una huella; si cambias el prompt y reutilizas el ID, se detiene para no confundir resultados. Ejecuta un solo proceso sobre la carpeta de recuperación y guarda estos archivos en almacenamiento privado: incluyen respuestas y podrían contener datos del trabajo.

Configura `GEMINI_API_KEY` mediante el mecanismo seguro de tu entorno, con la credencial del proyecto previsto. Los ejemplos leen explícitamente esa variable y no buscan otras. No incluyen claves, no las imprimen y no crean proyectos ni modifican la cuenta. Al ejecutarlos con trabajos pendientes realizarás llamadas que pueden generar cargos; las pruebas descritas más abajo no las realizan.

### Programa completo en Python

Guarda este archivo como `recuperar.py`. Requiere Python 3.10 o posterior y Pillow para verificar que los bytes recibidos se pueden decodificar como imagen:

```bash
python3 -m pip install Pillow
python3 recuperar.py trabajos.json recuperacion-python
```

```python
import base64, hashlib, io, json, os, pathlib, random, re, subprocess, sys, time
from email.utils import parsedate_to_datetime
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'
ATTEMPTS, BUDGET, CALL_TIMEOUT = 4, 90.0, 30.0


def atomic(path, data):
    temporary = path.with_suffix(path.suffix + '.tmp')
    temporary.write_bytes(data)
    temporary.replace(path)


def http_call(payload, timeout):
    key = os.environ.get('GEMINI_API_KEY')
    if not key:
        raise RuntimeError('Falta GEMINI_API_KEY')
    # Un proceso permite limitar toda la operación, incluida la lectura del cuerpo.
    process = subprocess.Popen([sys.executable, __file__, '--http-worker'],
                               stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    try:
        out, _ = process.communicate(json.dumps({'payload': payload, 'key': key}).encode(),
                                     timeout=timeout)
    except subprocess.TimeoutExpired:
        process.kill()
        process.communicate()
        raise TimeoutError('Resultado remoto desconocido')
    if process.returncode:
        raise RuntimeError('Transporte sin respuesta completa')
    return json.loads(out)


def worker():
    try:
        value = json.load(sys.stdin)
        request = Request(URL, data=json.dumps(value['payload']).encode(),
                          headers={'x-goog-api-key': value['key'],
                                   'Content-Type': 'application/json'})
        try:
            response = urlopen(request, timeout=CALL_TIMEOUT)
        except HTTPError as error:
            response = error
        with response:
            result = {'status': response.code, 'headers': dict(response.headers.items()),
                      'body': response.read().decode('utf-8'), 'at': time.time()}
        print(json.dumps(result))
    except Exception:
        sys.exit(2)  # No imprime credenciales ni el cuerpo de la petición.


def decision(response):
    status = response['status']
    try:
        body = json.loads(response['body'])
    except ValueError:
        body = {}
    error = body.get('error', {})
    details = error.get('details', [])
    violations = [v for d in details if d.get('@type', '').endswith('QuotaFailure')
                  for v in d.get('violations', [])]
    if any(str(v.get('quotaValue', 'missing')) == '0' or v.get('serviceDisabled') is True
           for v in violations):
        return 'halted', 0
    text = json.dumps(violations) + ' ' + str(error.get('message', ''))
    if re.search(r'(?i)\b(?:limit|quota)\s*[:=]\s*0\b|per.?day|daily|\brpd\b|monthly.{0,30}(?:cap|limit)', text):
        return 'halted', 0
    if status not in (429, 503):
        return ('received' if status == 200 else 'halted'), 0
    floor = 0.0
    headers = {k.lower(): v for k, v in response['headers'].items()}
    value = headers.get('retry-after', '')
    try:
        floor = float(value) if re.fullmatch(r'\d+', value) else max(
            0, parsedate_to_datetime(value).timestamp() - response['at'])
    except (TypeError, ValueError, OverflowError):
        pass
    for detail in details:
        if detail.get('@type', '').endswith('RetryInfo'):
            delay = detail.get('retryDelay', '')
            if re.fullmatch(r'\d+(?:\.\d+)?s', str(delay)):
                floor = max(floor, float(delay[:-1]))
    return 'deferred', floor


def save_images(response, folder):
    body = json.loads(response['body'])
    paths = []
    for candidate in body.get('candidates', []):
        for part in candidate.get('content', {}).get('parts', []):
            if part.get('thought') is True:
                continue
            blob = part.get('inlineData', {})
            if not blob.get('mimeType', '').startswith('image/') or not blob.get('data'):
                continue
            raw = base64.b64decode(blob['data'], validate=True)
            with Image.open(io.BytesIO(raw)) as image:
                image.load()  # Decodifica la imagen completa, no solo la cabecera.
                extension = {'PNG': 'png', 'JPEG': 'jpg', 'WEBP': 'webp'}.get(image.format)
                if not extension:
                    raise ValueError('Formato de imagen fuera del ejemplo')
            path = folder / f'imagen-{len(paths) + 1}.{extension}'
            atomic(path, raw)
            paths.append({'file': path.name, 'sha256': hashlib.sha256(raw).hexdigest()})
    if not paths:
        raise ValueError('HTTP 200 sin imagen final decodificable')
    return paths


def run_job(job, root, transport=http_call, now=time.time, clock=time.monotonic,
            sleep=time.sleep, jitter=random.random):
    payload = {'contents': [{'parts': [{'text': job['prompt']}]}],
               'generationConfig': {'responseModalities': ['TEXT', 'IMAGE'],
                                    'imageConfig': {'aspectRatio': '1:1', 'imageSize': '1K'}}}
    fingerprint = hashlib.sha256(json.dumps([MODEL, payload], sort_keys=True).encode()).hexdigest()
    folder = pathlib.Path(root) / job['id']
    folder.mkdir(parents=True, exist_ok=True)
    path = folder / 'estado.json'
    state = json.loads(path.read_text()) if path.exists() else {
        'status': 'pending', 'attempts': 0, 'spent': 0, 'hash': fingerprint}
    if state['hash'] != fingerprint:
        raise ValueError('El ID ya pertenece a otra petición')
    def commit():
        atomic(path, json.dumps(state, indent=2).encode())
    if state['status'] == 'sending':
        state['status'] = 'unknown'
        commit()
    if state['status'] in ('done', 'unknown', 'halted'):
        if state['status'] == 'done' and any(
            not (folder / item['file']).exists() or hashlib.sha256(
                (folder / item['file']).read_bytes()).hexdigest() != item['sha256']
            for item in state['images']):
            raise ValueError('Salida confirmada perdida o modificada: no regenerar automáticamente')
        return state['status']
    while True:
        if state['status'] == 'received':
            try:
                response = json.loads((folder / 'respuesta.json').read_text())
                state['images'] = save_images(response, folder)
                state['status'] = 'done'
            except Exception:
                state['status'] = 'halted'
            commit()
            return state['status']
        remaining = BUDGET - state['spent']
        wait = max(0, state.get('notBefore', 0) - now())
        if state['attempts'] >= ATTEMPTS or remaining <= wait + 0.05:
            commit()
            return 'deferred'
        if wait:
            start = clock()
            sleep(wait)
            state['spent'] += clock() - start
            commit()
        timeout = min(CALL_TIMEOUT, BUDGET - state['spent'])
        if timeout <= 0.05:
            return 'deferred'
        state.update(status='sending', attempts=state['attempts'] + 1)
        commit()  # Si el proceso muere desde aquí, se conserva la ambigüedad.
        start = clock()
        try:
            response = transport(payload, timeout)
            atomic(folder / 'respuesta.json', json.dumps(response).encode())
        except Exception:
            state.update(status='unknown', spent=state['spent'] + clock() - start)
            commit()
            return 'unknown'
        state['spent'] += clock() - start
        status, floor = decision(response)
        state['status'] = status
        if status == 'deferred':
            delay = max(floor, min(16, 2 ** (state['attempts'] - 1)) + jitter())
            state['notBefore'] = response['at'] + delay
        commit()
        if status == 'halted':
            return status


if __name__ == '__main__':
    if sys.argv[1:] == ['--http-worker']:
        worker()
    else:
        jobs = json.loads(pathlib.Path(sys.argv[1]).read_text())
        ids = [job['id'] for job in jobs]
        if len(set(ids)) != len(ids) or any(not re.fullmatch(r'[a-zA-Z0-9_-]+', x) for x in ids):
            raise ValueError('IDs únicos: letras, números, guion o guion bajo')
        if not os.environ.get('GEMINI_API_KEY'):
            raise RuntimeError('Configura GEMINI_API_KEY en tu entorno seguro')
        for job in jobs:
            print(job['id'], run_job(job, sys.argv[2]))
```

### Programa completo en Node.js

Guarda este archivo como `recuperar.mjs`. Requiere Node.js 22 o posterior y `sharp`; utiliza `fetch` y la señal de timeout del entorno de Node para limitar tanto el envío como la lectura del cuerpo:

```bash
npm install sharp
node recuperar.mjs trabajos.json recuperacion-node
```

```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 ATTEMPTS = 4, BUDGET = 90, CALL_TIMEOUT = 30;
const hash = value => createHash('sha256').update(value).digest('hex');
const exists = async name => fs.access(name).then(() => true, () => false);
const atomic = async (name, bytes) => {
  await fs.writeFile(`${name}.tmp`, bytes);
  await fs.rename(`${name}.tmp`, name);
};

async function httpCall(payload, timeout) {
  if (!process.env.GEMINI_API_KEY) throw new Error('Falta GEMINI_API_KEY');
  const response = await fetch(URL, {
    method: 'POST',
    headers: { 'x-goog-api-key': process.env.GEMINI_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload), signal: AbortSignal.timeout(Math.ceil(timeout * 1000)),
  });
  const body = await response.text(); // La señal también limita la lectura del cuerpo.
  return { status: response.status, headers: Object.fromEntries(response.headers),
    body, at: Date.now() / 1000 };
}

export function decision(response) {
  let body;
  try { body = JSON.parse(response.body); } catch { body = {}; }
  const error = body.error ?? {}, details = error.details ?? [];
  const violations = details.filter(d => (d['@type'] ?? '').endsWith('QuotaFailure'))
    .flatMap(d => d.violations ?? []);
  if (violations.some(v => String(v.quotaValue ?? 'missing') === '0' || v.serviceDisabled === true)) {
    return ['halted', 0];
  }
  const text = `${JSON.stringify(violations)} ${error.message ?? ''}`;
  if (/\b(?:limit|quota)\s*[:=]\s*0\b|per.?day|daily|\brpd\b|monthly.{0,30}(?:cap|limit)/i.test(text)) {
    return ['halted', 0];
  }
  if (![429, 503].includes(response.status)) return [response.status === 200 ? 'received' : 'halted', 0];
  const headers = Object.fromEntries(Object.entries(response.headers).map(([k, v]) => [k.toLowerCase(), v]));
  const value = headers['retry-after'] ?? '';
  let floor = /^\d+$/.test(value) ? Number(value) : Math.max(0, Date.parse(value) / 1000 - response.at);
  if (!Number.isFinite(floor)) floor = 0;
  for (const detail of details) {
    const delay = String(detail.retryDelay ?? '');
    if ((detail['@type'] ?? '').endsWith('RetryInfo') && /^\d+(?:\.\d+)?s$/.test(delay)) {
      floor = Math.max(floor, Number(delay.slice(0, -1)));
    }
  }
  return ['deferred', floor];
}

async function saveImages(response, folder) {
  const body = JSON.parse(response.body), images = [];
  for (const candidate of body.candidates ?? []) {
    for (const part of candidate.content?.parts ?? []) {
      if (part.thought === true) continue;
      const blob = part.inlineData ?? {};
      if (!blob.mimeType?.startsWith('image/') || !blob.data) continue;
      if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(blob.data)) {
        throw new Error('Base64 inválido');
      }
      const raw = Buffer.from(blob.data, 'base64');
      const metadata = await sharp(raw).metadata();
      await sharp(raw).raw().toBuffer(); // Fuerza la decodificación completa.
      const extension = { png: 'png', jpeg: 'jpg', webp: 'webp' }[metadata.format];
      if (!extension) throw new Error('Formato fuera del ejemplo');
      const file = `imagen-${images.length + 1}.${extension}`;
      await atomic(path.join(folder, file), raw);
      images.push({ file, sha256: hash(raw) });
    }
  }
  if (!images.length) throw new Error('HTTP 200 sin imagen final decodificable');
  return images;
}

export async function runJob(job, root, hooks = {}) {
  const transport = hooks.transport ?? httpCall;
  const now = hooks.now ?? (() => Date.now() / 1000);
  const clock = hooks.clock ?? (() => performance.now() / 1000);
  const sleep = hooks.sleep ?? (seconds => new Promise(resolve => setTimeout(resolve, seconds * 1000)));
  const jitter = hooks.jitter ?? Math.random;
  const payload = { contents: [{ parts: [{ text: job.prompt }] }], generationConfig: {
    responseModalities: ['TEXT', 'IMAGE'], imageConfig: { aspectRatio: '1:1', imageSize: '1K' },
  } };
  const fingerprint = hash(JSON.stringify([MODEL, payload]));
  const folder = path.join(root, job.id), statePath = path.join(folder, 'estado.json');
  await fs.mkdir(folder, { recursive: true });
  const state = await exists(statePath) ? JSON.parse(await fs.readFile(statePath, 'utf8')) :
    { status: 'pending', attempts: 0, spent: 0, hash: fingerprint };
  if (state.hash !== fingerprint) throw new Error('El ID ya pertenece a otra petición');
  const commit = () => atomic(statePath, JSON.stringify(state, null, 2));
  if (state.status === 'sending') { state.status = 'unknown'; await commit(); }
  if (['done', 'unknown', 'halted'].includes(state.status)) {
    if (state.status === 'done') {
      for (const item of state.images) {
        const file = path.join(folder, item.file);
        if (!await exists(file) || hash(await fs.readFile(file)) !== item.sha256) {
          throw new Error('Salida confirmada perdida o modificada: no regenerar automáticamente');
        }
      }
    }
    return state.status;
  }
  while (true) {
    if (state.status === 'received') {
      try {
        const response = JSON.parse(await fs.readFile(path.join(folder, 'respuesta.json'), 'utf8'));
        state.images = await saveImages(response, folder);
        state.status = 'done';
      } catch { state.status = 'halted'; }
      await commit();
      return state.status;
    }
    const remaining = BUDGET - state.spent;
    const wait = Math.max(0, (state.notBefore ?? 0) - now());
    if (state.attempts >= ATTEMPTS || remaining <= wait + 0.05) {
      await commit();
      return 'deferred';
    }
    if (wait) {
      const start = clock();
      await sleep(wait);
      state.spent += clock() - start;
      await commit();
    }
    const timeout = Math.min(CALL_TIMEOUT, BUDGET - state.spent);
    if (timeout <= 0.05) return 'deferred';
    state.status = 'sending'; state.attempts++;
    await commit();
    const start = clock();
    let response;
    try {
      response = await transport(payload, timeout);
      await atomic(path.join(folder, 'respuesta.json'), JSON.stringify(response));
    } catch {
      state.status = 'unknown'; state.spent += clock() - start;
      await commit();
      return 'unknown';
    }
    state.spent += clock() - start;
    const [status, floor] = decision(response);
    state.status = status;
    if (status === 'deferred') {
      const delay = Math.max(floor, Math.min(16, 2 ** (state.attempts - 1)) + jitter());
      state.notBefore = response.at + delay;
    }
    await commit();
    if (status === 'halted') return status;
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  const jobs = JSON.parse(await fs.readFile(process.argv[2], 'utf8'));
  const ids = jobs.map(job => job.id);
  if (new Set(ids).size !== ids.length || ids.some(id => !/^[a-zA-Z0-9_-]+$/.test(id))) {
    throw new Error('IDs únicos: letras, números, guion o guion bajo');
  }
  if (!process.env.GEMINI_API_KEY) throw new Error('Configura GEMINI_API_KEY en tu entorno seguro');
  for (const job of jobs) console.log(job.id, await runJob(job, process.argv[3]));
}
```

## Cómo interpretar el resultado y continuar sin repetir imágenes

Cada programa imprime el ID del trabajo y una decisión. El estado detallado queda en `estado.json` y la última respuesta completa recibida en `respuesta.json`.

![Estados de los trabajos de imagen: conservar los completados, aplazar los pendientes y aclarar los resultados desconocidos antes de reenviar.](https://blog.laozhang.ai/posts/es/resource-exhausted-error-nano-banana/img/confirmed-jobs-resume.webp)

| Resultado | Qué conserva | Qué haces después |
| --- | --- | --- |
| `done` | Imagen decodificada, ruta y huella SHA-256 | Utilizarla; otra ejecución verifica el archivo y no llama al modelo |
| `deferred` | Intentos consumidos, tiempo gastado y `notBefore` si hay espera pendiente | Esperar y revisar si aún hay margen; no borrar límites para insistir indefinidamente |
| `halted` | Respuesta recibida y estado detenido | Resolver cuota, pago, petición o ausencia de imagen antes de una nueva decisión |
| `unknown` | Trabajo ambiguo tras fallo de transporte o envío interrumpido | Conciliar los registros y resultados disponibles antes de autorizar otro envío |

Una tarea con respuesta ya guardada y estado interno `received` puede terminar la validación local al arrancar de nuevo, sin regenerar. Si el programa se cae entre enviar y guardar el estado de respuesta, deja `sending`; al reiniciar lo transforma en `unknown`. La existencia de una respuesta antigua en la carpeta no basta para atribuirla al último envío.

El marcador `done` exige bytes de imagen finales que Pillow o sharp puedan decodificar. No certifica que la imagen satisfaga el prompt, tenga la calidad necesaria o sea adecuada para publicarla: revisa el resultado visual como parte de tu trabajo. Si se pierde o modifica un archivo confirmado, el programa avisa en vez de regenerarlo automáticamente.

Tras corregir una causa permanente, conserva el estado anterior como evidencia y decide expresamente qué tareas pueden volver a intentarse. Puedes crear una nueva recuperación controlada para las tareas cuyo fallo esté confirmado; no incluyas imágenes `done` ni tareas `unknown` sin aclarar. Si se agotaron los cuatro envíos o los 90 segundos, ampliar el límite debe ser una decisión consciente, no una propiedad de reiniciar el proceso.

Estos archivos reducen duplicados de trabajos confirmados, pero **no garantizan ejecución exactamente una vez ni ausencia de doble cargo**. Un timeout no cancela necesariamente el procesamiento remoto, y el ejemplo no dispone de un identificador idempotente documentado que permita repetir el POST con esa garantía.

### Qué se comprobó sin llamar a la API

Se ejecutaron 15 escenarios por programa con transporte simulado, reloj controlable e imágenes pequeñas generadas localmente. Se verificaron resultados y archivos, no solo sintaxis: cero explícito y cuota diaria detienen el bucle; la ausencia de `quotaValue` permite tratar un 429 como potencialmente transitorio; 402 y 403 no se reintentan; 503 se recupera dentro de límites.

También se comprobó que una fecha HTTP se convierte en espera, que un mínimo de diez segundos prevalece sobre una cabecera de dos, y que un mínimo de 120 segundos aplaza la tarea sin otra llamada. En el caso de consumo acumulado, el tercer timeout bajó a 29 segundos y el total se detuvo en 90. Los casos de 200 solo con texto o con bytes inválidos no se marcaron como imagen terminada.

Por último, se confirmó que una imagen guardada no vuelve a generarse, que una respuesta pendiente de validación se recupera localmente y que un timeout o un estado `sending` interrumpido no provocan un segundo envío automático. Estas pruebas validan la lógica local de los ejemplos; la respuesta de tu proyecto, el coste y la disponibilidad del modelo requieren comprobación en tu entorno.

## Si el error persiste con cuota positiva

Reduce el ritmo y la concurrencia de la aplicación completa, no solo de un trabajador. Una ejecución local no conoce el consumo de otras aplicaciones que comparten proyecto. En Developer API también puede intervenir un límite de gasto en ventana móvil, distinto de RPM, del saldo Prepay o del tope mensual; la [documentación de límites](https://ai.google.dev/gemini-api/docs/rate-limits) describe su aplicación según historial y estado de la cuenta.

Si la cuota del mismo proyecto y modelo es positiva y sigue el rechazo, conserva el error saneado, la hora, el endpoint, la versión del cliente y los identificadores disponibles para soporte. El acceso de pago no garantiza toda la capacidad indicada. Antes de cambiar de modelo o proveedor, confirma modalidad, tamaño, precio, cuota y tratamiento de datos de la nueva ruta: un cambio de nombre no constituye una recuperación verificada.

## Preguntas frecuentes

### ¿RESOURCE_EXHAUSTED significa que Nano Banana Pro está caído?

No por sí solo. Puede ser cuota, un control de gasto o falta de capacidad en el servicio utilizado. Compara el detalle con el proyecto y modelo reales; un error aislado no demuestra una caída global. Los tratamientos de [Developer API](https://ai.google.dev/gemini-api/docs/rate-limits) y [Google Cloud](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/deploy/error-code-429) tienen condiciones distintas.

### ¿Puedo reintentar al cabo de dos segundos si el mensaje indica ocho?

No reduzcas el mínimo válido del servidor. `RetryInfo` pide esperar al menos el retraso indicado; si el presupuesto no permite esa espera y una nueva llamada, aplaza el trabajo. El mínimo tampoco garantiza que el siguiente intento tenga éxito. [Definición del retraso de reintento](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto).

### ¿Un HTTP 200 basta para marcar una imagen como terminada?

No. En estos ejemplos debe existir una parte final `inlineData` con MIME de imagen y bytes decodificables; las partes de razonamiento no cuentan como resultado final. Un bloqueo o una salida de texto pueden requerir revisar la respuesta aunque el transporte haya terminado correctamente. [Estructura de GenerateContent](https://ai.google.dev/api/generate-content).

### ¿Puedo volver a ejecutar el programa después de un timeout?

Sí, para continuar otras tareas, pero el trabajo que acabó en `unknown` seguirá detenido. Revisa si hay un resultado atribuible a ese envío antes de autorizar otro. Los trabajos `done` se conservan; repetir el comando no los regenera ni resuelve por sí solo la incertidumbre del timeout.
