Why NextWhy Next
一覧へ戻る
💻 開発読了 7 分

「stagingで止めればいい」で終わらせない - うるさいアラートが隠していた本当のバグ

stagingで毎日鳴っていたGoogle Playの404アラート。発生源を止めれば静かになるが、それと失敗モードを直すことは別の問題だった。404は再試行しても意味のない恒久的なエラーなのに、5xxと一緒くたにされて再試行されていたという話。

要点まとめ

キューに乗せて再試行するジョブで外部APIが404を返す場合、その404は再試行しても改善しない恒久的な状態であることが多い。しかしクライアントが404を5xx・429と同じ例外にまとめて投げると、再試行予算を消費しアラートを埋め尽くす。解決策は、レスポンスコードを最初に見るクライアント境界で、恒久的エラー(4xx)と一時的エラー(5xx・429・タイムアウト)を分けることだ。そして、うるさいアラートを前にしたとき「発生源を止める」ことと「失敗モードを直す」ことは別問題である。前者はこのアラート一つを消し、後者はその失敗が戻ってくるすべてのケースを消す。

目次

毎日同じ時刻に、同じアラートが届いた。stagingで購読の照合(reconcile)ジョブが失敗したというERRORだった。午前3時17分にcronが走り、3分後にアラートが上がる。スタックトレースは決まって一行目がこうだった。Google Play Voided Purchases API returned 404。3回再試行し、3回とも同じ404で落ち、ジョブはDLQに落ち、私たちのSlackには赤いアラートがまた一つ積み上がった。

これは障害ではなかった。何も壊れていない。ただうるさかっただけだ。そしてこのうるさいアラートをどう扱うか決める過程で、私は「止めること」と「直すこと」は別問題だということを改めて学んだ。

なぜ404だったのか

Voided Purchases APIは、返金・取消された購入の一覧を返すGoogle Playのエンドポイントだ。私たちはこれを使って、こっそり返金を受けながらアプリを使い続けるケースを毎日照合してあぶり出している。しかしこのエンドポイントが404を返すのは「データがない」という意味ではない。voidedpurchases.listにおける404は、該当のアプリケーション(パッケージ)が見つからない、あるいはサービスアカウントがそのパッケージへのアクセス権限を持っていないことを意味する。

stagingのサービスアカウントには本番パッケージに対するPlay Consoleのアクセス権がない。そもそもある必要もない。stagingは実際のストア返金トラフィックが流れる環境ではないからだ。だからstagingでこのAPIを呼べば404になる。今日も、明日も、設定を変えない限り永遠に。これは一時的に途切れてまた繋がるような障害ではなく、環境そのものが生み出す恒久的な状態だ。

ここで話が分かれる。一時的な障害なら再試行が正しい。ネットワークが瞬断したり相手のサーバーが一瞬5xxを返したりするなら、数秒後にもう一度呼べばいい。しかし恒久的な状態は再試行では良くならない。stagingのサービスアカウントにない権限が3秒後に生まれるわけがない。だからこの404を3回再試行するのは純粋な無駄であり、その先に来るERRORアラートは純粋なノイズだった。

なぜコードは再試行していたのか

原因はクライアントがレスポンスを分類する方法にあった。listVoidedPurchasesは200以外のすべてのレスポンスを一つにまとめて投げていた。

if (!res.ok) {
  throw AppHttpException.serviceUnavailable(
    `Google Play Voided Purchases API returned ${res.status}`,
  );
}

serviceUnavailableは私たちのキューが「再試行対象」として読み取るエラーだ。だから404であれ500であれ429であれ、ここから出たエラーはすべて3回再試行され、すべてアラートで終わる。問題はこの三つの状態の意味がまったく違うということだ。500と429は「今はダメだがすぐに直るかもしれない」であり、404は「この条件では今後もずっとダメ」だ。一つの例外にまとめた瞬間、再試行しても無駄なエラーが再試行パイプラインに乗ってしまう。

面白いのは、答えはすでに同じファイルの中にあったということだ。同じクライアントのgetSubscriptionPurchaseは、とっくに404を別扱いにしていた。

if (res.status === 404) {
  throw new GooglePlayApiError(404, "purchase not found");
}

片方のメソッドは404の意味を尊重し、もう片方はそれを握りつぶしていた。前例はコードの中にあったのに、新しいメソッドがそれに従わなかっただけだ。こうした非対称は、たいてい各メソッドが別の日に、別の関心事として書かれるときに生まれる。

直す: 境界で分ける

修正は二つの層で行われ、どちらもすでにあったパターンに従った。

まずクライアント境界で404を切り分けた。404は「見つからない」という意味を持つGooglePlayApiError(404)として投げ、残りの5xx・429・タイムアウトはそのままserviceUnavailableとして残した。本当に再試行が必要なものだけを再試行対象として維持したのだ。

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx・429・タイムアウト = 本当の再試行対象 */);
}

次に、照合サービスがこの404をどう受け止めるかを決めた。すでにサービスには「サービスアカウントキーがなければ静かにスキップする」という流れ(isConfigured === false)があった。404も本質的には同じ状況だ。この環境では呼べないAPIなのだ。だから同じ場所に乗せた。

try {
  const voided = await client.listVoidedPurchases();
  // ... 照合を続行
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - この環境ではアクセス不可、スキップ");
    return { skippedAppNotFound: true };
  }
  throw e; // 404でなければそのまま再スローして通常どおり再試行
}

