Nano Banana ProのRESOURCE_EXHAUSTEDの対処:再試行と画像ジョブの再開
Nano Banana ProでRESOURCE_EXHAUSTEDが出たら、まずエラー本文が示す上限と指標を確認します。上限0や日次枠なら再試行を止め、一時的な制限なら指定された待機時間と累計予算を守ります。画像を保存してから完了を記録すると、確認済みジョブを再生成せずに再開できます。
目次

Nano Banana Proの429 RESOURCE_EXHAUSTEDは、何度も送れば直るエラーとは限りません。適用上限が0、日次枠の使い切り、支払いによる停止なら自動再試行を止めます。毎分制限や一時的な容量不足なら、サーバーが指定する最低待機時間を守り、回数と累計時間を制限して再試行します。 画像が返った場合はファイルを保存してから完了を記録し、再開時に同じ画像を作り直さないようにします。
ここで扱うのは、Gemini Developer APIでgemini-3-pro-imageを呼ぶ画像生成処理です。Geminiアプリの利用上限、Vertex AIの予約済みスループット、代理サービス独自の制限、Gemini CLIの割り当てを同じものとして扱いません。まず、失敗した要求のエンドポイント・モデルID・HTTPステータスを確認してください。
429の本文から「待つ」「止める」を決める
RESOURCE_EXHAUSTEDという文字列だけでは、どの枠を使い切ったかは分かりません。HTTPステータスとエラー本文に含まれる指標を合わせて読みます。Developer APIのレート制限はプロジェクト単位で、モデルによって異なります。同じプロジェクトのキーを増やしても、独立した枠にはなりません。Googleのレート制限の説明
| 実際の応答・確認できた状態 | 画像ジョブの次の動作 |
|---|---|
quotaValue: 0やメッセージのlimit: 0で適用上限が明示されている | 自動再試行を停止。キーの所属プロジェクト、請求状態、対象モデルの有効な割り当てを照合する |
quotaId・quotaMetric・説明が日次枠を示している | ジョブを残して停止。正の枠の使い切りならリセットを待つ |
毎分制限、一時的な429、503 UNAVAILABLE | 同時実行を減らし、最低待機時間付きのバックオフを行う |
| 400、402、403、モデル不明などの404 | 同じ要求を繰り返さず、パラメータ・支払い・認証・提供状況を直す |
| タイムアウトや通信断で応答を確認できない | 結果不明として停止。サーバー側で完了した可能性があるため、無条件で再送しない |
| HTTP 200だが画像のデータがない | 完了にしない。promptFeedback、finishReason、返却されたpartsを確認する |
quotaValueは、Google RPCの定義ではその失敗時点で適用された上限値です。使用済み回数や残り回数ではありません。また、フィールドが欠けている応答を0で補ってはいけません。本文に毎分指標だけがあり、値が省略されているなら、上限0と確定せず、実際の割り当て表示を確認します。QuotaFailureの定義
日次リクエスト枠は太平洋時間の午前0時にリセットされます。日本時間の午前0時に固定したジョブ再開は正しくありません。なお、無料提供のないモデルの上限0は、日次リセットで無料枠が生まれる状態とは別です。2026年10月5日に確認したProの料金表ではFree TierはNot availableです。
有料設定済みなのに無料枠のゼロ上限が報告される場合は、キー・所属プロジェクト・請求状態の照合手順へ進んでください。Prepay残高0は公式資料ではHTTP 402の停止であり、自動で無料枠へ戻る挙動ではありません。請求ガイド
指定された待機時間を予算に合わせて短縮しない
サーバーに待機時間の指定がある場合、独自のバックオフより短くしてはいけません。Retry-Afterは秒数、またはHTTP形式の日時です。RetryInfo.retryDelayは、エラーを受け取ってから少なくとも待つ時間を示します。両方ある場合は長い方を採用します。HTTPのRetry-After、Google RPCのRetryInfo
例えば累計予算150秒の処理で、それまでの要求と待機に140秒を費やし、残りが10秒になったとします。応答の最低待機が15秒なら、その場で再試行せずジョブを延期します。「残り10秒に収めるため5秒だけ待つ」は、サーバーの指定を守れていません。
待機時間には、指数バックオフと小さなランダムなずれ(ジッター)も加えます。次の例は初回を含む総試行回数を4回、累計の要求・待機予算を150秒、1回の要求を最大45秒にします。これらはこの記事の運用例であり、Googleが保証する復旧時間やモデルの上限ではありません。次の要求では、残り予算と45秒の小さい方をタイムアウトに使います。

