GASのfetchAllで複数APIを
並列実行して処理を高速化する方法

100行ぶんのAI要約が終わらない——その原因は、たいてい「1件ずつ順番に待っている」ことです。GASのUrlFetchApp.fetchAll()(複数のHTTPリクエストをまとめて並列実行するメソッド)を使えば、待ち時間が重なり処理時間が大きく縮みます。基本の書き方から、Claude APIを並列で回す実装、1件ずつのエラー判定と再試行、件数の分割、6分制限との付き合い方まで動くコードで解説します。

|対象: GAS / UrlFetchApp / 外部API・AI連携

Table of Contents

なぜ遅いのか|fetchは1件ずつ待っている

外部APIを呼ぶGASが遅いとき、CPUが忙しいわけではありません。ほとんどの時間は「相手の返事を待っている時間」です。UrlFetchApp.fetch()をループで回すと、1件目の応答を待ち切ってから2件目を投げます。1件2秒のAPIなら、100件で単純に200秒——これだけで6分の実行時間制限の半分以上を食います。

// 遅い書き方: 1件ずつ順番に待つ
function fetchOneByOne() {
  const urls = [
    "https://example.com/api/1",
    "https://example.com/api/2",
    "https://example.com/api/3",
  ];

  const results = [];
  urls.forEach((url) => {
    const res = UrlFetchApp.fetch(url);   // ここで応答を待ち切ってから次へ
    results.push(res.getContentText());
  });

  return results;   // 1件2秒なら3件で約6秒
}

待ち時間が支配的なら、待ちを重ねればいい。それを実現するのがfetchAll()です。リクエストをまとめて渡すと、GASが並列に実行し、全部の応答が揃った時点で結果の配列を返します。

fetchAllが効く処理

AI要約・翻訳・分類の一括処理、複数サービスからのデータ取得、大量URLの生存確認。待ち時間が長く、件数が多い処理。

効かない・向かない処理

1件だけの呼び出し、前のレスポンスを次のリクエストに使う逐次処理、厳しいレート制限があるAPIへの連打。

基本の書き方|リクエストの配列を渡す

fetchAll()の引数は、リクエスト設定オブジェクトの配列です。設定の中身はfetch()の第2引数とほぼ同じで、そこにurlを含めるだけ。戻り値はHTTPResponseの配列で、渡した順番のまま返ってきます。

// 速い書き方: まとめて渡して並列に実行する
function fetchInParallel() {
  const urls = [
    "https://example.com/api/1",
    "https://example.com/api/2",
    "https://example.com/api/3",
  ];

  // fetchAll にはリクエスト設定の配列を渡す
  const requests = urls.map((url) => ({
    url: url,
    method: "get",
    muteHttpExceptions: true,   // 失敗しても例外を投げさせない
  }));

  const responses = UrlFetchApp.fetchAll(requests);   // 3件が同時に走る

  // レスポンスは渡した順番のまま返ってくる
  return responses.map((res) => res.getContentText());
}

大事なのがmuteHttpExceptions: true(HTTPエラーで例外を投げさせない設定)です。これを付けないと、30件のうち1件が500を返しただけで処理全体が例外で止まり、成功していた29件の結果もまとめて失われます。並列実行では必ず付けてください。

順番が保証される点も重要です。実行は同時でも、結果は渡した配列と同じ並びに揃えられます。だからresponses[i]requests[i]の結果——スプレッドシートの行との対応付けを、インデックスだけで安全に書けます。

AI要約を並列で回す|Claude APIの実装例

fetchAllが最も効くのがAI連携です。生成AIのAPIは1回の応答に数秒かかるため、逐次実行だと件数がそのまま待ち時間になります。ここではA列の問い合わせ文をClaude APIで要約し、B列に書き戻す例を示します。APIキーはPropertiesService(スクリプトに設定値を安全に保存する仕組み)から読み込みます。

// スプレッドシートのA列の問い合わせ文を、AIで一括要約してB列に書き戻す
const API_KEY = PropertiesService.getScriptProperties().getProperty("CLAUDE_API_KEY");
const MODEL = "claude-sonnet-5";

function summarizeAllInParallel() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const rows = sheet.getRange(2, 1, lastRow - 1, 1).getValues();

  // 空行を除きつつ、元の行番号を覚えておく
  const targets = [];
  rows.forEach((row, i) => {
    const text = String(row[0]).trim();
    if (text !== "") targets.push({ row: i + 2, text: text });
  });
  if (targets.length === 0) return;

  const requests = targets.map((t) => buildClaudeRequest(t.text));
  const responses = UrlFetchApp.fetchAll(requests);   // ここで一気に投げる

  // レスポンスは requests と同じ順番。インデックスで元の行に対応付ける
  responses.forEach((res, i) => {
    const summary = readSummary(res);
    sheet.getRange(targets[i].row, 2).setValue(summary);
  });
}

