田中力磨 › 一人塾のGAS自動化 実録 › Gemini API呼び出し

GASからGemini APIを呼ぶ処理が静かに止まるのを防ぐ——モデル廃止404・503再試行・エラー通知の間引き

モデル廃止の404、混雑の503、毎晩届く同じ失敗の通知。Gemini APIを呼ぶGASで実際に起きたことと、共通関数に入れた対策の記録。

2026-10-12 公開 / 滋賀県彦根市の一人塾「総合学習教室ブリッジ」で実際に動いている仕組みの記録 / Google Apps Script(GAS)

この記事は、教室で動いているコードと運用の記録をもとに、AI(Claude)が書きました。

何を自動化したか

この教室のGoogle Apps Script(GAS)は、SNS投稿の文章づくり、保護者へのメールの下書き、書類やレシートの読み取り、画像の生成などでGemini APIを呼んでいる。JSONで答えを受け取る共通関数 autoGeminiJson だけで20か所以上から呼ばれる。どれも同じAPIキーと、共通のモデル名の設定に頼るので、モデルの提供終了や一時的な混雑、月の利用上限に当たると、関係する処理がまとめて失敗する。しかも多くは夜や早朝に時間トリガーで動き、失敗しても誰も画面を見ていない。以前は gemini-1.5-flash の提供終了で呼び出しが404になった。

今は、定期処理から使う呼び出しを automation/Common.gs の共通関数に集めている。モデル名はスマホからでも差し替えられ、失敗は毎晩1通のメールで届く。2026-08-20からは、同じ種類の失敗が続くときの通知を間引いている。

仕組み(データの流れ)

呼び出しは次の順に共通関数を通る。失敗の記録と autoRunSafely はトリガー集約の記事で説明済みなので省く。

段階関数・設定中身
モデル名を決めるAUTO_GEMINI_TEXT_MODEL / AUTO_GEMINI_IMAGE_MODEL実行のたびにスクリプトプロパティを読む。未設定なら既定値
送るautoGeminiFetch_429・500・502・503・504 のときだけ、1.5秒→3秒→6秒と待って最大4回送る
答えを確かめるautoGeminiText / autoGeminiVision / autoGeminiImageFile答え(文章や画像)が無ければ失敗を記録し、例外を投げる
JSONを読むautoGeminiJson → autoParseJson応答からJSONの部分を切り出して読む。読めなければ例外
失敗を数えるautoAiRecordFailure(zz-QuietAlerts.gs で上書き)同じ種類の失敗は、前回の通知から7日より3時間短い時間がたつまで件数に数えない(1.5倍を超える悪化は即数える)。記録はシートに毎回残す
知らせるautoAiFailureAlert(毎晩21時台)3日より古い記録を外し、残りを1通のメールにまとめる

コード(要点)

モデル名は Common.gs の先頭で決め、URLの組み立てに使う。

// モデル名はスクリプトプロパティ AUTO_GEMINI_TEXT_MODEL / AUTO_GEMINI_IMAGE_MODEL で上書き可能。
// Googleのモデル廃止(過去にgemini-1.5-flash廃止で404になった前歴あり)の際に、
// コード変更・デプロイなしにスマホからでも差し替えられるようにするための逃げ道。
var AUTO_GEMINI_TEXT_MODEL  = PropertiesService.getScriptProperties().getProperty('AUTO_GEMINI_TEXT_MODEL')  || 'gemini-2.5-flash';
var AUTO_GEMINI_IMAGE_MODEL = PropertiesService.getScriptProperties().getProperty('AUTO_GEMINI_IMAGE_MODEL') || 'gemini-2.5-flash-image';
// …(省略)…
  var url = 'https://generativelanguage.googleapis.com/v1beta/models/'
          + AUTO_GEMINI_TEXT_MODEL + ':generateContent?key=' + autoGeminiKey();

関数の外に書いた処理は実行のたびに読み直されるので、プロパティを書き換えれば次の実行から新しいモデル名が使われる。同じプロジェクトのThreads投稿やLINEで届いたレシートの読み取りも同じキーを読むので、まとめて切り替わる。

