1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASトリガー設計と指数バックオフ再試行

1
Posted at

結論から先に: 「エラーが出たら再試行する」だけでは不十分で、待機時間を毎回倍にする指数バックオフと、実行の二重起動を防ぐロックの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

参考・関連リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?