function buildClaudeRequest(text) {
  return {
    url: "https://api.anthropic.com/v1/messages",
    method: "post",
    contentType: "application/json",
    headers: {
      "x-api-key": API_KEY,
      "anthropic-version": "2023-06-01",
    },
    payload: JSON.stringify({
      model: MODEL,
      max_tokens: 300,
      messages: [
        { role: "user", content: "次の問い合わせを日本語1文で要約してください。\n\n" + text },
      ],
    }),
    muteHttpExceptions: true,
  };
}

function readSummary(res) {
  const code = res.getResponseCode();
  if (code !== 200) return "エラー(" + code + ")";

  const body = JSON.parse(res.getContentText());
  return body.content[0].text;   // テキストブロックの本文
}

ポイントは、リクエストを組み立てる前に「対象の行番号」を配列で持っておくことです。空行を除くとインデックスがずれるため、{ row, text }の形で元の位置を覚えておけば、レスポンスを正しい行へ書き戻せます。

なお、書き戻しを1行ずつsetValueで行うと、そこが新たなボトルネックになります。行が連続している場合は結果をまとめてsetValuesで一括書き込みするほうが高速です。

1件ずつエラーを受け止める|失敗分だけ再試行

並列実行では「全部成功」か「全部失敗」ではなく、「30件中2件だけ失敗」が普通に起きます。特に多いのが429(レート制限超過。短時間に投げすぎ)です。並列で一気に投げるほど当たりやすくなります。

// 成功・失敗を1件ずつ判定し、失敗分だけ後で再実行できるようにする
function fetchWithResultCheck(requests) {
  const responses = UrlFetchApp.fetchAll(requests);

  const succeeded = [];
  const failed = [];

  responses.forEach((res, i) => {
    const code = res.getResponseCode();

    if (code === 200) {
      succeeded.push({ index: i, body: res.getContentText() });
      return;
    }

    // 429(レート超過)と5xx(サーバー側の一時障害)は再試行の価値がある
    const retryable = code === 429 || code >= 500;
    failed.push({ index: i, code: code, retryable: retryable });
    console.warn("失敗 index=" + i + " code=" + code + " body=" + res.getContentText());
  });

  return { succeeded: succeeded, failed: failed };
}

// 失敗したぶんだけ、少し待ってからもう一度まとめて投げる
function fetchWithRetry(requests, maxRetry) {
  let pending = requests.map((req, i) => ({ index: i, request: req }));
  const results = [];

  for (let attempt = 0; attempt <= maxRetry && pending.length > 0; attempt++) {
    if (attempt > 0) Utilities.sleep(2000 * attempt);   // 2秒 → 4秒 と待ちを伸ばす

    const responses = UrlFetchApp.fetchAll(pending.map((p) => p.request));
    const next = [];

    responses.forEach((res, i) => {
      const target = pending[i];
      const code = res.getResponseCode();

      if (code === 200) {
        results.push({ index: target.index, body: res.getContentText() });
      } else if (code === 429 || code >= 500) {
        next.push(target);          // 再試行のキューに戻す
      } else {
        results.push({ index: target.index, body: null });   // 400系は再試行しない
      }
    });

    pending = next;
  }

  return results.sort((a, b) => a.index - b.index);   // 元の順番に戻す
}

判定の基準はシンプルです。4295xxは「時間をおけば直る可能性がある」ので再試行、400401はリクエスト側の問題なので何度投げても同じ、再試行しません。再試行では待ち時間を少しずつ伸ばす(2秒→4秒)と、相手のAPIにも優しく、成功率も上がります。

件数が多いときはチャンクに分ける

500件を一度にfetchAll()へ渡す——これは避けてください。公式に「何件まで」と明示された上限はありませんが、件数が増えるほどタイムアウトやレート制限のエラーが起きやすくなります。実務では20〜50件ずつのかたまり(チャンク)に分けるのが安全です。

// 件数が多いときは、かたまり(チャンク)に分けて呼ぶ
function fetchAllInChunks(requests, chunkSize) {
  const size = chunkSize || 30;   // 実務では20〜50件が扱いやすい
  const all = [];

  for (let i = 0; i < requests.length; i += size) {
    const chunk = requests.slice(i, i + size);
    const responses = UrlFetchApp.fetchAll(chunk);
    responses.forEach((res) => all.push(res));

    console.log((i + chunk.length) + " / " + requests.length + " 件完了");
    Utilities.sleep(500);   // APIのレート制限に配慮して少し間を空ける
  }

  return all;   // 全チャンクの結果を、元の順番のまま連結して返す
}

チャンクに分けると、進捗をログに出せる利点もあります。途中で止まったときに「どこまで終わったか」が分かり、原因の切り分けが早くなります。

割り当て(quota)の消費に注意

fetchAllを使っても、1日のURL Fetch呼び出し回数の割り当ては「リクエスト1本ずつ」でカウントされます。速くはなりますが、回数が節約できるわけではありません。同じデータを何度も取りに行っている場合は、CacheServiceで結果を一時保存し、そもそもの呼び出し回数を減らすほうが効きます。

6分制限との付き合い方|続きから再開する