なお、画像用の既定値 gemini-2.5-flash-image は、公式の廃止予定の一覧で提供終了日が2026-10-02とされている(一覧の日付は、早ければその日に止まるという最短の予定日だ)。一覧はその後継に gemini-3.1-flash-image-preview を挙げているが、このプレビュー版も同じ一覧で提供終了日が2026-06-25とされ、後継は正式版の gemini-3.1-flash-image になっている。後継の後継まで確かめてから選びたい。既定値は書いた時点のもので、切り替えはプロパティ AUTO_GEMINI_IMAGE_MODEL で行う。

Common.gs の共通関数とThreads投稿の文章生成は、UrlFetchApp.fetch を直接呼ばずに次の関数を通す(投稿の採点やWebアプリ側など、まだ直接呼んでいる所もある)。

function autoGeminiFetch_(url, options) {
  var RETRY_CODES = [429, 500, 502, 503, 504];
  var waits = [1500, 3000, 6000];   // 最大4回(初回+3リトライ)
  var res = null;
  for (var attempt = 0; attempt <= waits.length; attempt++) {
    res = UrlFetchApp.fetch(url, options);
    var code = res.getResponseCode();
    if (RETRY_CODES.indexOf(code) < 0) return res;           // 成功、または待っても直らない失敗
    if (code === 429 && res.getContentText().indexOf('spending cap') >= 0) return res; // 月次上限超過は待っても直らない
    if (attempt === waits.length) break;                      // 打ち止め(最後の応答を返す)
    // …(省略)…
    Utilities.sleep(waits[attempt]);
  }
  return res;
}

400(プロンプトの不備)・403(鍵の問題)・404(モデルの提供終了)は待っても直らないので、最初の応答をそのまま返す。待ち時間は、GASの実行時間の上限(6分)を圧迫しないよう合計10.5秒に抑えた。呼び出し側は muteHttpExceptions: true を付け、HTTPのエラーでも例外にせず応答を受け取る。状態コードを見るのは autoGeminiFetch_、答えの有無を見るのは呼び出し側だ。

答えは、入っているかを確かめてから取り出す。JSONは autoGeminiJson で受け取る。

function autoGeminiText(prompt, opts) {
  // …(省略)…
  var json = JSON.parse(res.getContentText());
  if (!json.candidates || !json.candidates[0] || !json.candidates[0].content) {
    autoAiRecordFailure('autoGeminiText', 'HTTP ' + res.getResponseCode() + ' ' + res.getContentText().slice(0, 200));
    throw new Error('Gemini応答エラー(HTTP ' + res.getResponseCode() + '): ' + res.getContentText().slice(0, 300));
  }
  return json.candidates[0].content.parts.map(function(p) { return p.text || ''; }).join('').trim();
}

/** JSONを返させて安全にパースする */
function autoGeminiJson(prompt) {
  var raw = autoGeminiText(prompt + '\n\n出力はJSONのみ。コードブロックや説明文は不要。', { temperature: 0.2 });
  return autoParseJson(raw);
}

/** Gemini応答テキストからJSONを取り出してパースする */
function autoParseJson(raw) {
  var match = String(raw).match(/\{[\s\S]*\}|\[[\s\S]*\]/);
  if (!match) throw new Error('GeminiからJSONを抽出できませんでした: ' + String(raw).slice(0, 200));
  try {
    return JSON.parse(match[0]);
  } catch (e) {
    throw new Error('GeminiのJSONを解析できませんでした: ' + match[0].slice(0, 200));
  }
}

答えが無いときに空文字を返して先へ進ませず、記録してから例外を投げるのが要点だ。JSONは前置きの文やコードブロックの印が付いても読めるよう、最初の { から最後の } まで(配列なら [ から ] まで)を切り出す。読めなければ例外になるので、呼び出し側で代わりの値を決められる。

同じ失敗の通知を間引くのが automation/zz-QuietAlerts.gs だ。失敗の文の数字を # に置き換えたものを「指紋」にして、同じ種類の失敗かどうかを見分ける。

