GAS『Uncaught Exception』の原因と対処法

GAS『Uncaught Exception』の原因と対処法

実行中の例外がtry/catchで捕捉されずに上位まで伝播したときに出る。典型原因は「null参照・型不一致などのプログラムエラー」「権限/配信上限/レート制限などの実行環境エラー」「外部APIやネットワークの一時障害」。最短復旧は再現箇所の特定→防御的コーディング→エラー分類(再試行すべき/すべきでない)→通知と再実行の設計に落とすこと。

エラーの意味と発生条件(まず押さえる)

・try/catchで捕まえていない例外が最上位まで上がるとExecutionは失敗で終了

・onEdit/onOpen/doPost/doGet/時間トリガー/カスタム関数など、すべての入口で起こりうる

・プログラムエラー(TypeError/RangeError/ReferenceError)と、API例外(Authorization、Service error、Quota、Timeout)を分けて考える

最短復旧フロー(5手順)

1) 失敗した関数と行番号をExecutionsログ/コンソールで確認

2) 例外メッセージとstackから“最初の自分の行”を特定(原因行)

3) 入力(イベントe、シート名、ID、HTTPリクエスト)が前提通りか検証

4) try/catch+詳細ログで“再現→原因→修正”を一気に進める

5) 再発防止:ガード・バリデーション・リトライ・通知・継続設計を追加

トップレベルを安全化(共通ラッパで捕捉と通知)

/** 例外を捕捉し、詳細をログ→通知→必要に応じて再送へ回す */
function safeRun(entry, ctx = {}) {
  try {
    return entry();
  } catch (e) {
    const payload = {
      when: new Date().toISOString(),
      name: e && e.name,
      message: e && e.message,
      stack: e && e.stack,
      context: ctx
    };
    console.error('Uncaught', payload);
    // 例:管理者通知(必要ならコメントアウトを外す)
    // MailApp.sendEmail('admin@example.com', 'GAS Error', JSON.stringify(payload, null, 2));
    throw e; // 上位に再送(必要に応じて握りつぶすことも可能)
  }
}

// 入口関数例
function main() {
  return safeRun(() => {
    // 本処理
  }, { entry: 'main' });
}

イベント入口(onEdit/doPost/doGet)でのガードと有効な応答

function onEdit(e) {
  return safeRun(() => {
    if (!e?.range) return; // 手動実行などは無視
    const sh = e.range.getSheet();
    // ...安全な本処理...
  }, { entry: 'onEdit', a1: e?.range?.getA1Notation() });
}

function doPost(e) {
  return safeRun(() => {
    const raw = e?.postData?.contents || '';
    let obj;
    try { obj = JSON.parse(raw); } catch (_) { obj = { raw }; }
    const result = { ok: true, received: obj };
    return ContentService.createTextOutput(JSON.stringify(result))
      .setMimeType(ContentService.MimeType.JSON);
  }, { entry: 'doPost', type: e?.postData?.type, len: e?.postData?.length });
}

再試行可能・不可の分類(無限ループを防ぐ)

function isTransient(err) {
  const m = (err && err.message) || '';
  return /timeout|timed out|rate limit|quota|backend|internal|transient|service unavailable/i.test(m);
}
function withRetry(fn, tries = 4) {
  let wait = 400;
  for (let i = 0; i < tries; i++) {
    try { return fn(); }
    catch (e) {
      if (!isTransient(e) || i === tries - 1) throw e;
      Utilities.sleep(wait + Math.floor(Math.random() * 200));
      wait = Math.min(wait * 2, 8000);
    }
  }
}

・TypeError/ReferenceError/構文ミスは再試行不要。ネットワークやレート制限は指数バックオフで再試行に切替える

入力のバリデーションとnull安全(原因の8割を潰す)

const V = {
  req(cond, msg = '前提条件エラー') { if (!cond) throw new Error(msg); },
  nonEmptyString(s, name) {
    const ok = typeof s === 'string' && s.trim() !== '';
    if (!ok) throw new Error(name + ' が空/不正');
    return s.trim();
  }
};

