CI/CDの403エラー対策フロー図。Bashで失敗するリトライ処理を、Vitestとフェイクタイマーでテスト可能なNode.jsスクリプトに切り出す改善策を提示。

はじめに

夜間バッチで本番URLの疎通確認をしてから、Lighthouse計測のような重い処理を走らせるCIジョブを運用しています。
このジョブで「本番は健全なのに、CI runnerからのリクエストだけが断続的に403を返される」というflakeが起きました。

原因はCDNやWAFのBot対策がCI runnerのIPを一時的に弾いている可能性が高いと見ています。
ただし、その設定は手元ですぐには変更できません。
そこで「症状の緩和」と「根本原因の調査」を切り分け、まずはCI側のリトライ予算を広げる対応に絞りました。
あわせて、YAMLに直書きしていたbashのリトライループを、テストできるNode.jsスクリプトへ切り出しました。
この記事では、その手順と考え方を紹介します。

こんな人におすすめ

  • CIの疎通確認が、本番に問題がないのに断続的に失敗して困っている方
  • ワークフローYAMLにinline bashが増え、動作確認がCIを回すしかない状態の方
  • リトライやタイムアウトのロジックを、実時間を待たずにテストしたい方
  • 自分で直せない原因のflakeを、どう扱うか悩んでいる方

まず「障害」と「CI runnerだけブロック」を切り分ける

疎通確認が失敗したとき、最初に区別したいのは次の2つです。

  • CIの外からもアクセスできない(本番の真の障害)
  • CI runnerからだけアクセスできない(WAFやBot対策の可能性)

この区別がないと、無駄なアラートやIssueが量産されます。
今回は、最後に観測したHTTPステータスをジョブのoutputに残すようにしました。
後続のIssue自動起票ジョブは、このステータスを見て扱いを変えられます。

Before: YAMLに直書きしたリトライループ

もともとは、次のようなbashがYAMLの中に入っていました。

run: |
  set -uo pipefail
  STATUS="000"
  for attempt in $(seq 1 6); do
    STATUS=$(curl -sk -o /dev/null -w "%{http_code}" -L "$TARGET_URL" || echo "000")
    if [ "$STATUS" = "200" ]; then
      echo "reachable after ${attempt} attempts (HTTP 200)"
      echo "http_status=200" >> "$GITHUB_OUTPUT"
      exit 0
    fi
    echo "attempt ${attempt}/6: HTTP $STATUS — sleep 5s"
    sleep 5
  done
  echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
  exit 1

固定回数・固定sleepなので、リトライ予算は約30秒です。
予算を変えるにはYAMLを直接編集する必要があり、動作確認は実際にCIを回すしかありませんでした。

After: 依存を注入できるスクリプトに切り出す

ロジックを .mjs に移し、fetchImpl / delayImpl / now を引数で差し替えられるようにしました。

export async function waitForReachable({
  url,
  fetchImpl = fetch,
  timeoutMs = DEFAULT_TIMEOUT_MS,
  intervalMs = DEFAULT_INTERVAL_MS,
  delayImpl = delay,
  now = Date.now,
  onAttempt,
} = {}) {
  const deadline = now() + timeoutMs
  let attempt = 0
  let lastStatus = '000'

  while (true) {
    attempt += 1
    lastStatus = await probeStatus({ url, fetchImpl })
    onAttempt?.({ attempt, status: lastStatus })

    if (lastStatus === '200') {
      return { ok: true, status: lastStatus, attempts: attempt }
    }
    if (now() >= deadline) {
      return { ok: false, status: lastStatus, attempts: attempt }
    }
    await delayImpl(intervalMs)
  }
}

回数ではなく「締め切り時刻」で打ち切る形にしたので、予算は timeoutMs の数値だけで調整できます。
今回は30秒から約3分に広げました。
YAML側は次の1行になります。

run: node scripts/wait-for-reachable.mjs

fake clockで3分待ちを一瞬でテストする

実際に sleep するテストは遅く、不安定になりがちです。
now と delayImpl を差し替え、仮想的に時間を進めます。

function makeFakeClock() {
  let current = 0
  const now = () => current
  const delayImpl = vi.fn(async (ms) => { current += ms })
  return { now, delayImpl }
}

it('timeoutまで失敗が続いたら最後に観測したstatusでok:falseを返す', async () => {
  const fetchImpl = vi.fn().mockResolvedValue({ status: 403 })
  const { now, delayImpl } = makeFakeClock()

  const result = await waitForReachable({
    url: TEST_URL, fetchImpl, now, delayImpl,
    timeoutMs: 20_000, intervalMs: 5_000,
  })

  expect(result.ok).toBe(false)
  expect(result.status).toBe('403')
})

実時間を1秒も使わずに、タイムアウト到達時の挙動を検証できます。
成功までに何回失敗したか、といったケースも同じ要領で書けます。

ジョブ全体のtimeoutと、runbookも一緒に直す

リトライ予算だけを延ばしても、ジョブ自体のtimeoutが据え置きだと問題が残ります。
依存関係のインストールや計測本体と合算したとき、tail caseでジョブごと打ち切られるおそれがあるためです。
今回はジョブのtimeoutも15分から20分へ合わせて広げました。

もう一つ、既知のflakeはrunbookに構造化して残しておくと役立ちます。
次のような項目を書いておくと、次に遭遇した人が再現確認から始めずに済みます。

  • どのくらいの頻度で起きるか
  • どのくらいの時間で収まるか
  • どう見分けるか
  • 暫定対策は何か

つまづきやすいポイント

  • 症状緩和のリトライは、原因が別にあることを隠しやすいです。根本原因の調査は別Issueとして切り離し、リトライ拡張を「恒久対策」と誤解されないようにしておくと安全です
  • 404のような恒久的なエラーまでリトライすると、無駄に待ち続けます。リトライ対象にするステータスは意図して決めてください
  • リトライ予算とジョブtimeoutは連動させて見直してください。片方だけ変えると、別の箇所で打ち切られます
  • fake clockを使うテストでは、now と delayImpl の両方を差し替えないと、実時間が混ざってflakyなテストになります

まとめ

CIだけが403を受ける問題は、本番障害とは別物として切り分けると見通しが良くなります。
直せない根本原因は無理に追わず、リトライ予算の拡張という小さなスコープで先にマージする判断も有効です。
inline bashは、依存を注入できるスクリプトに切り出すとfake clockでテストでき、予算の調整もしやすくなります。
ジョブのtimeoutやrunbookまで一緒に整えておくと、次に同じ現象が起きたときの対応が軽くなります。