GAS『Service Error: Spreadsheets』の原因と対処

GAS『Service Error: Spreadsheets』の原因と対処

Google Apps ScriptでSpreadsheetサービス(SpreadsheetApp、Advanced Sheets API)を呼び出した際に、権限・対象IDや範囲の不整合・データ量や同時実行・保護設定・一時的なAPI障害などで処理が失敗すると発生する。発生条件を症状別に切り分け、最短復旧から恒久対策(権限/実行主体/データ分割/再試行/ロック/検証ロジック)までまとめた。

エラーメッセージの読み方(代表例)

Service Error: Spreadsheets
Service Error: Spreadsheets (The caller does not have permission)
Service Error: Spreadsheets (Insufficient Permission: Request had insufficient authentication scopes)
Service Error: Spreadsheets (Requested entity was not found)
Service Error: Spreadsheets (Internal error encountered)
Service Error: Spreadsheets (quota exceeded / rate limit)
Service Error: Spreadsheets (The operation timed out)

・丸裸の「Service Error: Spreadsheets」の場合は、下層の詳細が省略されていることがある(ログやtry/catchで補足すると特定しやすい)

発生条件の早見表(まずここを疑う)

□ アクセス権が不足:共有されていない/実行主体が違う/スコープ不足
□ 対象のID/シート/範囲が不正:IDタイプミス、シート名相違、A1表記ミス
□ ファイル状態:ごみ箱・削除・権限移管・共有ドライブ移動
□ リクエスト過大:書き込みセル数/ペイロードが大きすぎる、一括処理しすぎ
□ タイムアウト/同時実行:トリガー競合、処理時間オーバー、並列編集
□ 保護/フィルタ:保護範囲・シート保護で書き込み不可、フィルタビューの影響
□ 一時的障害:APIエラー/レート制限(後で成功するタイプ)

どのAPIで落ちているかを特定(ログの付け方)

// 例:詳細を掴むためのtry/catch
function main() {
  try {
    const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');       // SpreadsheetApp
    const sh = ss.getSheetByName('Data');
    sh.getRange(1,1).setValue(new Date());
  } catch (e) {
    console.error('ServiceError payload', {
      message: e && e.message,
      name: e && e.name,
      stack: e && e.stack
    });
    throw e; // ログに残して再送出
  }
}

// Advanced Sheets API の場合
function writeWithSheetsApi() {
  try {
    const sheetId = 'YOUR_SHEET_ID';
    const body = { values: [['hello', 'world']] };
    Sheets.Spreadsheets.Values.update(body, sheetId, 'A1',
      { valueInputOption: 'USER_ENTERED' });
  } catch (e) {
    console.error('SheetsAPI error', e);
    throw e;
  }
}

権限・共有が原因(Permission/Scopes)

// アクセス権の確認(自分に編集権があるか?)
function assertPermission(id) {
  const file = DriveApp.getFileById(id); // 取得できない=権限不足
  console.log('owner:', file.getOwner().getEmail(), 'isTrashed:', file.isTrashed());
}

// Webアプリ/トリガーの実行主体を意識
// ・ウェブアプリ:デプロイ時の「実行するユーザー(自分 or アクセスユーザー)」
// ・インストール型トリガー:作成したユーザーの権限で動く
// ⇒ 必要なら共有相手に編集権を付与
function grantEditor(id, email) {
  const file = DriveApp.getFileById(id);
  file.addEditor(email);
}

// Advanced Sheets APIを使うならスコープ付与(スクリプトエディタで有効化)
// エディタの「サービス」から「Google Sheets API」をONにする

・「Insufficient Permission」はスコープ不足/認可更新が多い(エディタで実行→再認可)

ID/シート/範囲の不整合(Not found/Bad range)

// ID・シート名の検証
function assertSheet(id, sheetName) {
  const ss = SpreadsheetApp.openById(id); // IDが違えばここで例外
  const sh = ss.getSheetByName(sheetName);
  if (!sh) throw new Error('sheet not found: ' + sheetName);
  return sh;
}

// A1表記を事前チェック(簡易)
function isValidA1(a1) {
  return /^[A-Za-z]+[0-9]+(:[A-Za-z]+[0-9]+)?$/.test(a1);
}

・URLから取ったIDに空白や余計な文字が混ざっていないかも確認

ごみ箱/共有ドライブ/移動が原因

// ごみ箱判定と復元
function reviveIfTrashed(id) {
  const f = DriveApp.getFileById(id);
  if (f.isTrashed()) {
    f.setTrashed(false); // 復元(権限があれば可能)
  }
}

// 共有ドライブに移動済みのとき、組織/共有設定でアクセスが変わる
// DriveAppで取得できない場合は管理側で共有設定を見直す

データ量/レート制限対策(分割・バッチ・Backoff)

// setValuesは「範囲と同じサイズの2次元配列」を一括で書く(行/列ループでsetValue連発は避ける)
function writeChunked(id, sheetName, startRow, startCol, values) {
  const maxCellsPerWrite = 50000;   // 目安:大きすぎる書き込みは分割
  const sh = assertSheet(id, sheetName);
  let r = 0;
  while (r < values.length) {
    let rows = 0, cells = 0;
    while (r + rows < values.length && cells + values[r + rows].length <= maxCellsPerWrite) {
      cells += values[r + rows].length;
      rows++;
    }
    const block = values.slice(r, r + rows);
    const range = sh.getRange(startRow + r, startCol, block.length, block[0].length);
    range.setValues(block);
    r += rows;
    Utilities.sleep(200); // 小休止でスロットリング
  }
}