fetchAllで速くなっても、GASの実行時間制限(無料アカウントで約6分)そのものは変わりません。数千件を扱うなら、時間内に終わらせようとせず「途中で切り上げて、続きから再開する」設計にします。処理済みの位置をPropertiesServiceに保存し、時間主導トリガーで自分自身を呼び直す形です。

// 6分に収まらないときは、処理位置を保存して続きから再開する
const PROP_KEY = "FETCH_CURSOR";
const TIME_LIMIT_MS = 4.5 * 60 * 1000;   // 6分の手前で切り上げる

function processWithResume() {
  const started = new Date().getTime();
  const props = PropertiesService.getScriptProperties();
  const sheet = SpreadsheetApp.getActiveSheet();

  let cursor = Number(props.getProperty(PROP_KEY) || 2);   // 2行目から開始
  const lastRow = sheet.getLastRow();

  while (cursor <= lastRow) {
    if (new Date().getTime() - started > TIME_LIMIT_MS) {
      props.setProperty(PROP_KEY, String(cursor));   // 次回の再開位置を保存
      createResumeTrigger();
      return;
    }

    const size = Math.min(30, lastRow - cursor + 1);
    const rows = sheet.getRange(cursor, 1, size, 1).getValues();

    const requests = rows.map((row) => buildClaudeRequest(String(row[0])));
    const responses = UrlFetchApp.fetchAll(requests);
    const out = responses.map((res) => [readSummary(res)]);

    sheet.getRange(cursor, 2, out.length, 1).setValues(out);
    SpreadsheetApp.flush();   // 途中結果を確実にシートへ反映する

    cursor += size;
  }

  props.deleteProperty(PROP_KEY);   // 最後まで終わったらリセット
}

function createResumeTrigger() {
  ScriptApp.newTrigger("processWithResume")
    .timeBased()
    .after(60 * 1000)   // 1分後に続きから再開
    .create();
}

制限の6分ぎりぎりまで粘らず、4分30秒あたりで切り上げるのがコツです。最後のチャンクが想定より長引いても、保存処理まで確実に到達できます。SpreadsheetApp.flush()でチャンクごとに書き込みを確定させておくと、強制終了しても途中結果が残ります。

なお、この例では再開のたびにトリガーが増えていくため、実運用では処理の冒頭で不要になった同名トリガーを削除する処理も入れてください。

まとめ

UrlFetchApp.fetchAll()は、外部API連携が遅いGASにいちばん効く一手です。ループのfetchをリクエスト配列に置き換えるだけで、待ち時間が重なり処理時間が縮みます。レスポンスは渡した順番のまま返るため、スプレッドシートの行との対応付けもインデックスで安全に書けます。

実務で押さえるべきは4点です。(1)muteHttpExceptions: trueを必ず付け、1件の失敗で全体を落とさない。(2)429と5xxだけを再試行し、待ち時間を伸ばす。(3)20〜50件のチャンクに分ける。(4)6分制限は続きから再開する設計で越える。AI要約や一括翻訳のように「1件数秒×大量件数」の処理ほど、効果がはっきり出ます。

よくある質問

複数のHTTPリクエストをまとめて渡し、並列に実行するGASのメソッドです。リクエスト設定の配列を渡すと、レスポンスの配列が同じ順番で返ってきます。1件ずつUrlFetchApp.fetchをforループで回すより待ち時間が重ならないため、件数が多いほど処理時間が短くなります。

APIの応答時間と件数によりますが、待ち時間が支配的な処理ほど効果が出ます。1件2秒かかるAPIを30件、fetchで順番に呼ぶと約60秒かかります。fetchAllならリクエストが同時に走るため、体感では数秒〜十数秒に縮むことが多いです。ただし並列数はGAS側の判断で決まるため、件数に比例して速くなるとは限りません。

各リクエストに muteHttpExceptions: true を付けていれば止まりません。HTTPエラーでも例外を投げずレスポンスが返るため、getResponseCode()で1件ずつ成功・失敗を判定できます。逆にこの指定がないと、1件の404や500で処理全体が例外になり、成功したぶんの結果も失われます。

公式に明示された上限件数はありませんが、数百件を一度に渡すとタイムアウトやエラーが起きやすくなります。実務では20〜50件ずつのかたまり(チャンク)に分けて呼ぶのが安全です。また1日のURL Fetch呼び出し回数の割り当ては消費されるため、fetchAllでもリクエスト1本ずつがカウントされます。

適用されます。fetchAllは待ち時間を重ねられるだけで、実行時間の上限そのものは変わりません(無料アカウントは約6分)。件数が多い場合は、処理済みの位置をPropertiesServiceに保存し、時間主導トリガーで続きから再開する設計にします。

返ってきます。実行は並列でも、戻り値の配列は渡したリクエスト配列と同じ順番に揃えられます。そのためインデックス(i番目のレスポンスはi番目のリクエストの結果)で元データと突き合わせできます。順番が保証されるので、スプレッドシートの行と対応付ける処理も安全に書けます。

GAS開発・AI連携を
相談する。

大量データのAI処理が時間内に終わらない、外部API連携が遅い、途中で止まる——実務で動く形までまとめてご相談いただけます。

AI×GAS自動化を見る