429 を「変化なし」と読まないための移動窓ゲート
外部 API を大量に叩くバッチには、静かに壊れる壊れ方がある。取得に失敗した件を「変化が無かった件」として数えてしまうという壊れ方だ。
例外は出ない。ログは正常終了と書く。件数のグラフも滑らかに続く。ただ、実際には何も見ていない。
脆弱性対応ウォッチ では、掲載中の CVE 全件を毎日 NVD と CISA KEV に突き合わせ直している。掲載後に KEV へ収載される、CVSS が改定される、ベンダーのパッチが出る、といった変化は実際に起きるからだ。本稿執筆時点で掲載は 271 件ある。
この「全件を毎日」を素朴に組むと、上の壊れ方をきれいに踏む。この記事では、踏んだ経緯と、並列を保ったまま律速する 移動窓(sliding window)ゲート の実装を、実測値つきで書く。コードは Node.js だけで動く形にしてあるので、そのまま貼って回せる。
前提: NVD のレート制限
NVD の REST API(services.nvd.nist.gov/rest/json/cves/2.0)が公表しているレート制限は次のとおりである。
- API キー無し: 30秒あたり 5 リクエスト
- API キー有り: 30秒あたり 50 リクエスト
キーがあれば10倍になるので、本番運用ではキーを取るのが正しい。ただし「キーが無い経路でも壊れずに完走する」ことは別に担保しておく必要がある。無人で回るバッチでは、キーの解決に失敗した回に全件が 429 で落ちて「変化なし」になるのが最悪の結果だからだ。
素朴に並列で投げると何が起きるか
最初の実装は、並列ワーカー6本、リクエスト間隔 150ms という素朴なものだった。84件を投げたときの実測は次のとおりである。
- 84件中 57件が HTTP 429
このとき出力の見た目は「変化のあった CVE: 0件」だった。57件は取得できていないのだから、変化があったかどうかを判定していない。にもかかわらず、変化の集計としては 0 が出る。
429 は失敗ではなく「まだ聞いていない」である。ところが集計器から見ると、変化を返さなかった件と区別が付かない。ここが問題の核心だった。
並列をやめるのは筋が悪い
一番簡単な直し方は、並列をやめて直列にし、リクエストのたびに6秒待つことだ。5 req/30s は平均するとそうなる。
ただしこれは所要時間を素直に最悪化する。各リクエストの応答時間がゼロでなければ、6秒 + 応答時間 が毎回積み上がる。271件なら 27分では済まない。
律速したいのは同時実行数ではなく、窓あたりの開始本数である。この2つは別物だ。応答を待っている時間はサーバのレート制限を消費しないので、並列ワーカーは維持したまま、開始のタイミングだけを窓で抑えればよい。
移動窓ゲート
実装は短い。開始時刻の配列を持ち、窓から出た分を捨て、残りが上限に達していたら「いちばん古い開始が窓から出るまで」待つ。
1 | |
要点は2つある。
ひとつ目は starts が固定窓ではなく移動窓であること。 「毎分0秒でカウンタをリセットする」固定窓にすると、窓の境目に集中したときに実質2倍のリクエストが通ってしまう。開始時刻を配列で持ち、いま時点から windowMs 遡った範囲だけを数えれば、どの瞬間を切り取っても上限を超えない。
ふたつ目は penalize() が全ワーカー共通であること。 429 を受けたときに、そのワーカーだけが待っても意味がない。窓を空けるべき相手はサーバなので、他のワーカーが追い打ちをかけている間は回復しない。cooldownUntil をゲート側に持たせ、acquire() の待ち時間計算に混ぜることで、429 を1回受けたら全ワーカーが揃って止まる。
実際の実装では Retry-After ヘッダがあればその秒数を、無ければ窓の長さをクールダウンに使っている。
実際に試す
ゲートの効きめを、ネットワークを使わずに再現できる形にした。サーバ役も移動窓で数えて、上限を超えたら 429 を返すようにしてある。
1 | |
30本を6並列で投げる。窓は3秒で上限5本(NVD の 30秒/5本を時間だけ縮めた比率)。実行結果:
1 | |
律速なしは 0.1 秒で終わるが、通ったのは 5本だけである。「速く終わった」と「終わった」は違うことがそのまま出ている。ゲートを入れると 18 秒かかるかわりに 29本が通る。
429 が1件残るのは正常である
ここで目を引くのは、ゲートを入れても 429 が 1 件出ていることだろう。バグではない。
ゲートが数えているのは「自分がリクエストを開始した時刻」で、サーバが数えているのは「リクエストが到着した時刻」である。この2つの窓は同期していない。ゲート側の窓の起点とサーバ側の窓の起点がずれていれば、境界付近で1本余分に到着する回がある。
だから 429 の発生をゼロにする設計にはしない。ゼロを目指すと、安全側に倒しすぎて所要時間が跳ね上がる。目指すのは「429 を受けたら全体で減速し、再試行で必ず取り切る」ことのほうだ。
実運用の測定値でもそうなっている。API キー無しで全件を回した回の実測は次のとおりである。
| 日付 | 対象 | 429率 | 最終的に取得できなかった件数 |
|---|---|---|---|
| 2026-07-26 | 84件 / 96リクエスト | 12.5% | 0件 |
| 2026-08-22 | 268件 | 12.7% | 0件 |
| 2026-08-23 | 269件 | 8.5% | 0件 |
1割前後の 429 は正常運転である。律速を外したときの 68%(84件中57件)とは桁が違う。
しきい値は「正常値の倍以上」に置く
以上を踏まえて、このバッチは次の条件で exit 1 にして、その回のデータを書かないようにしてある。
- 429 が全リクエストの 30% を超えた
- または、429 が原因で最終的に取得できなかった CVE が 3件以上
30% という数字は、正常値 12〜13% の倍以上・異常値 68% の半分以下という位置から取った。正常値のすぐ上に置かないのが要点である。すぐ上に置くと、平常運転の揺らぎで毎回落ちるようになり、やがて誰も見なくなる。
3件という下限を別に設けているのは、対象が少ない日に割合が意味を失うためだ。10件しか対象が無い回に3件落ちれば割合は 30% だが、100件の回に3件なら 3% で通ってしまう。割合と実数の両方で見る。
ローリングをやめた話
もうひとつ、同じ「静かに壊れる」型の話がある。
以前は1日12件ずつのローリングで再点検していた。84件を12件ずつなら一巡は約7日である。負荷は軽く、毎日それなりの件数を見ているので、動いているように見える。
だが変化を最大7日見落とす。KEV への収載や CVSS の改定は、収載された当日に知りたい情報である。一巡7日というのは、この用途では「見ていない」に近い。
全件でも所要は9分(キー無し・84件時点)だった。271件に増えた現在は30分前後だが、それでも1日1回なら十分に収まる。ローリングは負荷を平らにするのに有効な手法だが、平らにしてよいのは、遅れて気づいても困らない情報だけである。
まとめ
- 429 を「変化なし」と同じ扱いにすると、取得できていないことが集計から消える。取得失敗と 0 件は別々に数える
- 律速すべきは同時実行数ではなく窓あたりの開始本数。並列は保ったまま開始だけを移動窓で抑える
- 固定窓は境界で実質2倍が通る。開始時刻の配列で移動窓にする
- 429 を受けたときのクールダウンはワーカー単位ではなくゲート単位に持つ
- 429 ゼロは目標にしない。1割前後は正常運転。しきい値は正常値の倍以上に置き、割合と実数の両方で見る
このバッチの出力そのもの(どの CVE がいつ何から何へ変わったか)は、脆弱性対応ウォッチの変更履歴 で公開している。CVSS の改定や KEV 収載が後から入った例が実際に並んでいるので、「掲載して終わりにしない」がどういうことかはそちらを見てもらうのが早い。