From 6bdedd852322a0e8838bcae2811a6613d20330ab Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:33:10 -0800 Subject: [PATCH] fix(drone-airspace): refuse empty or incompletely paged FAA snapshots An upstream answer whose zones all fail validation normalized to an empty list, and paging that stopped at the page limit while the service still flagged more records was accepted as complete. Both were cached as a fresh snapshot for an hour and replaced the last good one. Treat them as failed refreshes so stale-on-error keeps serving the previous snapshot. --- docs/CURRENT-STATE.md | 4 +- server/providers/droneAirspace.js | 13 ++++- src/data/droneAirspaceProxy.test.mjs | 77 ++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d24b793bc..259b92ce6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4100,7 +4100,9 @@ Fire Perimeters uses capped, timed server reads with stale-on-error caching and Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last -good payload as stale when a refresh fails. Client-supplied URLs are rejected. +good payload as stale when a refresh fails. An answer with no valid zone, or +paging that ends while the service still flags more records, counts as a +failed refresh. Client-supplied URLs are rejected. Polygons keep holes; pick ownership is deliberately unregistered so sibling cards still work over a zone. The row legend and selected card state that the layer is informational only and not a substitute for B4UFLY / LAANC. diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js index 731852c2e..3109d5984 100644 --- a/server/providers/droneAirspace.js +++ b/server/providers/droneAirspace.js @@ -61,8 +61,12 @@ export function droneAirspaceProxy({ return readResponseJsonCapped(response, cap, signal); } + // A snapshot is accepted only once the service says it is complete. A page + // limit reached while still flagged, a flagged page that adds nothing, or + // no valid zone at all is a failed refresh, so the last good snapshot stays. async function fetchZones() { const features = []; + let complete = false; for (let page = 0; page < MAX_PAGES; page++) { const url = page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; @@ -70,10 +74,15 @@ export function droneAirspaceProxy({ if (!Array.isArray(payload?.features)) throw new Error('invalid_snapshot'); features.push(...payload.features); - if (!exceededTransferLimit(payload) || !payload.features.length) break; + if (!exceededTransferLimit(payload)) { + complete = true; + break; + } + if (!payload.features.length) break; } + if (!complete) throw new Error('incomplete_snapshot'); const rows = normalizeDroneAirspaceSnapshot({ features }); - if (!rows) throw new Error('invalid_snapshot'); + if (!rows?.length) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs index 52eb0dcc8..e56da166c 100644 --- a/src/data/droneAirspaceProxy.test.mjs +++ b/src/data/droneAirspaceProxy.test.mjs @@ -51,7 +51,7 @@ function install(options = {}, hook = 'configureServer') { } for (const hook of ['configureServer', 'configurePreviewServer']) { - test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => { + test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => { const calls = []; const request = install( { @@ -62,7 +62,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { assert.equal(options.redirect, 'error'); return Response.json({ features: [feature(calls.length)], - exceededTransferLimit: true, + exceededTransferLimit: calls.length < 3, }); }, }, @@ -71,8 +71,8 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { const res = await request('/?url=https://invalid.example&resultOffset=999'); assert.equal(res.status, 200); assert.equal(res.body.fetchedAt, 1234); - assert.equal(res.body.rows.length, 5); - assert.equal(calls.length, 5); + assert.equal(res.body.rows.length, 3); + assert.equal(calls.length, 3); assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); assert.equal(calls[0].searchParams.get('f'), 'geojson'); assert.equal(calls[0].searchParams.has('resultOffset'), false); @@ -80,6 +80,75 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { }); } +/** + * Serve one good snapshot, then let the upstream answer `bad` after the TTL: + * the bad answer must not replace it, so the client gets the last good rows + * marked stale, and a cold proxy answers 502 rather than caching the bad one. + */ +async function assertRejectedSnapshot(bad) { + let now = 1000; + let broken = false; + let calls = 0; + const request = install({ + now: () => now, + fetchImpl: async () => { + calls += 1; + return broken ? bad(calls) : Response.json({ features: [feature(7)] }); + }, + }); + const first = await request(); + assert.equal(first.status, 200); + broken = true; + now += 3_600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, first.body.fetchedAt); + assert.deepEqual( + stale.body.rows.map((row) => row.stableId), + ['7'], + ); + calls = 0; + const cold = install({ fetchImpl: async () => bad(++calls) }); + const res = await cold(); + assert.equal(res.status, 502); + assert.equal(res.body.error, 'drone_airspace_unavailable'); +} + +test('an empty upstream snapshot keeps the last good one', () => + assertRejectedSnapshot(() => Response.json({ features: [] }))); + +test('a page whose every feature is invalid keeps the last good one', () => + assertRejectedSnapshot(() => + Response.json({ + features: [ + { + ...feature(8), + properties: { ...feature(8).properties, TYPE_CODE: 'W' }, + }, + { ...feature(9), geometry: null }, + ], + }), + )); + +test('paging that exhausts its page limit still flagged keeps the last good one', () => + assertRejectedSnapshot((call) => + Response.json({ + features: [feature(100 + call)], + exceededTransferLimit: true, + }), + )); + +test('a flagged page that adds no features keeps the last good one', () => + assertRejectedSnapshot((call) => + Response.json( + call % 2 + ? { features: [feature(100 + call)], exceededTransferLimit: true } + : { features: [], properties: { exceededTransferLimit: true } }, + ), + )); + test('a cached snapshot is reused until TTL, then refreshed', async () => { let now = 1000; let fetches = 0;