Apps Script ゲートウェイ経由の無人ジョブが稀に Unauthorized で落ちる — 本文落ちの判定と有界リトライ
GAS Web App を API ゲートウェイにした Node の夜間ジョブが、トークンも正しいのに稀に Unauthorized / 非JSON で落ちる。受信ログの (no action) で「POST 本文が落ちた一時障害」と 30 秒で判定し、一時障害の署名だけを 2s→4s→8s で有界リトライする分類関数・テスト・本番入口での検証手順まで。
約3.9万トークンの節約 (API料金換算で約59円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「Apps Script ゲートウェイ経由の無人ジョブが稀に Unauthorized で落ちる — 本文落ちの判定と有界リトライ」は、業務自動化カテゴリのAI指示書(MDファイル)です。GAS Web App を API ゲートウェイにした Node の夜間ジョブが、トークンも正しいのに稀に Unauthorized / 非JSON で落ちる。受信ログの (no action) で「POST 本文が落ちた一時障害」と 30 秒で判定し、一時障害の署名だけを 2s→4s→8s で有界リトライする分類関数・テスト・本番入口での検証手順まで。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約3.9万トークン(API料金換算で約59円)・87%のトークンを節約できます。
- カテゴリ
- 業務自動化
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約4.5万トークン
- この巻物使用時
- 約6,000トークン
- 節約量
- 約3.9万トークン (約59円)
- 更新日
- 2026-09-13
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/apps-script-unauthorized/raw を読み込んで、この指示書どおりに実装して"
中身
Apps Script Web App を API ゲートウェイにしている無人ジョブが、たまに「Unauthorized」で落ちる件の切り分けと直し方
この巻物が解く問題
Node(または任意の HTTP クライアント)から Google Apps Script の Web App(https://script.google.com/macros/s/<デプロイID>/exec)へ
JSON を POST し、その中で外部 API(予約システム・会計 SaaS など)を代理呼び出しする「GAS ゲートウェイ」構成で、
夜間ジョブがごく稀に Unauthorized や「JSON でない応答」で落ちる。同じトークン・同じプロセスの数秒前後の呼び出しは通っている。
よくある誤診: 「外部 API のトークンが切れた」「GAS のデプロイが古い」「アクセス権がリセットされた」。 どれも違うことが多い。本文が空のまま GAS に届いているのが真因で、クライアントに再試行が無いだけ。
30 秒で判定する方法(ここを先に見る)
- ゲートウェイ側に受信ログを持たせておく(無ければ今すぐ足す。下のコード参照)。認証に失敗したときは
実行元 —/アクション (no action)/Unauthorizedのように「payload に何が入っていたか」が分かる形で記録する。 - 落ちた時刻の行を見る。
(no action)で Unauthorized → リクエスト本文が届いていない。トークンは正しい。- アクション名が入っていて Unauthorized → 本当にトークン不一致。資格情報ファイルの mtime と Script Properties を見る。
- 行が無いのに非 JSON 応答 → Google がスクリプト実行前に HTML エラーページを返した。
- 外部 API の 401 なら、ゲートウェイの代理呼び出し部が返す形(例
{ok:false, code:401, path, raw})になるので文言が違う。 単語だけのUnauthorizedはゲートウェイ自身の文言。
なぜ本文が落ちるか
fetch(url, { method:'POST', redirect:'follow' }) は Fetch 仕様により 301/302 を追うとき POST を GET に変えて本文を捨てる。
Apps Script の /exec は正常時も 302 で結果ページへ飛ばすが、それは実行後の話。Google 側の負荷・ルーティングで
実行前に別 URL へ 302 を返す瞬間があり、そのとき本文の無い GET が doGet(e) に届く。ゲートウェイは
e.parameter = {} からトークンを読めず Unauthorized を返す。もう 1 種は Google が HTML のエラーページを返すもの。
どちらも数秒〜数十秒で回復する一時障害で、観測上の連続回数は 1 回。
直し方: クライアントに「分類 → 有界リトライ」を入れる
分類を純関数に切り出すとネットワーク無しでテストできる。
// gas-client.mjs(Node 18+ / ESM)
export function classifyGasResponse({ status, text }) {
if (status === 429 || (typeof status === 'number' && status >= 500)) return { kind: 'retry', reason: `HTTP ${status}` };
let json;
try { json = JSON.parse(text); } catch { return { kind: 'retry', reason: `非JSON応答 ${String(text).slice(0, 80)}` }; }
if (json && json.ok === false) {
if (json.error === 'Unauthorized') return { kind: 'retry', reason: 'Unauthorized(本文が届かなかった可能性)' };
return { kind: 'fatal', reason: json.error || JSON.stringify(json).slice(0, 200) };
}
return { kind: 'ok', json };
}
export async function gas(action, params = {}, opts = {}) {
const retries = opts.retries ?? Number(process.env.GAS_RETRIES ?? 3); // 最大 4 回試行
const base = opts.baseDelayMs ?? Number(process.env.GAS_RETRY_BASE_MS ?? 2000); // 2s → 4s → 8s
const fetchImpl = opts.fetchImpl ?? ((...a) => globalThis.fetch(...a));
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
let lastReason = '不明';
for (let attempt = 1; attempt <= retries + 1; attempt++) {
let outcome;
try {
const res = await fetchImpl(process.env.GAS_EXEC_URL, {
method: 'POST', redirect: 'follow',
headers: { 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify({ token: process.env.GAS_TOKEN, action, params }),
});
outcome = classifyGasResponse({ status: res.status, text: await res.text() });
} catch (e) { outcome = { kind: 'retry', reason: `fetch 失敗: ${e.message}` }; }
if (outcome.kind === 'ok') return outcome.json;
if (outcome.kind === 'fatal') throw new Error(`${action}: ${outcome.reason}`);
lastReason = outcome.reason;
if (attempt > retries) break;
const delay = base * 2 ** (attempt - 1);
console.error(`gas ${action}: ${outcome.reason} → ${attempt}/${retries} 回目の再試行を ${delay}ms 後に実行`);
await sleep(delay);
}
throw new Error(`${action}: ${lastReason}(${retries + 1}回試行して回復せず)`);
}
設計上の要点:
- 再試行するのは一時障害の署名だけ(HTTP 429/5xx・非 JSON・
Unauthorized・fetch 例外)。業務エラー(範囲が大きすぎる等)のok:falseは即失敗にする。何でも再試行すると本当の不具合が 14 秒遅れて同じ文言で出るだけになる。 - 本当のトークン不一致は同じ文言で失敗するが、末尾の「N回試行して回復せず」で見分けられる。
- 再試行ごとに stderr へ 1 行出す。夜間ログに残るので「本番で何回吸収したか」が後から数えられる。
fetchImpl/sleepを注入可能にし、キュー式のダミー fetch で (a) Unauthorized→OK (b) 非JSON→OK (c) HTTP 500→OK (d) 業務エラーは fetch 1 回で即 throw (e) 全部失敗で遅延が [base, 2base, 4base] (f) fetch 例外→次で成功 をテストする。
ゲートウェイ側(Apps Script)に受信ログを持たせる最小形
// WebApiGateway.gs
var LOG_SHEET = 'リモートAPI実行ログ';
function handleRequest(e, method) {
var payload = {};
if (method === 'POST' && e.postData && e.postData.contents) payload = JSON.parse(e.postData.contents);
else if (e.parameter) payload = e.parameter;
var expected = PropertiesService.getScriptProperties().getProperty('REMOTE_API_TOKEN');
if (payload.token !== expected) {
logRemoteApi('—', payload.action || '(no action)', false, 'Unauthorized');
return jsonResponse({ ok: false, error: 'Unauthorized' });
}
// ... action の dispatch ...
}
function logRemoteApi(who, action, ok, err) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(LOG_SHEET) || ss.insertSheet(LOG_SHEET);
sh.insertRowAfter(1);
sh.getRange(2, 1, 1, 5).setValues([[new Date(), who, action, ok ? '✅ OK' : '❌ FAIL', err || '']]);
}
ログの読み方の罠: 日時セルは 2026/09/10 7:01:20 のように時が 1 桁で返るので、正規表現は / ([0-9]{1,2}):/ のように書く
(0[678]: と書くと分の 08: に誤マッチする)。readRange に上限(例 2000 セル)があるなら 5 列 × 399 行ずつ読む。
検証は本番と同じ入口で
手元で node job.mjs を叩くのではなく、スケジューラのタスクそのものを起動する(Windows なら
schtasks /Run /TN <タスク名>、cron なら同じラッパーを同じユーザーで)。fetch → 固定ブランチの checkout → 実行、という
本番手順が丸ごと走り、LastTaskResult も更新されるので、見張り番(watchdog)の判定と食い違わない。
完了判定は「成果物ファイルが生成された」「ログに NG 行が無い」「再試行行の有無」の 3 つで、成功表示だけで判断しない。
再発防止のチェックリスト
- ゲートウェイに受信ログがある(失敗時に payload の中身が分かる形)
- クライアントの再試行は「一時障害の署名だけ」に限定されている
- 再試行が stderr に残り、夜間ログから回数を数えられる
- 見張り番の再走が同じ一時障害を踏んでも、1 回の run 内で吸収される(再走 1 回だけに頼らない)
- 「Unauthorized=認証切れ」と読む前に受信ログのアクション列を見る手順が引き継ぎに書いてある
よくある質問
+「Apps Script ゲートウェイ経由の無人ジョブが稀に Unauthorized で落ちる — 本文落ちの判定と有界リトライ」とは何ですか?
GAS Web App を API ゲートウェイにした Node の夜間ジョブが、トークンも正しいのに稀に Unauthorized / 非JSON で落ちる。受信ログの (no action) で「POST 本文が落ちた一時障害」と 30 秒で判定し、一時障害の署名だけを 2s→4s→8s で有界リトライする分類関数・テスト・本番入口での検証手順まで。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約4.5万トークンかかりますが、この巻物を使えば約6,000トークンで済みます。差し引き約3.9万トークン(API料金換算で約59円)・87%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
Google Meet 自動参加&動画配信Bot 開発指示書
指定した時刻に Google Meet へ自動参加し、動画を再生しながら画面共有する Bot を、Claude Code に一発で作らせる開発指示 MD。朝会の定例動画配信・ウェビナーの自動放送に。
受信メール添付を案件フォルダへ自動取込するパイプライン
メールを読むアプリとドライブに書くアプリが別、という現実的な構成で顧客メールの添付を案件フォルダへ無人保存する設計。権限追加を避ける理由、実行時間制限下の予算3本立て、二重の重複防止、base64url/行数上限/変換判定などの実装罠、案件と顧客のマッチング、名寄せは候補提示+人の承認にする型まで。
Gmail 自動仕分け&返信ドラフト生成MD
受信メールを AI が分類 (要返信/情報/営業/スパム) してラベル付けし、要返信メールには返信ドラフトまで自動生成する仕組みを作らせる指示書。DWD (ドメイン全体委任) 設定手順込み。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア