From 9837379a1b199aac34a0bef74751cb0ce6ad945b 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 | 142 ++++++++++++++++++++++++++++++------------------ 1 file changed, 88 insertions(+), 54 deletions(-) diff --git a/proxy/server.js b/proxy/server.js index 2891a979..bedafaac 100644 --- a/proxy/server.js +++ b/proxy/server.js @@ -9,6 +9,16 @@ 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'); // 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 @@ -19,16 +29,31 @@ const USER_AGENT = process.env.ADSBLOL_USER_AGENT || 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, headers: { 'User-Agent': USER_AGENT } }, (res) => { + const req = client.get(url, { + timeout: UPSTREAM_TIMEOUT_MS, + headers: { 'User-Agent': USER_AGENT } + }, (res) => { if (res.statusCode !== 200) { // 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. + // Consuming it also drains the socket, which res.resume() did before. let err = ''; res.on('data', chunk => { if (err.length < 200) err += chunk; }); res.on('end', () => { @@ -62,6 +87,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 => ({ @@ -97,20 +124,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() { @@ -123,60 +155,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'); @@ -189,5 +222,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 8bbf79a9ab9b1bfd6f597e2ae7c3a3db8c2e89e2 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 24 Aug 2026 09:42:21 +0100 Subject: [PATCH 2/2] Throttle upstream retries while adsb.lol is failing Review catch on #19. The refresh gate compared against cache.fetchedAt, which is only stamped on a *successful* fetch: const age = cache ? Date.now() - cache.fetchedAt : Infinity; if (age >= CACHE_TTL_MS) await refreshRemote(); So while adsb.lol is failing, `cache` stays null, `age` stays Infinity, and every request starts its own fetch. inFlight collapses concurrent callers but not sequential ones, so the cache halved upstream load when things worked and did nothing at all when they did not - reverting to the full client poll rate precisely when adsb.lol is least able to serve it. That is the opposite of what this PR is for, and it is not hypothetical: jonathan-node-1 was measured at ~33 upstream requests/min through the 403 outage, against ~14/min once the cache was being populated. Track lastAttemptAt, set when an attempt begins regardless of outcome, and refresh only when the cached payload has aged out AND no attempt has been made within the same window. Measured against the live API, 20 requests over 10 s with the upstream deliberately 403-ing (ADSBLOL_USER_AGENT set to a blocked token): before 20 upstream attempts (one per request) after 4 upstream attempts (one per 3 s TTL) Success path unchanged: 6 requests over 6 s produce 2 upstream fetches, and the served payload is unaffected. Also documents the four ADSBLOL_* tuning vars in .env.example, which already carried ADSBLOL_ENABLED and ADSBLOL_RADIUS, and records at the call site that getAircraftData() is contracted never to throw - an invariant currently spread across readLocalFile() and refreshRemote() with nothing enforcing it. Co-Authored-By: Claude Opus 5 --- .env.example | 20 ++++++++++++++++++++ proxy/server.js | 22 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 2c910898..26c29720 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,23 @@ ADSBLOL_ENABLED=true # Radius in nautical miles for adsb.lol queries # Aircraft within this radius of your receiver location will be fetched ADSBLOL_RADIUS=40 + +# User-Agent sent to adsb.lol. They reject missing or generic agents with +# 403 "User-Agent too generic; include valid contact info." Set this to +# something that identifies you and carries a real contact address. +ADSBLOL_USER_AGENT=retina-node/1.0 (+https://github.com/offworldlabs/tar1090-node) + +# How long an adsb.lol response may be reused before refetching (ms). +# Also throttles retries while adsb.lol is failing. Consumers project +# positions forward from the payload's own `now`, and blah2-api refuses to +# extrapolate more than 5 s, so keep this comfortably inside that budget. +ADSBLOL_CACHE_TTL_MS=3000 + +# Upstream request timeout (ms). Must stay below the consumer's HTTP timeout +# (blah2-api uses 5000) so a slow upstream degrades into stale-but-served. +ADSBLOL_TIMEOUT_MS=3000 + +# How long the last good response keeps being served while adsb.lol is +# failing (ms). Past this the proxy reports source 'none' rather than +# passing off arbitrarily old data as current. +ADSBLOL_MAX_STALE_MS=60000 diff --git a/proxy/server.js b/proxy/server.js index bedafaac..609da7d7 100644 --- a/proxy/server.js +++ b/proxy/server.js @@ -36,6 +36,10 @@ 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; +// When the last upstream attempt started, recorded regardless of outcome. +// cache.fetchedAt only advances on success, so gating refreshes on it alone +// leaves a failing upstream unthrottled - see getAircraftData(). +let lastAttemptAt = 0; function emptyPayload() { return { now: Date.now() / 1000, messages: 0, aircraft: [] }; @@ -128,6 +132,7 @@ function convertAdsbLolToReadsb(adsbLolData) { // Never rejects - a failed refresh leaves the previous cache in place. function refreshRemote() { if (!inFlight) { + lastAttemptAt = Date.now(); console.log('Fetching from adsb.lol...'); inFlight = fetchUrl(ADSBLOL_API) .then((raw) => { @@ -167,8 +172,16 @@ async function getAircraftData() { 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) { + // Refresh only when the cached payload has aged out AND we have not already + // tried within this window. Without the second condition a failing upstream + // never advances cache.fetchedAt, so `age` stays Infinity and every request + // starts its own fetch - inFlight collapses concurrent callers but not + // sequential ones. That reverts to the full client poll rate precisely when + // adsb.lol is least able to serve it: measured at ~33 upstream requests/min + // during the 403 outage, against ~14/min once the cache was being populated. + const cacheAge = cache ? Date.now() - cache.fetchedAt : Infinity; + const sinceAttempt = Date.now() - lastAttemptAt; + if (cacheAge >= CACHE_TTL_MS && sinceAttempt >= CACHE_TTL_MS) { await refreshRemote(); } @@ -191,6 +204,11 @@ async function getAircraftData() { const server = http.createServer(async (req, res) => { if (req.url === '/data/aircraft.json') { + // getAircraftData() is contracted never to throw or reject: readLocalFile() + // swallows its own errors and refreshRemote() carries an internal .catch. + // That contract is what makes it safe to call without a try/catch here - in + // an async http.createServer callback an unhandled rejection surfaces as an + // unhandledRejection rather than a clean response. Keep it if you edit them. const { data, source, ageMs, stale } = await getAircraftData(); res.writeHead(200, { 'Content-Type': 'application/json',