From d84fefbbea12e37cf2fd087a425332aa33591250 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 5 Oct 2026 02:35:23 +0800 Subject: [PATCH] Fix(asn): report unloaded local data as an error, not a cacheable empty - PeeringDB is 'disabled' only without a Cloudflare key; configured but not loaded (still downloading, or the download failed) is an 'error', so the ASN Profile isn't cached for a week without it. - Connectivity without an as-rel snapshot is an 'error' too, not an empty graph; a missing as2org still only blocks while it downloads, as RIPEstat names the nodes. - A CAIDA as2org / as-rel file that parses to nothing (corrupt) no longer counts as loaded. Co-Authored-By: Claude Opus 5.5 --- api/asn-profile.js | 33 +++++++++++++++++++++++---------- common/as-org-db.js | 7 +++++++ common/as-rel-db.js | 7 +++++++ tests/api-handlers.test.js | 30 +++++++++++++++++++++++------- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/api/asn-profile.js b/api/asn-profile.js index f3b28023..4501a79b 100644 --- a/api/asn-profile.js +++ b/api/asn-profile.js @@ -20,12 +20,27 @@ import { } from '../common/asn-profile.js'; import { getAsnConnectivity } from './asn-connectivity.js'; -// A section whose local dataset is still downloading at boot fails (status -// 'error'): left out of the answer and keeping it uncached, while every -// other section answers as usual. -const unlessLoading = (checks, label, fn) => (...args) => { - if (isStillLoading(...checks)) throw new Error(`${label} still loading`); - return fn(...args); +// A section whose local data isn't there fails (status 'error'): left out +// of the answer and keeping it uncached, while every other section answers +// as usual — never an 'empty' / 'disabled' that would be cached for a week. + +// Connectivity needs the as-rel snapshot (without it every graph is empty); +// as2org only names the nodes, RIPEstat covers a failed download, so it +// counts only while still downloading. Exported for tests. +export const connectivityReadiness = ({ relLoaded = isAsRelLoaded, orgLoaded = isAsOrgLoaded, fetch = getAsnConnectivity } = {}) => + (...args) => { + if (!relLoaded()) throw new Error('CAIDA as-rel snapshot not loaded'); + if (isStillLoading(orgLoaded)) throw new Error('CAIDA as2org snapshot still loading'); + return fetch(...args); + }; + +// PeeringDB is 'disabled' only where it isn't configured (no Cloudflare key, +// so never downloaded). Configured but not loaded — still downloading, or +// the download failed — is an error. Exported for tests. +export const peeringdbReadiness = ({ configured = hasRadarApiKey, loaded = isPeeringdbLoaded } = {}) => () => { + if (!configured()) return false; + if (!loaded()) throw new Error('PeeringDB index not loaded'); + return true; }; export default async (req, res) => { @@ -36,15 +51,13 @@ export default async (req, res) => { fetchRadarAsn: (n) => loadAsnSummary(String(n)), fetchRadarPrefixes: (n) => RADAR_VIEWS['bgp-prefixes'].fetch({ asn: String(n) }), isPartialPrefixes: (body) => !isCompleteRadarAnswer(body), - getConnectivity: unlessLoading([isAsRelLoaded, isAsOrgLoaded], 'CAIDA snapshot', getAsnConnectivity), + getConnectivity: connectivityReadiness(), rdapAutnum: (n) => rdapAutnum(n, { timeoutMs: SOURCE_TIMEOUTS.autnum }), isAutnumMissing, queryAsRank: (n) => queryAsRank(n, { timeoutMs: SOURCE_TIMEOUTS.rank }), // Private-API pass-through: the caller's headers go upstream. requestReputation: (n) => requestAsnReputation(n, req.headers, { timeoutMs: SOURCE_TIMEOUTS.reputation }), - // The index only downloads with the Cloudflare key; without it the - // section is 'disabled', never loading. - isPeeringdbLoaded: unlessLoading([() => !hasRadarApiKey() || isPeeringdbLoaded()], 'PeeringDB index', isPeeringdbLoaded), + isPeeringdbLoaded: peeringdbReadiness(), lookupPeeringdb, }); const body = await composeAsnProfile(asn, loaders); diff --git a/common/as-org-db.js b/common/as-org-db.js index 88556d04..c0e9358e 100644 --- a/common/as-org-db.js +++ b/common/as-org-db.js @@ -72,6 +72,13 @@ function loadDatabase() { const text = fs.readFileSync(filePath, 'utf8'); asnToOrgName.clear(); parsePipeText(text); + // The parser skips what it can't read, so a corrupt file parses to + // nothing rather than throwing: that is not a loaded snapshot. + if (asnToOrgName.size === 0) { + loadedFrom = null; + logger.warn({ path: filePath }, '⚠️ CAIDA as2org snapshot holds no ASNs; treating it as missing'); + return; + } loadedFrom = path.basename(filePath); logger.info(`📦 CAIDA as2org loaded (${loadedFrom}) — ${asnToOrgName.size} ASNs in ${Date.now() - start}ms`); } catch (error) { diff --git a/common/as-rel-db.js b/common/as-rel-db.js index 3d793751..b521101b 100644 --- a/common/as-rel-db.js +++ b/common/as-rel-db.js @@ -114,6 +114,13 @@ function loadDatabase() { customerCount.clear(); parsePipeText(text); rebuildTier1Set(); + // A corrupt file parses to nothing rather than throwing: that is not + // a loaded snapshot. + if (providersIndex.size === 0 && peersIndex.size === 0) { + loadedFrom = null; + logger.warn({ path: filePath }, '⚠️ CAIDA as-rel snapshot holds no relationships; treating it as missing'); + return; + } loadedFrom = path.basename(filePath); logger.info(`📦 CAIDA as-rel loaded (${loadedFrom}) — ${providersIndex.size} customers, ${customerCount.size} providers, ${peersIndex.size} peering ASes, ${tier1Set.size} Tier 1s in ${Date.now() - start}ms`); } catch (error) { diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index 67a1b574..f24030a2 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -22,8 +22,8 @@ import invisibilityHandler from '../api/invisibility-test.js'; import macCheckerHandler from '../api/mac-checker.js'; import githubStarsHandler from '../api/github-stars.js'; import personaEvaluateHandler from '../api/persona.js'; -import asnProfileHandler from '../api/asn-profile.js'; -import { isPeeringdbLoaded } from '../common/peeringdb-db.js'; +import asnProfileHandler, { peeringdbReadiness, connectivityReadiness } from '../api/asn-profile.js'; +import { isAsRelLoaded } from '../common/as-rel-db.js'; import { isCompleteProfile } from '../common/asn-profile.js'; import updateAchievementHandler from '../api/update-user-achievement.js'; import ipcheckIngHandler from '../api/ipcheck-ing.js'; @@ -741,13 +741,29 @@ describe('asn-profile handler', () => { assert.equal(res.body.status.reputation, 'disabled'); assert.equal(res.body.status.whois, 'error'); assert.equal(res.body.status.rank, 'error'); - assert.ok(['ok', 'empty'].includes(res.body.status.connectivity)); + // Local: ok / empty with an as-rel snapshot, an error (never a + // cacheable empty) without one — a fresh checkout. + if (isAsRelLoaded()) assert.ok(['ok', 'empty'].includes(res.body.status.connectivity)); + else assert.equal(res.body.status.connectivity, 'error'); assert.equal(res.body.whois, null); assert.equal(res.body.rank, null); - // Local and never an error: disabled without an index file (the - // checkout default), ok / empty once the updater has built one. - if (isPeeringdbLoaded()) assert.ok(['ok', 'empty'].includes(res.body.status.peeringdb)); - else assert.equal(res.body.status.peeringdb, 'disabled'); + // No Cloudflare key: PeeringDB isn't configured here. + assert.equal(res.body.status.peeringdb, 'disabled'); + }); + + it('PeeringDB: disabled only when unconfigured; configured but not loaded is an error', () => { + assert.equal(peeringdbReadiness({ configured: () => false, loaded: () => false })(), false); + assert.equal(peeringdbReadiness({ configured: () => true, loaded: () => true })(), true); + assert.throws(() => peeringdbReadiness({ configured: () => true, loaded: () => false })(), /not loaded/); + }); + + it('connectivity: no as-rel snapshot is an error; as2org matters only while loading', async () => { + const fetch = async () => 'graph'; + const missingRel = connectivityReadiness({ relLoaded: () => false, orgLoaded: () => true, fetch }); + assert.throws(() => missingRel(13335), /as-rel snapshot not loaded/); + // as2org failed after boot: RIPEstat names the nodes, the graph is served. + const noOrg = connectivityReadiness({ relLoaded: () => true, orgLoaded: () => false, fetch }); + assert.equal(await noOrg(13335), 'graph'); }); it('shows a partial Radar summary but flags it incomplete (not cacheable)', async () => {