公式SDKには既定の自動再試行があります。外側の4回にSDK側の再試行を重ねると、想定より多くの要求が発生します。以下は再試行を一か所で管理するため、SDKを介さずgenerateContentのRESTを使います。Googleの再試行ガイド
Python:画像保存と再開判定までつなぐ
この例はPython 3.11以降、httpx、Pillowを使います。プロンプトをUTF-8のテキストファイルから読み込み、出力ディレクトリに画像PNGとジョブ記録JSONを保存します。コードをrecover.pyとして保存し、使用するPython環境に依存パッケージを入れてください。prompt.txtには、例えば「白い背景に青い陶器のマグカップを置いた横長の画像を1枚生成してください」と書きます。必要な題材に置き換え、既存ファイルを入力として渡します。
python -m pip install httpx Pillow
python recover.py prompt.txt jobs-python実際にAPIへ送る場合だけ、同じプロセスにGEMINI_API_KEYを設定します。キーをソースへ書いたり、ログへ出したりする必要はありません。この例では変数を明示して渡すため、SDKによる環境変数の自動選択は使いません。呼び出しには対象モデルの料金が適用されます。
要求はcontents[].parts[].text、generationConfig.responseModalities、generationConfig.imageConfig.imageSizeです。返却画像はcandidates[].content.parts[].inlineDataのmimeTypeとBase64データから読みます。thought: trueのpartは最終画像として保存しません。現在のGenerateContentリファレンスに合わせた構造で、Interactions APIのresponse_formatやSDKのoutput_imageを混ぜていません。
import asyncio, base64, hashlib, io, json, os, random, re, sys, time
from pathlib import Path
from email.utils import parsedate_to_datetime
import httpx
from PIL import Image
MODEL = 'gemini-3-pro-image'
BUDGET, CALL_LIMIT, ATTEMPTS = 150.0, 45.0, 4
def decision(status, headers, body):
error = body.get('error', {}) if isinstance(body, dict) else {}
details = error.get('details', [])
floors = [0.0]
raw = headers.get('Retry-After', headers.get('retry-after', ''))
if re.fullmatch(r'\d+', str(raw)):
floors.append(float(raw))
elif raw:
try:
floors.append(max(0, parsedate_to_datetime(raw).timestamp() - time.time()))
except (ValueError, TypeError, OverflowError):
pass
blocked = status not in (429, 503)
for d in details:
kind = d.get('@type', '')
if kind.endswith('RetryInfo'):
delay = d.get('retryDelay', '')
if re.fullmatch(r'\d+(?:\.\d+)?s', delay):
floors.append(float(delay[:-1]))
if kind.endswith('QuotaFailure'):
for v in d.get('violations', []):
label = ' '.join(str(v.get(k, '')) for k in
('quotaId', 'quotaMetric', 'description')).lower()
zero = 'quotaValue' in v and str(v['quotaValue']) == '0'
daily = bool(re.search(r'per.?day|daily|\brpd\b', label))
disabled = bool(re.search(r'service.?disabled', label))
blocked = blocked or zero or daily or disabled
# Missing quotaValue is deliberately not interpreted as zero.
if re.search(r'\blimit\s*[:=]\s*0\b', error.get('message', ''), re.I):
blocked = True
return ('blocked' if blocked else 'retry'), max(floors)
def picture(body):
for c in body.get('candidates', []):
for p in c.get('content', {}).get('parts', []):
inline = p.get('inlineData', {})
if p.get('thought') or not inline.get('mimeType', '').startswith('image/'):
continue
data = base64.b64decode(inline.get('data', ''), validate=True)
with Image.open(io.BytesIO(data)) as im:
im.load()
out = io.BytesIO()
im.save(out, format='PNG')
return out.getvalue()
raise ValueError('HTTP 200 but no final, decodable image')
def store(path, state):
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
tmp.replace(path)
async def recover(prompt, folder, key, post=None, clock=time.monotonic,
sleep=asyncio.sleep):
request = {'contents': [{'parts': [{'text': prompt}]}],
'generationConfig': {'responseModalities': ['TEXT', 'IMAGE'],
'imageConfig': {'aspectRatio': '16:9', 'imageSize': '1K'}}}
identity = hashlib.sha256(json.dumps([MODEL, request], sort_keys=True).encode()).hexdigest()
folder.mkdir(parents=True, exist_ok=True)
path, image = folder / (identity + '.json'), folder / (identity + '.png')
state = json.loads(path.read_text()) if path.exists() else {'status': 'pending'}
if state['status'] == 'done':
if not image.exists() or hashlib.sha256(image.read_bytes()).hexdigest() != state['sha256']:
raise RuntimeError('Saved image missing or changed; do not regenerate automatically')
return str(image)
if state['status'] in ('sending', 'unknown', 'blocked', 'no_image'):
raise RuntimeError('Inspect saved job before explicitly permitting a new request')
if state.get('not_before', 0) > time.time():
return 'deferred: server minimum has not elapsed'
started = clock()
async with httpx.AsyncClient() as session:
async def live(timeout):
url = f'https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent'
r = await session.post(url, json=request, headers={'x-goog-api-key': key},
timeout=timeout)
return r.status_code, dict(r.headers), r.json()
send = post or live
for attempt in range(ATTEMPTS):
remaining = BUDGET - (clock() - started)
if remaining <= 0:
break
state = {'status': 'sending', 'attempt': attempt + 1}
store(path, state)
try:
status, headers, body = await asyncio.wait_for(
send(min(CALL_LIMIT, remaining)), timeout=min(CALL_LIMIT, remaining))
except Exception:
store(path, {'status': 'unknown'})
raise RuntimeError('Unconfirmed POST result; automatic retry stopped') from None
if status == 200:
try:
data = picture(body)
except Exception:
store(path, {'status': 'no_image', 'response': body})
raise RuntimeError('Response saved; inspect blocked/text/invalid-image result') from None
temporary = image.with_suffix('.png.tmp')
temporary.write_bytes(data)
temporary.replace(image)
store(path, {'status': 'done', 'sha256': hashlib.sha256(data).hexdigest(),
'responseId': body.get('responseId')})
return str(image)
action, minimum = decision(status, headers, body)
if action == 'blocked':
store(path, {'status': 'blocked', 'http': status, 'error': body})
return f'blocked: HTTP {status}'
delay = max(minimum, min(2 ** (attempt + 1), 30)) + random.uniform(0, 1)
state = {'status': 'pending', 'not_before': time.time() + delay,
'http': status, 'error': body}
store(path, state)
remaining = BUDGET - (clock() - started)
if attempt + 1 == ATTEMPTS or delay + 1 >= remaining:
return 'deferred: inspect state and resume after not_before'
await sleep(delay)
return 'deferred: cumulative time budget exhausted'
if __name__ == '__main__':
prompt = Path(sys.argv[1]).read_text(encoding='utf-8')
key = os.environ['GEMINI_API_KEY']
print(asyncio.run(recover(prompt, Path(sys.argv[2]), key)))上限0・日次枠・サービス無効の情報があれば、RetryInfoが同時に付いていてもblockedにします。根本条件が未解決なら、数秒待つ指定だけを理由に再試行するべきではないためです。quotaValueがないことは停止理由にしていません。未知の指標や独自サービスの本文は、この判定だけで確定せず、人が保存した応答を確認します。
HTTP 200の場合も、画像データを復号し、Pillowで実際に読み込めてからPNGを書きます。画像のない応答や壊れた画像はno_imageとして保存し、生成を繰り返しません。finishReasonやpromptFeedbackは保存したJSONで確認できます。正しい返却形式と、作った画像が用途に合うかは別の確認です。
Node.js:同じ停止条件をfetchで実装する
こちらはNode.js 22以降の組み込みfetchと画像デコーダーsharpを使います。recover.mjsを保存したディレクトリでsharpを用意します。Python版とは記録形式が異なるため、出力先を分けてください。
npm install sharp
node recover.mjs prompt.txt jobs-nodeこの例もGEMINI_API_KEYを明示的に渡します。AbortSignal.timeoutは要求と応答本文の読み込みに使い、タイムアウトしたPOSTはunknownとして止めます。Base64の形だけで成功とせず、sharpが画像として読み込んでPNGへ変換できたことを確認します。
import { promises as 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 budgetMs = 150000, callMs = 45000, attempts = 4;
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
export function inspect(status, headers, body) {
const error = body?.error ?? {};
let stop = ![429, 503].includes(status), minimum = 0;
const after = headers['retry-after'];
if (/^\d+$/.test(after ?? '')) minimum = Number(after) * 1000;
else if (after && Number.isFinite(Date.parse(after)))
minimum = Math.max(0, Date.parse(after) - Date.now());
for (const detail of error.details ?? []) {
if (detail['@type']?.endsWith('RetryInfo') &&
/^\d+(\.\d+)?s$/.test(detail.retryDelay ?? ''))
minimum = Math.max(minimum, Number(detail.retryDelay.slice(0, -1)) * 1000);
if (detail['@type']?.endsWith('QuotaFailure')) {
for (const violation of detail.violations ?? []) {
const label = ['quotaId', 'quotaMetric', 'description']
.map(k => violation[k] ?? '').join(' ');
stop ||= (Object.hasOwn(violation, 'quotaValue') &&
String(violation.quotaValue) === '0') ||
/per.?day|daily|\brpd\b|service.?disabled/i.test(label);
}
}
}
stop ||= /\blimit\s*[:=]\s*0\b/i.test(error.message ?? '');
return { stop, minimum };
}
export async function finalImage(body) {
for (const candidate of body.candidates ?? []) {
for (const part of candidate.content?.parts ?? []) {
const blob = part.inlineData;
if (part.thought || !blob?.mimeType?.startsWith('image/')) continue;
const encoded = blob.data;
if (typeof encoded !== 'string' || !encoded.length || encoded.length % 4 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) throw Error('Invalid base64');
return await sharp(Buffer.from(encoded, 'base64')).png().toBuffer();
}
}
throw Error('HTTP 200 without a final image');
}
async function save(file, record) {
await fs.writeFile(file + '.tmp', JSON.stringify(record));
await fs.rename(file + '.tmp', file);
}
export async function runJob(prompt, directory, key, options = {}) {
const clock = options.clock ?? (() => performance.now());
const sleep = options.sleep ?? pause;
const request = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '16:9', imageSize: '1K' } }
};
const id = hash(JSON.stringify({ model, request }));
await fs.mkdir(directory, { recursive: true });
const record = path.join(directory, id + '.json');
const output = path.join(directory, id + '.png');
let state;
try { state = JSON.parse(await fs.readFile(record, 'utf8')); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
state = { status: 'pending' };
}
if (state.status === 'done') {
const bytes = await fs.readFile(output);
if (hash(bytes) !== state.sha256) throw Error('Saved result changed; inspect it');
return output;
}
if (['sending', 'unknown', 'blocked', 'no_image'].includes(state.status))
throw Error('Inspect job before explicitly permitting another POST');
if ((state.notBefore ?? 0) > Date.now()) return 'deferred: wait until notBefore';
const started = clock();
const post = options.post ?? (async timeout => {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{ method: 'POST', headers: { 'x-goog-api-key': key,
'Content-Type': 'application/json' }, body: JSON.stringify(request),
signal: AbortSignal.timeout(Math.max(1, Math.floor(timeout))) });
return { status: response.status, headers: Object.fromEntries(response.headers),
body: await response.json() };
});
for (let attempt = 1; attempt <= attempts; attempt++) {
const remaining = budgetMs - (clock() - started);
if (remaining <= 0) break;
await save(record, { status: 'sending', attempt });
let response;
try { response = await post(Math.min(callMs, remaining)); }
catch {
await save(record, { status: 'unknown' });
throw Error('POST outcome unknown; automatic retry stopped');
}
if (response.status === 200) {
let bytes;
try { bytes = await finalImage(response.body); }
catch {
await save(record, { status: 'no_image', response: response.body });
throw Error('Inspect saved response; no decodable final image');
}
await fs.writeFile(output + '.tmp', bytes);
await fs.rename(output + '.tmp', output);
await save(record, { status: 'done', sha256: hash(bytes),
responseId: response.body.responseId });
return output;
}
const { stop, minimum } = inspect(response.status, response.headers, response.body);
if (stop) {
await save(record, { status: 'blocked', http: response.status, error: response.body });
return `blocked: HTTP ${response.status}`;
}
const wait = Math.max(minimum, Math.min(2 ** attempt * 1000, 30000)) +
Math.random() * 1000;
await save(record, { status: 'pending', notBefore: Date.now() + wait,
http: response.status, error: response.body });
if (attempt === attempts || wait + 1000 >= budgetMs - (clock() - started))
return 'deferred: inspect state and resume after notBefore';
await sleep(wait);
}
return 'deferred: cumulative time budget exhausted';
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const prompt = await fs.readFile(process.argv[2], 'utf8');
if (!process.env.GEMINI_API_KEY) throw Error('GEMINI_API_KEY is required');
console.log(await runJob(prompt, process.argv[3], process.env.GEMINI_API_KEY));
}完了済みの画像を再生成せず、止まった作業だけ再開する
同じモデル・プロンプト・生成設定の組からジョブIDを作るので、同じコマンドを再度実行すると対応する記録を読みます。doneのジョブは保存画像のハッシュを照合してファイルの場所を返し、APIを呼びません。 複数のプロンプトを処理する場合も、各ファイルを順に実行すれば、既に保存できた成果を使いながら続きを処理できます。多重実行は避け、一つの出力ディレクトリには一つの実行プロセスだけを使ってください。
| 保存された状態 | 再開時の扱い |
|---|---|
done | 保存画像とハッシュを確認し、その画像を使う。ファイル欠落・改変なら停止する |
pending | 保存された最低待機期限を過ぎていれば、新しい実行の予算内で再試行する |
blocked | 割り当て・支払い・要求設定を確認するまで送らない |
no_image | 保存した応答と要求を調べ、必要な変更を決める |
unknownまたは残ったsending | 完了したか不明。再送による重複の可能性を確認するまで送らない |

