GAS『Unknown Error Occurred』の原因と対処法

GAS『Unknown Error Occurred』の原因と対処法

原因が特定の例外名に分類されず、Apps Script実行環境や依存サービスの一時失敗・境界条件・前提崩れなどが重なったときに出る汎用エラー。まず再現性の有無で切り分け、詳細ログを仕込み、指数バックオフ・サイズ分割・排他制御・入力検証・再開設計で安定化させる。

エラーの意味と発生条件(まず把握)

・固有名(TypeError/Service Error等)に収まらない失敗を包括的に表す。

・UrlFetch/Sheets/Drive/Gmailなど外部I/O境界や、実行時間の終盤、トリガー起動時に起きやすい。

・「直前まで動いた/リトライで通る」なら一時障害の疑いが高い。

最短復旧フロー(再現性で分岐)

1) もう一度実行→通るなら一時障害、通らなければ恒久要因。

2) Executionsで失敗ステップを特定(I/O直前にログを入れておく)。

3) 暫定復旧:指数バックオフ+短タイムアウト+サイズ縮小。

4) 恒久対策:入力検証、チャンク処理、排他、冪等化、再開トリガー。

共通ラッパで例外を可視化(どこで落ちたかを特定)

function safeRun(fn, ctx = {}) {
  try { return fn(); }
  catch (e) {
    console.error('UnknownErrorTrace', {
      time: new Date().toISOString(),
      name: e?.name, message: e?.message, stack: e?.stack,
      context: ctx
    });
    throw e;
  }
}

指数バックオフ+ジッタ(Unknown含む一時失敗に効く)

function withRetry(fn, tries = 5, base = 400, max = 8000) {
  let wait = base;
  for (let i = 0; i < tries; i++) {
    try { return fn(); }
    catch (e) {
      const m = String(e?.message || '');
      const transient = /unknown|internal|backend|server|unavailable|timeout|rate|quota/i.test(m);
      if (!transient || i === tries - 1) throw e;
      Utilities.sleep(wait + Math.floor(Math.random() * 250));
      wait = Math.min(wait * 2, max);
    }
  }
}

UrlFetchの安定化(fetchAll+短タイムアウト+検証)

function getJson(url) {
  const opt = { muteHttpExceptions: true, followRedirects: true, timeout: 15000 };
  const res = withRetry(() => UrlFetchApp.fetch(url, opt));
  const code = res.getResponseCode();
  if (code >= 500) throw new Error('Server ' + code);
  if (code >= 400) throw new Error('Client ' + code);
  const text = res.getContentText() || '';
  try { return JSON.parse(text); } catch (_) { return { raw: text }; }
}

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

Sheets/Driveはチャンク+排他(境界症状を減らす)

function withSheetLock(work, waitMs = 20000) {
  const lock = LockService.getDocumentLock();
  if (!lock.tryLock(waitMs)) throw new Error('Lock failed');
  try { return work(); } finally { lock.releaseLock(); }
}

function setValuesChunked(sh, r, c, values2d) {
  if (!Array.isArray(values2d) || !Array.isArray(values2d[0])) throw new Error('2D array required');
  const COLS = values2d[0].length, MAX_CELLS = 50000;
  let i = 0;
  return withSheetLock(() => {
    while (i < values2d.length) {
      const rows = Math.min(values2d.length - i, Math.floor(MAX_CELLS / COLS));
      const block = values2d.slice(i, i + rows);
      withRetry(() => sh.getRange(r + i, c, block.length, COLS).setValues(block));
      i += rows;
      Utilities.sleep(120);
    }
  });
}

入力検証とnull安全(未知エラーの主因を潰す)

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

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

Webアプリ/HTMLサービス:責務分離と失敗ハンドラ

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

<!-- index.html -->
<script>
function send(){
  google.script.run
    .withSuccessHandler(res => console.log('OK', res))
    .withFailureHandler(err => alert('失敗: ' + (err && err.message)))
    .serverProcessClient({ ping: 'pong' });
}
</script>

長時間処理は早期終了+再開(タイムアウト連鎖を避ける)

function processLarge(items) {
  const p = PropertiesService.getScriptProperties();
  let i = Number(p.getProperty('cursor') || 0);
  const start = Date.now(), LIMIT = 5*60*1000;
  for (; i < items.length; i++) {
    withRetry(() => heavy(items[i]));
    if (Date.now() - start > LIMIT) break;
  }
  p.setProperty('cursor', String(i));
  if (i < items.length) ScriptApp.newTrigger('resume').timeBased().after(60*1000).create();
}

最小再現テンプレ(“どこまで通るか”を確認)

function reproduce() {
  try {
    const id = 'YOUR_SHEET_ID';
    const sh = SpreadsheetApp.openById(id).getSheetByName('Data');
    withRetry(() => sh.getRange('A1').setValue(new Date().toISOString()));
    return 'ok';
  } catch (e) {
    console.error('repro', { msg: e.message, stack: e.stack });
    throw e;
  }
}

構造化ログとアラート(後追いできる形に残す)

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

NG→OK早見表(Unknownを出しにくくする設計)

・NG:巨大setValuesを一度に → OK:5万セル以下にチャンク+軽いスロットリング

・NG:直列UrlFetchで長待ち → OK:fetchAll+短タイムアウト+指数バックオフ

・NG:同時実行でappendRowが競合 → OK:LockServiceで排他、まとめ書きに変更

・NG:入力を盲信して深いプロパティ参照 → OK:null安全・型検証・フォールバック

・NG:サーバ例外をUIに素通し → OK:withFailureHandlerでユーザー向けに整形

・NG:長時間バッチを一発勝負 → OK:カーソル保存+再開トリガーで分割実行

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

・再現性の有無を確認(リトライで通るか)

・失敗箇所直前に詳細ログを仕込み、I/O/サイズ/同時実行を特定

・withRetry(指数バックオフ+ジッタ)を適用しタイムアウトは短め

・setValues/Drive操作はチャンク化+排他制御を導入

・Webアプリはサーバ側でI/O、クライアントは呼び出しと表示に限定

・長時間処理は早期終了+再開で分割

・構造化ログと簡易アラート(メール/Slack等)で再発時に即追跡できる体制に