// Advanced Sheets API の指数バックオフ
function withRetry(fn, tries = 5) {
  let wait = 500;
  for (let i = 0; i < tries; i++) {
    try { return fn(); } catch (e) {
      const msg = (e && e.message) || '';
      const retryable = /Rate limit|quota|Internal error|timeout/i.test(msg);
      if (!retryable || i === tries - 1) throw e;
      Utilities.sleep(wait);
      wait = Math.min(wait * 2, 8000);
    }
  }
}

・巨大データは「横×縦」の分割を書き込み、間にsleepやバックオフを入れると安定する

タイムアウト/同時実行の衝突(LockService)

// トリガーや複数リクエストの同時実行を排他
function guardedJob() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) { throw new Error('busy'); }
  try {
    // スプレッドシート操作
  } finally {
    lock.releaseLock();
  }
}

・同じシートに同時に書き込むジョブがあると内部で失敗することがある

保護範囲/シート保護で書けない

// 保護の一覧を出して目視
function listProtections(id, sheetName) {
  const sh = assertSheet(id, sheetName);
  const protections = sh.getProtections(SpreadsheetApp.ProtectionType.RANGE);
  protections.forEach(p => console.log('protected range', p.getRange().getA1Notation(), 'editors', p.getEditors().map(e => e.getEmail())));
}

// 一時的に自己を編集許可(許可があれば)
function allowMeTemporarily(id, sheetName) {
  const sh = assertSheet(id, sheetName);
  const me = Session.getActiveUser().getEmail();
  sh.getProtections(SpreadsheetApp.ProtectionType.RANGE).forEach(p => p.addEditor(me));
}

・保護はエラー時に詳細が出ないことがあるため、事前に検査して分岐させる

Webアプリ/トリガーの実行主体と共有の整合

// Webアプリ:デプロイ時の設定が重要
// ・「実行するユーザー」= 自分:スクリプト所有者の権限で実行(所有者がシート編集可ならOK)
// ・「アクセスできるユーザー」= 自分のみ/全員/ドメイン内 など
// 共有ドライブのシートにアクセスする場合、実行主体のアカウントに編集権が必要

典型NG→OKスニペット集

// NG:1セルずつ書く(時間・レートリミットに掛かりやすい)
for (let r = 0; r < rows; r++) {
  for (let c = 0; c < cols; c++) {
    sh.getRange(startRow + r, startCol + c).setValue(values[r][c]);
  }
}

// OK:矩形で一括
sh.getRange(startRow, startCol, values.length, values[0].length).setValues(values);

// NG:IDの取り違え(フォルダIDやURLそのままを渡す)
SpreadsheetApp.openById('https://docs.google.com/spreadsheets/...'); // 例外

// OK:IDのみ
SpreadsheetApp.openById('1AbCdEfGhIjK...');

// NG:存在しないシート名
ss.getSheetByName('Data ') // 末尾空白でnot found

// OK:定数で管理しtrim
const SHEET = 'Data';
const sh = ss.getSheetByName(SHEET);

検証・ダイアグノスティクス(事前チェック)

function healthCheck(id, sheetName) {
  try {
    const sh = assertSheet(id, sheetName);
    const range = sh.getRange(1,1,1,1);
    range.setValue('✅ ' + new Date().toISOString());
    console.log('ok');
  } catch (e) {
    console.error('healthCheck fail', e);
  }
}

・夜間バッチや初回実行前にヘルスチェックを用意しておくと原因特定が速い

再現→解消の通し例(権限→分割→再試行)

// 1) 失敗例(権限不足)
function demoFail() {
  const ss = SpreadsheetApp.openById('OTHER_OWNER_SHEET_ID'); // 自分に権限なし
  ss.getSheetByName('Data').getRange('A1').setValue('x');     // → Service Error: Spreadsheets
}

// 2) 付与→成功
function fixPermission() {
  const id = 'OTHER_OWNER_SHEET_ID';
  grantEditor(id, Session.getActiveUser().getEmail());
  healthCheck(id, 'Data');
}

// 3) 大量書き込みを分割+リトライ
function demoBulkWrite() {
  const id = 'YOUR_SHEET_ID';
  const values = Array.from({length: 120000}, (_,i) => [i, 'row ' + i]); // 大量
  withRetry(() => writeChunked(id, 'Data', 2, 1, values));
}

チェックリスト(上から順に)

□ 実行主体は誰か(自分/アクセスユーザー/トリガー作成者)と共有は整合しているか
□ Advanced Sheets API を使うならサービス有効化+再認可は済んでいるか
□ Spreadsheet ID/シート名/範囲A1は正しいか(trim/バリデーション)
□ ファイルがごみ箱/移動/共有ドライブ化していないか(DriveAppで確認)
□ 保護範囲/シート保護に阻まれていないか(一覧を出して検査)
□ 1回の書き込みが大きすぎないか(setValues一括+適切に分割)
□ 競合しうる同時実行はLockServiceで排他しているか
□ 一時的エラー・レート制限に指数バックオフで再試行しているか

テンプレ(堅牢な書き込みラッパ)

function safeWrite({sheetId, sheetName, a1, values}) {
  return withRetry(() => {
    const sh = assertSheet(sheetId, sheetName);
    if (!isValidA1(a1)) throw new Error('bad A1: ' + a1);
    const range = sh.getRange(a1);
    if (values.length && values[0].length) {
      // 2次元配列でサイズ一致を保証
      if (range.getNumRows() !== values.length || range.getNumColumns() !== values[0].length) {
        throw new Error('range/data size mismatch');
      }
      range.setValues(values);
    } else {
      range.setValue(values); // 単一値
    }
  });
}