diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b1b73d2c..b615bb70d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. `/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 MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. diff --git a/server/providers/acartia.js b/server/providers/acartia.js index e3dafd220..c8f8b90d7 100644 --- a/server/providers/acartia.js +++ b/server/providers/acartia.js @@ -100,7 +100,9 @@ export function acartiaProxy({ signal, ); 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 }; } catch (error) { if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); diff --git a/server/providers/acartia.test.mjs b/server/providers/acartia.test.mjs index bf61d987c..e7b3cd32e 100644 --- a/server/providers/acartia.test.mjs +++ b/server/providers/acartia.test.mjs @@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async assert.equal(calls, 2); }); -test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { - for (const bad of [ +test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => { + for (const quiet of [ [], [null], [{ ...sighting(), data_source_name: '' }], - { results: [sighting()] }, + [{ ...sighting(), latitude: 37.8 }], + [{ ...sighting(), type: 'Unknown' }], ]) { let at = NOW; - let fail = false; + let calls = 0; + let empty = false; const cache = new Map(); const request = install({ cache, now: () => at, - fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + fetchImpl: async () => { + calls += 1; + return Response.json(empty ? quiet : [sighting()]); + }, }); - const fresh = await request(); - const retained = cache.get('sightings'); - fail = true; + assert.equal((await request()).body.rows.length, 1); + empty = 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(coldCache.size, 0); + const result = await request(); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { fetchedAt: at, rows: [] }); + assert.equal(result.headers['X-Data-Stale'], undefined); + assert.equal(result.headers['Retry-After'], undefined); + assert.deepEqual(cache.get('sightings').value.rows, []); + // The quiet feed keeps the normal ten-minute cadence, not a failure retry. + at += 599_999; + assert.equal((await request()).status, 200); + assert.equal(calls, 2); + at += 1; + await request(); + assert.equal(calls, 3); + const cold = install({ fetchImpl: async () => Response.json(quiet) }); + 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 () => { let at = NOW; let fail = false;