結論から先に: 「エラーが出たら再試行する」だけでは不十分で、待機時間を毎回倍にする指数バックオフと、実行の二重起動を防ぐロックの2つを組み合わせて初めて、安心して自動実行に任せられる仕組みになりました。
課題
GASで定期実行するスクリプトを外部APIと組み合わせていると、API側の一時的な不調やレート制限で処理が失敗することがある。最初はエラーが出るたびに気づいて手動で再実行していたが、これでは「自動化」の意味がない。
最初のうちは失敗通知が来るたびに、その場でスクリプトエディタを開いて手動実行し直していた。数回ならまだしも、これが日常的に発生するようになると、「結局いつも手を動かしている」という、自動化の名を借りた手動運用になっていた。
完成形
今は、外部API呼び出しが失敗しても、待機時間を少しずつ伸ばしながら自動で再試行し、それでも駄目な場合だけ通知が届く仕組みにしている。手動対応が必要なのは、本当に見に行く価値がある失敗のときだけになった。
AIとの作り方
最初にAIへ「エラーが出たら再試行する処理を書いて」と頼んだところ、失敗するたびに即座にリトライするコードが出てきた。一見動くのだが、API側がまだ不調な状態で連続してリクエストを送ることになり、かえって状況を悪化させかねない実装だった。この実装をそのまま使わずに済んだのは幸いだった。もし気づかずに本番で動かしていたら、不調なAPIに追い打ちをかけるような形になっていたかもしれない。
そこで「即座に再試行」ではなく「待機時間を毎回倍にしながら再試行する」指数バックオフの方針を自分から指定し直した。合わせて「最大何回まで再試行するか」「それでも失敗したら何をするか(通知するだけで処理は止める)」を条件として渡し、実装をAIにやり直させた。方針を先に決めてから細部を任せる、という週1の型がここでもそのまま活きた。この型を最初に決めておいたことで、実装をやり直す際も「方針は変えず、実装だけ直して」と伝えるだけで済み、手戻りが最小限で済んだ。最初に方針を握っておくメリットは、こういう「やり直し」の場面でこそ実感しやすい。
サンプルコード
function fetchWithBackoff(url, options, maxRetries) {
let waitMs = 1000;
for (let i = 0; i < maxRetries; i++) {
try {
const response = UrlFetchApp.fetch(url, options);
return response;
} catch (e) {
if (i === maxRetries - 1) {
notifyFailure(url, e.message);
throw e;
}
Utilities.sleep(waitMs);
waitMs *= 2;
}
}
}
function notifyFailure(url, message) {
GmailApp.sendEmail(
'example@example.com',
'[要確認] API呼び出しが失敗しました',
`URL: ${url}\nエラー: ${message}`
);
}
maxRetriesは用途に応じて3〜5回程度から始めるのがちょうどよい。GASの実行時間制限もあるため、待機時間を伸ばしすぎると別の問題(タイムアウト)を引き起こす点には注意が必要だ。
実行ログを残す
ハマりどころでも触れるが、再試行が実際に何回起きたかを記録しておくため、以下のようなログ関数も組み合わせている。
function logRetryEvent(url, attempt, success) {
const sheet = SpreadsheetApp.getActive().getSheetByName('retry_logs');
sheet.appendRow([new Date(), url, attempt, success ? '成功' : '失敗']);
}
fetchWithBackoffの中からこの関数を呼ぶようにしてから、「どの時間帯に失敗が集中しているか」が一目で分かるようになった。原因調査の第一歩は、まず記録を残すことだと実感した部分だ。
ハマりどころ
再試行の仕組み自体は動いたが、最初は「再試行した」ログを残していなかったため、後から見返しても何回失敗して復旧したのかが分からなかった。スプレッドシートに実行ログを1行ずつ残すようにしてから、障害の傾向(特定の時間帯に集中する、など)が見えるようになった。
実際にログを蓄積してみると、特定の時間帯(外部API側のメンテナンス時間と重なる時間帯)に失敗が集中していることが分かった。原因が分かってしまえば、その時間帯だけトリガーを止める、という単純な対策で十分だった。ログを残していなければ、この傾向にはまず気づけなかっただろう。
もう1つ、トリガーの実行間隔と再試行のタイミングが重なり、同じ処理が二重に走ってしまったことがある。GASのトリガーは実行完了を保証してくれないため、処理の先頭でロック(LockService)を取得するようにして防いだ。
function runWithLock() {
const lock = LockService.getScriptLock();
try {
lock.waitLock(5000); // 最大5秒待って取得できなければ諦める
} catch (e) {
Logger.log('別の実行がロックを保持中のためスキップします');
return;
}
try {
fetchWithBackoff(url, options, 3);
} finally {
lock.releaseLock();
}
}
waitLockの待機時間を短くしすぎると正常なケースでもスキップが多発し、長くしすぎると本来のトリガー実行が詰まってしまう。5秒程度から始めて、実際の実行時間を見ながら調整するのが現実的だった。最初から正解の値が分かるわけではないので、まず動かしてみて様子を見る、という進め方に落ち着いている。
まとめ
再試行の仕組みは一度組んでしまえば地味に効き続けるが、「即座に再試行」という最初の実装のままでは、むしろ障害を長引かせる可能性がある。AIに実装を任せる前に、再試行の方針そのものを自分で決めておく必要があった。ログを残す・ロックを取る、というどちらも地味な工程が、後になって一番頼りになった。
前回は「スプレッドシートを簡易DBにするGASパターン集」について、次回は「LLMに構造化JSONを返させる実務テクニック」について書く予定です。
note(作った経緯・所感はこちら): https://note.com/kar8
参考・関連リンク
- UrlFetchApp 公式リファレンス: https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
- LockService 公式リファレンス: https://developers.google.com/apps-script/reference/lock