diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 9c7a704..0c07a43 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -18,3 +18,6 @@ {"d":"2026-04-08","t":"FEAT","m":"New /api/quick-ix endpoint: lightweight PeeringDB IX connections + network name, 1h cache"} {"d":"2026-04-08","t":"FIX","m":"validate: reduce reverse-dns timeout 15s→5s, route-leak asn-neighbours 30s→8s, comparison endpoint 4x 30s→8s — prevent semaphore starvation"} {"d":"2026-04-08","t":"FEAT","m":"validate: add 15min result cache — subsequent lookups return in ~18ms vs 700ms+ cold"} +{"d":"2026-04-09","t":"FIX","m":"lookup: remove WithRetry on Prefixes+Neighbours (was 8s+8s=16s, now 8s max), add 9s timedFetch hard cap per source"} +{"d":"2026-04-09","t":"FIX","m":"validate Phase1: reduce timeout 8s→5s; Phase2 per-check cap 10s→5s; rdns sample 20→3; total cold ≤10s vs 16s before"} +{"d":"2026-04-09","t":"FIX","m":"doLookup: add 15s AbortController on initial fetch — skeleton no longer spins indefinitely on slow/failed lookups"} diff --git a/public/index.html b/public/index.html index 86fa6aa..e3ebb66 100644 --- a/public/index.html +++ b/public/index.html @@ -1162,8 +1162,11 @@ async function doLookup() { $('skeleton').classList.remove('hidden'); $('metaBar').textContent = ''; + const lookupCtrl = new AbortController(); + const lookupTimer = setTimeout(() => lookupCtrl.abort(), 15000); try { - const resp = await fetch('/api/lookup?asn=' + raw); + const resp = await fetch('/api/lookup?asn=' + raw, { signal: lookupCtrl.signal }); + clearTimeout(lookupTimer); const d = await resp.json(); if (d.error) { @@ -1200,8 +1203,9 @@ async function doLookup() { // v0.6.1 new features loadNewFeatures(raw); } catch (e) { + clearTimeout(lookupTimer); $('skeleton').classList.add('hidden'); - $('metaBar').textContent = 'Error: ' + e.message; + $('metaBar').textContent = e.name === 'AbortError' ? 'Lookup timed out — try again' : 'Error: ' + e.message; } finally { $('searchBtn').disabled = false; $('searchBtn').textContent = 'Lookup'; diff --git a/server.js b/server.js index 00086d2..ffeb5f8 100644 --- a/server.js +++ b/server.js @@ -2927,12 +2927,12 @@ const server = http.createServer(async (req, res) => { const targetAsn = parseInt(rawAsn); try { - // Phase 1: Fetch core data needed by multiple validations + // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 8000 }), + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), fetchPeeringDB("/net?asn=" + rawAsn), - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 8000 }), - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), ]); const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); @@ -3060,11 +3060,11 @@ const server = http.createServer(async (req, res) => { return checkManrsMembership(rawAsn); }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); - // 17. Reverse DNS Coverage (sample up to 20 prefixes for better coverage) - var rdnsSampleSize = Math.min(20, samplePrefixes.length); + // 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs) + var rdnsSampleSize = Math.min(3, samplePrefixes.length); validationPromises.rdns = Promise.all( samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { - return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 5000 }).then(function(data) { + return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; var hasDelegation = false; var details = []; @@ -3248,9 +3248,14 @@ const server = http.createServer(async (req, res) => { }).catch(function() { return []; }) : Promise.resolve([]); - // Run all validations in parallel + // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s var keys = Object.keys(validationPromises); - var promises = keys.map(function(k) { return validationPromises[k]; }); + var promises = keys.map(function(k) { + return Promise.race([ + validationPromises[k], + new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), + ]); + }); var settled = await Promise.allSettled(promises); var facCountries = await facCountriesPromise; @@ -3413,13 +3418,16 @@ const server = http.createServer(async (req, res) => { let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; - // Per-source timing tracking + // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking const sourceTiming = {}; function timedFetch(name, promise) { const ts = Date.now(); - return Promise.resolve(promise) - .then(r => { sourceTiming[name] = Date.now() - ts; return r; }) - .catch(() => { sourceTiming[name] = null; return null; }); + return Promise.race([ + Promise.resolve(promise), + new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }), + ]) + .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; }) + .catch(function() { sourceTiming[name] = null; return null; }); } const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; @@ -3440,13 +3448,13 @@ const server = http.createServer(async (req, res) => { ]).then(d => { rdapCacheSet(asn, d); return d; }); const promises = [ - timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 8000 })), - timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 8000 })), + timedFetch("RIPE Stat Prefixes", fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 8000 })), + timedFetch("RIPE Stat Neighbours", fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 8000 })), timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)), timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)), timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")), timedFetch("bgp.he.net", fetchBgpHeNet(asn)), - timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 12000 })), + timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 8000 })), timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)), timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)), timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),