catchでは404だけを飲み込んでwarnログを残し、skip統計を返す。再スローしないので再試行もDLQもERRORアラートも発生しない。逆に404以外のエラーはそのまま再スローされ、元の再試行ロジックが正常に働く。うるさいものだけが静かになり、本当の問題は依然としてうるさいまま残る。

ここにテストを付けた。クライアントが404でGooglePlayApiErrorを投げるか、照合が404をスキップするか、そして404以外のエラーは依然として再スローされるか。最後のケースが重要だ。404を黙らせようとして誤って500まで飲み込んでしまうと、本当の障害が見えなくなる。

「stagingだけ止めればいいのでは?」

PRを出したあとで、良い質問を受けた。stagingでそもそもこのジョブを回さなければいいのではないか。もっともな直感だ。stagingには実際の返金トラフィックがないのだから、APIを呼ぶ理由自体がない。そこで確かめてみた。

止めるのは思ったより単純ではなかった。stagingでこの機能を止める自然なスイッチはサービスアカウントキーの有無(isConfigured)だが、そのキーはこのジョブ専用ではなかった。アプリ内課金レシートの検証、Googleのリアルタイム通知(RTDN)のWebhook処理、購読照合プロセッサ。サブスクリプション検証全体が同じキーにぶら下がっていた。stagingで決済・購読をテストするには、そのキーが必要だ。キーを抜いてジョブを止めれば、本来テストすべきものまで一緒に止まってしまう。

では、このジョブだけを止める専用トグルを作ればいいのではないか。そんなトグルはなかった。GOOGLE_VOIDED_RECONCILE_ENABLEDのようなスイッチを新しく入れる必要があるということだ。つまり「stagingだけ止める」も結局はコードと環境変数を触る作業になる。タダで止める方法は最初から存在しなかった。

止めることと直すことは別問題だ

しかし本当の要点は難易度ではなかった。トグルを作るのが簡単だったとしても、それは404の修正の代わりにはならない。二つのアプローチが解いている問題が違うからだ。

  • 404を再試行対象から外すことは根本的な防御だ。どんな環境であれ、stagingであれ、設定を誤った本番であれ、誰かがトグルを入れ忘れた未来であれ、再試行しても無駄な404がアラートの暴走に発展しないよう防ぐ。
  • stagingでジョブを止めることは運用上の最適化だ。「この環境では呼ぶ理由がないから呼び出し自体を省く」という、それに上乗せすると良い選択だ。

順序が大事だ。404の防御を残したままトグルを足すのは構わない。しかしトグルだけを入れて404の防御を外せば、いつか本番で設定が狂って404が出た日にアラートがまた爆発する。そのときは「stagingだから無視」で済ますこともできない。発生源を止めることは今見えているアラート一つを消し、失敗モードを直すことはそのアラートが再び現れるすべてのケースを消す。

うるさいアラートに出会うと、手はまず発生源に伸びる。このジョブを止めよう、このアラートルールを消そう、この環境から外そう。自然な衝動であり、時にはそれが正しい。しかし止める前に一度問わなければならない。自分は今アラートを止めているのか、それともアラートが知らせてくれた失敗モードを直しているのか。前者はこのアラートを静かにし、後者はこの失敗が別の姿で戻ってくるのを防ぐ。

境界で問うべきこと

この話はGoogle Play APIだけのものではない。外部を呼び出し、その失敗をキューに乗せて再試行するすべてのコードが、同じ分かれ道に立つ。再試行ポリシーをキューの設定でいじる前に、クライアント境界でまず問うべきだ。

このエラーは再試行で改善するのか。5xx・429・タイムアウトはたいていそうだ。今はダメでもすぐに直るかもしれない。4xxはたいていそうではない。404、403、400は条件が変わらない限り同じ結果を返す。この二つを一つの例外にまとめると、再試行しても無駄な失敗が再試行予算を消費しアラートを埋め尽くす。分けるべき場所はキューではなく、レスポンスコードを最初に見るクライアント境界だ。

そして直す前に一度コードベースを見渡すことも忘れないようにしたい。私たちのケースでは、同じファイルの隣のメソッドがすでに404を正しく扱っていた。新しく発明する必要はなかった。答えがすでにコードの中にあるのに、新しいコードがそれに従わなかっただけ、というケースは思ったより多い。

よくある質問

バックグラウンドジョブが404で延々と再試行され、失敗アラートが繰り返し届きます。どうすればいいですか?

まず、その404が一時的なものか恒久的なものかを見分ける必要があります。リソースがそもそもその環境に存在しない、あるいはアクセス権限がないために出る404であれば、再試行しても結果は変わりません。こうした場合はクライアントがレスポンスコードを最初に見る地点で404を別のエラータイプとして分岐させ、ジョブが再スローせずにwarnログとともにスキップする(graceful skip)ようにするのが正解です。そうすれば再試行・DLQ・ERRORアラートは発生しません。

一時的なエラーと恒久的なエラーは何で見分けますか?

おおむね5xx・429・タイムアウトは一時的なので再試行の対象です。今はダメでもすぐに直る可能性があるからです。一方、404・403・400のような4xxはリクエストや条件そのものが変わらない限り同じ結果になるため、再試行しても意味がありません。この区別はキューの再試行ポリシーではなく、レスポンスコードを最初に見るクライアント境界で行うのが望ましいです。

関連記事