Merge pull request #1269 from bilawalsidhu/codex/fix-aircraft-refresh

fix(aircraft): coalesce refreshes and cool down OpenSky failures
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-22 23:23:12 +01:00
commit a18dd29262
5 files changed
+333 -166

No files matched your search

+1
View File
@@ -0,0 +1 @@
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
+6 -8
View File
@@ -9,14 +9,12 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery. search keeps a successful geocode, with no search or view-based region recovery.
Docker Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` Global OpenSky and adsb.lol military snapshot refreshes are single-flight through
from the shell or project `.env` before `scripts/docker-start.mjs` runs; shell body reading and cache publication. Each caller retains its own response and
values take precedence. The launcher's `none` policy resolves to an empty trusted viewport-specific regional fallback. OpenSky 5xx responses cancel their bodies,
peer list and is assigned before importing Vite, preventing later dotenv loading start a 15-second cooldown and serve dated last-good data with the upstream status
from replacing it. Unset or blank values retain automatic gateway discovery. in the stale reason. Cold failures use the existing regional fallback or a JSON
Startup logs distinguish an explicit `none` policy from a missing default route. upstream error; no error HTML is relayed as JSON.
This changes neither loopback-only port publishing nor the Provider Settings
Host, Origin, proxy-header and sharing checks.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
+64 -47
View File
@@ -1,4 +1,7 @@
import { readResponseTextCapped } from '../common/http.js'; import {
coalesceProxyRequest,
readResponseTextCapped,
} from '../common/http.js';
/** /**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
@@ -19,6 +22,7 @@ import { readResponseTextCapped } from '../common/http.js';
export function adsbLolProxy() { export function adsbLolProxy() {
/** @type {string|null} Cached upstream JSON body. */ /** @type {string|null} Cached upstream JSON body. */
let _cache = null; let _cache = null;
const inFlight = new Map();
/** @type {number} Epoch-ms when the cache was populated. */ /** @type {number} Epoch-ms when the cache was populated. */
let _cacheAt = 0; let _cacheAt = 0;
/** @type {number} Epoch-ms until which upstream is not contacted. */ /** @type {number} Epoch-ms until which upstream is not contacted. */
@@ -104,60 +108,73 @@ export function adsbLolProxy() {
); );
return; return;
} }
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); const refresh = coalesceProxyRequest(inFlight, 'global', async () => {
const upstream = await fetch('https://api.adsb.lol/v2/mil', { const result = (status, body, cacheStatus, extra = {}) => ({
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, status,
signal, body,
}); cacheStatus,
if (upstream.ok) { extra,
});
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
signal,
});
if (upstream.ok) {
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
_cache = body;
_cacheAt = Date.now();
_cooldownUntil = 0;
_cooldownStatus = 0;
return result(200, body, 'MISS');
}
if (upstream.status === 429 || upstream.status >= 500) {
const failedAt = Date.now();
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
_cooldownStatus = upstream.status;
console.warn(
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
);
if (_cache) {
// Do not wait for a failing response body before serving usable data.
upstream.body?.cancel().catch(() => {});
return result(200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
});
}
}
const body = await readResponseTextCapped( const body = await readResponseTextCapped(
upstream, upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES, ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal, signal,
); );
_cache = body; return result(
_cacheAt = Date.now(); upstream.status,
_cooldownUntil = 0; body,
_cooldownStatus = 0; 'MISS',
serve(res, 200, body, 'MISS'); _cooldownUntil > Date.now()
return; ? {
} 'Retry-After': String(
if (upstream.status === 429 || upstream.status >= 500) { Math.ceil((_cooldownUntil - Date.now()) / 1000),
const failedAt = Date.now(); ),
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt); }
_cooldownStatus = upstream.status; : {},
console.warn(
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
); );
if (_cache) { });
// Do not wait for a failing response body before serving usable data. const response = await refresh.promise;
upstream.body?.cancel().catch(() => {});
serve(res, 200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
});
return;
}
}
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
serve( serve(
res, res,
upstream.status, response.status,
body, response.body,
'MISS', response.cacheStatus,
_cooldownUntil > Date.now() response.extra,
? {
'Retry-After': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
}
: {},
); );
} catch (e) { } catch (e) {
console.error('[adsb.lol Proxy]', e.message); console.error('[adsb.lol Proxy]', e.message);
+141 -111
View File
@@ -67,15 +67,24 @@ async function fetchOpenSkyStates(headers) {
try { try {
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
if (upstream.status === 429) { if (upstream.status === 429 || upstream.status >= 500) {
// Start the cooldown from the headers and drop the rejected body // Start the cooldown from the headers and drop the rejected body
// unread, so a stalled or oversized 429 body cannot skip it. // unread, so a stalled or oversized 429 body cannot skip it.
_openskyCooldownUntil = _openskyCooldownUntil =
Date.now() + openSkyRateLimitCooldownMs(upstream); Date.now() +
(upstream.status === 429
? openSkyRateLimitCooldownMs(upstream)
: 15000);
_openskyCooldownStatus = upstream.status;
void upstream.body?.cancel().catch(() => {}); void upstream.body?.cancel().catch(() => {});
return { return {
upstream, upstream,
body: JSON.stringify({ error: 'OpenSky rate limited.' }), body: JSON.stringify({
error:
upstream.status === 429
? 'OpenSky rate limited.'
: 'OpenSky upstream unavailable.',
}),
}; };
} }
const body = await readResponseTextCapped( const body = await readResponseTextCapped(
@@ -109,6 +118,8 @@ async function fetchOpenSkyStates(headers) {
let _openskyTtlMs = OPENSKY_CACHE_MS; let _openskyTtlMs = OPENSKY_CACHE_MS;
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */ /** @type {number} Epoch-ms before which no upstream fetch is attempted. */
let _openskyCooldownUntil = 0; let _openskyCooldownUntil = 0;
let _openskyCooldownStatus = 429;
const _openskySnapshotInFlight = new Map();
/** /**
* Picks the cache TTL from the remaining daily credit budget. * Picks the cache TTL from the remaining daily credit budget.
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
@@ -460,7 +471,9 @@ export function openSkyProxy() {
requestedMode: cachedMeta.requestedMode || requestedMode, requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown', usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale reason: isStale
? 'rate_limited_serving_stale' ? _openskyCooldownStatus === 429
? 'rate_limited_serving_stale'
: `upstream_http_${_openskyCooldownStatus}_serving_stale`
: cachedMeta.reason || 'cached', : cachedMeta.reason || 'cached',
staleSeconds: isStale staleSeconds: isStale
? (now - _openskyCacheTime) / 1000 ? (now - _openskyCacheTime) / 1000
@@ -487,80 +500,140 @@ export function openSkyProxy() {
) )
return; return;
res.writeHead( res.writeHead(
429, _openskyCooldownStatus,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
cacheStatus: 'COOLDOWN', cacheStatus: 'COOLDOWN',
requestedMode, requestedMode,
usedMode: 'none', usedMode: 'none',
reason: 'rate_limited', reason:
_openskyCooldownStatus === 429
? 'rate_limited'
: `upstream_http_${_openskyCooldownStatus}`,
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
}), }),
); );
res.end( res.end(
JSON.stringify({ JSON.stringify({
error: 'OpenSky rate limited; proxy cooling down.', error: 'OpenSky upstream unavailable; proxy cooling down.',
}), }),
); );
return; return;
} }
const basicUser = process.env.OPENSKY_USERNAME || ''; const refresh = coalesceProxyRequest(
const basicPass = process.env.OPENSKY_PASSWORD || ''; _openskySnapshotInFlight,
const hasBasicCreds = Boolean(basicUser && basicPass); 'global',
const headers = { Accept: 'application/json' }; async () => {
let usedMode = 'anon'; const basicUser = process.env.OPENSKY_USERNAME || '';
let reason = 'forced_anonymous'; const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') { if (requestedMode === 'basic') {
if (hasBasicCreds) { if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic'; usedMode = 'basic';
reason = 'basic_credentials'; reason = 'basic_credentials';
} else { } else {
reason = 'missing_basic_creds'; reason = 'missing_basic_creds';
} }
} else if (requestedMode === 'oauth') { } else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken(); const token = await getOpenSkyToken();
if (token) { if (token) {
headers.Authorization = `Bearer ${token}`; headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth'; usedMode = 'oauth';
reason = 'oauth_token'; reason = 'oauth_token';
} else { } else {
reason = 'oauth_invalid_or_missing'; reason = 'oauth_invalid_or_missing';
} }
} else if (requestedMode === 'auto') { } else if (requestedMode === 'auto') {
const token = await getOpenSkyToken(); const token = await getOpenSkyToken();
if (token) { if (token) {
headers.Authorization = `Bearer ${token}`; headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth'; usedMode = 'oauth';
reason = 'oauth_token'; reason = 'oauth_token';
} else if (hasBasicCreds) { } else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic'; usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic'; reason = 'oauth_unavailable_fallback_basic';
} else { } else {
reason = 'missing_oauth_and_basic_creds'; reason = 'missing_oauth_and_basic_creds';
} }
} }
let { upstream, body } = await fetchOpenSkyStates(headers); let { upstream, body } = await fetchOpenSkyStates(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) &&
requestedMode === 'auto' && requestedMode === 'auto' &&
usedMode === 'oauth' && usedMode === 'oauth' &&
hasBasicCreds hasBasicCreds
) { ) {
const retryHeaders = { const retryHeaders = {
Accept: 'application/json', Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
}; };
({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); ({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic'; usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic'; reason = 'oauth_rejected_fallback_basic';
} }
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; const sourceEpochMs = upstream.ok
? openSkySourceEpochMs(body)
: null;
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
return {
upstream,
body,
usedMode,
reason,
hasBasicCreds,
sourceEpochMs,
};
},
);
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
await refresh.promise;
if ( if (
upstream.ok && upstream.ok &&
openSkySourceIsStale(sourceEpochMs, now) && openSkySourceIsStale(sourceEpochMs, now) &&
@@ -571,20 +644,16 @@ export function openSkyProxy() {
'opensky_snapshot_stale_regional_fallback', 'opensky_snapshot_stale_regional_fallback',
)) ))
) { ) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = { requestedMode, usedMode, reason };
return; return;
} }
if (upstream.status === 429) { if (upstream.status === 429 || upstream.status >= 500) {
reason = 'rate_limited'; reason =
upstream.status === 429
? 'rate_limited'
: `upstream_http_${upstream.status}`;
// Credit governor: fetchOpenSkyStates already started the cooldown // Credit governor: fetchOpenSkyStates already started the cooldown
// from OpenSky's retry-after — no upstream attempts until then. // from OpenSky's retry-after — no upstream attempts until then.
const cooldownMs = openSkyRateLimitCooldownMs(upstream); const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now());
// Serve the last-good body instead of the 429 when we have one — // Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying. // the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) { if (_openskyCacheBody && _openskyCacheStatus === 200) {
@@ -594,7 +663,7 @@ export function openSkyProxy() {
cacheStatus: 'STALE', cacheStatus: 'STALE',
requestedMode, requestedMode,
usedMode, usedMode,
reason: 'rate_limited_serving_stale', reason: `${reason}_serving_stale`,
staleSeconds: (now - _openskyCacheTime) / 1000, staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000, retryAfterSeconds: cooldownMs / 1000,
}), }),
@@ -652,45 +721,6 @@ export function openSkyProxy() {
} }
} }
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
res.writeHead( res.writeHead(
upstream.status, upstream.status,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
+121
View File
@@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(res2.statusCode, 502); assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1); assert.equal(callCount, 1);
}); });
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'error', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['opensky', 'military']) {
const module =
kind === 'opensky'
? await import(
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
)
: await import('../../server/providers/aircraft/adsb-lol.js');
const request = install(
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
);
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
let calls = 0;
let release;
let blocked;
let fail = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
await blocked;
if (fail) throw new Error('fixture connection failed');
return Response.json(
{ time: now / 1000, states: [], ac: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
now += 13000;
fail = phase === 'failed';
blocked = new Promise((resolve) => {
release = resolve;
});
const before = calls;
const pending = Array.from({ length: 5 }, () => request(route));
await new Promise(setImmediate);
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
release();
const responses = await Promise.all(pending);
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
assert.ok(responses.every((res) => res.statusCode === 200));
assert.ok(responses.every((res) => res.body === responses[0].body));
}
}
});
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const status of [502, 503, 504]) {
const module = await import(
`../../server/providers/aircraft/opensky.js?server-error=${status}`
);
const request = install(module.openSkyProxy());
let fail = false;
let calls = 0;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{ status },
);
return Response.json(
{ time: now / 1000, states: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
const good = await request('/api/flights');
now += 10000;
fail = true;
const stale = await request('/api/flights');
assert.equal(stale.statusCode, 200);
assert.equal(stale.body, good.body);
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
assert.equal(
stale.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
assert.equal(cancelled, true);
const cooling = await request('/api/flights');
assert.equal(cooling.body, good.body);
assert.equal(
cooling.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(calls, 2);
now += 16000;
fail = false;
assert.equal(
(await request('/api/flights')).headers['x-opensky-cache'],
'MISS',
);
assert.equal(calls, 3);
}
const coldModule =
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('<html>failure</html>', { status: 503 });
});
const cold = install(coldModule.openSkyProxy());
for (let i = 0; i < 2; i++) {
const response = await cold('/api/flights');
assert.equal(response.statusCode, 503);
assert.match(JSON.parse(response.body).error, /OpenSky/);
}
assert.equal(calls, 1);
});