2014年から運営している個人開発のアプリ事業で、AdMob 収益を Google Sheets にまとめて Gemini API で月次のサマリーを書き出す Apps Script を組んでいたことがあります。50 行程度なら問題なく動いていたのに、対象を 500 行に増やした途端、毎回違う行で処理が止まり、実行ログには Address unavailable と「スクリプトの実行時間が長すぎます」が交互に現れる、という不可解な状態に陥りました。
この症状は、Apps Script から Gemini を本格的に使い始めると多くの方がぶつかります。原因は一つではなく、二段構えの限界が同時に効いていることがほとんどです。まずは「どこで止まっているか」を切り分けるところから始めるのが近道でした。
起きているのは二種類の「タイムアウト」
Apps Script で Gemini API を呼ぶときに引っかかる限界は、大きく二つあります。
一つ目はスクリプト全体の最大実行時間です。Google アカウントの種類によって、無料アカウントは 6 分、Workspace 契約のアカウントは 30 分が上限として設定されています。これを超えると Exceeded maximum execution time という記述がログに残り、書き込み途中のセルがあれば中途半端な状態で停止します。
二つ目は UrlFetchApp.fetch のレスポンス待ちタイムアウトです。公式には明示されていませんが、おおむね 60 秒〜2 分でブロックされ、Exception: Address unavailable や DNS 関連のエラーとして表面化します。Gemini 2.5 Pro のような長文生成・長時間思考を行うモデルでは、1 リクエストでこの境界を越えてしまうことが珍しくありません。
どちらが先に発火するかは入力サイズと出力長によって変わるため、症状だけ見ると一貫性のないエラーに見えるのが厄介な点です。
まず「どちらで落ちているか」を切り分ける
エラーログを開いて、止まる直前のメッセージで判断します。
Exceeded maximum execution time が出ているなら、スクリプト全体が 6 分(または 30 分)を超えています。これはリクエスト数を抑えても、1 回ずつ長くなれば結局到達する種類のエラーです。
Address unavailable / Exception: Request failed / DNS エラーが出ているなら、UrlFetchApp.fetch の応答待ちで切れています。1 リクエスト単位の話なので、maxOutputTokens や入力サイズを見直すと改善する余地があります。
両方が混在している場合は、まず 1 リクエスト単位を縮めて UrlFetchApp 側を安定させてから、全体実行時間の問題に取り組むほうが切り分けが楽でした。
1 リクエスト単位を確実に通すための工夫
UrlFetchApp 側を安定させるための変更点は、現場で効きやすい順にいくつかあります。
function callGemini_(prompt) {
const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent';
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const body = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
maxOutputTokens: 800,
thinkingConfig: { thinkingBudget: 1024 },
responseMimeType: 'application/json',
},
};
const res = UrlFetchApp.fetch(`${ENDPOINT}?key=${apiKey}`, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(body),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
throw new Error(`Gemini API ${res.getResponseCode()}: ${res.getContentText().slice(0, 200)}`);
}
const json = JSON.parse(res.getContentText());
return json.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}ポイントは三つあります。第一に maxOutputTokens を意識的に短く設定し、1 リクエストの上限を自分でコントロールする点。第二に thinkingBudget を絞り、思考トークンが膨らんで応答時間が延びるのを抑える点。第三に responseMimeType で JSON 出力を指定して、ストリーミングではなく完全な応答を一括で受け取る前提に揃える点です。
Apps Script の UrlFetchApp は Server-Sent Events を逐次処理できないため、streamGenerateContent 系のエンドポイントは原則使えません。リアルタイム性が欲しい場合は Apps Script ではなく Cloud Functions などのバックエンドに逃がしたほうが素直です。
それでも単発で 60 秒以上かかってしまう場合は、入力プロンプトを意味のある単位で分割するのが現実的です。長い文書の要約なら段落単位、コードレビューならファイル単位、というように、ドメインに応じた切れ目を選びます。
全体実行時間を超えないためのチェックポイント方式
入力件数が多い処理では、6 分(または 30 分)の上限内に収まりきらないことが珍しくありません。私自身が Sheets の一括処理で落ち着いた構成は、PropertiesService に処理済みの行番号を保存し、時間ベーストリガーで再開する方式でした。
function runBatch() {
const props = PropertiesService.getScriptProperties();
const startRow = Number(props.getProperty('lastRow') || '2');
const sheet = SpreadsheetApp.getActiveSheet();
const lastRow = sheet.getLastRow();
// 安全側に 4 分でデッドラインを切り、上限手前で能動的に降りる
const deadline = Date.now() + 4 * 60 * 1000;
for (let row = startRow; row <= lastRow; row++) {
if (Date.now() > deadline) {
props.setProperty('lastRow', String(row));
scheduleNextRun_();
return;
}
const prompt = sheet.getRange(row, 1).getValue();
if (!prompt) continue;
if (sheet.getRange(row, 2).getValue()) continue; // 既に書き込み済みならスキップ
const result = callGemini_(prompt);
sheet.getRange(row, 2).setValue(result);
SpreadsheetApp.flush();
}
// 全行完了したら進捗をリセット
props.deleteProperty('lastRow');
}
function scheduleNextRun_() {
// 既存トリガーを掃除してから 1 分後に再実行を予約
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'runBatch')
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('runBatch').timeBased().after(60 * 1000).create();
}設計上の勘どころは「進捗をシートと PropertiesService の両方に書く」「自分でデッドラインを設定して上限の手前で能動的に降りる」の二点です。Google 側が強制終了するのを待つと、中途半端な状態でセルが残り、リカバリが面倒になります。
なお、トリガーは 1 プロジェクトあたり 20 個までの制限があるため、毎回新しいトリガーを作る場合は古いものを必ず削除しておきます。掃除を忘れると、ある日突然 Triggers limit exceeded で全実行が止まり、原因を追うのに時間を取られます。
API キーの取り回しと冪等性
長時間処理を組むときに見落としがちなのが、API キーの取り回しと再実行時の冪等性です。
API キーはコードに直接書かず、PropertiesService.getScriptProperties().setProperty('GEMINI_API_KEY', '...') で別管理にしておくと、リポジトリに鍵を含めずに済みます。Apps Script のエディタ画面右下の「プロジェクトの設定」からも GUI で値を編集できます。
冪等性のほうは、再開時にすでに書き込み済みのセルを上書きしない、という単純なガードで十分です。空セルだけを対象に処理することで、二重課金と上書き事故を同時に避けられます。先ほどのサンプルで if (sheet.getRange(row, 2).getValue()) continue; を入れているのは、まさにこの理由です。
本番運用に乗せる前に確認しておきたいこと
最後に、本番で運用に乗せる前に押さえておくと安心な点をまとめておきます。
- 1 リクエストあたりの応答時間をログに出す。極端に遅い行を特定でき、入力分割の指針になります。
muteHttpExceptions: trueを付けてレスポンスコードを自分で確認します。429 / 500 系で指数バックオフのリトライを組めます。- 再開可能な状態でテストを止める癖をつける。エディタから手動停止すると
PropertiesServiceの進捗が更新されないため、次回トリガー実行時に同じ行から始まらなくなります。 - 件数が数千行以上になる場合は、Apps Script ではなく Cloud Functions や Cloud Run に逃がす選択肢を早めに検討します。30 分の上限を超える処理を Workspace 契約で延々と引き伸ばすより、別の実行基盤に分けたほうが結局は早く済みます。
私自身、Sheets の月次処理を Cloud Functions に移しただけで、エラー対応に費やしていた時間がかなり減りました。Apps Script は「Sheets や Docs にすぐ書き戻せる軽い接着剤」として割り切り、重い処理は別の場所で動かすのが、結局は運用負荷を下げる近道だと感じています。同じところでつまずいている方のヒントになれば嬉しいです。