function quietSignature_(where, message) {
  return (String(where) + '|' + String(message)).replace(/\d+/g, '#').slice(0, 180);
}
// …(省略)…
function autoAiRecordFailure(where, message) {
  try {
    // …(省略)…
    // 比べる相手は「最後に通知したとき」(n・nm)。旧形式(t・m だけ)の記録は t・m をそれとみなす
    var prev = (seen[sig] && typeof seen[sig] === 'object') ? seen[sig] : null;
    var notifiedAt = prev ? (prev.n || prev.t || '') : '';
    var notifiedT = Date.parse(notifiedAt) || 0;
    var notifiedM = prev ? (Number(prev.nm !== undefined ? prev.nm : prev.m) || 0) : 0;

    var windowMs = (QUIET_REPEAT_SUPPRESS_DAYS * 24 - QUIET_REPEAT_SLACK_HOURS) * 3600000;
    var withinWindow = notifiedT > 0 && (now - notifiedT) < windowMs;
    var worsened = notifiedM > 0 && mag > notifiedM * QUIET_ESCALATE_RATIO;
    var suppress = withinWindow && !worsened;

    var nowIso = new Date(now).toISOString();
    seen[sig] = suppress
      ? { t: nowIso, m: mag, n: notifiedAt, nm: notifiedM }   // 通知しない回は「最後に起きた」だけ更新
      : { t: nowIso, m: mag, n: nowIso, nm: mag };            // 通知する回は両方を更新
    // …(省略)…
    quietAppendLog_(entry, suppress);
    if (suppress) return;
    // …(省略)…
  } catch (e) { /* 記録自体の失敗で本処理を巻き込まない */ }
}

指紋ごとに、最後に起きた時刻と数字の合計(t・m)と、最後に通知した時刻と数字の合計(n・nm)を分けて持つ。前回の通知から、7日(QUIET_REPEAT_SUPPRESS_DAYS)より3時間(QUIET_REPEAT_SLACK_HOURS)短い時間がたつまでの再発は件数に数えず、合計が前回通知したときの1.5倍を超えたときだけ悪化とみなして数える。3時間を引くのは、毎晩の実行時刻が数分前後するからだ。ちょうど7日で区切ると、7日目の実行が前回より数分早いだけで数えられず、通知が8日目にずれる。抑制した回も自動化用のスプレッドシート(ハブ)の「通知抑制ログ」シートに残し、60日起きていない指紋は捨て、毎晩のメールには3日以内の失敗だけを載せる。

ハマった点と回避策

503で生成の回が消え、429では待つ意味が無い場合があった

2026-08-04、Threads投稿の文章を作る呼び出しが503で2回失敗し、その回の生成がむだに消えた。503は一時的な混雑なので、autoGeminiFetch_ を作り、Threads投稿の生成もこれに通した。一方、2026-08-27には月の利用上限(monthly spending cap)を超えた429が返った。429はふつう待てば通るが、上限超過は翌月まで直らない。そこで spending cap を含む429だけはすぐ返し、夜のメールには「コード修正では直りません」と添えた。

思考にトークンを取られて、答えが途中で切れる

gemini-2.5系は既定で答える前の「思考」にトークンを使い、出力が途中で途切れることがあった。JSONは途中で切れると読めない。generationConfig に thinkingConfig: { thinkingBudget: 0 } を入れて思考を止め、Threads投稿やPR動画の文字起こしなど、共通関数を通らない呼び出しにも同じ指定を入れた。

人が直すまで消えない検出で、毎晩同じメールが届いた

夜のデータ整合性チェックは、検出が1件でもあれば失敗として記録する。入会日の欠落や氏名の重複の疑いは自動修復の対象外なので、人が直すまで同じ内容が記録され、「【要確認】自動処理の失敗が1件」のメールが毎晩届いた。直近5件の一覧は古いものを消さないため、対策済みの2026-08-04の503も本文に残り続けた。2026-08-20に足した zz-QuietAlerts.gs は、名前を zz- で始めて Common.gs より後に読み込ませる。GASは全ファイルが1つのグローバル空間を共有し、同じ名前の関数は後に読み込まれた方が使われるので、Common.gs を変えずに2つの関数を差し替えられ、ファイルを消せば元に戻る。

