mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
3cb30c3f97
commit
5391687d46
5 files changed
+155
-5
No files matched your search
@@ -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
|
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
|
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.
|
||||||
|
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,
|
`/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
|
126–121° W, cached ten minutes and stale for at most 24 h. A valid empty
|
||||||
|
|||||||
@@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
state.loadedScope = query.scope;
|
state.loadedScope = query.scope;
|
||||||
state.pendingClear = false;
|
state.pendingClear = false;
|
||||||
// A successful page replaces this queried area, including a healthy
|
// 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) {
|
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);
|
if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id);
|
||||||
}
|
}
|
||||||
for (const row of snapshot.rows) {
|
for (const row of snapshot.rows) {
|
||||||
@@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
const sourceError =
|
const sourceError =
|
||||||
gridError ||
|
gridError ||
|
||||||
staleError ||
|
staleError ||
|
||||||
|
(snapshot.sources?.inaturalist?.ok === false
|
||||||
|
? snapshot.sources.inaturalist.error ||
|
||||||
|
new Error(msg('Wildlife sightings source unavailable'))
|
||||||
|
: null) ||
|
||||||
(snapshot.sources?.acartia?.ok === false
|
(snapshot.sources?.acartia?.ok === false
|
||||||
? new Error(msg('Acartia feed unavailable'))
|
? new Error(msg('Acartia feed unavailable'))
|
||||||
: null);
|
: null);
|
||||||
|
|||||||
@@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure
|
|||||||
h.layer.destroy();
|
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 () => {
|
test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => {
|
||||||
const rows = Array.from({ length: 32 }, (_, i) => ({
|
const rows = Array.from({ length: 32 }, (_, i) => ({
|
||||||
...photographed,
|
...photographed,
|
||||||
|
|||||||
@@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({
|
|||||||
['all', 'Mammalia', 'whales'].includes(preset) &&
|
['all', 'Mammalia', 'whales'].includes(preset) &&
|
||||||
requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX));
|
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) => {
|
requestBoxes.map((bounds) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
scope: scope === 'global' ? 'global' : 'view',
|
scope: scope === 'global' ? 'global' : 'view',
|
||||||
@@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({
|
|||||||
acartiaRequest,
|
acartiaRequest,
|
||||||
]);
|
]);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (inatResult.status === 'rejected') throw inatResult.reason;
|
const inatSnapshots = [];
|
||||||
const inatSnapshots = inatResult.value;
|
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 inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows);
|
||||||
const acartiaSnapshot =
|
const acartiaSnapshot =
|
||||||
acartiaResult.status === 'fulfilled' ? acartiaResult.value : null;
|
acartiaResult.status === 'fulfilled' ? acartiaResult.value : null;
|
||||||
@@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({
|
|||||||
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
||||||
sources: {
|
sources: {
|
||||||
inaturalist: {
|
inaturalist: {
|
||||||
ok: true,
|
ok: !inatError,
|
||||||
stale: inatStale,
|
stale: inatStale,
|
||||||
fetchedAt: inatFetchedAt,
|
fetchedAt: inatFetchedAt,
|
||||||
count: rows.filter((row) => row.origin === 'inaturalist').length,
|
count: rows.filter((row) => row.origin === 'inaturalist').length,
|
||||||
|
...(inatError ? { error: inatError, failedBoxes } : {}),
|
||||||
},
|
},
|
||||||
acartia: {
|
acartia: {
|
||||||
ok: !useAcartia || acartiaResult.status === 'fulfilled',
|
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);
|
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 () => {
|
test('proxy staleness and original fetch dates pass through with per-source counts', async () => {
|
||||||
const fetchedAt = now - 50 * 60_000;
|
const fetchedAt = now - 50 * 60_000;
|
||||||
const source = createWildlifeSightingsSource({
|
const source = createWildlifeSightingsSource({
|
||||||
|
|||||||
Reference in new issue
Block a user