function writeCell(sheetId, sheetName, a1, value) {
  return safeRun(() => {
    V.nonEmptyString(sheetId, 'sheetId');
    V.nonEmptyString(sheetName, 'sheetName');
    V.nonEmptyString(a1, 'a1');
    const sh = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
    V.req(sh, `シートが見つかりません: ${sheetName}`);
    sh.getRange(a1).setValue(value ?? '');
    return true;
  }, { entry: 'writeCell', sheetName, a1 });
}

外部API・UrlFetchは並列+タイムアウト+フォールバック

function fetchParallel(urls) {
  const reqs = urls.map(u => ({ url: u, muteHttpExceptions: true, followRedirects: true, timeout: 15000 }));
  return withRetry(() => UrlFetchApp.fetchAll(reqs))
    .map(r => ({ code: r.getResponseCode(), body: r.getContentText() }));
}

・直列fetchの積み上がりは例外の温床。fetchAllと短めtimeoutで“落ちにくく、落ちても早く戻る”

シート操作は一括・チャンク化(TypeErrorを副作用で起こさない)

// 悪手:1セルずつ(遅い+例外が出ると中途半端)
// 良手:データは配列で処理→setValuesで一括→大きいときはチャンク
function setBlock(sh, r, c, values) {
  if (!Array.isArray(values) || !Array.isArray(values[0])) throw new Error('2次元配列が必要');
  const MAX_CELLS = 50000;
  let i = 0;
  while (i < values.length) {
    const rows = Math.min(values.length - i, Math.floor(MAX_CELLS / values[0].length));
    const block = values.slice(i, i + rows);
    sh.getRange(r + i, c, block.length, block[0].length).setValues(block);
    i += rows;
    Utilities.sleep(120);
  }
}

クライアント連携:google.script.runの失敗ハンドラを必ず付ける

/* index.html(HtmlServiceで配信) */
<script>
  function submitForm() {
    google.script.run
      .withSuccessHandler(res => console.log('OK', res))
      .withFailureHandler(err => {
        console.error('Server error', err && err.message);
        alert('失敗しました: ' + (err && err.message));
      })
      .serverSave({ name: document.querySelector('#name').value });
  }
</script>

・サーバ側のUncaughtをUIに露出させない。ユーザーには要点のみ返す

障害の見える化(構造化ログ・サマリ行)

function logError(e, ctx) {
  const row = [
    new Date(),
    e?.name || '',
    e?.message || '',
    (e?.stack || '').slice(0, 500),
    JSON.stringify(ctx).slice(0, 500)
  ];
  try {
    const sh = SpreadsheetApp.openById('LOG_SHEET_ID').getSheetByName('Errors');
    sh.appendRow(row);
  } catch (_) {
    console.error('log append failed', row);
  }
}

最小再現テンプレ(バグを小さく切り出す)

function reproduce() {
  // 1) 問題の行周辺だけに削った最小コードを書く
  // 2) 固定入力(擬似e、固定ID)で実行
  // 3) 通る→元に差分を戻す/落ちる→さらに削る
  const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
  const sh = ss.getSheetByName('Data'); // 存在しないと落ちる
  if (!sh) throw new Error('Dataシートが無い'); // 明確なメッセージ
  return sh.getRange('A1').getValue();
}

よくあるNG→OK(クイック修正)

・NG:eやシート取得のnull前提を放置 → OK:非nullガードとエラーメッセージを明示

・NG:外部APIを直列fetch → OK:fetchAll+短timeout+指数バックオフ

・NG:単一セルを連続setValue → OK:配列処理→setValues一括+チャンク

・NG:サーバ例外をUIに垂れ流し → OK:withFailureHandlerでユーザ向け文言に変換

・NG:捕捉せず終了 → OK:safeRun+logErrorで詳細ログと通知

チェックリスト(導入順)

・Executionsで失敗関数・行番号・stackを確認したか

・入口関数をsafeRunでラップし、詳細ログと通知が出るか

・プログラムエラー(Type/Reference)と環境/一時エラーを分類したか

・null/型検証・ID/シート名のバリデーションを入れたか

・UrlFetch/外部I/OはfetchAll+バックオフ設計にしたか

・シート操作は一括・チャンク化で中断に強くしたか

・クライアントはwithFailureHandlerで失敗時のUXを確保したか

・最小再現コードで原因を特定し、再発防止のテストを追加したか