ただ、最初の版には穴があった。抑制した回にも「前回の時刻と数字」を上書きしていたため、比べる相手が「前回通知したとき」ではなく「前の晩」になっていた。毎晩続く失敗は最初の1回のあと二度と数えられず、毎晩1割ずつ増える悪化も、前の晩と比べるので1.5倍を超えない。メールの末尾には「7日に1回だけ通知」と書いてあったので、読む側はそれを信じてしまう。2026-09-27に、日付を進めながらコードを動かして確かめたところ見つかり(同じ失敗を20日続けると通知は初日だけだった)、最後に通知した時刻と数字を別に持つように直した。直したあとは同じ条件で0・7・14日目に通知され、1割ずつの悪化も1.5倍を超えた日に通知される。この確かめ方は、時計とプロパティを差し替えて実物のファイルを何日分も回すテストとして残してある。

zz- のファイルで関数を差し替えるやり方は、読み込み順に頼っている。まねるなら、差し替えた側が動いているかを一度確かめたい。

導入手順

  1. Gemini APIのキーを Google AI Studio で取得し(APIキーの説明)、GASのスクリプトプロパティ GEMINI_API_KEY に入れる(コードには書かない)。このキーは課金を有効にした有料枠で使う。規約(Gemini API 追加利用規約)には、無料枠では送った内容がGoogleの製品やサービスの改善に使われ、人が読むこともあると書かれており、無料枠には個人情報を送らないよう求めている。この教室も有料枠で使っている
  2. ファイルの先頭でモデル名をスクリプトプロパティ(例 AUTO_GEMINI_TEXT_MODEL)から読み、空なら既定値を使う。Geminiを呼ぶほかのファイルも同じプロパティを読む
  3. muteHttpExceptions: true を付けた送信を再試行つきの関数に通す。送り直すのは 429・500・502・503・504 だけにし(月の利用上限を超えた429は除く)、待ち時間は短くとどめる
  4. gemini-2.5系なら generationConfig に thinkingConfig: { thinkingBudget: 0 } を入れる
  5. 応答に答え(candidates)が無ければ、失敗を記録してから例外を投げる
  6. JSONがほしいときは、プロンプトの最後に「JSONのみ」と書き、応答から { 〜 }(または [ 〜 ])を切り出して読む
  7. 失敗の記録と毎晩のまとめ通知を用意する(トリガー集約の記事参照)。モデル名のプロパティに存在しない名前を入れてGeminiを呼ぶ関数を手動実行し(初回は外部への接続とメール送信の権限を承認する)、続けて autoAiFailureAlert を手動実行して、404の記録とメールを確かめたら元に戻す。上の間引きを入れていると、前回の通知から7日(正確には7日より3時間短い時間)たつ前に試し直した分はメールにならないので、そのときは「通知抑制ログ」シートで確かめる

よくある質問

Q. 毎晩続く失敗は、7日ごとに通知し直されますか

される。2026-09-27の修正で、前回通知してから7日目の晩に、また数えて通知するようにした(毎晩の実行時刻は数分前後するので、7日ちょうどより3時間早くても数える)。その間の晩の記録は、ハブの「通知抑制ログ」シートで確かめられる。

Q. responseMimeType を指定すれば、JSONの切り出しは要りませんか

この教室では残している。PR動画の文字起こしでは responseMimeType: 'application/json' を指定しているが、そこでも autoParseJson を通し、配列かどうかと各項目の値を確かめてから使っている。

Q. モデル名には何を入れればよいですか

Google公式のモデル一覧にある現行のモデル名を入れ、提供終了の予定は廃止予定の一覧で確かめる。世代が変わると、思考の止め方などの設定の受け付け方が変わることがあるので、差し替えたら一度手動で実行して確かめる。プロパティを消せば、コードに書いた既定値に戻る。

筆者の本とnote(有料)で、まとめて読む