From 5391687d4649c3fd9e7f0d0c46624caaf6c7582e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:06:41 -0800 Subject: [PATCH] fix(wildlife): draw what arrived when one sighting source fails An iNaturalist rejection discarded a successful Acartia reply, and the antimeridian Promise.all lost both halves when one failed. Each box now settles independently; the failed part is reported on sources.inaturalist (error and failed boxes), the layer keeps that area's last iNaturalist rows and shows DEGRADED with retry timing, and the update fails outright only when every requested source failed. --- docs/CURRENT-STATE.md | 4 + src/layers/wildlifeSightings/lifecycle.js | 16 +++- .../wildlifeSightings/ownership.test.mjs | 41 +++++++++++ src/layers/wildlifeSightings/source.js | 26 ++++++- src/layers/wildlifeSightings/source.test.mjs | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fbbaa19b0..2449130f4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -121,6 +121,10 @@ Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. +A partial failure still draws what arrived: a failed iNaturalist request, or +one failed half of an antimeridian view, keeps a good Acartia reply and the +other half, retains that area's last iNaturalist rows and reports DEGRADED. +The update fails outright only when every requested source failed. `/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. A valid empty diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index e092a6ef1..6da425c7a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) { state.loadedScope = query.scope; state.pendingClear = false; // A successful page replaces this queried area, including a healthy - // empty page. Keep only other visited areas in the bounded LRU. + // empty page. Keep only other visited areas in the bounded LRU. Areas + // whose iNaturalist request failed keep their last iNaturalist rows. + const inat = snapshot.sources?.inaturalist; + const failedInatBoxes = + inat?.ok === false ? inat.failedBoxes || query.boxes : null; for (const [id, row] of state.rows) { + if ( + failedInatBoxes && + row.origin === 'inaturalist' && + rowInWildlifeBoxes(row, failedInatBoxes, 0) + ) + continue; if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id); } for (const row of snapshot.rows) { @@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) { const sourceError = gridError || staleError || + (snapshot.sources?.inaturalist?.ok === false + ? snapshot.sources.inaturalist.error || + new Error(msg('Wildlife sightings source unavailable')) + : null) || (snapshot.sources?.acartia?.ok === false ? new Error(msg('Acartia feed unavailable')) : null); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c8bfaac62..e118a87bc 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure h.layer.destroy(); }); +test('an iNaturalist failure beside a good Acartia reply keeps both and reports degraded', async () => { + let inatHealthy = true; + const acartia = { + ...base, + stableId: 'acartia:1', + origin: 'acartia', + iconic: 'Mammalia', + lon: -122.3, + provider: { name: 'Synthetic Provider', entity: null }, + }; + const failure = Object.assign(new Error('iNaturalist HTTP 502'), { + status: 502, + retryAfterSec: 30, + }); + const h = harness({ + getSnapshot: async () => [], + getSnapshotDetail: async ({ boxes }) => ({ + rows: inatHealthy ? [base, acartia] : [acartia], + sources: { + inaturalist: inatHealthy + ? { ok: true } + : { ok: false, error: failure, failedBoxes: boxes }, + acartia: { ok: true }, + }, + }), + }); + await h.update(); + assert.equal(h.layer.getStats().count, 2); + inatHealthy = false; + await h.update(); + // The failed iNaturalist area keeps its last rows; Acartia still refreshes. + assert.equal(h.layer.getStats().count, 2); + assert.equal(h.layer.getStats().error, 'iNaturalist HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); + assert.equal( + h.layer.getRowControls().info, + 'iNaturalist contributors · Acartia 1 (Salish Sea) · licences per record', + ); + h.layer.destroy(); +}); + test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => { const rows = Array.from({ length: 32 }, (_, i) => ({ ...photographed, diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 7af6d1e72..8bd41bf1d 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({ ['all', 'Mammalia', 'whales'].includes(preset) && requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX)); - const inatRequest = Promise.all( + // Each box settles independently: one failed antimeridian half must not + // discard the other half, and neither may discard a good Acartia reply. + const inatRequest = Promise.allSettled( requestBoxes.map((bounds) => { const params = new URLSearchParams({ scope: scope === 'global' ? 'global' : 'view', @@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({ acartiaRequest, ]); signal?.throwIfAborted(); - if (inatResult.status === 'rejected') throw inatResult.reason; - const inatSnapshots = inatResult.value; + const inatSnapshots = []; + const failedBoxes = []; + let inatError = null; + inatResult.value.forEach((result, index) => { + if (result.status === 'fulfilled') inatSnapshots.push(result.value); + else { + failedBoxes.push(requestBoxes[index]); + inatError ??= result.reason; + } + }); + // Fail outright only when nothing succeeded; otherwise draw what arrived + // and report the failed source in its per-source status. + if ( + !inatSnapshots.length && + (!useAcartia || acartiaResult.status !== 'fulfilled') + ) + throw inatError; const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({ ...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}), sources: { inaturalist: { - ok: true, + ok: !inatError, stale: inatStale, fetchedAt: inatFetchedAt, count: rows.filter((row) => row.origin === 'inaturalist').length, + ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { ok: !useAcartia || acartiaResult.status === 'fulfilled', diff --git a/src/layers/wildlifeSightings/source.test.mjs b/src/layers/wildlifeSightings/source.test.mjs index 7c1191e4b..2a21ca372 100644 --- a/src/layers/wildlifeSightings/source.test.mjs +++ b/src/layers/wildlifeSightings/source.test.mjs @@ -148,6 +148,79 @@ test('Acartia failure preserves iNat rows with an explicit failed source', async assert.equal(result.sources.inaturalist.ok, true); }); +test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + url.startsWith('/api/acartia') + ? Response.json(acartiaEnvelope()) + : new Response('', { + status: 502, + headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' }, + }), + }); + const result = await source.getSnapshotDetail({ box: seattle }); + assert.deepEqual(result.rows, [acartia]); + assert.equal(result.totalResults, 1); + assert.equal(result.fetchedAt, now); + assert.equal(result.sources.acartia.ok, true); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 0); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 502', + ); + assert.equal(result.sources.inaturalist.error.retryAfterSec, 30); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]); +}); + +test('observations reject only when every requested source failed', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }), + }); + await assert.rejects(source.getSnapshotDetail({ box: seattle }), { + message: 'iNaturalist HTTP 503', + }); + const antimeridian = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects( + antimeridian.getSnapshotDetail({ + boxes: [ + { south: 10, west: 179, north: 12, east: 180 }, + { south: 10, west: -180, north: 12, east: -178 }, + ], + }), + { message: 'iNaturalist HTTP 503' }, + ); +}); + +test('one failed antimeridian half keeps the other half and reports the failed box', async () => { + const east = { south: 10, west: 179, north: 12, east: 180 }; + const west = { south: 10, west: -180, north: 12, east: -178 }; + const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 }; + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new URL(url, 'https://example.test').searchParams.get('west') === '179' + ? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 })) + : new Response('', { status: 503 }), + }); + const result = await source.getSnapshotDetail({ boxes: [east, west] }); + assert.deepEqual(result.rows, [eastRow]); + assert.equal(result.totalResults, 3); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 1); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 503', + ); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]); +}); + test('proxy staleness and original fetch dates pass through with per-source counts', async () => { const fetchedAt = now - 50 * 60_000; const source = createWildlifeSightingsSource({