diff --git a/CHANGELOG.md b/CHANGELOG.md index df26ae5d3..cd2c4ccdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Reuse cached military aircraft during adsb.lol rate limits and server errors, + honor bounded retry delays, and preserve cached observation times and stale + indicators. Show installation zoom guidance without a false LOAD FAILED. + - GBFS rejects upstream redirects, caps streamed responses at 5 MiB, and keeps its deadline active through body reads. Rejected downloads are cancelled. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bc8ae4646..53ff64060 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## Military feed cooldown and loading guidance + +The adsb.lol military proxy reuses its last response during upstream 429/5xx +failures and observes Retry-After, bounded to 5–120 seconds (defaults: 30 seconds +for rate limits, 15 seconds for server errors). A failure with no cached data +still reports an error. Cached responses carry their age; the browser preserves +observation timestamps and marks fallback data stale instead of inventing fresh +positions or reporting a failed load. Fresh responses clear that stale state. + +Mapped-installation zoom guidance appears in the layer row without counting as +a global loading failure. Guidance statuses do not suppress independent refresh +errors. + + ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 12477b9e9..fc5ab0bd3 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -56,6 +56,11 @@ export function adsbLolProxy() { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-ADS-B-Cache': cacheStatus, + ...(['HIT', 'STALE'].includes(cacheStatus) + ? { + 'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)), + } + : {}), ...extra, }); res.end(body); @@ -70,7 +75,9 @@ export function adsbLolProxy() { return; } if (now < _cooldownUntil) { - const retryAfter = String(Math.ceil((_cooldownUntil - now) / 1000)); + const retryAfter = String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ); if (_cache) { serve(res, 200, _cache, 'STALE', { 'X-ADS-B-Upstream-Status': String(_cooldownStatus), @@ -90,32 +97,48 @@ export function adsbLolProxy() { const upstream = await fetch('https://api.adsb.lol/v2/mil', { headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, }); - const body = await upstream.text(); if (upstream.ok) { + const body = await upstream.text(); _cache = body; - _cacheAt = now; + _cacheAt = Date.now(); _cooldownUntil = 0; _cooldownStatus = 0; serve(res, 200, body, 'MISS'); return; } if (upstream.status === 429 || upstream.status >= 500) { - _cooldownUntil = now + cooldownFor(upstream, now); + 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 - now) / 1000)} s${_cache ? ', serving stale' : ''}`, + `[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(() => {}); serve(res, 200, _cache, 'STALE', { 'X-ADS-B-Upstream-Status': String(upstream.status), 'X-ADS-B-Retry-After-Seconds': String( - Math.ceil((_cooldownUntil - now) / 1000), + Math.ceil((_cooldownUntil - Date.now()) / 1000), ), }); return; } } - serve(res, upstream.status, body, 'MISS'); + const body = await upstream.text(); + serve( + res, + upstream.status, + body, + 'MISS', + _cooldownUntil > Date.now() + ? { + 'Retry-After': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + } + : {}, + ); } catch (e) { console.error('[adsb.lol Proxy]', e.message); if (_cache) { diff --git a/src/data/manager.js b/src/data/manager.js index 97ed39d45..9ba4ae24c 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -81,7 +81,7 @@ export function layerFeedState(stats = {}) { if ( (presentedError || state.unavailable === true || state.available === false) && !hasPriorData - && !['zoom-in', 'empty', 'idle'].includes(status) + && !GUIDANCE_STATUSES.includes(status) ) { return 'unavailable'; } @@ -90,7 +90,7 @@ export function layerFeedState(stats = {}) { // operation, not feed faults. One honesty carve-out: layers keep their // rendered records through the guidance state, so a genuinely stale cache // still reads STALE; a guidance prompt alone never reads DEGRADED. - if (['zoom-in', 'empty', 'idle'].includes(status)) { + if (GUIDANCE_STATUSES.includes(status)) { return state.stale ? 'stale' : 'nominal'; } if ( diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index cdacb64ef..220747dcd 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -2833,7 +2833,9 @@ const militaryFlightsLayer = { _retryAt = 0; _lastError = null; const currentIcaos = new Set(); - const receiptNowMs = Date.now(); + const cacheAgeMs = Number(response.headers?.get?.('x-ads-b-cache-age-ms')); + const receiptNowMs = Date.now() - (Number.isFinite(cacheAgeMs) && cacheAgeMs > 0 ? cacheAgeMs : 0); + _backoff = response.headers?.get?.('x-ads-b-cache') === 'STALE'; // Field-test fix (RS46): coarse floor cells to warm for the below-ground // clamp — collected during the loop (low airborne contacts only), // batch-resolved once after it. Never a fetch inside the loop. @@ -3215,7 +3217,7 @@ const militaryFlightsLayer = { registerMilitaryIcaos(currentIcaos); _count = _billboards.size; - _lastUpdate = Date.now(); + _lastUpdate = receiptNowMs; _lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, status: 'accepted', diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 74ce13832..122b6b132 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -219,6 +219,49 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p } }); +test('cached military snapshots retain their observation time and expose stale fallback', async (t) => { + let now = 1_800_000_000_000; + const receivedAt = now; + const history = []; + const viewer = { camera: { positionCartographic: null }, scene: {} }; + _setTrackedMilitaryRefreshStateForTest({ + icao24: 'ae01ce', + entity: { gevLabelModel: { title: 'RCH451', details: [] } }, + billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false }, + billboardCollection: { show: false, remove() {} }, + viewer, + history, + meta: { rawLat: 31, rawLon: -97, onGround: false }, + }); + let cache = 'MISS'; + t.mock.method(Date, 'now', () => now); + t.mock.method(globalThis, 'fetch', async () => Response.json({ + ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000, + track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }], + }, { headers: { + 'X-ADS-B-Cache': cache, + ...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }), + } })); + await militaryFlightsLayer.update(viewer); + assert.equal(history.length, 1); + assert.equal(history[0].epochMs, receivedAt - 2000); + for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) { + cache = status; + now = receivedAt + age; + await militaryFlightsLayer.update(viewer); + assert.equal(history.length, 1, 'replayed positions cannot create a new fix'); + assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt); + assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); + assert.equal(militaryFlightsLayer.getStats().error, null); + } + cache = 'MISS'; + now += 15000; + await militaryFlightsLayer.update(viewer); + assert.equal(history.length, 2, 'fresh data resumes position updates'); + assert.equal(militaryFlightsLayer.getStats().lastUpdate, now); + assert.equal(militaryFlightsLayer.getStats().stale, false); +}); + test('real military track path creates no native label and publishes every cached host line', () => { const icao24 = 'ae01ce'; const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 8_534.4); diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index 226e6177e..a5540e1ce 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -26,9 +26,8 @@ export function normalizeLayerLoading(layer = {}) { // normal operation, never a batch failure — mirrors layerFeedState's carve-out // so a prompt stored alongside the status cannot turn the chip red. const guidance = GUIDANCE_STATUSES.includes(status); - const error = stoppingInstallations || guidance - ? null - : stats.error || stats.lastError || stats.managerRefreshError || null; + const error = stoppingInstallations ? null + : (!guidance && stats.error) || stats.lastError || stats.managerRefreshError || null; const unavailable = !stoppingInstallations && (stats.unavailable === true || stats.available === false || ['unavailable', 'offline', 'down', 'error'].includes(status)); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 6b837169e..7537efb06 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -699,3 +699,14 @@ test('a guidance status such as zoom-in never counts as a participant failure', assert.equal(state.terminal, 'complete'); assert.equal(presentLoadingFeedback(state, settled, 1500).label, 'MAPPED SITES LOADED'); }); + + +test('guidance does not suppress independent manager and feed failures', () => { + for (const field of ['lastError', 'managerRefreshError']) { + const record = normalizeLayerLoading({ + id: 'militaryInstallations', enabled: true, + stats: { status: 'zoom-in', error: 'Zoom in to load mapped sites.', [field]: 'Network unavailable' }, + }); + assert.equal(record.error, 'Network unavailable'); + } +}); diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index eb7f5bc00..250924b42 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -258,10 +258,12 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do assert.equal(limited.body, first.body); assert.equal(limited.headers['x-ads-b-cache'], 'STALE'); assert.equal(limited.headers['x-ads-b-upstream-status'], '429'); + assert.equal(limited.headers['x-ads-b-cache-age-ms'], '13000'); now += 5_000; const cooling = await request('/api/adsblol/mil'); assert.equal(calls, 2, 'no upstream call inside the Retry-After window'); assert.equal(cooling.headers['x-ads-b-cache'], 'STALE'); + assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000'); now += 16_000; await request('/api/adsblol/mil'); assert.equal(calls, 3, 'upstream is retried once Retry-After elapses'); @@ -277,6 +279,7 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as const request = install(providers.adsbLolProxy()); const limited = await request('/api/adsblol/mil'); assert.equal(limited.statusCode, 429); + assert.ok(limited.headers['retry-after']); const again = await request('/api/adsblol/mil'); assert.equal(again.statusCode, 429); assert.equal( @@ -289,3 +292,64 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as 'a cooling-down miss carries Retry-After', ); }); + +test('military fallback cancels a stalled 5xx body and starts cooldown at receipt', async (t) => { + let now = 1_800_000_000_000; + let calls = 0; + let cancelled = false; + t.mock.method(Date, 'now', () => now); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + if (++calls === 1) return Response.json({ ac: [] }); + now += 10000; + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + status: 503, + headers: { 'Retry-After': new Date(now + 20000).toUTCString() }, + }, + ); + }); + const request = install(providers.adsbLolProxy()); + await request('/api/adsblol/mil'); + now += 1000; + const hit = await request('/api/adsblol/mil'); + assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000'); + now += 12000; + const fallback = await request('/api/adsblol/mil'); + assert.equal(fallback.statusCode, 200); + assert.equal(cancelled, true); + assert.equal(fallback.headers['x-ads-b-cache-age-ms'], '23000'); + assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20'); + now += 19000; + await request('/api/adsblol/mil'); + assert.equal(calls, 2); + now += 2000; + await request('/api/adsblol/mil'); + assert.equal(calls, 3); +}); + +test('military cooldown bounds untrusted Retry-After and defaults server errors', async (t) => { + t.mock.method(console, 'warn', () => {}); + for (const [status, raw, seconds] of [ + [429, '1', 5], + [429, '99999', 120], + [503, 'invalid', 15], + ]) { + t.mock.method( + globalThis, + 'fetch', + async () => + new Response('{}', { + status, + headers: { 'Retry-After': raw }, + }), + ); + const result = await install(providers.adsbLolProxy())('/api/adsblol/mil'); + assert.equal(Number(result.headers['retry-after']), seconds); + } +});