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;