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.
This commit is contained in:
Sameh Khamis committed 2026-11-05 16:06:41 -08:00
1 parent 3cb30c3f97
commit 5391687d46
5 files changed
+155 -5

No files matched your search

+4
View File
@@ -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
+15 -1
View File
@@ -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);
@@ -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,
+22 -4
View File
@@ -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',
@@ -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({