blockedなどの停止記録を解除したい場合は、原因と結果を確認し、記録を別の場所へ保管したうえで、そのジョブのJSONを出力ディレクトリから外します。それが新しい要求を許可する明示的な操作です。保存済みのdoneを削除して再送する必要はありません。プロンプトやモデルを変更すると別のIDになるため、確認済み成果の再利用が必要なら、既存ファイルを先に選びます。
PNGを書いた直後、完了JSONを書く前にプロセスが落ちる場合もあります。記録がsendingのままなら、コードは自動で再送しません。保存先のPNGとログを調べて、既に受け取った結果を回収できるか判断します。一方、サーバーで生成されたものの応答を受信できなかった場合、この同期APIの例だけで結果の取得や「必ず一度だけの生成」を保証することはできません。
記録JSONにはエラー本文や画像なしの応答が残ります。プロンプトやプロジェクト情報を含み得るため、公開せず、利用環境の保管・アクセス方針に合わせてください。これはサンプルの単一プロセス向け保存方式です。複数ワーカーへ広げるなら、排他的なジョブ取得、永続キュー、処理中状態の管理を別途実装します。
このコードで確認したこと
両言語の例は、外部APIへ接続しない模擬応答で動作を確認しました。明示されたゼロ上限と日次枠の停止、値が省略された毎分指標の扱い、秒数・HTTP日時・RPCによる最低待機、4回の試行上限、累計予算から次の要求のタイムアウトを減らす動作を確認しています。さらに、復号できる画像の保存、HTTP 200で画像がない場合の停止、結果不明のPOSTを再送しない動作、完了済みジョブの再利用、保存画像の改変時の停止も確認しました。
この確認はコードの分岐と保存動作に対するローカル検証です。実際の有料画像生成、対象プロジェクトの利用資格、Google側の復旧、生成品質を測った結果ではありません。初めて実サービスへ接続する前に、利用できるモデル・割り当て・料金を確認し、必要最小限の作業から開始してください。
Vertex AIや別モデルへ切り替える前に確認すること
失敗した要求がVertex AI側なら、AI StudioのTierを上げる手順に置き換えてはいけません。Google Cloudの429エラーの説明では、従量制で処理容量が利用できない場合と、Provisioned Throughputの予約量を超えた場合を分けています。従量制では急な負荷増加を避けること、指数バックオフ、利用条件を満たす場合のglobal endpointなどが選択肢です。予約スループットの購入やglobal endpointへの変更は、対応モデル・リージョン・費用・データ所在地の条件を確認する必要があります。
Developer APIで別モデルを使う場合は、例えば現行のgemini-3.1-flash-imageが必要な出力仕様に合うかを、画像生成のモデル選択で確認します。別モデルの利用資格、上限、画質、画像サイズ、料金が今回の作業に合うと確認できた場合だけ、モデルIDを変更します。同じプロジェクトの別モデルが必ず別の制限で使えるとは考えず、元の要求が結果不明なら、切り替え後の生成も重複し得ることを先に確認してください。
旧gemini-2.5-flash-imageには公式料金表で2026年10月2日の提供終了予定が示されており、この記事の日付は予定日後です。また、現在の画像生成ガイドはImagenをGemini APIで利用できないと説明しています。古いサンプルの名前を代入するだけのフォールバックは採用しないでください。料金表の旧モデル注記、現行画像生成ガイド
よくある質問
RESOURCE_EXHAUSTEDは何秒待てば解消しますか?
一律の秒数はありません。一時的な制限ならRetry-AfterとRetryInfoの最低待機を守って再試行します。適用上限0、日次枠、支払い停止なら、短い待機だけでは必要な条件が変わりません。最低待機が残り予算を超えた場合は、そのジョブを延期してください。
Nano Banana Proを有料にすれば429はなくなりますか?
なくなる保証はありません。有料で利用可能な状態でも、モデル別・プロジェクト別の制限や実際の容量があります。まず同じモデルの有効なレート制限を確認し、一時的な負荷なら同時実行を減らします。無料枠0との不一致がある場合は、有料設定と要求の照合が先です。
HTTP 200ならジョブを完了にしてよいですか?
画像生成では、実際の最終画像データを受け取り、復号・保存できてから完了にします。テキストだけ、候補なし、ポリシー停止、壊れたデータなら、200でも必要な画像を得られていません。generateContentでは候補、parts、promptFeedbackとfinishReasonを調べます。
タイムアウト後に同じ要求を送っても二重生成になりませんか?
二重生成にならないとは保証できません。クライアントが応答を受け取れなくても、サーバー側では作業が進んでいる場合があります。上の例はその状態をunknownとして残し、自動再送を止めます。完了が確認できて保存された画像だけを、再開時に再利用します。





