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({