壁紙用の画像を夜のうちに 20 枚ぶん作っておき、朝にまとめて選びたいと考えました。そのとき、いちばん困るのは「3 枚目で止まったまま朝を迎える」ことです。
何で止まったのかが分からないと、手が打てません。ネットワークの瞬断なら、もう一度回せば済みます。ところが API キーの失効だったなら、何度回しても同じ場所で落ちます。
10/27 の release notes に、gemini-api というコマンドが載りました。公式ページでは「ステートレスでゼロ依存、AI コーディングエージェント・スクリプト・CI 向け」のベータ版と説明されています。対話型の Gemini CLI とは別物で、API を 1 コマンドずつ呼ぶだけの道具です。
ここでは、このコマンドの終了コードだけに絞ってお伝えします。コマンドの使い方の一覧ではなく、夜間バッチに載せるときの「止める・待つ・飛ばす」の線引きです。
なお、以下は公式ページの記載にもとづく設計メモで、本番で長期間回した記録ではありません。ベータ版ですので、動作は手元で --dry-run から確かめてからお使いください。
公式が決めている終了コードは四つだけ
公式ページの表では、終了コードは次の四つです。
| コード | 意味(公式の記載) | 私の扱い |
|---|---|---|
| 0 | 成功 | 次の項目へ |
| 1 | 実行時または API のエラー(ネットワーク、レート制限、失敗したバックグラウンドジョブ) | 間隔を空けて、上限つきで再試行 |
| 2 | 使い方または入力の検証エラー(未知のフラグ、入力の欠落、`--body` のキー重複) | 再試行しない。その項目だけ記録して飛ばす |
| 3 | 認証または認可の失敗 | 再試行しない。バッチ全体を止める |
ここで注目したいのは、1 に三つの性質の違う失敗が入っていることです。瞬断、レート制限、失敗したバックグラウンドジョブは、待てば直るものと、直らないものが混ざっています。
逆に 2 と 3 は、待っても状況が変わりません。入力が壊れているか、鍵が通らないかのどちらかだからです。
原則: 待てば変わる失敗だけを待ち、変わらない失敗は早く止める。
ラッパーを書く
この線引きをそのまま書いたのが、次のスクリプトです。1 項目ずつ run_with_retry に渡し、終了コードで分岐します。
#!/usr/bin/env bash
# nightly-gemini.sh — gemini-api の終了コードで再試行の可否を分ける
set -u
MAX_ATTEMPTS=3
LOG_DIR="${LOG_DIR:-./logs}"
mkdir -p "$LOG_DIR" ./out
# 戻り値: 0=成功, 2=入力の問題(飛ばす), 3=認証の問題(全体を止める), 1=再試行を使い切った
run_with_retry() {
local name="$1"; shift
local attempt=1 code wait_sec
while :; do
# --agent-mode: エラーを構造化 JSON で stderr に出す。試行ごとに保存しておく
"$@" --agent-mode 2> "$LOG_DIR/${name}.attempt${attempt}.err"
code=$?
case "$code" in
0) return 0 ;;
2) echo "[$name] 入力の検証エラー(exit 2)。再試行せず飛ばします" >&2; return 2 ;;
3) echo "[$name] 認証エラー(exit 3)。バッチを止めます" >&2; return 3 ;;
1)
if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
echo "[$name] ${MAX_ATTEMPTS} 回試して失敗(exit 1)" >&2
return 1
fi
wait_sec=$(( 30 * attempt * attempt )) # 30秒 → 120秒
echo "[$name] exit 1。${wait_sec} 秒待って再試行 (${attempt}/${MAX_ATTEMPTS})" >&2
sleep "$wait_sec"
attempt=$(( attempt + 1 ))
;;
*) echo "[$name] 想定外の終了コード $code" >&2; return "$code" ;;
esac
done
}
skipped=0; failed=0; line=0
while IFS= read -r prompt; do
line=$(( line + 1 ))
n=$(printf '%03d' "$line") # 入力ファイルの行番号。ログと出力のファイル名が安定する
run_with_retry "wp-$n" gemini-api image "$prompt" --out "./out/wp-$n.jpg"
case $? in
0) ;;
2) skipped=$(( skipped + 1 )) ;;
3) echo "認証に失敗したため中断します" >&2; exit 3 ;;
*) failed=$(( failed + 1 )) ;;
esac
done < prompts.txt
echo "完了: 飛ばした=${skipped} 失敗=${failed}"
[ "$failed" -eq 0 ] && [ "$skipped" -eq 0 ]ファイル名を入力の行番号から作っているのは、途中で落ちた行があっても、朝に「何行目の画像か」がそのまま分かるようにするためです。
ログを試行ごとに分けているのは、--agent-mode の stderr に残る内容が、あとで 1 の中身(瞬断かレート制限か)を見分ける唯一の手がかりになるからです。エラーの JSON の項目名は公式ページに載っていないため、まず一度わざと失敗させて、実物を見てから集計を書くことをお勧めします。
再実行で「二重に払う」のは、1 の中のバックグラウンドジョブ
ここまでのラッパーは、数秒で終わる画像生成のような処理向けです。
公式の例にある Deep Research のような長いジョブでは、話が変わります。--background を付けると、コマンドは対話の ID をすぐ返すとされています。その ID を持たずに、同じコマンドを丸ごと再実行すると、新しいジョブがもう一つ始まります。
終了コード 1 に「失敗したバックグラウンドジョブ」が含まれる以上、ラッパーが 1 を見て同じコマンドを再実行する設計は、課金を二重に生む可能性があると考えたほうが安全です。
長いジョブは、ラッパーに包まず、次の順で扱うのが私の線引きです。
--background --stream=falseで起動して、返ってきた ID をファイルに書き出します- 以降は ID に対して
agent statusで状態を見ます(出力の形は公式ページで確認してから、パースを書きます) - 止めたいときは
agent cancel <id>を使います
つまり、再試行してよいのは「何も始まっていないことが分かる失敗」だけです。ID が手元にあるなら、再実行ではなく問い合わせに切り替えます。
朝ではなく、前の晩に落とす
2 の「入力の検証エラー」は、夜に回してから知るには惜しい失敗です。公式ページによると --dry-run は、入力を検証してリクエストの中身をオフラインでプレビューし、認証情報は伏せて表示します。
そこで、バッチを本番で回す前に、各行を --dry-run だけで通す前段を置きます。
# preflight.sh — 本番の前に、全行の入力だけを検証する
bad=0
while IFS= read -r prompt; do
if ! gemini-api image "$prompt" --out /dev/null --dry-run >/dev/null 2>&1; then
echo "検証エラー: $prompt" >&2
bad=$(( bad + 1 ))
fi
done < prompts.txt
[ "$bad" -eq 0 ] || { echo "${bad} 行に問題があります" >&2; exit 2; }--out /dev/null が --dry-run 下で問題なく通るかどうかは、私は公式ページから読み取れませんでした。通らなければ、--out ./out/dryrun.jpg のように普通のパスへ替えてください。
ベータ版を夜間に載せるときの二つの備え
公式ページには、ベータの注意が二つ書かれています。
一つ目は、マイナーバージョンの間でも破壊的変更がありうることです。インストール時に GEMINI_VERSION へリリースタグを指定して、バージョンを固定します。終了コードの意味が変わると、ここで書いた分岐がそのまま崩れるからです。
二つ目は、generate と agent run を -o json や --jq と組み合わせるときは --stream=false が必要なことです。ラッパーの中で -o json を付けたのに何も出ない、という場面ではまずここを疑います。
今夜やること
- 認証は環境変数
GEMINI_API_KEYで渡します(スクリプトに直書きせず、export GEMINI_API_KEY=YOUR_API_KEYの形で呼び出し側のシェルに置きます) preflight.shを 3 行ぶんのプロンプトで試し、終了コードが 0 か 2 になることを確かめます- わざとキーを一文字変えて
run_with_retryを回し、3 で即座に止まることを見ます
終了コードの分岐は、書いた時点では正しそうに見えても、止まる場面を一度自分の目で見るまでは安心できません。実際に落として確かめてから夜に任せるほうが、私は安心できると感じています。個人開発では、朝に原因を調べ直す時間がいちばん惜しいのです。
関連して、同じ Gemini の CLI でも対話型のほうでつまずいたときは Gemini CLI が起動しない・認証が通らないときに見る場所 を、モデルの自動ルーティングで応答が変わったと感じたときは Flash の返事の調子が変わった朝、最初に見るのは自分のプロンプトではありません をあわせてご覧ください。