fix(wildlife): treat a valid empty Acartia snapshot as a fresh success

A quiet or fully filtered feed raised invalid_snapshot, so the proxy
answered 502 with a 15 s cooldown and re-polled about four times a
minute. Only a non-array payload is malformed now; an empty projection
caches for the normal ten minutes and clears earlier rows.
This commit is contained in:
Sameh Khamis committed 2026-11-05 16:03:52 -08:00
1 parent bd99000433
commit 82138478d0
3 files changed
+68 -25

No files matched your search

+4 -1
View File
@@ -123,7 +123,10 @@ known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED
with retry timing; logs exclude request URLs and upstream exception text. with retry timing; logs exclude request URLs and upstream exception text.
`/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N,
126–121° W, cached ten minutes and stale for at most 24 h. WHALES accepts only 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty
snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
positions are rounded to 0.01° on the server. Acartia comments are never retained. positions are rounded to 0.01° on the server. Acartia comments are never retained.
+3 -1
View File
@@ -100,7 +100,9 @@ export function acartiaProxy({
signal, signal,
); );
const rows = normalizeAcartiaSightings(payload, now()); const rows = normalizeAcartiaSightings(payload, now());
if (!rows?.length) throw new Error('invalid_snapshot'); // Only a non-array is malformed. A quiet feed, or one whose rows all fall
// outside coverage or the species checks, is a valid empty snapshot.
if (!rows) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows }; return { fetchedAt: now(), rows };
} catch (error) { } catch (error) {
if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS);
+61 -23
View File
@@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async
assert.equal(calls, 2); assert.equal(calls, 2);
}); });
test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => {
for (const bad of [ for (const quiet of [
[], [],
[null], [null],
[{ ...sighting(), data_source_name: '' }], [{ ...sighting(), data_source_name: '' }],
{ results: [sighting()] }, [{ ...sighting(), latitude: 37.8 }],
[{ ...sighting(), type: 'Unknown' }],
]) { ]) {
let at = NOW; let at = NOW;
let fail = false; let calls = 0;
let empty = false;
const cache = new Map(); const cache = new Map();
const request = install({ const request = install({
cache, cache,
now: () => at, now: () => at,
fetchImpl: async () => Response.json(fail ? bad : [sighting()]), fetchImpl: async () => {
calls += 1;
return Response.json(empty ? quiet : [sighting()]);
},
}); });
const fresh = await request(); assert.equal((await request()).body.rows.length, 1);
const retained = cache.get('sightings'); empty = true;
fail = true;
at += 600_001; at += 600_001;
const stale = await request(); const result = await request();
assert.equal(stale.status, 200); assert.equal(result.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true'); assert.deepEqual(result.body, { fetchedAt: at, rows: [] });
assert.equal(stale.body.stale, true); assert.equal(result.headers['X-Data-Stale'], undefined);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); assert.equal(result.headers['Retry-After'], undefined);
assert.equal(cache.get('sightings'), retained); assert.deepEqual(cache.get('sightings').value.rows, []);
const coldCache = new Map(); // The quiet feed keeps the normal ten-minute cadence, not a failure retry.
const cold = install({ at += 599_999;
cache: coldCache, assert.equal((await request()).status, 200);
fetchImpl: async () => Response.json(bad), assert.equal(calls, 2);
}); at += 1;
const result = await cold(); await request();
assert.equal(result.status, 502); assert.equal(calls, 3);
assert.equal(result.body.error, 'acartia_unavailable'); const cold = install({ fetchImpl: async () => Response.json(quiet) });
assert.equal(coldCache.size, 0); const coldResult = await cold();
assert.equal(coldResult.status, 200);
assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] });
} }
}); });
test('an invented wrapped snapshot never replaces the cache', async () => {
const bad = { results: [sighting()] };
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(fail ? bad : [sighting()]),
});
const fresh = await request();
const retained = cache.get('sightings');
fail = true;
at += 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, fresh.body.fetchedAt);
assert.equal(cache.get('sightings'), retained);
const coldCache = new Map();
const cold = install({
cache: coldCache,
fetchImpl: async () => Response.json(bad),
});
const result = await cold();
assert.equal(result.status, 502);
assert.equal(result.body.error, 'acartia_unavailable');
assert.equal(result.headers['Retry-After'], '15');
assert.equal(coldCache.size, 0);
});
test('failure serves stale for at most 24 hours and never reports a cold success', async () => { test('failure serves stale for at most 24 hours and never reports a cold success', async () => {
let at = NOW; let at = NOW;
let fail = false; let fail = false;