mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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.
This commit is contained in:
1 parent
ec1adbdc19
commit
b4067f9859
2 files changed
+87
-9
No files matched your search
@@ -25,6 +25,36 @@ let _openskyCacheMeta = null;
|
|||||||
let _openskyCacheSourceEpochMs = null;
|
let _openskyCacheSourceEpochMs = null;
|
||||||
/** TTL for the OpenSky response cache (ms). */
|
/** TTL for the OpenSky response cache (ms). */
|
||||||
const OPENSKY_CACHE_MS = 9000;
|
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<string, string>} 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) -------------------
|
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
|
||||||
// The global /states/all this proxy fetches costs 4 CREDITS per call against
|
// 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
|
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
|
||||||
@@ -107,6 +137,7 @@ export async function getOpenSkyToken() {
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
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)}`,
|
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -470,10 +501,7 @@ export function openSkyProxy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let upstream = await fetch(
|
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||||
if (
|
if (
|
||||||
(upstream.status === 401 || upstream.status === 403) &&
|
(upstream.status === 401 || upstream.status === 403) &&
|
||||||
@@ -485,15 +513,11 @@ export function openSkyProxy() {
|
|||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||||
};
|
};
|
||||||
upstream = await fetch(
|
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
|
||||||
{ headers: retryHeaders },
|
|
||||||
);
|
|
||||||
usedMode = 'basic';
|
usedMode = 'basic';
|
||||||
reason = 'oauth_rejected_fallback_basic';
|
reason = 'oauth_rejected_fallback_basic';
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = await upstream.text();
|
|
||||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||||
if (
|
if (
|
||||||
upstream.ok &&
|
upstream.ok &&
|
||||||
|
|||||||
@@ -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');
|
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) => {
|
test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => {
|
||||||
let now = Date.now();
|
let now = Date.now();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user