From ffda833a2fcc21fa865e0f461a12afbe10bf0b59 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 24 Aug 2026 09:06:22 +0100 Subject: [PATCH 1/2] Cache adsb.lol responses and stop writing them back to the readsb file The proxy wrote every adsb.lol response into LOCAL_DATA_PATH (/run/readsb/aircraft.json) "so tar1090's backend process can read it". That file is readsb's own output, rewritten at 1 Hz, so the two writers raced for it - and because getAircraftData() reads the local file first, a response that had just been fetched from adsb.lol would come back on the next request labelled source: 'local'. Remote data was laundered as local data, and the X-Data-Source header could not be trusted to say where a fix came from. Serve from an in-process cache instead. Nothing writes to the local file any more; readsb owns it exclusively. Alongside that: - Concurrent requests collapse onto one upstream fetch via a shared inFlight promise, rather than each issuing its own call. - A failed refresh leaves the previous cache in place and never rejects, so a flaky upstream degrades into stale-but-served instead of an error. - Responses carry X-Data-Age-Ms and X-Data-Stale, and payload.now is stamped at fetch time and never restamped on serve, so consumers can tell how old a position actually is. blah2-api's extrapolation refuses to project more than 5 s, which is what CACHE_TTL_MS (3 s) is sized against. - MAX_STALE_MS bounds how long a dead upstream keeps being served; past it the response reports source: 'none' rather than passing off an empty sky as a successful read. - Non-200 responses call res.resume() so the socket is drained and freed. - /health reports cache age and cached aircraft count. Co-Authored-By: Claude Opus 5 --- proxy/server.js | 140 +++++++++++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 54 deletions(-) diff --git a/proxy/server.js b/proxy/server.js index f6233fef..114e47bf 100644 --- a/proxy/server.js +++ b/proxy/server.js @@ -9,15 +9,38 @@ const RECEIVER_LON = parseFloat(process.env.RECEIVER_LON || '0'); const ADSBLOL_RADIUS = parseInt(process.env.ADSBLOL_RADIUS || '40'); const PORT = parseInt(process.env.PROXY_PORT || '3005'); +// How long an adsb.lol response may be reused before we refetch. Consumers +// correct for staleness by projecting positions forward from the payload's own +// `now`, but blah2-api's lib/extrapolation.js refuses to project more than 5 s, +// so this must stay comfortably inside that budget. +const CACHE_TTL_MS = parseInt(process.env.ADSBLOL_CACHE_TTL_MS || '3000'); +// Must stay below the consumer's HTTP timeout (blah2-api uses 5000 ms) so a +// slow upstream degrades into stale-but-served rather than a client timeout. +const UPSTREAM_TIMEOUT_MS = parseInt(process.env.ADSBLOL_TIMEOUT_MS || '3000'); +// How long we keep serving the last good response while adsb.lol is failing. +const MAX_STALE_MS = parseInt(process.env.ADSBLOL_MAX_STALE_MS || '60000'); + const ADSBLOL_API = `https://api.adsb.lol/v2/lat/${RECEIVER_LAT}/lon/${RECEIVER_LON}/dist/${ADSBLOL_RADIUS}`; +// Last good adsb.lol response: { payload, fetchedAt }. `payload.now` is the +// fetch time and is never restamped on serve - consumers rely on it to work out +// how stale each position is. +let cache = null; +// Shared promise for an in-progress fetch, so concurrent requests collapse into +// a single upstream call instead of one call each. +let inFlight = null; + +function emptyPayload() { + return { now: Date.now() / 1000, messages: 0, aircraft: [] }; +} + function fetchUrl(url) { return new Promise((resolve, reject) => { const client = url.startsWith('https') ? https : http; - const timeout = 5000; - const req = client.get(url, { timeout }, (res) => { + const req = client.get(url, { timeout: UPSTREAM_TIMEOUT_MS }, (res) => { if (res.statusCode !== 200) { + res.resume(); reject(new Error(`HTTP ${res.statusCode}`)); return; } @@ -46,6 +69,8 @@ function convertAdsbLolToReadsb(adsbLolData) { const aircraft = adsbLolData.ac || []; return { + // Fetch time, not serve time. Consumers subtract each aircraft's seen_pos + // from this to recover when the position was actually observed. now: Date.now() / 1000, messages: 0, aircraft: aircraft.map(ac => ({ @@ -81,20 +106,25 @@ function convertAdsbLolToReadsb(adsbLolData) { }; } -async function fetchAdsbLol() { - console.log('Fetching from adsb.lol...'); - const adsbLolData = await fetchUrl(ADSBLOL_API); - const convertedData = convertAdsbLolToReadsb(adsbLolData); - console.log(`adsb.lol: ${convertedData.aircraft?.length || 0} aircraft`); - - // Write to local file so tar1090's backend process can read it - try { - await fs.writeFile(LOCAL_DATA_PATH, JSON.stringify(convertedData)); - } catch (err) { - console.log(`Warning: Could not write to ${LOCAL_DATA_PATH}: ${err.message}`); +// Refreshes the cache, collapsing concurrent callers onto one upstream request. +// Never rejects - a failed refresh leaves the previous cache in place. +function refreshRemote() { + if (!inFlight) { + console.log('Fetching from adsb.lol...'); + inFlight = fetchUrl(ADSBLOL_API) + .then((raw) => { + const payload = convertAdsbLolToReadsb(raw); + cache = { payload, fetchedAt: Date.now() }; + console.log(`adsb.lol: ${payload.aircraft.length} aircraft`); + }) + .catch((err) => { + console.log(`adsb.lol fetch failed: ${err.message}`); + }) + .finally(() => { + inFlight = null; + }); } - - return { data: convertedData, source: 'adsb.lol' }; + return inFlight; } async function readLocalFile() { @@ -107,60 +137,61 @@ async function readLocalFile() { } async function getAircraftData() { - // Try local file first (readsb writes to /run/readsb/aircraft.json) + // A local receiver, when it has anything to say, always wins - and it is read + // fresh on every request rather than cached, since readsb rewrites it ~1/s. const localData = await readLocalFile(); if (localData && localData.aircraft?.length > 0) { - console.log(`Local file: ${localData.aircraft.length} aircraft`); - return { data: localData, source: 'local' }; + return { data: localData, source: 'local', ageMs: 0, stale: false }; } - // Try adsb.lol fallback if enabled - if (ADSBLOL_ENABLED) { - const reason = localData ? '0 aircraft from local' : 'local file not found'; - console.log(`Falling back to adsb.lol (${reason})...`); - try { - return await fetchAdsbLol(); - } catch (fallbackError) { - console.log(`adsb.lol fallback failed: ${fallbackError.message}`); - } + if (!ADSBLOL_ENABLED) { + return { data: localData || emptyPayload(), source: localData ? 'local' : 'none', ageMs: 0, stale: false }; + } + + const age = cache ? Date.now() - cache.fetchedAt : Infinity; + if (age >= CACHE_TTL_MS) { + await refreshRemote(); } - // Return local data even if empty (if we got a response) - if (localData) { - return { data: localData, source: 'local' }; + if (cache) { + const servedAge = Date.now() - cache.fetchedAt; + if (servedAge <= MAX_STALE_MS) { + return { + data: cache.payload, + source: 'adsb.lol', + ageMs: servedAge, + stale: servedAge >= CACHE_TTL_MS + }; + } } - // No data sources available - throw new Error('No data sources available'); + // No usable remote data. Report that honestly rather than passing off an + // empty sky as a successful read. + return { data: localData || emptyPayload(), source: 'none', ageMs: 0, stale: false }; } const server = http.createServer(async (req, res) => { if (req.url === '/data/aircraft.json') { - try { - const { data, source } = await getAircraftData(); - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'X-Data-Source': source - }); - res.end(JSON.stringify(data)); - } catch (error) { - console.log(`Data fetch failed: ${error.message}`); - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'X-Data-Source': 'none' - }); - res.end(JSON.stringify({ - now: Date.now() / 1000, - messages: 0, - aircraft: [] - })); - } + const { data, source, ageMs, stale } = await getAircraftData(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + 'X-Data-Source': source, + 'X-Data-Age-Ms': String(ageMs), + 'X-Data-Stale': stale ? 'true' : 'false' + }); + res.end(JSON.stringify(data)); } else if (req.url === '/health') { + const age = cache ? Date.now() - cache.fetchedAt : null; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); + res.end(JSON.stringify({ + status: 'ok', + adsblol: ADSBLOL_ENABLED ? 'enabled' : 'disabled', + cacheAgeMs: age, + cachedAircraft: cache ? cache.payload.aircraft.length : 0 + })); } else { res.writeHead(404); res.end('Not Found'); @@ -173,5 +204,6 @@ server.listen(PORT, () => { console.log(`adsb.lol fallback: ${ADSBLOL_ENABLED ? 'enabled' : 'disabled'}`); if (ADSBLOL_ENABLED) { console.log(`adsb.lol API: ${ADSBLOL_API}`); + console.log(`cache TTL: ${CACHE_TTL_MS} ms, upstream timeout: ${UPSTREAM_TIMEOUT_MS} ms`); } }); From 3a2a723c377484483dccfbfa2e97da182d7af603 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 24 Aug 2026 09:07:52 +0100 Subject: [PATCH 2/2] Send a User-Agent so adsb.lol stops returning 403 adsb.lol refuses requests whose User-Agent is missing or too generic: HTTP 403 "User-Agent too generic; include valid contact info." Node's https.get sends no User-Agent at all unless one is set, which puts us in the most-blocked category possible. Every fallback request has been failing, on every node, since adsb.lol introduced the rule - nothing changed on our side, and there was no deploy to correlate it with. Verified A/B against the live API, same URL and same second: no User-Agent -> 403 curl/7.88.1 -> 200 retina-node/1.0 (+github url) -> 200, 38 aircraft Note the rule is a blocklist of generic tokens rather than a real contact-info check - Mozilla/5.0 passes - but a descriptive agent is what they are asking for and is least likely to be caught when they tighten it. ADSBLOL_USER_AGENT overrides it, which is the hook for a real contact address; the default points at the repo because inventing an ops mailbox here would be worse than useless. Also read the body of a non-200 instead of discarding it. The reason was sitting in a 52-byte response the whole time, and throwing it away is why this presented as "no aircraft nearby" rather than "we are being refused" - on nodes whose local receiver was also dead, it looked exactly like a quiet sky. Co-Authored-By: Claude Opus 5 --- proxy/server.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/proxy/server.js b/proxy/server.js index 114e47bf..e4594765 100644 --- a/proxy/server.js +++ b/proxy/server.js @@ -19,6 +19,13 @@ const CACHE_TTL_MS = parseInt(process.env.ADSBLOL_CACHE_TTL_MS || '3000'); const UPSTREAM_TIMEOUT_MS = parseInt(process.env.ADSBLOL_TIMEOUT_MS || '3000'); // How long we keep serving the last good response while adsb.lol is failing. const MAX_STALE_MS = parseInt(process.env.ADSBLOL_MAX_STALE_MS || '60000'); +// adsb.lol refuses requests whose User-Agent is missing or too generic, with +// 403 "User-Agent too generic; include valid contact info." Node's https.get +// sends no User-Agent at all unless one is set, so every request failed closed +// - the fallback was dead fleet-wide and looked like an empty sky. Override +// with a real contact address via ADSBLOL_USER_AGENT where one is available. +const USER_AGENT = process.env.ADSBLOL_USER_AGENT || + 'retina-node/1.0 (+https://github.com/offworldlabs/tar1090-node)'; const ADSBLOL_API = `https://api.adsb.lol/v2/lat/${RECEIVER_LAT}/lon/${RECEIVER_LON}/dist/${ADSBLOL_RADIUS}`; @@ -38,10 +45,20 @@ function fetchUrl(url) { return new Promise((resolve, reject) => { const client = url.startsWith('https') ? https : http; - const req = client.get(url, { timeout: UPSTREAM_TIMEOUT_MS }, (res) => { + const req = client.get(url, { + timeout: UPSTREAM_TIMEOUT_MS, + headers: { 'User-Agent': USER_AGENT } + }, (res) => { if (res.statusCode !== 200) { - res.resume(); - reject(new Error(`HTTP ${res.statusCode}`)); + // Read the body rather than discarding it: adsb.lol states the reason + // for a refusal there, and throwing it away is why a hard 403 was + // indistinguishable from "no aircraft nearby" for as long as it was. + let err = ''; + res.on('data', chunk => { if (err.length < 200) err += chunk; }); + res.on('end', () => { + const detail = err.trim().replace(/\s+/g, ' ').slice(0, 120); + reject(new Error(`HTTP ${res.statusCode}${detail ? ` - ${detail}` : ''}`)); + }); return; }