From b4067f98595ab7eb4edd6e181396fab251b9e708 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:23:44 -0700 Subject: [PATCH] fix: time-limit OpenSky snapshot requests and retry once A stalled global snapshot held the request for over a minute. Each attempt now gets 10 seconds, a timed-out or failed attempt is retried once, and a second failure is answered from the stale cache or the regional fallback. The OAuth token request gets the same limit. --- server/providers/aircraft/opensky.js | 42 +++++++++++++++++----- src/tooling/liveProviders.test.mjs | 54 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..db4920668 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -25,6 +25,36 @@ let _openskyCacheMeta = null; let _openskyCacheSourceEpochMs = null; /** TTL for the OpenSky response cache (ms). */ const OPENSKY_CACHE_MS = 9000; +/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */ +const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000; +const OPENSKY_STATES_URL = + 'https://opensky-network.org/api/states/all?extended=1'; + +/** + * Fetch and read the global snapshot, giving each attempt + * OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or + * the connection fails. A second failure throws, which the proxy answers + * from its stale cache or the regional fallback. + * @param {Record} headers Request headers. + * @returns {Promise<{upstream: Response, body: string}>} + */ +async function fetchOpenSkyStates(headers) { + for (let attempt = 1; ; attempt++) { + try { + const upstream = await fetch(OPENSKY_STATES_URL, { + headers, + signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS), + }); + return { upstream, body: await upstream.text() }; + } catch (error) { + if (attempt >= 2) throw error; + console.warn( + '[OpenSky] global snapshot attempt failed, retrying:', + error?.message || error, + ); + } + } +} // --- OpenSky credit governor (field-test fix 2026-07-06) ------------------- // The global /states/all this proxy fetches costs 4 CREDITS per call against // OpenSky's ~4000/day authenticated budget — a day with the app open burned @@ -107,6 +137,7 @@ export async function getOpenSkyToken() { { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS), body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`, }, ); @@ -470,10 +501,7 @@ export function openSkyProxy() { } } - let upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers }, - ); + let { upstream, body } = await fetchOpenSkyStates(headers); // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials if ( (upstream.status === 401 || upstream.status === 403) && @@ -485,15 +513,11 @@ export function openSkyProxy() { Accept: 'application/json', Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, }; - upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers: retryHeaders }, - ); + ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); usedMode = 'basic'; reason = 'oauth_rejected_fallback_basic'; } - let body = await upstream.text(); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; if ( upstream.ok && diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..ae136b994 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -139,6 +139,60 @@ test('OpenSky state and track routes share tokens, retain cache and use regional assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123'); }); +test('OpenSky global snapshot attempts are time-limited, retried once, then fall back', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, + }); + t.mock.method(console, 'warn', () => {}); + const timedOut = () => + Promise.reject( + new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError', + ), + ); + const load = async (respond) => { + const attempts = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + assert.ok(options.signal instanceof AbortSignal); + attempts.push(url); + return respond(attempts.length); + } + if (url.includes('/lat/')) + return Response.json({ + now: Date.now() / 1000, + ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }], + }); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?timeout=${Math.random()}` + ); + const response = await install(fresh.openSkyProxy())( + '/api/opensky', + '?lat=30&lon=-97', + ); + return { response, attempts }; + }; + const recovered = await load((attempt) => + attempt === 1 + ? timedOut() + : Response.json({ time: Math.floor(Date.now() / 1000), states: [] }), + ); + assert.equal(recovered.attempts.length, 2); + assert.equal(recovered.response.statusCode, 200); + assert.equal(recovered.response.headers['x-flight-source'], undefined); + const stalled = await load(() => timedOut()); + assert.equal(stalled.attempts.length, 2); + assert.equal(stalled.response.statusCode, 200); + assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol'); +}); + test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => { let now = Date.now(); let calls = 0;