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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class RateGate {
constructor({ rate, windowMs }) {
this.rate = rate;
this.windowMs = windowMs;
this.starts = [];
this.cooldownUntil = 0;
}
async acquire() {
for (;;) {
const now = Date.now();
this.starts = this.starts.filter((t) => now - t < this.windowMs);
const wait = Math.max(
0,
this.cooldownUntil - now,
this.starts.length >= this.rate ? this.starts[0] + this.windowMs - now : 0
);
if (wait <= 0) { this.starts.push(now); return; }
await sleep(wait);
}
}
penalize(ms) { this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); }
}

要点は2つある。

ひとつ目は starts が固定窓ではなく移動窓であること。 「毎分0秒でカウンタをリセットする」固定窓にすると、窓の境目に集中したときに実質2倍のリクエストが通ってしまう。開始時刻を配列で持ち、いま時点から windowMs 遡った範囲だけを数えれば、どの瞬間を切り取っても上限を超えない。

ふたつ目は penalize() が全ワーカー共通であること。 429 を受けたときに、そのワーカーだけが待っても意味がない。窓を空けるべき相手はサーバなので、他のワーカーが追い打ちをかけている間は回復しない。cooldownUntil をゲート側に持たせ、acquire() の待ち時間計算に混ぜることで、429 を1回受けたら全ワーカーが揃って止まる。

実際の実装では Retry-After ヘッダがあればその秒数を、無ければ窓の長さをクールダウンに使っている。

実際に試す

ゲートの効きめを、ネットワークを使わずに再現できる形にした。サーバ役も移動窓で数えて、上限を超えたら 429 を返すようにしてある。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// rate-gate.mjs — node rate-gate.mjs で動く(Node.js 20 で確認)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class RateGate {
constructor({ rate, windowMs }) {
this.rate = rate;
this.windowMs = windowMs;
this.starts = [];
this.cooldownUntil = 0;
}
async acquire() {
for (;;) {
const now = Date.now();
this.starts = this.starts.filter((t) => now - t < this.windowMs);
const wait = Math.max(
0,
this.cooldownUntil - now,
this.starts.length >= this.rate ? this.starts[0] + this.windowMs - now : 0
);
if (wait <= 0) { this.starts.push(now); return; }
await sleep(wait);
}
}
penalize(ms) { this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms); }
}

// サーバ役: 窓あたり rate 本までを許し、超えたら 429 を返す
function makeServer({ rate, windowMs }) {
const seen = [];
return () => {
const now = Date.now();
while (seen.length && now - seen[0] >= windowMs) seen.shift();
seen.push(now);
return seen.length > rate ? 429 : 200;
};
}

async function run({ workers, gate, total, server }) {
const codes = { 200: 0, 429: 0 };
let next = 0;
const t0 = Date.now();
await Promise.all(
Array.from({ length: workers }, async () => {
while (next < total) {
next += 1;
if (gate) await gate.acquire();
const code = server();
codes[code] += 1;
if (code === 429 && gate) gate.penalize(gate.windowMs);
await sleep(20); // 応答時間の代わり
}
})
);
return { ...codes, elapsedMs: Date.now() - t0 };
}

const RATE = 5, WINDOW = 3000, TOTAL = 30, WORKERS = 6;

console.log("律速なし:", await run({
workers: WORKERS, gate: null, total: TOTAL, server: makeServer({ rate: RATE, windowMs: WINDOW }),
}));
console.log("移動窓ゲート:", await run({
workers: WORKERS, gate: new RateGate({ rate: RATE, windowMs: WINDOW }), total: TOTAL,
server: makeServer({ rate: RATE, windowMs: WINDOW }),
}));

30本を6並列で投げる。窓は3秒で上限5本(NVD の 30秒/5本を時間だけ縮めた比率)。実行結果:

1
2
3
$ node rate-gate.mjs
律速なし: { '200': 5, '429': 25, elapsedMs: 115 }
移動窓ゲート: { '200': 29, '429': 1, elapsedMs: 18032 }

律速なしは 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 収載が後から入った例が実際に並んでいるので、「掲載して終わりにしない」がどういうことかはそちらを見てもらうのが早い。