mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(status): preserve cached aircraft age and independent refresh errors
This commit is contained in:
1 parent
8eb6d09105
commit
dae5f45426
9 files changed
+174
-14
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user