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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 14:33:10 -08:00
1 parent 08b8be26fe
commit 6bdedd8523
3 files changed
+87 -7

No files matched your search

+3 -1
View File
@@ -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 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 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 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 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 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. layer is informational only and not a substitute for B4UFLY / LAANC.
+11 -2
View File
@@ -61,8 +61,12 @@ export function droneAirspaceProxy({
return readResponseJsonCapped(response, cap, signal); 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() { async function fetchZones() {
const features = []; const features = [];
let complete = false;
for (let page = 0; page < MAX_PAGES; page++) { for (let page = 0; page < MAX_PAGES; page++) {
const url = const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
@@ -70,10 +74,15 @@ export function droneAirspaceProxy({
if (!Array.isArray(payload?.features)) if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot'); throw new Error('invalid_snapshot');
features.push(...payload.features); 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 }); const rows = normalizeDroneAirspaceSnapshot({ features });
if (!rows) throw new Error('invalid_snapshot'); if (!rows?.length) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows }; return { fetchedAt: now(), rows };
} }
+73 -4
View File
@@ -51,7 +51,7 @@ function install(options = {}, hook = 'configureServer') {
} }
for (const hook of ['configureServer', 'configurePreviewServer']) { 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 calls = [];
const request = install( const request = install(
{ {
@@ -62,7 +62,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
assert.equal(options.redirect, 'error'); assert.equal(options.redirect, 'error');
return Response.json({ return Response.json({
features: [feature(calls.length)], 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'); const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234); assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 5); assert.equal(res.body.rows.length, 3);
assert.equal(calls.length, 5); assert.equal(calls.length, 3);
assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); 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.get('f'), 'geojson');
assert.equal(calls[0].searchParams.has('resultOffset'), false); 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 () => { test('a cached snapshot is reused until TTL, then refreshed', async () => {
let now = 1000; let now = 1000;
let fetches = 0; let fetches = 0;