fix: cap lookup/validate at ≤10s cold, fix infinite skeleton spinner
lookup: - Remove WithRetry on Prefixes/Neighbours (8s+retry+8s=17s → 8s max) - Add 9s hard cap inside timedFetch per source - Visibility timeout 12s → 8s validate: - Phase 1 timeout 8s → 5s (prevents blocking Phase 2) - Phase 2 per-check cap: 10s → 5s (total ≤10s for any ASN) - rdns sample: 20 → 3 (was 20 concurrent RIPE Stat calls) - rdns per-call timeout: 5s → 4s Frontend doLookup: - Add 15s AbortController on /api/lookup fetch - Show 'timed out — try again' instead of infinite skeleton spinner
This commit is contained in:
parent
e1dcbe517f
commit
5b04fc663f
@ -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":"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":"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-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"}
|
||||||
|
|||||||
@ -1162,8 +1162,11 @@ async function doLookup() {
|
|||||||
$('skeleton').classList.remove('hidden');
|
$('skeleton').classList.remove('hidden');
|
||||||
$('metaBar').textContent = '';
|
$('metaBar').textContent = '';
|
||||||
|
|
||||||
|
const lookupCtrl = new AbortController();
|
||||||
|
const lookupTimer = setTimeout(() => lookupCtrl.abort(), 15000);
|
||||||
try {
|
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();
|
const d = await resp.json();
|
||||||
|
|
||||||
if (d.error) {
|
if (d.error) {
|
||||||
@ -1200,8 +1203,9 @@ async function doLookup() {
|
|||||||
// v0.6.1 new features
|
// v0.6.1 new features
|
||||||
loadNewFeatures(raw);
|
loadNewFeatures(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clearTimeout(lookupTimer);
|
||||||
$('skeleton').classList.add('hidden');
|
$('skeleton').classList.add('hidden');
|
||||||
$('metaBar').textContent = 'Error: ' + e.message;
|
$('metaBar').textContent = e.name === 'AbortError' ? 'Lookup timed out — try again' : 'Error: ' + e.message;
|
||||||
} finally {
|
} finally {
|
||||||
$('searchBtn').disabled = false;
|
$('searchBtn').disabled = false;
|
||||||
$('searchBtn').textContent = 'Lookup';
|
$('searchBtn').textContent = 'Lookup';
|
||||||
|
|||||||
40
server.js
40
server.js
@ -2927,12 +2927,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const targetAsn = parseInt(rawAsn);
|
const targetAsn = parseInt(rawAsn);
|
||||||
|
|
||||||
try {
|
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([
|
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),
|
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/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
||||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn),
|
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; });
|
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);
|
return checkManrsMembership(rawAsn);
|
||||||
}).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; });
|
}).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)
|
// 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs)
|
||||||
var rdnsSampleSize = Math.min(20, samplePrefixes.length);
|
var rdnsSampleSize = Math.min(3, samplePrefixes.length);
|
||||||
validationPromises.rdns = Promise.all(
|
validationPromises.rdns = Promise.all(
|
||||||
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
|
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 pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
|
||||||
var hasDelegation = false;
|
var hasDelegation = false;
|
||||||
var details = [];
|
var details = [];
|
||||||
@ -3248,9 +3248,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}).catch(function() { return []; })
|
}).catch(function() { return []; })
|
||||||
: Promise.resolve([]);
|
: 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 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 settled = await Promise.allSettled(promises);
|
||||||
var facCountries = await facCountriesPromise;
|
var facCountries = await facCountriesPromise;
|
||||||
|
|
||||||
@ -3413,13 +3418,16 @@ const server = http.createServer(async (req, res) => {
|
|||||||
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
|
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
|
||||||
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
|
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 = {};
|
const sourceTiming = {};
|
||||||
function timedFetch(name, promise) {
|
function timedFetch(name, promise) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
return Promise.resolve(promise)
|
return Promise.race([
|
||||||
.then(r => { sourceTiming[name] = Date.now() - ts; return r; })
|
Promise.resolve(promise),
|
||||||
.catch(() => { sourceTiming[name] = null; return null; });
|
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;
|
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; });
|
]).then(d => { rdapCacheSet(asn, d); return d; });
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/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", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/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 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 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("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")),
|
||||||
timedFetch("bgp.he.net", fetchBgpHeNet(asn)),
|
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("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 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))),
|
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user