fix: never cache null responses + increase RIPE Stat timeout for large carriers

Root cause of neighbour=0 for large carriers (AS9002, AS3491, AS12956):
1. RIPE Stat asn-neighbours returns 5000+ entries for Tier-1 carriers,
   exceeding the 30s timeout → fetchJSON returns null
2. null was cached in ripeStatCache for 15 minutes (the endpoint TTL)
3. All subsequent requests hit the null cache → perpetual 0 neighbours

Fixes:
- Never cache null results in ripeStatCache (only successful responses)
- Never persist null entries to disk cache
- Increase RIPE Stat timeout from 30s to 45s for prefix/neighbour queries
- Increase RIPE Stat semaphore from 10 to 15 concurrent requests

Verified: AS9002 up=146 down=2702, AS3491 up=90 down=710
This commit is contained in:
Rene Fichtmueller 2026-03-30 07:58:24 +02:00
parent 9bc1292bac
commit 8f51f32dc3
2 changed files with 32 additions and 22 deletions

View File

@ -445,7 +445,7 @@ class Semaphore {
if (this.queue.length > 0) { this.current++; this.queue.shift()(); } if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
} }
} }
const ripeStatSemaphore = new Semaphore(10); const ripeStatSemaphore = new Semaphore(15);
// Cached + throttled RIPE Stat fetch // Cached + throttled RIPE Stat fetch
async function fetchRipeStatCached(url, options) { async function fetchRipeStatCached(url, options) {
@ -474,11 +474,14 @@ async function fetchRipeStatCached(url, options) {
const result = await fetchJSON(url, options); const result = await fetchJSON(url, options);
// Store in cache (evict oldest if full) // Only cache successful results — never cache null (failed/rate-limited responses)
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) { // Caching null causes cascading failures: retry hits cache, returns null again
ripeStatCache.delete(ripeStatCache.keys().next().value); if (result !== null) {
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
ripeStatCache.delete(ripeStatCache.keys().next().value);
}
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
} }
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
return result; return result;
} finally { } finally {
ripeStatSemaphore.release(); ripeStatSemaphore.release();
@ -489,17 +492,19 @@ async function fetchRipeStatCached(url, options) {
async function fetchRipeStatCachedWithRetry(url, options) { async function fetchRipeStatCachedWithRetry(url, options) {
const result = await fetchRipeStatCached(url, options); const result = await fetchRipeStatCached(url, options);
if (result !== null) return result; if (result !== null) return result;
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1500));
return fetchRipeStatCached(url, options); return fetchRipeStatCached(url, options);
} }
// RIPE Stat cache disk persistence // RIPE Stat cache disk persistence (skip null entries)
function saveRipeStatCacheToDisk(filePath) { function saveRipeStatCacheToDisk(filePath) {
try { try {
const obj = {}; const obj = {};
for (const [k, v] of ripeStatCache) obj[k] = v; for (const [k, v] of ripeStatCache) {
if (v.data !== null) obj[k] = v;
}
fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj })); fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
console.log("[RIPE-CACHE] Saved " + ripeStatCache.size + " entries to disk"); console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
} catch (e) { } catch (e) {
console.warn("[RIPE-CACHE] Disk save failed:", e.message); console.warn("[RIPE-CACHE] Disk save failed:", e.message);
} }
@ -2591,8 +2596,8 @@ const server = http.createServer(async (req, res) => {
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
const promises = [ const promises = [
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 30000 }), fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 }),
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 30000 }), fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 }),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn), fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn),
fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"), fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"),

View File

@ -445,7 +445,7 @@ class Semaphore {
if (this.queue.length > 0) { this.current++; this.queue.shift()(); } if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
} }
} }
const ripeStatSemaphore = new Semaphore(10); const ripeStatSemaphore = new Semaphore(15);
// Cached + throttled RIPE Stat fetch // Cached + throttled RIPE Stat fetch
async function fetchRipeStatCached(url, options) { async function fetchRipeStatCached(url, options) {
@ -474,11 +474,14 @@ async function fetchRipeStatCached(url, options) {
const result = await fetchJSON(url, options); const result = await fetchJSON(url, options);
// Store in cache (evict oldest if full) // Only cache successful results — never cache null (failed/rate-limited responses)
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) { // Caching null causes cascading failures: retry hits cache, returns null again
ripeStatCache.delete(ripeStatCache.keys().next().value); if (result !== null) {
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
ripeStatCache.delete(ripeStatCache.keys().next().value);
}
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
} }
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
return result; return result;
} finally { } finally {
ripeStatSemaphore.release(); ripeStatSemaphore.release();
@ -489,17 +492,19 @@ async function fetchRipeStatCached(url, options) {
async function fetchRipeStatCachedWithRetry(url, options) { async function fetchRipeStatCachedWithRetry(url, options) {
const result = await fetchRipeStatCached(url, options); const result = await fetchRipeStatCached(url, options);
if (result !== null) return result; if (result !== null) return result;
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1500));
return fetchRipeStatCached(url, options); return fetchRipeStatCached(url, options);
} }
// RIPE Stat cache disk persistence // RIPE Stat cache disk persistence (skip null entries)
function saveRipeStatCacheToDisk(filePath) { function saveRipeStatCacheToDisk(filePath) {
try { try {
const obj = {}; const obj = {};
for (const [k, v] of ripeStatCache) obj[k] = v; for (const [k, v] of ripeStatCache) {
if (v.data !== null) obj[k] = v;
}
fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj })); fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
console.log("[RIPE-CACHE] Saved " + ripeStatCache.size + " entries to disk"); console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
} catch (e) { } catch (e) {
console.warn("[RIPE-CACHE] Disk save failed:", e.message); console.warn("[RIPE-CACHE] Disk save failed:", e.message);
} }
@ -2591,8 +2596,8 @@ const server = http.createServer(async (req, res) => {
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
const promises = [ const promises = [
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 30000 }), fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 }),
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 30000 }), fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 }),
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn), fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn),
fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"), fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"),