From 7bb0b7be0b1a59c6f2e872272a30b75861574237 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sat, 24 Oct 2026 12:55:52 -0400 Subject: [PATCH 1/2] feat(qcm): one fetch-with-deadline and latest-wins helper, bounded quad paging The map had three hand-written copies of "abort after N ms"; two applied the deadline only when AbortSignal.any existed. request.js is the one place now: fetchWithDeadline (never throws; ok/timeout/aborted/error), withDeadline, createLatest and createSearchGuard. - arcgis-pager: pageThrough defaults to the 20 s REQUEST_POLICY deadline. - map-index paging stops at MAX_PAGES, dedupes by cell id, stops when a page adds nothing, and reports timedOut instead of hanging. - weather and NWS alerts read their registry timeoutMs; the registry gives every source a 10 s default, place names 20 s (measured up to 31 s on a slow day, recorded beside it). - A missing timeoutMs comes back as an error result, not an exception. QQA-13 PR-A. Ports headlessmode-archive 85b71df0 (PR #784, rebuilt head 9acbd293) onto master. PR-B moves the remaining modules onto it. --- README.md | 3 +- scripts/qcm/README.md | 8 + scripts/qcm/arcgis-pager.test.mjs | 16 ++ scripts/qcm/map-index.test.mjs | 52 ++++- scripts/qcm/registry.test.mjs | 18 +- scripts/qcm/request.test.mjs | 191 ++++++++++++++++++ .../js/quiet-corners-maps/arcgis-pager.js | 18 +- .../static/js/quiet-corners-maps/request.js | 116 +++++++++++ .../js/quiet-corners-maps/source-map-index.js | 72 ++++--- .../quiet-corners-maps/source-nws-alerts.js | 14 +- .../js/quiet-corners-maps/source-registry.js | 16 +- .../source-terrain-names.js | 61 ++---- .../js/quiet-corners-maps/source-weather.js | 18 +- 13 files changed, 505 insertions(+), 98 deletions(-) create mode 100644 scripts/qcm/request.test.mjs create mode 100644 themes/headlessmode/static/js/quiet-corners-maps/request.js diff --git a/README.md b/README.md index 43da789..73a657e 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,8 @@ side, and a badge read whichever finished last (2026-09-26, #567 and #571). `layer-panel-browser.test.mjs` holds each govunits answer back so the browse always comes due mid-pass, and fails if more than one pass's requests are out together. An ArcGIS `/query` answers one page at a time and says so in -`exceededTransferLimit`; `arcgis-pager.js` loops until it stops saying so and +`exceededTransferLimit`; `arcgis-pager.js` loops until it stops saying so (each page +under `request.js`'s deadline, so a service that never answers ends as an error) and reports `coverage`, 'complete' only when the service ran out of more. Every adapter returns that word and `main.js` reads it — except that the recreation-site and trail blocks used to write `state.states. = 'ready'` whenever the fetch diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index 43c131e..91af9c7 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -290,6 +290,14 @@ does the same through its fetcher's generation (the late answer is `stale`). `terrain-surface.test.mjs` holds the cases: decode failure after switch-off, decode success after switch-off, an older decode landing after a newer ask, and an uninterrupted ask that still reports once. +## Remote reads (QQA-13) + +`quiet-corners-maps/request.js` is the one way the map reads from the network. `fetchWithDeadline(url, { fetchImpl, signal, timeoutMs })` answers `{ ok, status: 'ok' | 'timeout' | 'aborted' | 'error' }` and never throws. Its timer is a plain `AbortController`, so it works without `AbortSignal.any`, it ends a fetch that ignores its signal, and it clears the timer and the listener on the caller's signal when the call settles. A caller's cancel is `aborted`, never `timeout`. `createLatest()` is latest-wins: a new call aborts the previous one and the older answer comes back `stale`. `source-terrain-names.js` re-exports `fetchJson` and `createSearchGuard` from it for the callers that already import them. + +The deadline is per source: `SOURCES..timeoutMs` in `source-registry.js`, `DEFAULT_SOURCE_TIMEOUT_MS` (10 s) unless the source cites a slower measurement in `timeoutMeasured`. GNIS place names keep 20 s because their latency is measured up to 31 s. Only the modules moved onto the helper honour it so far: GNIS place names, the quad index, and the NWS forecast and alerts loaders. Every other source (road names, hillshade, MVUM, stream names, topo tiles and the rest) carries the value but still runs at `REQUEST_POLICY.timeoutMs` (20 s) until it is converted. The quad index pages at most `MAX_PAGES` (20) times and stops on a page that adds no new cell, ending `coverage: 'partial'`; its `timeoutMs` bounds each attempt, including `searchByName`, and a timeout is not retried. + +`request.test.mjs` covers the helper (timeout, abort, cleanup, no `AbortSignal.any`, latest-wins); `map-index.test.mjs`, `arcgis-pager.test.mjs` and `registry.test.mjs` hold the quad paging cap, the pager deadline and the per-source `timeoutMs` rule. + ## Accessibility checks `a11y-dynamic-browser.test.mjs` drives the built `/quiet-corners/` app (the Field Maps app; `/quiet-corners/maps/` is its documentation page, checked as one state) offline and holds it to zero axe-core WCAG 2.2 A/AA findings in each state a reader reaches: first load, the dock panels, the phone sheet at half and expanded, the place finder, a name card and the 3D dialog (skipped, said so, when the browser has no WebGL). "Needs review" counts as a finding. A waiver is a row in `EXCLUSIONS` naming the rule, a selector and a reason that is true today; where axe cannot read what is behind a label or glyph the row also carries `plate`, and the test measures that text against its own background itself. It also asserts the keyboard contract (Tab order, focus never hidden, Escape returns focus), 44 px touch targets at 390 px, 200% text without clipping or sideways scroll, reflow at 320 px, and the contrast and backplate opacity of the labels the app paints on the map. Not asserted yet: with the phone sheet expanded, five map controls stay in the Tab order under the sheet (Zoom in, Search places, Share view, Use my location, Map credits). diff --git a/scripts/qcm/arcgis-pager.test.mjs b/scripts/qcm/arcgis-pager.test.mjs index de96507..037b4b2 100644 --- a/scripts/qcm/arcgis-pager.test.mjs +++ b/scripts/qcm/arcgis-pager.test.mjs @@ -40,3 +40,19 @@ test('the page cap is honoured and an HTTP error names the layer', async () => { fetchImpl: async () => ({ ok: false, status: 503 }) }), /Designated Wilderness: the service answered 503/); }); + +test('pageThrough ends by its own timeoutMs on a fetch that never answers, and a caller abort is not a timeout', async () => { + const never = () => new Promise(() => {}); + const t0 = Date.now(); + await assert.rejects( + pageThrough({ url: 'x', params: {}, label: 'Designated Wilderness', normalize: norm, fetchImpl: never, timeoutMs: 50 }), + /Designated Wilderness: .*did not answer/, + ); + assert.ok(Date.now() - t0 < 50 + 250, `took ${Date.now() - t0} ms`); + const caller = new AbortController(); + setTimeout(() => caller.abort(), 20); + await assert.rejects( + pageThrough({ url: 'x', params: {}, normalize: norm, fetchImpl: never, signal: caller.signal, timeoutMs: 5000 }), + (err) => err.name === 'AbortError', + ); +}); diff --git a/scripts/qcm/map-index.test.mjs b/scripts/qcm/map-index.test.mjs index 9329024..9cd07d7 100644 --- a/scripts/qcm/map-index.test.mjs +++ b/scripts/qcm/map-index.test.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { OUT_FIELDS, buildBboxQueryUrl, buildNameQueryUrl, normalizeCell, neighborsOf, - fetchCellsForBbox, searchByName, + fetchCellsForBbox, searchByName, MAX_PAGES, } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-map-index.js'; import { SOURCES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; @@ -155,3 +155,53 @@ test('searchByName: ok with cells on success, ok:false with error text on throw' assert.match(fail.error, /network down/); assert.deepEqual(OUT_FIELDS, ['CELL_ID', 'CELL_NAME', 'PRIMARY_STATE', 'STATE_ALPHA', 'CELL_MAPCODE']); }); + +test('fetchCellsForBbox: a service flagging more forever with no features stops inside MAX_PAGES as partial', async () => { + let calls = 0; + const fetchImpl = async () => { calls += 1; return jsonResponse({ features: [], exceededTransferLimit: true }); }; + const result = await fetchCellsForBbox({ bboxLonLat: BBOX, fetchImpl }); + assert.equal(result.coverage, 'partial'); + assert.ok(calls <= MAX_PAGES, `${calls} fetches`); + assert.equal(calls, 1, 'a page that adds nothing ends the loop at once'); + assert.deepEqual(result.cells, []); +}); + +test('fetchCellsForBbox: a service repeating the same page is cut as partial, and MAX_PAGES caps a service that keeps adding', async () => { + let calls = 0; + const same = async () => { calls += 1; return jsonResponse({ features: cloneFixture().features.slice(0, 2), exceededTransferLimit: true }); }; + const repeat = await fetchCellsForBbox({ bboxLonLat: BBOX, fetchImpl: same }); + assert.equal(repeat.coverage, 'partial'); + assert.equal(calls, 2, 'one real page, then one that added nothing new'); + assert.equal(repeat.cells.length, 2); + + calls = 0; + const forever = async () => { + calls += 1; + const f = cloneFixture().features[0]; + f.attributes = { ...f.attributes, CELL_ID: `cell-${calls}` }; + return jsonResponse({ features: [f], exceededTransferLimit: true }); + }; + const capped = await fetchCellsForBbox({ bboxLonLat: BBOX, fetchImpl: forever }); + assert.equal(capped.coverage, 'partial'); + assert.equal(calls, MAX_PAGES); + assert.equal(capped.pages, MAX_PAGES); +}); + +test('fetchCellsForBbox and searchByName end by their own timeoutMs on a fetch that never answers', async () => { + const never = () => new Promise(() => {}); + const t0 = Date.now(); + const cells = await fetchCellsForBbox({ bboxLonLat: BBOX, fetchImpl: never, timeoutMs: 50, retries: 2 }); + assert.equal(cells.coverage, 'unavailable'); + assert.equal(cells.timedOut, true); + assert.ok(Date.now() - t0 < 50 + 250, `took ${Date.now() - t0} ms: a timeout is not retried`); + + const t1 = Date.now(); + const named = await searchByName({ name: 'rainbow springs', fetchImpl: never, timeoutMs: 50 }); + assert.equal(named.ok, false); + assert.equal(named.timedOut, true); + assert.ok(Date.now() - t1 < 50 + 250); +}); + +test('the quad index asks the registry for its timeout', () => { + assert.equal(typeof SOURCES.usgsQuadIndex.timeoutMs, 'number'); +}); diff --git a/scripts/qcm/registry.test.mjs b/scripts/qcm/registry.test.mjs index b2c6a9a..ddc135d 100644 --- a/scripts/qcm/registry.test.mjs +++ b/scripts/qcm/registry.test.mjs @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { - SOURCES, APPROVED_ORIGINS, REQUEST_POLICY, FAILURE, cspDirectivesFor, + SOURCES, APPROVED_ORIGINS, REQUEST_POLICY, DEFAULT_SOURCE_TIMEOUT_MS, FAILURE, cspDirectivesFor, } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; const REQUIRED = ['id', 'name', 'kind', 'origin', 'serviceUrl', 'metadataUrl', 'spatialReference', @@ -258,3 +258,19 @@ test('usgsRoadNames carries the measured service facts and its own docs row', () assert.ok(row, 'a source-table row names the road-name layers'); assert.ok(row.includes(s.serviceUrl)); }); + +// QQA-13: one 10 s ceiling would turn the place-names path into routine timeouts, so the +// deadline is per source. A source may exceed the default only by citing its measurement. +test('every source carries a timeoutMs of 10000 or less unless it cites a measurement', () => { + assert.equal(DEFAULT_SOURCE_TIMEOUT_MS, 10000); + for (const [key, src] of Object.entries(SOURCES)) { + assert.ok(Number.isInteger(src.timeoutMs) && src.timeoutMs > 0, `${key} lacks a timeoutMs`); + if (src.timeoutMs > DEFAULT_SOURCE_TIMEOUT_MS) { + assert.match(src.timeoutMeasured || '', /\d/, `${key} exceeds ${DEFAULT_SOURCE_TIMEOUT_MS} ms without a cited measurement`); + } + } + assert.equal(SOURCES.gnisNames.timeoutMs, 20000); + assert.equal(SOURCES.nwsForecast.timeoutMs, 10000); + assert.equal(SOURCES.nwsAlerts.timeoutMs, 10000); + assert.equal(SOURCES.usgsQuadIndex.timeoutMs, 10000); +}); diff --git a/scripts/qcm/request.test.mjs b/scripts/qcm/request.test.mjs new file mode 100644 index 0000000..478ffdc --- /dev/null +++ b/scripts/qcm/request.test.mjs @@ -0,0 +1,191 @@ +// The one request helper every remote read in the map goes through: a deadline that +// always fires, a caller abort that is never called a timeout, and latest-wins. +// No network: every fetch is a fake, and the timing slack is scheduler allowance. +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { + fetchWithDeadline, withDeadline, createLatest, createSearchGuard, +} from '../../themes/headlessmode/static/js/quiet-corners-maps/request.js'; +import { createForecastLoader } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-weather.js'; +import { SOURCES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; +import { alertsFor } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js'; + +const SLACK_MS = 250; +const never = () => new Promise(() => {}); +const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body }); + +// Run `fn` with AbortSignal.any removed, the way an older browser or a test double sees it. +async function withoutAbortSignalAny(fn) { + const saved = AbortSignal.any; + delete AbortSignal.any; + try { return await fn(); } finally { AbortSignal.any = saved; } +} + +test('a fetch that never resolves ends as timeout within the deadline plus slack', async () => { + const t0 = Date.now(); + const got = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: never, timeoutMs: 50 }); + assert.equal(got.ok, false); + assert.equal(got.status, 'timeout'); + assert.ok(Date.now() - t0 < 50 + SLACK_MS, `took ${Date.now() - t0} ms`); +}); + +test('a caller abort is aborted, never timeout, before and during the request', async () => { + const pre = new AbortController(); + pre.abort(); + let calls = 0; + const r0 = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: () => { calls += 1; return never(); }, signal: pre.signal, timeoutMs: 5000 }); + assert.equal(r0.status, 'aborted'); + assert.equal(calls, 0, 'an already-cancelled call never reaches the network'); + + const mid = new AbortController(); + const pending = fetchWithDeadline('https://x.invalid/a', { fetchImpl: never, signal: mid.signal, timeoutMs: 5000 }); + setTimeout(() => mid.abort(), 20); + const r1 = await pending; + assert.equal(r1.ok, false); + assert.equal(r1.status, 'aborted'); +}); + +test('the timer is cleared and the caller listener removed once the call settles', async () => { + const set = [], cleared = []; + const realSet = globalThis.setTimeout, realClear = globalThis.clearTimeout; + globalThis.setTimeout = (...a) => { const id = realSet(...a); set.push(id); return id; }; + globalThis.clearTimeout = (id) => { cleared.push(id); return realClear(id); }; + const caller = new AbortController(); + let added = 0, removed = 0; + const realAdd = caller.signal.addEventListener.bind(caller.signal); + const realRemove = caller.signal.removeEventListener.bind(caller.signal); + caller.signal.addEventListener = (...a) => { added += 1; return realAdd(...a); }; + caller.signal.removeEventListener = (...a) => { removed += 1; return realRemove(...a); }; + try { + const ok = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => json({ a: 1 }), signal: caller.signal, timeoutMs: 5000 }); + assert.deepEqual(ok.body, { a: 1 }); + const bad = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => { throw new Error('offline'); }, signal: caller.signal, timeoutMs: 5000 }); + assert.equal(bad.status, 'error'); + assert.equal(bad.error, 'offline'); + } finally { globalThis.setTimeout = realSet; globalThis.clearTimeout = realClear; } + assert.equal(added, 2); + assert.equal(removed, added, 'every listener added is removed'); + for (const id of set) assert.ok(cleared.includes(id), 'every timer started is cleared'); +}); + +test('the deadline fires without AbortSignal.any, for the helper and both loaders', async () => { + await withoutAbortSignalAny(async () => { + assert.equal(typeof AbortSignal.any, 'undefined'); + const got = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: never, timeoutMs: 40 }); + assert.equal(got.status, 'timeout'); + + // A load that ends only when its signal aborts: if no timeout reaches it, this hangs. + const endsOnAbort = ({ signal }) => new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ status: 'aborted-by-signal' }), { once: true }); + }); + const loader = createForecastLoader({ load: endsOnAbort, timeoutMs: 40 }); + const t0 = Date.now(); + const weather = await loader.request({ lat: 35, lon: -83 }); + assert.equal(weather.status, 'aborted-by-signal'); + assert.ok(Date.now() - t0 < 40 + SLACK_MS); + + const t1 = Date.now(); + const alerts = await alertsFor({ lat: 35, lon: -83, timeoutMs: 40, fetchImpl: (_u, { signal }) => new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), { once: true }); + }) }); + assert.notEqual(alerts, undefined); + assert.ok(Date.now() - t1 < 40 + SLACK_MS, 'the alerts request ended by its own deadline'); + }); +}); + +test('withDeadline: timedOut is true only when the timer, not the caller, ended it', async () => { + const d = withDeadline(undefined, 20); + await new Promise((r) => setTimeout(r, 60)); + assert.equal(d.signal.aborted, true); + assert.equal(d.timedOut, true); + d.dispose(); + const caller = new AbortController(); + const e = withDeadline(caller.signal, 5000); + caller.abort(); + assert.equal(e.signal.aborted, true); + assert.equal(e.timedOut, false); + e.dispose(); + assert.throws(() => withDeadline(undefined, 0), RangeError); + assert.throws(() => withDeadline(undefined, undefined), RangeError); +}); + +test('HTTP failure, bad JSON and a good body keep their own shapes', async () => { + const http = await fetchWithDeadline('u', { fetchImpl: async () => ({ ok: false, status: 503 }), timeoutMs: 1000 }); + assert.deepEqual([http.ok, http.status, http.httpStatus, http.error], [false, 'error', 503, 'HTTP 503']); + const bad = await fetchWithDeadline('u', { fetchImpl: async () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError('x'); } }), timeoutMs: 1000 }); + assert.deepEqual([bad.ok, bad.status, bad.malformed, bad.error], [false, 'error', true, 'Malformed JSON response.']); + const sent = []; + const good = await fetchWithDeadline('u', { fetchImpl: async (u, init) => { sent.push(init); return json({ z: 1 }); }, init: { method: 'POST' }, timeoutMs: 1000 }); + assert.deepEqual([good.ok, good.status, good.httpStatus, good.body], [true, 'ok', 200, { z: 1 }]); + assert.equal(sent[0].method, 'POST'); + assert.ok(sent[0].signal instanceof AbortSignal); +}); + +test('createLatest: a new call aborts the previous one and the first result is stale', async () => { + const latest = createLatest(); + const seen = []; + const task = (label) => (signal) => new Promise((resolve) => { + seen.push({ label, signal }); + signal.addEventListener('abort', () => resolve(`${label}-aborted`), { once: true }); + if (label === 'second') setTimeout(() => resolve('second-done'), 10); + }); + const first = latest.run(task('first')); + const second = latest.run(task('second')); + const [a, b] = await Promise.all([first, second]); + assert.equal(seen[0].signal.aborted, true, 'the first call was aborted by the second'); + assert.equal(seen[1].signal.aborted, false); + assert.equal(a.stale, true); + assert.equal(b.stale, false); + assert.equal(b.value, 'second-done'); +}); + +test('createLatest: a stale call that throws is stale, a current one rethrows', async () => { + const latest = createLatest(); + const first = latest.run(() => new Promise((_, reject) => setTimeout(() => reject(new Error('late')), 10))); + const second = latest.run(async () => 'ok'); + assert.deepEqual(await first, { stale: true, value: undefined }); + assert.equal((await second).value, 'ok'); + await assert.rejects(latest.run(async () => { throw new Error('boom'); }), /boom/); +}); + +test('createSearchGuard keeps its bump / isCurrent contract on the shared helper', () => { + const guard = createSearchGuard(); + const a = guard.bump(); + assert.equal(guard.isCurrent(a), true); + const b = guard.bump(); + assert.equal(guard.isCurrent(a), false); + assert.equal(guard.isCurrent(b), true); +}); + +// The registry's timeoutMs is the deadline for the two NWS loaders, not a number they restate. +// A fake clock stands in for the wait: nothing ends the request but the timer. +test('the forecast and alerts loaders take their default deadline from the registry', async () => { + const endsOnAbort = (signal, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), { once: true }); + for (const [id, run] of [ + ['nwsForecast', (opts) => createForecastLoader({ load: ({ signal }) => new Promise((resolve) => signal.addEventListener('abort', () => resolve({ status: 'ended' }), { once: true })), ...opts }).request({ lat: 35, lon: -83 })], + ['nwsAlerts', (opts) => alertsFor({ lat: 35, lon: -83, fetchImpl: (_u, { signal }) => new Promise((_, reject) => endsOnAbort(signal, reject)), ...opts })], + ]) { + const ms = SOURCES[id].timeoutMs; + mock.timers.enable({ apis: ['setTimeout'] }); + try { + let settled = false; + const pending = run({}).then(() => { settled = true; }); + mock.timers.tick(ms - 1); + await new Promise((r) => setImmediate(r)); + assert.equal(settled, false, `${id} ended before its registry deadline of ${ms} ms`); + mock.timers.tick(1); + await new Promise((r) => setImmediate(r)); + assert.equal(settled, true, `${id} did not end at its registry deadline of ${ms} ms`); + await pending; + } finally { mock.timers.reset(); } + } +}); + +test('fetchWithDeadline with no timeoutMs returns an error rather than throwing', async () => { + let called = false; + const result = await fetchWithDeadline('https://example.test/a', { fetchImpl: async () => { called = true; return new Response('{}'); } }); + assert.equal(result.ok, false); + assert.equal(result.status, 'error'); + assert.match(result.error, /timeoutMs/); + assert.equal(called, false); +}); diff --git a/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js b/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js index e95e92d..1d4cce8 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js @@ -6,6 +6,8 @@ // page (Delaware, 26,863 against a 2,000 cap). This existed once, inside the state // land fetcher; the federal layers need the same loop, and two hand-written copies // of a paging protocol is the drift this file exists to prevent. +import { REQUEST_POLICY } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; /** A page bigger than any measured service allows, and a hard stop on the loop. * 20 pages of 2,000 is 40,000 features — past any honest sheet, and the point at @@ -14,6 +16,11 @@ export const PAGE_CAP = 2000; export const MAX_PAGES = 20; /** Read every page of one ArcGIS query. + * + * Each page is its own request with its own `timeoutMs`. The default is the old + * REQUEST_POLICY ceiling: callers that have not yet passed their source's own timeout + * keep the 20 s the wetlands and federal-land measurements were taken against. + * A timeout or a caller cancel throws, like any other failed page. * * `normalize` turns a raw feature into this map's shape and may return null to * drop it. `coverage` is 'complete' only when the service stopped flagging more; @@ -22,7 +29,7 @@ export const MAX_PAGES = 20; */ export async function pageThrough({ url, params, normalize, label = 'the service', - fetchImpl = fetch, signal, pageSize = PAGE_CAP, maxPages = MAX_PAGES, + fetchImpl = fetch, signal, pageSize = PAGE_CAP, maxPages = MAX_PAGES, timeoutMs = REQUEST_POLICY.timeoutMs, } = {}) { const size = Math.max(1, Math.min(pageSize, PAGE_CAP)); const features = [], seen = new Set(); @@ -36,9 +43,12 @@ export async function pageThrough({ resultRecordCount: String(size), f: 'json', }); - const res = await fetchImpl(url, { method: 'POST', body, signal }); - if (!res.ok) throw new Error(`${label}: the service answered ${res.status}.`); - const json = await res.json(); + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs, init: { method: 'POST', body } }); + if (got.status === 'aborted') throw Object.assign(new Error(`${label}: the request was cancelled.`), { name: 'AbortError' }); + if (got.status === 'timeout') throw new Error(`${label}: the service did not answer within ${timeoutMs} ms.`); + if (got.httpStatus !== undefined && !got.ok) throw new Error(`${label}: the service answered ${got.httpStatus}.`); + if (!got.ok) throw new Error(`${label}: ${got.error}`); + const json = got.body; if (json.error) throw new Error(`${label}: ${json.error.message || 'the service refused the query.'}`); const raw = Array.isArray(json.features) ? json.features : []; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/request.js b/themes/headlessmode/static/js/quiet-corners-maps/request.js new file mode 100644 index 0000000..64269af --- /dev/null +++ b/themes/headlessmode/static/js/quiet-corners-maps/request.js @@ -0,0 +1,116 @@ +// One way to read from the network in this map: a deadline that always fires, a caller +// cancel that is never mistaken for a timeout, and latest-wins for a reader who keeps typing. +// +// Three hand-written copies of "abort after N ms" existed before this file, and two of them +// only applied the deadline when `AbortSignal.any` was there. The deadline here uses a plain +// AbortController, so it works in every browser and in a test double, and it settles the +// call itself, so a fetch that ignores its signal still ends as a timeout. + +/** A signal that aborts when the caller's does or after `timeoutMs`, whichever is first. + * `timedOut` is true only when the timer ended it. Call `dispose()` once the work settles: + * it clears the timer and removes the listener from the caller's signal. */ +export function withDeadline(signal, timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('timeoutMs must be a positive number; a request with no deadline is the bug this file prevents.'); + } + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(); + if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort); } + const timer = setTimeout(() => { + if (!(signal && signal.aborted)) timedOut = true; + controller.abort(); + }, timeoutMs); + return { + signal: controller.signal, + get timedOut() { return timedOut; }, + dispose() { + clearTimeout(timer); + if (signal) signal.removeEventListener('abort', onAbort); + }, + }; +} + +const readJson = (res) => res.json(); + +/** One bounded request that reads its JSON body inside the deadline. Never throws. + * - `{ ok: true, status: 'ok', httpStatus, body }` + * - `{ ok: false, status: 'timeout', error }` -- `timeoutMs` passed with no answer, or the + * request was abandoned by something other than the caller. + * - `{ ok: false, status: 'aborted', error }` -- the caller's own `signal` ended it. + * - `{ ok: false, status: 'error', error }` -- network failure, a non-2xx (`httpStatus` + * is set), or a body that is not JSON (`malformed: true`). + * `init` is passed to fetch beside the deadline signal; `read` swaps the body reader. */ +export async function fetchWithDeadline(url, { fetchImpl = globalThis.fetch, signal, timeoutMs, init, read = readJson } = {}) { + if (signal && signal.aborted) return { ok: false, status: 'aborted', error: 'aborted' }; + // A missing or bad deadline is a caller bug, but this call promises never to throw, so it + // comes back as an error the panel can show instead of an exception that kills the panel. + let deadline; + try { deadline = withDeadline(signal, timeoutMs); } catch (err) { return { ok: false, status: 'error', error: err.message }; } + // Rejects when the deadline or the caller ends the call, so a fetchImpl that never + // looks at its signal cannot hold this call open. It waits one macrotask first: a real + // fetch rejects within microtasks of the abort, and an answer already in hand still counts. + let grace; + const ended = new Promise((_, reject) => { + deadline.signal.addEventListener('abort', () => { + grace = setTimeout(() => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), 0); + }, { once: true }); + }); + const work = (async () => { + const res = await fetchImpl(url, { ...init, signal: deadline.signal }); + if (!res.ok) return { ok: false, status: 'error', httpStatus: res.status, error: `HTTP ${res.status}` }; + let body; + try { body = await read(res); } catch (err) { + if (deadline.signal.aborted) throw err; + return { ok: false, status: 'error', malformed: true, error: 'Malformed JSON response.' }; + } + return { ok: true, status: 'ok', httpStatus: res.status, body }; + })(); + try { + return await Promise.race([work, ended]); + } catch (err) { + // The caller's cancel is judged first. An AbortError nobody here asked for (the browser + // giving up on the request) reads as a timeout, as fetchJson always treated it. + if (signal && signal.aborted) return { ok: false, status: 'aborted', error: err?.message || 'aborted' }; + if (deadline.timedOut || err?.name === 'AbortError') return { ok: false, status: 'timeout', error: `No answer within ${timeoutMs} ms.` }; + return { ok: false, status: 'error', error: err?.message || String(err) }; + } finally { + clearTimeout(grace); + deadline.dispose(); + } +} + +/** Latest wins. `begin()` aborts whatever the previous call was doing and hands out a + * token and a fresh signal; `isCurrent(token)` is false once a newer call has begun. + * `run(task)` wraps both: `task(signal)` runs, and the answer is `{ stale, value }` -- + * a stale answer is never to be drawn. A stale task that throws is stale, not an error. */ +export function createLatest() { + let generation = 0; + let controller = null; + const begin = () => { + if (controller) controller.abort(); + controller = new AbortController(); + return { token: ++generation, signal: controller.signal }; + }; + const isCurrent = (token) => token === generation; + async function run(task) { + const { token, signal } = begin(); + try { + const value = await task(signal); + return { stale: !isCurrent(token), value }; + } catch (err) { + if (!isCurrent(token)) return { stale: true, value: undefined }; + throw err; + } + } + return { begin, isCurrent, run }; +} + +/** A late response for a query the reader has already replaced must never overwrite what + * is on screen. `bump()` on every new submission, hand the returned token to the in-flight + * call, and check `isCurrent(token)` once it resolves before rendering -- an older token + * means a newer search has since started and this result is stale. */ +export function createSearchGuard() { + const latest = createLatest(); + return { bump: () => latest.begin().token, isCurrent: latest.isCurrent }; +} diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-map-index.js b/themes/headlessmode/static/js/quiet-corners-maps/source-map-index.js index 429a235..a640fa1 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-map-index.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-map-index.js @@ -3,8 +3,13 @@ // arithmetic cell geometry.js already computes from lon/lat alone. import { SOURCES, REQUEST_POLICY } from './source-registry.js'; import { cellForPoint, cellNeighbors, cellsForBbox } from './geometry.js'; +import { fetchWithDeadline } from './request.js'; +import { MAX_PAGES } from './arcgis-pager.js'; + +export { MAX_PAGES }; const LAYER = SOURCES.usgsQuadIndex.layers[0]; +const TIMEOUT_MS = SOURCES.usgsQuadIndex.timeoutMs; export const OUT_FIELDS = LAYER.fields; const MAX_BBOX_DEG = 2; const TOLERANCE_DEG = 1e-3; @@ -75,53 +80,60 @@ const isRetryableStatus = (status) => status === 429 || status >= 500; // Retries like REQUEST_POLICY: 429/5xx or a network throw; never another 4xx. A JSON // `error` object or malformed JSON fails the page immediately (a data problem, not transient). -async function fetchJsonPage(url, { fetchImpl, signal, retries }) { +// Each attempt has its own `timeoutMs`, and a timeout is NOT retried: a service that did not +// answer in time is the failure, and three more waits would triple what the reader sits through. +async function fetchJsonPage(url, { fetchImpl, signal, retries, timeoutMs }) { const backoff = REQUEST_POLICY.retryBackoffMs; const maxAttempts = retries + 1; let lastError = null; for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (signal?.aborted) return { ok: false, error: 'aborted' }; - let res; - try { - res = await fetchImpl(url, { signal }); - } catch (err) { - if (signal?.aborted || err?.name === 'AbortError') return { ok: false, error: 'aborted' }; - lastError = err?.message || String(err); + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs }); + if (got.status === 'aborted') return { ok: false, error: 'aborted' }; + if (got.status === 'timeout') return { ok: false, error: got.error, timedOut: true }; + if (got.status === 'error' && !got.malformed && (got.httpStatus === undefined || isRetryableStatus(got.httpStatus))) { + lastError = got.error; if (attempt < maxAttempts - 1) { await sleep(backoff[Math.min(attempt, backoff.length - 1)]); continue; } return { ok: false, error: lastError }; } - if (!res.ok) { - if (isRetryableStatus(res.status) && attempt < maxAttempts - 1) { await sleep(backoff[Math.min(attempt, backoff.length - 1)]); continue; } - return { ok: false, error: `HTTP ${res.status}` }; - } - let body; - try { body = await res.json(); } catch { return { ok: false, error: 'Malformed JSON response.' }; } - if (body?.error) return { ok: false, error: body.error.message || 'USGS quad index returned an error.' }; - return { ok: true, body }; + if (!got.ok) return { ok: false, error: got.error }; + if (got.body?.error) return { ok: false, error: got.body.error.message || 'USGS quad index returned an error.' }; + return { ok: true, body: got.body }; } return { ok: false, error: lastError || 'Request failed.' }; } -// timeoutMs is part of the public contract; a per-attempt deadline is fetchImpl's job (e.g. AbortSignal.timeout). +// Paging stops at `maxPages`, and at a page that adds no cell not already read: a service +// that flags "more" forever, or ignores resultOffset and repeats itself, ends as 'partial' +// rather than as a loop. An honest bbox (2 degrees at most) is one or two pages. export async function fetchCellsForBbox({ bboxLonLat, fetchImpl = globalThis.fetch, signal, pageSize = 200, - timeoutMs = REQUEST_POLICY.timeoutMs, retries = REQUEST_POLICY.retries, + timeoutMs = TIMEOUT_MS, retries = REQUEST_POLICY.retries, maxPages = MAX_PAGES, }) { - const cells = []; const warnings = []; + const cells = []; const warnings = []; const seen = new Set(); let pages = 0; let offset = 0; let coverage = 'complete'; let error = null; + let timedOut = false; for (;;) { if (signal?.aborted) { coverage = pages === 0 ? 'unavailable' : 'partial'; break; } const url = buildBboxQueryUrl({ bboxLonLat, offset, pageSize }); - const result = await fetchJsonPage(url, { fetchImpl, signal, retries }); - if (!result.ok) { error = result.error; coverage = pages === 0 ? 'unavailable' : 'partial'; break; } + const result = await fetchJsonPage(url, { fetchImpl, signal, retries, timeoutMs }); + if (!result.ok) { error = result.error; timedOut = result.timedOut === true; coverage = pages === 0 ? 'unavailable' : 'partial'; break; } pages += 1; const features = Array.isArray(result.body.features) ? result.body.features : []; - for (const feature of features) cells.push(normalizeCell(feature)); - if (result.body.exceededTransferLimit === true) { offset += features.length; continue; } - break; + let added = 0; + for (const feature of features) { + const cell = normalizeCell(feature); + if (seen.has(cell.cellId)) continue; + seen.add(cell.cellId); + cells.push(cell); + added += 1; + } + if (result.body.exceededTransferLimit !== true) break; + if (added === 0) { error = 'The quad index flagged more pages but returned nothing new.'; coverage = 'partial'; break; } + if (pages >= maxPages) { error = `The quad index kept paging past ${maxPages} pages.`; coverage = 'partial'; break; } + offset += features.length; } if (coverage === 'complete') { for (const arithmetic of cellsForBbox(bboxLonLat)) { @@ -133,18 +145,18 @@ export async function fetchCellsForBbox({ } const out = { coverage, cells, pages, warnings }; if (error) out.error = error; + if (timedOut) out.timedOut = true; return out; } -export async function searchByName({ name, state, fetchImpl = globalThis.fetch, signal, limit = 20 }) { +export async function searchByName({ name, state, fetchImpl = globalThis.fetch, signal, limit = 20, timeoutMs = TIMEOUT_MS }) { let url; try { url = buildNameQueryUrl({ name, state, limit }); } catch (err) { return { ok: false, cells: [], error: err.message }; } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs }); + if (!got.ok) return { ok: false, cells: [], error: got.error, ...(got.status === 'timeout' ? { timedOut: true } : {}) }; + if (got.body?.error) return { ok: false, cells: [], error: got.body.error.message || 'USGS quad index returned an error.' }; try { - const res = await fetchImpl(url, { signal }); - if (!res.ok) return { ok: false, cells: [], error: `HTTP ${res.status}` }; - const body = await res.json(); - if (body?.error) return { ok: false, cells: [], error: body.error.message || 'USGS quad index returned an error.' }; - const features = Array.isArray(body.features) ? body.features : []; + const features = Array.isArray(got.body?.features) ? got.body.features : []; return { ok: true, cells: features.map(normalizeCell), error: null }; } catch (err) { return { ok: false, cells: [], error: err?.message || String(err) }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js index 2f4b985..3e4bef1 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js @@ -22,6 +22,7 @@ // product is in force -- never that the ground is free of hazards. Every reader-facing string // is built here so scripts/qcm/nws-alerts.test.mjs can scan the lot. import { SOURCES } from './source-registry.js'; +import { withDeadline } from './request.js'; const SRC = SOURCES.nwsAlerts; export const SERVICE = SRC.serviceUrl; @@ -180,9 +181,9 @@ export async function fetchAlerts({ lat, lon, fetchImpl = fetch, signal } = {}) } } -// QUX-09b (D5): the same 20 s ceiling source-weather.js's loader carries -- a hung request must -// still land in FAILED_TEXT above, not hold this card's pending note up forever. -export const DEFAULT_TIMEOUT_MS = 20000; +// QUX-09b (D5): the registry's per-source deadline, as source-weather.js's loader carries -- a hung +// request must still land in FAILED_TEXT above, not hold this card's pending note up forever. +export const DEFAULT_TIMEOUT_MS = SRC.timeoutMs; /** One place at a time. Move the map from A to B and A's slower answer can land second; * showing it puts A's warnings under B's name. The previous ask is aborted, and the page @@ -191,10 +192,9 @@ let live = null; export async function alertsFor({ lat, lon, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { if (live) live.abort(); const ctl = (live = new AbortController()); - const signal = typeof AbortSignal.any === 'function' - ? AbortSignal.any([ctl.signal, AbortSignal.timeout(timeoutMs)]) - : ctl.signal; - const got = await fetchAlerts({ lat, lon, fetchImpl, signal }); + const deadline = withDeadline(ctl.signal, timeoutMs); + let got; + try { got = await fetchAlerts({ lat, lon, fetchImpl, signal: deadline.signal }); } finally { deadline.dispose(); } if (live === ctl) live = null; return got; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js b/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js index 2d86b53..35a4465 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js @@ -197,7 +197,15 @@ const CALTRANS_COVERAGE_AREAS = Object.freeze([ { id: 'ca-d12', name: 'District 12 (Orange County)', serviceUrl: `${CALTRANS_CCTV_BASE}/d12/cctv/cctvStatusD12.json`, bbox: { minLon: -118.2, minLat: 33.2, maxLon: -117.4, maxLat: 34.1 } }, ].map((area) => Object.freeze({ ...area, bbox: Object.freeze(area.bbox) }))); -export const SOURCES = deepFreeze({ +/** The deadline for one request to a source that has not cited a slower measurement. + * Per source, not one number for the app: the GNIS names path is measured at 31 s. + * Only the modules moved onto request.js read it so far (GNIS names, quad index, the NWS + * forecast and alerts loaders); the rest still run at REQUEST_POLICY.timeoutMs until PR-B. */ +export const DEFAULT_SOURCE_TIMEOUT_MS = 10000; +const withTimeouts = (sources) => Object.fromEntries(Object.entries(sources) + .map(([key, src]) => [key, { timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, ...src }])); + +export const SOURCES = deepFreeze(withTimeouts({ usgsTopo: { id: 'usgsTopo', name: 'USGS Topo basemap', @@ -298,6 +306,10 @@ export const SOURCES = deepFreeze({ attribution: 'USGS Geographic Names Information System (GNIS) via The National Map', termsUrl: USGS_TERMS_URL, dateBasis: 'service-metadata', + // Keeps the old 20 s, not the 10 s default: source-terrain-names.js records measured + // latency up to 31 s, and a flat 10 s would make the place-names path time out routinely. + timeoutMs: 20000, + timeoutMeasured: 'up to 31 s (source-terrain-names.js, fetchJson)', // A published gazetteer compilation with a vintage, not a reading of today. claim: 'inventory', coverageSemantics: 'Named populated places and physical/hydrographic features from the federal ' @@ -2239,7 +2251,7 @@ export const SOURCES = deepFreeze({ + 'picture was taken.', measuredOn: '2026-10-05', }, -}); +})); export const APPROVED_ORIGINS = Object.freeze( [...new Set(Object.values(SOURCES).map((s) => s.origin))].sort(), diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-names.js b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-names.js index 61c6ef0..f959ea8 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-names.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-names.js @@ -6,10 +6,14 @@ // (measured 2026-09-25) -- every `where` this module builds passes reader text // through escapeLiteral first and always wraps it in a single quoted literal, so // there is nothing for a caller to concatenate raw. -import { SOURCES, REQUEST_POLICY } from './source-registry.js'; +import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { normalizeFeature, kindsForZoom, KINDS } from './terrain-names.js'; +export { createSearchGuard } from './request.js'; + const SOURCE = SOURCES.gnisNames; +const TIMEOUT_MS = SOURCE.timeoutMs; const LAYERS_BY_ID = Object.fromEntries(SOURCE.layers.map((l) => [l.id, l])); const DEFAULT_CAP = SOURCE.limits.recordBudgetPerExtent; const PAGE_SIZE_CAP = SOURCE.limits.maxRecordCount; @@ -136,33 +140,17 @@ function readCount(body) { return Number.isFinite(Number(body.count)) ? Number(body.count) : null; } -/** One request, capped at REQUEST_POLICY.timeoutMs (measured latency up to 31s; - * no automatic retry loop here -- a single slow/failed request fails the whole - * bounded fetch to `error`/`timeout` rather than masking it). */ +/** One request, capped at `timeoutMs` (default: the registry's per-source value for GNIS, + * 20 s -- measured latency up to 31 s, so a flat 10 s would time out routinely; no + * automatic retry loop here -- a single slow/failed request fails the whole bounded + * fetch to `error`/`timeout` rather than masking it). The deadline, the caller-abort + * rule and the cleanup live in request.js; this adds the ArcGIS `error` body check. */ export async function fetchJson(url, { fetchImpl, signal, timeoutMs }) { - const controller = new AbortController(); - const onAbort = () => controller.abort(); - if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort); } - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetchImpl(url, { signal: controller.signal }); - if (!res.ok) return { ok: false, timedOut: false, error: `HTTP ${res.status}` }; - let body; - try { body = await res.json(); } catch { return { ok: false, timedOut: false, error: 'Malformed JSON response.' }; } - const err = readError(body); - if (err) return { ok: false, timedOut: false, error: err }; - return { ok: true, body }; - } catch (err) { - // A fetch abandoned via AbortController rejects with an AbortError -- either - // this module's own 20s deadline firing, or the underlying request giving up. - // An externally passed `signal` aborting is the caller's cancellation, not a - // service timeout, so it is excluded here. - const timedOut = err?.name === 'AbortError' && !(signal && signal.aborted); - return { ok: false, timedOut, error: err?.message || String(err) }; - } finally { - clearTimeout(timer); - if (signal) signal.removeEventListener('abort', onAbort); - } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs }); + if (!got.ok) return { ok: false, timedOut: got.status === 'timeout', error: got.error }; + const err = readError(got.body); + if (err) return { ok: false, timedOut: false, error: err }; + return { ok: true, body: got.body }; } /** A submission longer than this never reaches the network -- the caller shows a @@ -180,7 +168,7 @@ export const MAX_QUERY_LEN = 80; * - `{ status: 'too-long', max }` -- longer than MAX_QUERY_LEN; no request sent. * - `{ status: 'aborted' }` -- the caller's own `signal` cancelled this * request (a newer submission superseded it). - * - `{ status: 'timeout' }` -- exceeded `timeoutMs` (default REQUEST_POLICY). + * - `{ status: 'timeout' }` -- exceeded `timeoutMs` (default: GNIS's registry timeoutMs). * - `{ status: 'error', error }` -- malformed where clause, HTTP failure, or * unparseable JSON. * - `{ status: 'ready', features, capped }` -- normalized, deduped by gaz_id; @@ -189,7 +177,7 @@ export const MAX_QUERY_LEN = 80; */ export async function searchByName(text, { state = null, kinds = null, limit = 20, - fetch: fetchImpl = globalThis.fetch, signal, timeoutMs = REQUEST_POLICY.timeoutMs, + fetch: fetchImpl = globalThis.fetch, signal, timeoutMs = TIMEOUT_MS, } = {}) { const trimmed = typeof text === 'string' ? text.trim() : ''; if (!trimmed) return { status: 'empty' }; @@ -236,19 +224,6 @@ export function zoomForKind(kind) { return Object.prototype.hasOwnProperty.call(ZOOM_BY_KIND, kind) ? ZOOM_BY_KIND[kind] : FALLBACK_ZOOM; } -/** A late response for a query the reader has already replaced must never - * overwrite what is on screen. `bump()` on every new submission, hand the - * returned token to the in-flight call, and check `isCurrent(token)` once it - * resolves before rendering -- an older token means a newer search has since - * started and this result is stale. Pure counter: carries no fetch, no timers. */ -export function createSearchGuard() { - let generation = 0; - return { - bump() { return ++generation; }, - isCurrent(token) { return token === generation; }, - }; -} - /** Rung order the budget is spent in: places, then summits/gaps/ridges/landforms, * then water, then stream mouths (the priority ladder in terrain-names.js). */ const RUNG_ORDER = [3, 5, 7, 6]; @@ -284,7 +259,7 @@ export async function fetchBounded(bounds, kinds, { fetch: fetchImpl = globalThi wanted = kinds.filter((c) => allowed.has(c)); } const groups = groupByLayer(wanted); - const timeoutMs = REQUEST_POLICY.timeoutMs; + const timeoutMs = TIMEOUT_MS; const ordered = RUNG_ORDER.filter((l) => groups.has(l)).map((l) => [l, groups.get(l)]); // Count and decide, rung by rung, what the budget buys. diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js b/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js index 3910cb0..1cb24dd 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js @@ -4,6 +4,7 @@ // content/quiet-corners-maps.md. TWO STEPS: `/points/{lat},{lon}` maps a lat/lon to an office and grid cell and hands back the // forecast URL, used AS GIVEN -- hand-building `/gridpoints/BMX/96,76/...` hard-codes a grid the office may renumber. import { SOURCES } from './source-registry.js'; +import { withDeadline } from './request.js'; const API = SOURCES.nwsForecast.origin; // The refresh intervals are the service's own cache-control, not taste: `max-age=3600` on the hourly product, `max-age=85988` on /points -- a grid cell never moves. @@ -156,11 +157,11 @@ export async function fetchHourly({ lat, lon, fetchImpl = fetch, signal, onPoint /** One forecast at a time for one selected place. Move the map from A to B and A's slower answer can land second; showing it * puts A's weather under B's name, which is worse than showing nothing. Each request takes a ticket; an answer whose ticket is * no longer the latest comes back `superseded`, never becomes `current`, and is aborted. The memo enforces REFRESH_MS. */ -// QUX-09b (D5): a request must not hold a pending note up forever -- 20 s, combined with the -// caller's own abort so a superseded pick still cancels immediately. `AbortSignal.any` is Node 20+ -// and every evergreen browser; the plain controller signal is the fallback for a test double that -// does not implement it. -export const DEFAULT_TIMEOUT_MS = 20000; +// QUX-09b (D5): a request must not hold a pending note up forever -- the registry's per-source +// deadline, combined with the caller's own abort so a superseded pick still cancels immediately. +// The deadline comes from request.js's withDeadline, which needs no `AbortSignal.any`: a browser +// or test double without it used to lose the timeout altogether. +export const DEFAULT_TIMEOUT_MS = SOURCES.nwsForecast.timeoutMs; export function createForecastLoader({ load = fetchHourly, fetchImpl, now = () => Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { let seq = 0, inFlight = null, memo = null; @@ -175,12 +176,11 @@ export function createForecastLoader({ load = fetchHourly, fetchImpl, now = () = const ticket = ++seq; if (inFlight) inFlight.abort(); inFlight = new AbortController(); - const signal = typeof AbortSignal.any === 'function' - ? AbortSignal.any([inFlight.signal, AbortSignal.timeout(timeoutMs)]) - : inFlight.signal; + const deadline = withDeadline(inFlight.signal, timeoutMs); // A superseded request's early `onPoint` would stamp the abandoned place's zone on the card. const opts = rest.onPoint ? { ...rest, onPoint: (p) => { if (ticket === seq) rest.onPoint(p); } } : rest; - const result = await load({ lat, lon, fetchImpl, signal, ...opts }); + let result; + try { result = await load({ lat, lon, fetchImpl, signal: deadline.signal, ...opts }); } finally { deadline.dispose(); } if (ticket !== seq) return { status: 'superseded', key }; memo = { key, at: now(), result }; return (loader.current = result); From 0a7d421b29bc3892e60a0d974dac1605ca6da93a Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sat, 24 Oct 2026 12:59:02 -0400 Subject: [PATCH 2/2] fix(qcm): every remote read bounded, cancelable and latest-wins (modules) Every source module now reads through request.js fetchWithDeadline instead of a raw fetch, so each ends on its own deadline and a caller's cancel ends it as a cancel. A hung service used to leave a pending note up forever. - Default deadlines come from the source registry (10 s). The 20 s REQUEST_POLICY value is no longer a request deadline. A caller's timeoutMs still wins, and pager callers pass their registry entry. - request.js gains `anyStatus` (read a non-2xx body) and `orThrow`. No signature or return shape changes. - build.js passes the signal through and checks for a cancel after each extra, so a cancel ends the build instead of reading as "this extra failed". - fetchAqi, fetchFire, fetchAccess and fetchSmoke keep "never throws the map": a missing or malformed area answers null. no-raw-fetch.test.mjs fails on a new raw fetch call outside request.js. adapter-deadlines.test.mjs runs every adapter against a fetch that never answers, and fails when a new source module is missing from its table. Seven modules reached master after this work was written (the E-P1..E-P6 field-map layers: avalanche, NRHP, NTAD rail and airports, NWS observations, SNOTEL, stream gauges; and address-geocode.js). They still make their own raw calls, so main.js plus those seven are on the no-raw-fetch allow list, and the six source modules sit in a `pending` list in adapter-deadlines.test.mjs. Each list fails if an entry stops matching. address-geocode.js's header comment no longer says there is no shared request helper. QQA-13 PR-B1. Ports headlessmode-archive c2e09cbe (PR #788, rebuilt head 95ff4d8f) onto master, stacked on the PR-A branch. --- scripts/qcm/README.md | 6 +- scripts/qcm/adapter-deadlines.test.mjs | 238 ++++++++++++++++++ scripts/qcm/build.test.mjs | 37 ++- scripts/qcm/no-raw-fetch.test.mjs | 106 ++++++++ scripts/qcm/registry.test.mjs | 3 + scripts/qcm/request.test.mjs | 43 +++- .../js/quiet-corners-maps/address-geocode.js | 4 +- .../js/quiet-corners-maps/arcgis-pager.js | 9 +- .../static/js/quiet-corners-maps/build.js | 17 +- .../js/quiet-corners-maps/camera-layer.js | 14 +- .../static/js/quiet-corners-maps/request.js | 25 +- .../js/quiet-corners-maps/source-aerial.js | 10 +- .../js/quiet-corners-maps/source-aqi.js | 8 +- .../js/quiet-corners-maps/source-aurora.js | 10 +- .../source-blm-rec-sites.js | 2 +- .../quiet-corners-maps/source-county-lines.js | 2 + .../quiet-corners-maps/source-ecoregions.js | 11 +- .../js/quiet-corners-maps/source-elevation.js | 9 +- .../quiet-corners-maps/source-federal-land.js | 2 + .../js/quiet-corners-maps/source-fire.js | 8 +- .../quiet-corners-maps/source-flood-zones.js | 7 +- .../js/quiet-corners-maps/source-gbif.js | 7 +- .../js/quiet-corners-maps/source-hillshade.js | 37 +-- .../source-land-boundaries.js | 2 + .../quiet-corners-maps/source-land-cover.js | 18 +- .../js/quiet-corners-maps/source-mvum.js | 28 +-- .../quiet-corners-maps/source-nws-alerts.js | 28 ++- .../js/quiet-corners-maps/source-nws-radar.js | 14 +- .../js/quiet-corners-maps/source-padus.js | 8 +- .../js/quiet-corners-maps/source-rec-sites.js | 2 + .../js/quiet-corners-maps/source-registry.js | 15 +- .../quiet-corners-maps/source-road-basic.js | 3 +- .../quiet-corners-maps/source-road-names.js | 20 +- .../js/quiet-corners-maps/source-smoke.js | 13 +- .../quiet-corners-maps/source-stream-names.js | 4 +- .../source-surface-ownership.js | 2 + .../source-terrain-surface.js | 13 +- .../js/quiet-corners-maps/source-terrain.js | 10 +- .../js/quiet-corners-maps/source-trails.js | 2 + .../js/quiet-corners-maps/source-usgs-topo.js | 17 +- .../js/quiet-corners-maps/source-weather.js | 16 +- .../js/quiet-corners-maps/source-wetlands.js | 15 +- 42 files changed, 648 insertions(+), 197 deletions(-) create mode 100644 scripts/qcm/adapter-deadlines.test.mjs create mode 100644 scripts/qcm/no-raw-fetch.test.mjs diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index 91af9c7..fd015a3 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -292,11 +292,11 @@ switch-off, an older decode landing after a newer ask, and an uninterrupted ask ## Remote reads (QQA-13) -`quiet-corners-maps/request.js` is the one way the map reads from the network. `fetchWithDeadline(url, { fetchImpl, signal, timeoutMs })` answers `{ ok, status: 'ok' | 'timeout' | 'aborted' | 'error' }` and never throws. Its timer is a plain `AbortController`, so it works without `AbortSignal.any`, it ends a fetch that ignores its signal, and it clears the timer and the listener on the caller's signal when the call settles. A caller's cancel is `aborted`, never `timeout`. `createLatest()` is latest-wins: a new call aborts the previous one and the older answer comes back `stale`. `source-terrain-names.js` re-exports `fetchJson` and `createSearchGuard` from it for the callers that already import them. +`quiet-corners-maps/request.js` is the one way the map reads from the network. `fetchWithDeadline(url, { fetchImpl, signal, timeoutMs, init, read, anyStatus })` (`read` swaps the body reader, e.g. `r => r.blob()`; `anyStatus` also reads the body of a non-2xx for services whose error answer carries the reason) answers `{ ok, status: 'ok' | 'timeout' | 'aborted' | 'error' }` and never throws. Its timer is a plain `AbortController`, so it works without `AbortSignal.any`, it ends a fetch that ignores its signal, and it clears the timer and the listener on the caller's signal when the call settles. A caller's cancel is `aborted`, never `timeout`. `orThrow(result, message)` is for the few adapters that throw on failure: the body of an ok result, else an Error carrying the result's `status` and `httpStatus` (a caller's cancel keeps the name `AbortError`). `createLatest()` is latest-wins: a new call aborts the previous one and the older answer comes back `stale`. `source-terrain-names.js` re-exports `fetchJson` and `createSearchGuard` from it for the callers that already import them. -The deadline is per source: `SOURCES..timeoutMs` in `source-registry.js`, `DEFAULT_SOURCE_TIMEOUT_MS` (10 s) unless the source cites a slower measurement in `timeoutMeasured`. GNIS place names keep 20 s because their latency is measured up to 31 s. Only the modules moved onto the helper honour it so far: GNIS place names, the quad index, and the NWS forecast and alerts loaders. Every other source (road names, hillshade, MVUM, stream names, topo tiles and the rest) carries the value but still runs at `REQUEST_POLICY.timeoutMs` (20 s) until it is converted. The quad index pages at most `MAX_PAGES` (20) times and stops on a page that adds no new cell, ending `coverage: 'partial'`; its `timeoutMs` bounds each attempt, including `searchByName`, and a timeout is not retried. +The deadline is per source: `SOURCES..timeoutMs` in `source-registry.js`, `DEFAULT_SOURCE_TIMEOUT_MS` (10 s) unless the source cites a slower measurement in `timeoutMeasured`. GNIS place names keep 20 s because their latency is measured up to 31 s, and the wetlands and government-units layers keep the 20 s page their measurements were taken against. Every module in that directory except `main.js` and the seven listed next reads through the helper at its own source's value (the paged boundary and site callers pass it to `pageThrough`; a module with no registry entry, such as the ecoregions export, uses the default). `main.js` still has raw reads until PR-B2. The seven that reached master after this work was written (`address-geocode.js`, `source-avalanche.js`, `source-nrhp.js`, `source-ntad-rail-airports.js`, `source-nws-observations.js`, `source-snotel.js`, `source-stream-gauges.js`) still make their own raw calls, with no deadline from the helper, and are on the `ALLOW` list until a follow-up moves them. `REQUEST_POLICY.timeoutMs` is no longer a request deadline. The quad index pages at most `MAX_PAGES` (20) times and stops on a page that adds no new cell, ending `coverage: 'partial'`; its `timeoutMs` bounds each attempt, including `searchByName`, and a timeout is not retried. -`request.test.mjs` covers the helper (timeout, abort, cleanup, no `AbortSignal.any`, latest-wins); `map-index.test.mjs`, `arcgis-pager.test.mjs` and `registry.test.mjs` hold the quad paging cap, the pager deadline and the per-source `timeoutMs` rule. +`request.test.mjs` covers the helper (timeout, abort, cleanup, no `AbortSignal.any`, `anyStatus`, `orThrow`, latest-wins); `no-raw-fetch.test.mjs` fails on any raw `fetch(` or `fetchImpl(` call in that directory outside `request.js` and its `ALLOW` list (`main.js`, removed in PR-B2, and the seven named above; it also fails if an entry stops holding a raw call, so the list cannot rot); `adapter-deadlines.test.mjs` hands every adapter that takes `fetchImpl` a fetch that never answers and asserts it settles at its deadline, and that a caller's abort ends it as a cancel, not a timeout (a new `source-*.js` module that takes `fetchImpl` must be added to its table); `build.test.mjs` holds that the build's cancel reaches the aerial, relief and elevation requests; `map-index.test.mjs`, `arcgis-pager.test.mjs` and `registry.test.mjs` hold the quad paging cap, the pager deadline and the per-source `timeoutMs` rule. ## Accessibility checks diff --git a/scripts/qcm/adapter-deadlines.test.mjs b/scripts/qcm/adapter-deadlines.test.mjs new file mode 100644 index 0000000..9047b53 --- /dev/null +++ b/scripts/qcm/adapter-deadlines.test.mjs @@ -0,0 +1,238 @@ +// Every adapter that reads from a remote service ends on its own deadline, and a caller's +// cancel ends it as a cancel. One table, one never-answering fetch: a fetch that hangs is the +// failure this guards (the page waits on a pending note forever), so no adapter is tested +// against a slow-but-answering one here. +// +// An adapter that takes a `timeoutMs` is run with a small one in real time. One that does +// not is run on node:test mock timers, with the clock moved by its registry timeoutMs, so +// the table stays fast and still pins the value the adapter really uses. `rounds` is how many +// answers in a row the adapter waits for before it can finish (a count, then a page). +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +const M = '../../themes/headlessmode/static/js/quiet-corners-maps/'; +const mod = async (name) => import(`${M}${name}.js`); +const { SOURCES, DEFAULT_SOURCE_TIMEOUT_MS } = await mod('source-registry'); +const { pageThrough } = await mod('arcgis-pager'); +const { fetchCellsForBbox, searchByName } = await mod('source-map-index'); +const { fetchCaltransCctv } = await mod('camera-layer'); +const { fetchAerial } = await mod('source-aerial'); +const { fetchTerrain } = await mod('source-terrain'); +const { fetchElevations, profileFor } = await mod('source-elevation'); +const { fetchAqi } = await mod('source-aqi'); +const { fetchAurora } = await mod('source-aurora'); +const { loadEcoregions } = await mod('source-ecoregions'); +const { fetchFire } = await mod('source-fire'); +const { fetchSmoke } = await mod('source-smoke'); +const { fetchFloodView } = await mod('source-flood-zones'); +const { fetchJson: fetchGbif } = await mod('source-gbif'); +const { createHillshadeFetcher, hillshadeRequestFor } = await mod('source-hillshade'); +const landCover = await mod('source-land-cover'); +const terrainSurface = await mod('source-terrain-surface'); +const { fetchLayer: fetchMvum } = await mod('source-mvum'); +const { fetchAlerts, alertsFor } = await mod('source-nws-alerts'); +const { fetchFrameTime } = await mod('source-nws-radar'); +const { fetchAccess } = await mod('source-padus'); +const { createRoadNamesFetcher } = await mod('source-road-names'); +const { fetchTiles } = await mod('source-usgs-topo'); +const { fetchHourly } = await mod('source-weather'); +const { fetchWetlands } = await mod('source-wetlands'); +const { fetchStreamNames } = await mod('source-stream-names'); +const { fetchBlmRecSites } = await mod('source-blm-rec-sites'); +const { fetchCountyLines } = await mod('source-county-lines'); +const { fetchFederalLand } = await mod('source-federal-land'); +const { fetchRecSites } = await mod('source-rec-sites'); +const { fetchRoadQualityReport } = await mod('source-road-basic'); +const { fetchSurfaceOwnership } = await mod('source-surface-ownership'); +const { fetchTrails } = await mod('source-trails'); +const { fetchLandBoundaries } = await mod('source-land-boundaries'); +const stateSources = await mod('state-sources'); + +const SLACK_MS = 250; +const SMALL_MS = 50; +const never = () => new Promise(() => {}); +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +const bbox = { minLon: -83.6, minLat: 35.0, maxLon: -83.4, maxLat: 35.2 }; +const sheet = { id: 'S1', pixel: { width: 800, height: 600 } }; +const feature = { id: 'r1', name: 'A trail', paths: [[[-83.5, 35.0], [-83.49, 35.01]]] }; +const landPair = (() => { + for (const state of stateSources.STATE_CODES) { + for (const family of stateSources.LAYER_IDS) { + const layer = stateSources.layerFor(state, family); + if (layer && stateSources.isBrowserFetchable(layer) && stateSources.isSemanticallyUniform(layer)) return { state, family }; + } + } + throw new Error('no state layer is fetchable from a browser'); +})(); + +// name, the registry source whose timeoutMs it must use (null: DEFAULT_SOURCE_TIMEOUT_MS), whether +// it takes `timeoutMs`, the sequential answers it waits for, and how to call it. +const ROWS = [ + { name: 'pageThrough', param: true, call: (o) => pageThrough({ url: 'x', params: {}, normalize: (r) => r, ...o }) }, + { name: 'fetchCellsForBbox', source: 'usgsQuadIndex', param: true, call: (o) => fetchCellsForBbox({ bboxLonLat: bbox, retries: 0, ...o }) }, + { name: 'searchByName', source: 'usgsQuadIndex', param: true, call: (o) => searchByName({ name: 'rainbow springs', ...o }) }, + { name: 'fetchStreamNames', source: 'nhdFlowlines', param: true, call: ({ fetchImpl, ...o }) => fetchStreamNames(bbox, { zoom: 13, fetch: fetchImpl, ...o }) }, + { name: 'fetchCaltransCctv', source: 'caltransCctv', call: (o) => fetchCaltransCctv(o) }, + { name: 'fetchAerial', source: 'usdaNaip', call: (o) => fetchAerial({ sheet, bboxLonLat: bbox, ...o }) }, + { name: 'fetchTerrain', source: 'usgs3dep', call: (o) => fetchTerrain({ sheet, bboxLonLat: bbox, ...o }) }, + { name: 'fetchElevations', source: 'usgs3dep', call: (o) => fetchElevations({ points: [[-83.5, 35.0]], ...o }) }, + { name: 'profileFor', source: 'usgs3dep', call: (o) => profileFor(feature, o) }, + { name: 'fetchAqi', source: 'epaAirNowAqi', call: ({ fetchImpl, signal }) => fetchAqi(bbox, { fetchImpl, signal }) }, + { name: 'fetchAurora', source: 'swpcAurora', call: (o) => fetchAurora(o) }, + { name: 'loadEcoregions', source: null, call: (o) => loadEcoregions(o) }, + { name: 'fetchFire', source: 'nifcFirePerimeters', call: ({ fetchImpl, signal }) => fetchFire(bbox, { fetchImpl, signal }) }, + { name: 'fetchSmoke', source: 'noaaHmsSmoke', call: ({ fetchImpl, signal }) => fetchSmoke(bbox, { fetchImpl, signal }) }, + { name: 'fetchFloodView', source: 'femaNfhl', call: ({ fetchImpl, signal }) => fetchFloodView(bbox, { fetchImpl, signal }) }, + { name: 'gbif fetchJson', source: 'gbifOccurrence', call: ({ fetchImpl, signal }) => fetchGbif('https://api.gbif.org/v1/x', { fetchImpl, signal }) }, + { name: 'createHillshadeFetcher', source: 'usgs3depHillshade', param: true, timeoutIn: 'factory', + call: ({ fetchImpl, signal, timeoutMs }) => createHillshadeFetcher({ fetchImpl, ...(timeoutMs ? { timeoutMs } : {}) }) + .fetchImage(hillshadeRequestFor({ bboxLonLat: bbox, width: 400, height: 300 }), { signal }), + verdict: (v) => v.status }, + { name: 'land-cover fetchSurface', source: 'nlcdLandCover', + call: ({ fetchImpl, signal }) => landCover.fetchSurface(landCover.surfaceRequestFor({ bboxLonLat: bbox, width: 400, height: 300 }), { fetchImpl, signal }) }, + { name: 'land-cover fetchPoint', source: 'nlcdLandCover', + call: ({ fetchImpl, signal }) => landCover.fetchPoint(landCover.pointRequestFor({ lon: -83.5, lat: 35.1 }), { fetchImpl, signal }) }, + { name: 'fetchMvum', source: 'usfsMvum', param: true, rounds: 2, + call: (o) => fetchMvum({ layer: 'roads', bboxLonLat: bbox, retries: 0, ...o }) }, + { name: 'fetchAlerts', source: 'nwsAlerts', call: (o) => fetchAlerts({ lat: 35.1, lon: -83.5, ...o }), verdict: (v) => v.detail }, + { name: 'alertsFor', source: 'nwsAlerts', param: true, call: ({ fetchImpl, signal, timeoutMs }) => alertsFor({ lat: 35.1, lon: -83.5, fetchImpl, timeoutMs, signal }), verdict: (v) => v.detail }, + { name: 'fetchFrameTime', source: 'nwsRadar', call: (o) => fetchFrameTime(o) }, + { name: 'fetchAccess', source: 'padusPublicAccess', call: ({ fetchImpl, signal }) => fetchAccess(bbox, { fetchImpl, signal }) }, + { name: 'fetchRoadNames', source: 'usgsRoadNames', call: ({ fetchImpl, signal }) => createRoadNamesFetcher().fetchRoadNames({ bbox, zoom: 14, fetchImpl, signal }) }, + { name: 'terrain-surface fetchSlopeImage', source: 'usgs3depSurface', + call: ({ fetchImpl, signal }) => terrainSurface.fetchSlopeImage(terrainSurface.slopeRequestFor({ bboxLonLat: bbox, width: 400, height: 300 }), { fetchImpl, signal }) }, + { name: 'terrain-surface fetchPoint', source: 'usgs3depSurface', + call: ({ fetchImpl, signal }) => terrainSurface.fetchPoint({ lon: -83.5, lat: 35.1 }, 'slope', { fetchImpl, signal }) }, + { name: 'fetchTiles', source: 'usgsTopo', param: true, + call: async (o) => { const r = await fetchTiles([{ key: '1/1/1', url: 'https://x.invalid/1' }], { retries: 0, ...o }); return r.results.get('1/1/1').error; } }, + { name: 'fetchHourly', source: 'nwsForecast', call: (o) => fetchHourly({ lat: 35.1, lon: -83.5, ...o }) }, + { name: 'fetchWetlands', source: 'fwsNwiWetlands', rounds: 2, call: ({ fetchImpl, signal }) => fetchWetlands({ bboxLonLat: bbox, fetchImpl, signal }) }, + // The pager callers have no timeoutMs of their own: the value they hand pageThrough is what is pinned. + { name: 'fetchBlmRecSites', source: 'blmRecSites', call: (o) => fetchBlmRecSites({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchCountyLines', source: 'usgsGovUnits', call: (o) => fetchCountyLines({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchFederalLand', source: 'usgsGovUnits', call: (o) => fetchFederalLand({ family: 'nationalPark', bboxLonLat: bbox, ...o }) }, + { name: 'fetchRecSites', source: null, call: (o) => fetchRecSites({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchRoadQualityReport', source: null, call: (o) => fetchRoadQualityReport({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchSurfaceOwnership', source: 'usfsSurfaceOwnership', call: (o) => fetchSurfaceOwnership({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchTrails', source: null, call: (o) => fetchTrails({ bboxLonLat: bbox, ...o }) }, + { name: 'fetchLandBoundaries', source: null, call: (o) => fetchLandBoundaries({ ...landPair, bboxLonLat: bbox, ...o }) }, +]; + +const registryMs = (row) => (row.source ? SOURCES[row.source].timeoutMs : DEFAULT_SOURCE_TIMEOUT_MS); + +/** Settles to {value} or {error}, and reports when it has. */ +function watch(promise) { + const out = { done: false }; + promise.then((value) => { out.done = true; out.value = value; }, (error) => { out.done = true; out.error = error; }); + return out; +} + +/** Move the mock clock `ms`, then in 1 ms steps (the helper settles one macrotask after a + * deadline) until the call is done, for at most `steps` steps. */ +async function advance(out, ms, steps = 25) { + await flush(); + mock.timers.tick(ms); + for (let i = 0; i < steps && !out.done; i += 1) { await flush(); mock.timers.tick(1); } + await flush(); +} + +const looksLikeTimeout = (out, row) => { + if (out.error) return !(out.error.name === 'AbortError') && /within \d+ ms|did not answer|time(d)? ?out/i.test(String(out.error.message)); + const text = row.verdict ? row.verdict(out.value) : out.value; + return typeof text === 'string' && /within \d+ ms|did not answer|time(d)? ?out/i.test(text); +}; + +/** The call with no `timeoutMs` passed, on mock timers: not settled one tick short of the + * registry value, settled once it has passed. This is the value the adapter really uses. */ +async function assertRegistryDeadline(t, row) { + const ms = registryMs(row); + const rounds = row.rounds || 1; + mock.timers.enable({ apis: ['setTimeout'] }); + t.after(() => mock.timers.reset()); + const out = watch(row.call({ fetchImpl: never })); + await flush(); + mock.timers.tick(ms - 1); + await flush(); + assert.equal(out.done, false, `settled before its ${ms} ms deadline`); + for (let r = 0; r < rounds && !out.done; r += 1) await advance(out, r === 0 ? 1 : ms); + assert.ok(out.done, `still waiting ${rounds} x ${ms} ms in: the call has no deadline of its own`); +} + +for (const row of ROWS) { + const ms = registryMs(row); + const rounds = row.rounds || 1; + + test(`${row.name} ends on its deadline when the service never answers`, async (t) => { + if (row.param) { + const t0 = Date.now(); + const out = watch(row.call({ fetchImpl: never, timeoutMs: SMALL_MS })); + while (!out.done && Date.now() - t0 < 5000) await new Promise((r) => setTimeout(r, 5)); + assert.ok(out.done, 'still waiting after 5 s'); + assert.ok(Date.now() - t0 < SMALL_MS * rounds + SLACK_MS, `took ${Date.now() - t0} ms`); + return; + } + await assertRegistryDeadline(t, row); + }); + + // The default is the moved value, so a row that takes `timeoutMs` is run once more without it. + if (row.param) test(`${row.name} ends on its registry deadline when no timeoutMs is passed`, (t) => assertRegistryDeadline(t, row)); + + test(`${row.name} ends as a cancel, not a timeout, when the caller aborts`, async (t) => { + mock.timers.enable({ apis: ['setTimeout'] }); + t.after(() => mock.timers.reset()); + const caller = new AbortController(); + const out = watch(row.call({ fetchImpl: never, signal: caller.signal, ...(row.param ? { timeoutMs: ms } : {}) })); + await flush(); + mock.timers.tick(5); + await flush(); + assert.equal(out.done, false, 'settled with nobody asking'); + caller.abort(); + await advance(out, 0); + assert.ok(out.done, 'a caller abort did not end the call'); + assert.equal(looksLikeTimeout(out, row), false, `ended as a timeout: ${out.error?.message ?? JSON.stringify(out.value)}`); + }); +} + +// These four promise "never throws the map": a reader-asked layer whose area is missing or +// malformed reads as "no answer", it does not reject into the caller. Building the URL is part of +// the call, so it sits inside that promise too (B1 moved it out of the old try for three of them; +// fetchSmoke built it synchronously before any promise existed). +test('the never-throws adapters answer null for a missing or malformed area', async () => { + const fetchImpl = async () => { throw new Error('no network in this test'); }; + for (const [name, fn] of [['fetchAqi', fetchAqi], ['fetchFire', fetchFire], ['fetchAccess', fetchAccess], ['fetchSmoke', fetchSmoke]]) { + for (const bbox of [null, undefined, {}]) { + let got; + try { got = await fn(bbox, { fetchImpl }); } catch (e) { assert.fail(`${name}(${JSON.stringify(bbox)}) threw ${e.constructor.name}: ${e.message}`); } + assert.equal(got, null, `${name}(${JSON.stringify(bbox)})`); + } + } +}); + +test('the table covers every source module that reads from a service', async () => { + const { readdirSync } = await import('node:fs'); + const dir = new URL(M, import.meta.url); + // Modules whose adapters are tested above through their exports, or that only build + // requests, plus main.js (PR-B2) and the layer factories, which call these same adapters. + const covered = [ + 'arcgis-pager', 'source-map-index', 'source-terrain-names', 'source-stream-names', 'camera-layer', + 'source-aerial', 'source-terrain', 'source-elevation', 'source-aqi', 'source-aurora', 'source-ecoregions', + 'source-fire', 'source-smoke', 'source-flood-zones', 'source-gbif', 'source-hillshade', 'source-land-cover', + 'source-mvum', 'source-nws-alerts', 'source-nws-radar', 'source-padus', 'source-road-names', + 'source-terrain-surface', 'source-usgs-topo', 'source-weather', 'source-wetlands', 'source-blm-rec-sites', + 'source-county-lines', 'source-federal-land', 'source-rec-sites', 'source-road-basic', + 'source-surface-ownership', 'source-trails', 'source-land-boundaries', + ]; + // Source modules that reached master after QQA-13 and still make their own raw calls (no-raw-fetch.test.mjs + // lists them too). They have no deadline to pin yet; a row replaces the entry when each moves onto request.js. + const pending = [ + 'source-avalanche', 'source-nrhp', 'source-ntad-rail-airports', + 'source-nws-observations', 'source-snotel', 'source-stream-gauges', + ]; + const { readFileSync } = await import('node:fs'); + const reaching = readdirSync(dir).filter((f) => f.startsWith('source-') && f.endsWith('.js')) + .filter((f) => /\bfetchImpl\b|\bfetch:\s*fetchImpl/.test(readFileSync(new URL(f, dir), 'utf8'))) + .map((f) => f.replace(/\.js$/, '')); + assert.deepEqual(reaching.filter((n) => !covered.includes(n) && !pending.includes(n)), [], 'add a row for the new source module'); + assert.deepEqual(pending.filter((n) => !reaching.includes(n)), [], 'no longer reads from a service: drop it from pending'); +}); diff --git a/scripts/qcm/build.test.mjs b/scripts/qcm/build.test.mjs index 7377f9a..ba8bc81 100644 --- a/scripts/qcm/build.test.mjs +++ b/scripts/qcm/build.test.mjs @@ -62,12 +62,12 @@ function stubWorker() { } /** runBuild with every seam stubbed; returns the worker requests and what createPdf was handed. */ -async function build({ mvum = {}, roadQuality, layers, trails, land, sites, fetchImpl, names } = {}) { +async function build({ mvum = {}, roadQuality, layers, trails, land, sites, fetchImpl, names, signal } = {}) { const worker = stubWorker(); const seen = {}; const result = await runBuild({ recipe: layers ? { ...recipe, layers: { ...recipe.layers, ...layers } } : recipe, - plan, workerFactory: () => worker, PDFLib: {}, + plan, workerFactory: () => worker, PDFLib: {}, signal, adapters: { fetchImpl: fetchImpl || (async () => ({ ok: true, status: 200, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer })), createImageBitmap: async () => ({ width: 256, height: 256, close() {} }), @@ -237,6 +237,39 @@ test('the aerial companion is built only when asked, and never fails the map', a assert.ok(broken.seen.sheets.length > 0, 'and the map still printed'); }); +// A cancel must reach the extras' own requests, and end the build instead of being read as +// "this extra failed" and carried on to the next one. +test('the build cancel reaches the aerial and elevation requests and ends the build', async () => { + const first = plan.sheets[0]; + const named = { ...featureOn(first, 'T-1', 'trail', 'yearlong'), symbolKey: 'trail-nst', name: 'Appalachian Trail' }; + const bytes = { ok: true, status: 200, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, json: async () => ({ samples: [] }) }; + // Cancel while one request is in flight, and record whether THAT request's signal followed. + const cancelDuring = async (needle, layers) => { + const cancel = new AbortController(); + const seen = { requestAborted: null, elevationAsked: 0 }; + await assert.rejects(build({ + layers, trails: { features: [named] }, signal: cancel.signal, + fetchImpl: async (url, init) => { + if (String(url).includes('getSamples')) seen.elevationAsked += 1; + if (String(url).includes(needle) && seen.requestAborted === null) { + cancel.abort(); + seen.requestAborted = init.signal.aborted; + throw new Error('cancelled mid-request'); + } + return bytes; + }, + }), { name: 'AbortError' }); + return seen; + }; + const aerial = await cancelDuring('imagery', { aerial: true, tripPage: true, trails: true }); + assert.equal(aerial.requestAborted, true, 'the aerial request never heard the cancel'); + assert.equal(aerial.elevationAsked, 0, 'a cancelled aerial sheet rolled on to the trip page'); + const relief = await cancelDuring('elevation.nationalmap.gov', { terrain: true, tripPage: true, trails: true }); + assert.equal(relief.requestAborted, true, 'the relief request never heard the cancel'); + const profile = await cancelDuring('getSamples', { tripPage: true, trails: true }); + assert.equal(profile.requestAborted, true, 'the elevation profile never heard the cancel'); +}); + // The two newest layers, at the seam the parity invariant actually broke at once: // a boundary reaches the SHEET through state.land and the preview through the same // list, so a federal boundary that prints must be a federal boundary that previews. diff --git a/scripts/qcm/no-raw-fetch.test.mjs b/scripts/qcm/no-raw-fetch.test.mjs new file mode 100644 index 0000000..4ba5de9 --- /dev/null +++ b/scripts/qcm/no-raw-fetch.test.mjs @@ -0,0 +1,106 @@ +// Every remote read in the map goes through request.js, so no module can forget its +// deadline or its cancel. This is the review-time half of that rule: a module that calls +// fetch( or fetchImpl( itself fails here, before a reader meets a request that never ends. +// It reads the source text only -- comments and string contents are blanked first, so +// prose that mentions fetch() is not a call. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIR = new URL('../../themes/headlessmode/static/js/quiet-corners-maps/', import.meta.url); +const HELPER = 'request.js'; +// PR-B2 moves main.js onto request.js and removes this entry (and the list). Until then the +// page's own call sites (camera snapshot, the tile probe, the print adapters) are the only raw calls. +// +// The other seven entries are modules that reached master after QQA-13 was written (the E-P1..E-P6 +// field-map layers and the finder's street-address lookup). Each still owns a raw call and its own +// signal, so none has a deadline from request.js yet. Moving them onto fetchWithDeadline is a +// follow-up; until then they are listed so this test guards every other module, and the stale-entry +// test below drops an entry the day its raw call goes. +const ALLOW = [ + 'main.js', + 'address-geocode.js', + 'source-avalanche.js', 'source-nrhp.js', 'source-ntad-rail-airports.js', + 'source-nws-observations.js', 'source-snotel.js', 'source-stream-gauges.js', +]; + +// A call to fetch/fetchImpl that is not a method of something else (`x.fetch(` is not it) +// -- except the global spellings, which are the same call. +const RAW_CALL = /(?:^|[^A-Za-z0-9_$.])(?:globalThis\.|window\.|self\.)?(?:fetch|fetchImpl)\s*\(/; + +/** The source with comments, string contents and template text blanked to spaces, keeping + * `${ ... }` code and every newline, so a hit's line number is the file's. */ +function codeOnly(src) { + let out = ''; + const stack = []; // 'tpl' for an open template, 'expr' for ${ inside one + let i = 0; + const blank = (ch) => (ch === '\n' ? '\n' : ' '); + while (i < src.length) { + const ch = src[i], next = src[i + 1], top = stack[stack.length - 1]; + if (top === 'tpl') { + if (ch === '\\') { out += ' ' + blank(next); i += 2; continue; } + if (ch === '`') { stack.pop(); out += ' '; i += 1; continue; } + if (ch === '$' && next === '{') { stack.push('expr'); out += ' '; i += 2; continue; } + out += blank(ch); i += 1; continue; + } + if (ch === '/' && next === '/') { while (i < src.length && src[i] !== '\n') { out += ' '; i += 1; } continue; } + if (ch === '/' && next === '*') { + const end = src.indexOf('*/', i + 2), stop = end === -1 ? src.length : end + 2; + for (; i < stop; i += 1) out += blank(src[i]); + continue; + } + if (ch === "'" || ch === '"') { + out += ' '; i += 1; + while (i < src.length && src[i] !== ch && src[i] !== '\n') { if (src[i] === '\\') { out += ' '; i += 1; } out += blank(src[i]); i += 1; } + if (src[i] === ch) { out += ' '; i += 1; } + continue; + } + if (ch === '`') { stack.push('tpl'); out += ' '; i += 1; continue; } + if (top === 'expr' && ch === '}') { stack.pop(); out += ' '; i += 1; continue; } + if (top === 'expr' && ch === '{') { stack.push('expr'); out += ch; i += 1; continue; } + out += ch; i += 1; + } + return out; +} + +const rawCalls = (src) => codeOnly(src).split('\n') + .map((line, n) => (RAW_CALL.test(line) ? n + 1 : 0)).filter(Boolean); + +const files = readdirSync(DIR).filter((f) => f.endsWith('.js')).sort(); +const read = (f) => readFileSync(join(DIR.pathname, f), 'utf8'); + +test('the detector sees a call and ignores prose', () => { + assert.deepEqual(rawCalls('const r = await fetchImpl(url, { signal });'), [1]); + assert.deepEqual(rawCalls('x = fetch(u)\ny = globalThis.fetch(u)'), [1, 2]); + assert.deepEqual(rawCalls('await ok(`${fetch(a)}`)'), [1], 'code inside a template hole is code'); + assert.deepEqual(rawCalls('// fetch(u)\n/* fetchImpl(\n u) */\nconst s = "fetch(u)"; const t = `read via fetch() here`;'), []); + assert.deepEqual(rawCalls('const f = { fetchImpl, fetch: g }; thing.fetch(u); await fetchWithDeadline(u, {})'), []); + assert.deepEqual(rawCalls('fetchImpl = fetch, signal'), [], 'a default value is a reference, not a call'); +}); + +test('the scan covers the whole module directory', () => { + assert.ok(files.length > 60, `only ${files.length} files found`); + assert.ok(files.includes(HELPER) && files.includes('main.js')); +}); + +test('no module but request.js and the allow list calls fetch( or fetchImpl( itself', () => { + const offenders = []; + for (const f of files) { + if (f === HELPER || ALLOW.includes(f)) continue; + const lines = rawCalls(read(f)); + if (lines.length) offenders.push(`${f}:${lines.join(',')}`); + } + assert.deepEqual(offenders, [], 'route the read through fetchWithDeadline (request.js) with the source\'s registry timeoutMs'); +}); + +test('every allow list entry still holds a raw call, so a stale entry fails', () => { + for (const f of ALLOW) { + assert.ok(files.includes(f), `${f} is gone: drop it from ALLOW`); + assert.ok(rawCalls(read(f)).length > 0, `${f} no longer calls fetch( itself: drop it from ALLOW`); + } +}); + +test('request.js is where the network is reached', () => { + assert.match(read(HELPER), /await fetchImpl\(url,/); +}); diff --git a/scripts/qcm/registry.test.mjs b/scripts/qcm/registry.test.mjs index ddc135d..79a110d 100644 --- a/scripts/qcm/registry.test.mjs +++ b/scripts/qcm/registry.test.mjs @@ -270,6 +270,9 @@ test('every source carries a timeoutMs of 10000 or less unless it cites a measur } } assert.equal(SOURCES.gnisNames.timeoutMs, 20000); + // The paged reads were measured against a 20 s page, so they keep it rather than the 10 s default. + assert.equal(SOURCES.fwsNwiWetlands.timeoutMs, 20000); + assert.equal(SOURCES.usgsGovUnits.timeoutMs, 20000); assert.equal(SOURCES.nwsForecast.timeoutMs, 10000); assert.equal(SOURCES.nwsAlerts.timeoutMs, 10000); assert.equal(SOURCES.usgsQuadIndex.timeoutMs, 10000); diff --git a/scripts/qcm/request.test.mjs b/scripts/qcm/request.test.mjs index 478ffdc..55f867b 100644 --- a/scripts/qcm/request.test.mjs +++ b/scripts/qcm/request.test.mjs @@ -4,7 +4,7 @@ import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; import { - fetchWithDeadline, withDeadline, createLatest, createSearchGuard, + fetchWithDeadline, withDeadline, createLatest, createSearchGuard, orThrow, } from '../../themes/headlessmode/static/js/quiet-corners-maps/request.js'; import { createForecastLoader } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-weather.js'; import { SOURCES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; @@ -189,3 +189,44 @@ test('fetchWithDeadline with no timeoutMs returns an error rather than throwing' assert.match(result.error, /timeoutMs/); assert.equal(called, false); }); + +test('anyStatus reads the body of a non-2xx answer; without it the body is never read', async () => { + const problem = { detail: 'No forecast for this point.' }; + let reads = 0; + const notFound = { ok: false, status: 404, json: async () => { reads += 1; return problem; } }; + const plain = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => notFound, timeoutMs: 1000 }); + assert.equal(plain.ok, false); + assert.equal(plain.httpStatus, 404); + assert.equal(plain.body, undefined); + assert.equal(reads, 0); + const any = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => notFound, timeoutMs: 1000, anyStatus: true }); + assert.equal(any.ok, false); + assert.equal(any.status, 'error'); + assert.equal(any.httpStatus, 404); + assert.deepEqual(any.body, problem); + const unreadable = { ok: false, status: 502, json: async () => { throw new SyntaxError(''); } }; + const bad = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => unreadable, timeoutMs: 1000, anyStatus: true }); + assert.equal(bad.httpStatus, 502); + assert.equal(bad.body, undefined); + assert.equal(bad.malformed, undefined, 'a bad body on an error status is the error, not a malformed answer'); + const okBad = { ok: true, status: 200, json: async () => { throw new SyntaxError(''); } }; + const malformed = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => okBad, timeoutMs: 1000, anyStatus: true }); + assert.equal(malformed.malformed, true); +}); + +test('a custom reader that fails is not reported as bad JSON', async () => { + const res = { ok: true, status: 200, arrayBuffer: async () => { throw new Error('stream cut'); } }; + const got = await fetchWithDeadline('https://x.invalid/a', { fetchImpl: async () => res, timeoutMs: 1000, read: (r) => r.arrayBuffer() }); + assert.equal(got.malformed, true); + assert.doesNotMatch(got.error, /JSON/); +}); + +test('orThrow hands back an ok body and throws everything else with its status', async () => { + assert.deepEqual(orThrow({ ok: true, status: 'ok', body: { a: 1 } }), { a: 1 }); + assert.throws(() => orThrow({ ok: false, status: 'error', httpStatus: 503, error: 'HTTP 503' }), + (err) => err.message === 'HTTP 503' && err.status === 'error' && err.httpStatus === 503); + assert.throws(() => orThrow({ ok: false, status: 'timeout', error: 'No answer within 5 ms.' }, 'Elevation: slow.'), + (err) => err.message === 'Elevation: slow.' && err.status === 'timeout' && err.httpStatus === undefined); + assert.throws(() => orThrow({ ok: false, status: 'aborted', error: 'aborted' }), + (err) => err.name === 'AbortError' && err.status === 'aborted'); +}); diff --git a/themes/headlessmode/static/js/quiet-corners-maps/address-geocode.js b/themes/headlessmode/static/js/quiet-corners-maps/address-geocode.js index 2944006..692a748 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/address-geocode.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/address-geocode.js @@ -13,8 +13,8 @@ // logged, stored, or returned in a status; the caller keeps it out of the share URL, // saved views, the title and analytics. The request carries no cookies and no referrer. // -// There is no shared request helper on this base (no request.js / fetchWithDeadline), -// so the deadline is kept here, in this one function. +// request.js (fetchWithDeadline) is the shared helper now. This function still keeps its own +// deadline and cancel handling, so no-raw-fetch.test.mjs lists it until it moves onto it. export const GEOCODE_PATH = '/api/geocode'; /** Client-side deadline; the function's own upstream deadline is 8 s. */ diff --git a/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js b/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js index 1d4cce8..3273ee8 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/arcgis-pager.js @@ -6,7 +6,7 @@ // page (Delaware, 26,863 against a 2,000 cap). This existed once, inside the state // land fetcher; the federal layers need the same loop, and two hand-written copies // of a paging protocol is the drift this file exists to prevent. -import { REQUEST_POLICY } from './source-registry.js'; +import { DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; import { fetchWithDeadline } from './request.js'; /** A page bigger than any measured service allows, and a hard stop on the loop. @@ -17,9 +17,8 @@ export const MAX_PAGES = 20; /** Read every page of one ArcGIS query. * - * Each page is its own request with its own `timeoutMs`. The default is the old - * REQUEST_POLICY ceiling: callers that have not yet passed their source's own timeout - * keep the 20 s the wetlands and federal-land measurements were taken against. + * Each page is its own request with its own `timeoutMs`. Callers pass their + * source's registry `timeoutMs`; one that does not gets the registry default. * A timeout or a caller cancel throws, like any other failed page. * * `normalize` turns a raw feature into this map's shape and may return null to @@ -29,7 +28,7 @@ export const MAX_PAGES = 20; */ export async function pageThrough({ url, params, normalize, label = 'the service', - fetchImpl = fetch, signal, pageSize = PAGE_CAP, maxPages = MAX_PAGES, timeoutMs = REQUEST_POLICY.timeoutMs, + fetchImpl = fetch, signal, pageSize = PAGE_CAP, maxPages = MAX_PAGES, timeoutMs = DEFAULT_SOURCE_TIMEOUT_MS, } = {}) { const size = Math.max(1, Math.min(pageSize, PAGE_CAP)); const features = [], seen = new Set(); diff --git a/themes/headlessmode/static/js/quiet-corners-maps/build.js b/themes/headlessmode/static/js/quiet-corners-maps/build.js index 1cbab56..605aef2 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/build.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/build.js @@ -70,12 +70,14 @@ const labelTextFor = (features) => { * sheet whose image fails is simply absent from the set: the map sheets are * already built and must still print. One function, not one per companion kind -- * the only thing that differs is which service is called. */ -async function companionSheets(plan, recipe, fetchImpl, check, fetchOne) { +async function companionSheets(plan, recipe, fetchImpl, check, fetchOne, signal) { const out = []; for (const sheet of plan.sheets) { check(); - try { out.push(await fetchOne({ sheet, bboxLonLat: recipe.bboxLonLat, fetchImpl })); } + try { out.push(await fetchOne({ sheet, bboxLonLat: recipe.bboxLonLat, fetchImpl, signal })); } catch { /* an extra page, never the map */ } + // A cancel ends the request as an error like any other; it must not read as "this extra failed". + check(); } return out; } @@ -84,15 +86,16 @@ async function companionSheets(plan, recipe, fetchImpl, check, fetchOne) { * Unnamed geometry is left out — a table of "unnamed" rows tells nobody anything. * Elevation is sampled per route, so this is capped rather than unbounded. */ const MAX_TRIP_ROUTES = 12; -async function tripRows(features, plan, sheets, fetchImpl, { zone, hemisphere, scale }) { +async function tripRows(features, plan, sheets, fetchImpl, { zone, hemisphere, scale, signal, check }) { const named = features.filter((f) => f.name && (f.paths || []).length).slice(0, MAX_TRIP_ROUTES); const rows = []; for (const f of named) { const on = plan.sheets.filter((sheet) => vectorsForSheet([f], sheet, { zone, hemisphere, scale }).vectors.length).map((s) => s.id); let profile = { lengthM: null, gainM: null, sampled: false }; // Climb is detail: a 3DEP outage costs the column, not the row or the page. - try { profile = { ...(await profileFor(f, { fetchImpl })), sampled: true }; } + try { profile = { ...(await profileFor(f, { fetchImpl, signal })), sampled: true }; } catch { /* leave the climb blank rather than guess it */ } + check(); rows.push({ id: f.id, name: f.name, useCode: f.useCode || null, sheets: on, ...profile }); } return rows; @@ -259,9 +262,9 @@ export async function runBuild({ recipe, plan, adapters, workerFactory, PDFLib, // Both extras are built HERE rather than handed in: this is the only place that // knows the sheets and the clipped features. Both are wrapped so a failure costs // the extra page and never the map — that is what makes them extras. - const aerials = recipe.layers?.aerial ? await companionSheets(plan, recipe, fetchImpl, check, fetchAerial) : []; - const companions = recipe.layers?.terrain ? await companionSheets(plan, recipe, fetchImpl, check, fetchTerrain) : []; - const trips = recipe.layers?.tripPage ? await tripRows(features, plan, sheets, fetchImpl, { zone, hemisphere, scale: recipe.scale }) : []; + const aerials = recipe.layers?.aerial ? await companionSheets(plan, recipe, fetchImpl, check, fetchAerial, signal) : []; + const companions = recipe.layers?.terrain ? await companionSheets(plan, recipe, fetchImpl, check, fetchTerrain, signal) : []; + const trips = recipe.layers?.tripPage ? await tripRows(features, plan, sheets, fetchImpl, { zone, hemisphere, scale: recipe.scale, signal, check }) : []; const pdfBytes = await createPdfImpl({ PDFLib, plan, sheets, meta, legend, manifest, trips, aerials, companions }); progress(PHASES.ASSEMBLE, pages, pages); return { pdfBytes, manifest, stats: { level, tileCount: tiles.count, sheets: sheets.length, ms: Date.now() - started, bytes: pdfBytes.byteLength } }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js b/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js index 0d5451b..ff1f759 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js @@ -8,6 +8,7 @@ // page no longer calls it: the road list is the build-time snapshot (cameras/index.json plus one // file per area), read by map area with filesInView; the function stays exported and tested. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { normalizeCaltransCctv, isLinkOnly } from './source-camera.js'; const CALTRANS_OPERATOR = 'Caltrans'; @@ -76,14 +77,13 @@ export function failureProvider(files, view, layer, fallback) { /** GETs the registry's own caltransCctv.serviceUrl and normalizes the result. Never throws -- * a network failure, a non-ok response or a JSON parse error all become { ok:false, reason }. */ export async function fetchCaltransCctv({ fetchImpl = fetch, signal, fetchedAt } = {}) { + const got = await fetchWithDeadline(SOURCES.caltransCctv.serviceUrl, { fetchImpl, signal, timeoutMs: SOURCES.caltransCctv.timeoutMs }); + if (!got.ok) { + if (got.httpStatus) return { ok: false, reason: `Caltrans's server returned ${got.httpStatus}.` }; + return { ok: false, reason: got.error || 'The request to Caltrans failed.' }; + } try { - const res = await fetchImpl(SOURCES.caltransCctv.serviceUrl, { signal }); - if (!res || !res.ok) { - const status = res && res.status; - return { ok: false, reason: status ? `Caltrans's server returned ${status}.` : "Caltrans's server did not respond." }; - } - const payload = await res.json(); - return { ok: true, cameras: normalizeCaltransCctv(payload, { fetchedAt }) }; + return { ok: true, cameras: normalizeCaltransCctv(got.body, { fetchedAt }) }; } catch (err) { return { ok: false, reason: (err && err.message) || 'The request to Caltrans failed.' }; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/request.js b/themes/headlessmode/static/js/quiet-corners-maps/request.js index 64269af..873497c 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/request.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/request.js @@ -40,8 +40,11 @@ const readJson = (res) => res.json(); * - `{ ok: false, status: 'aborted', error }` -- the caller's own `signal` ended it. * - `{ ok: false, status: 'error', error }` -- network failure, a non-2xx (`httpStatus` * is set), or a body that is not JSON (`malformed: true`). - * `init` is passed to fetch beside the deadline signal; `read` swaps the body reader. */ -export async function fetchWithDeadline(url, { fetchImpl = globalThis.fetch, signal, timeoutMs, init, read = readJson } = {}) { + * `init` is passed to fetch beside the deadline signal; `read` swaps the body reader. + * `anyStatus: true` is for a service whose error answer carries a reason worth showing: the + * body is read whatever the status, `ok` is the response's own, and a body that cannot be + * read on a non-2xx is `undefined` rather than malformed. */ +export async function fetchWithDeadline(url, { fetchImpl = globalThis.fetch, signal, timeoutMs, init, read = readJson, anyStatus = false } = {}) { if (signal && signal.aborted) return { ok: false, status: 'aborted', error: 'aborted' }; // A missing or bad deadline is a caller bug, but this call promises never to throw, so it // comes back as an error the panel can show instead of an exception that kills the panel. @@ -58,12 +61,14 @@ export async function fetchWithDeadline(url, { fetchImpl = globalThis.fetch, sig }); const work = (async () => { const res = await fetchImpl(url, { ...init, signal: deadline.signal }); - if (!res.ok) return { ok: false, status: 'error', httpStatus: res.status, error: `HTTP ${res.status}` }; + if (!res.ok && !anyStatus) return { ok: false, status: 'error', httpStatus: res.status, error: `HTTP ${res.status}` }; let body; try { body = await read(res); } catch (err) { if (deadline.signal.aborted) throw err; - return { ok: false, status: 'error', malformed: true, error: 'Malformed JSON response.' }; + if (!res.ok) return { ok: false, status: 'error', httpStatus: res.status, error: `HTTP ${res.status}` }; + return { ok: false, status: 'error', malformed: true, error: read === readJson ? 'Malformed JSON response.' : 'Unreadable response body.' }; } + if (!res.ok) return { ok: false, status: 'error', httpStatus: res.status, error: `HTTP ${res.status}`, body }; return { ok: true, status: 'ok', httpStatus: res.status, body }; })(); try { @@ -80,6 +85,18 @@ export async function fetchWithDeadline(url, { fetchImpl = globalThis.fetch, sig } } +/** For a caller that throws on failure: the body of an ok result, else an Error carrying the + * result's `status` and `httpStatus`, with `message` (default: the result's own error). A + * caller's cancel keeps the name `AbortError`, so it is never reported as a failure. */ +export function orThrow(result, message) { + if (result.ok) return result.body; + const err = new Error(message || result.error); + err.status = result.status; + if (result.httpStatus !== undefined) err.httpStatus = result.httpStatus; + if (result.status === 'aborted') err.name = 'AbortError'; + throw err; +} + /** Latest wins. `begin()` aborts whatever the previous call was doing and hands out a * token and a fresh signal; `isCurrent(token)` is false once a newer call has begun. * `run(task)` wraps both: `task(signal)` runs, and the answer is `{ stale, value }` -- diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-aerial.js b/themes/headlessmode/static/js/quiet-corners-maps/source-aerial.js index 99835d5..c429f00 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-aerial.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-aerial.js @@ -11,6 +11,9 @@ // hashes -- real, distinct imagery. That check exists because 3DEP's exportImage // returned a BYTE-IDENTICAL image for two different states, and a source that // answers every request with the same picture is worse than one that errors. +import { fetchWithDeadline, orThrow } from './request.js'; +import { SOURCES } from './source-registry.js'; + const SERVICE = 'https://imagery.nationalmap.gov/arcgis/rest/services/USGSNAIPPlus/ImageServer'; // A sheet's raster at 300 dpi is 2400x2850, which NAIP will happily render but @@ -45,9 +48,10 @@ export function aerialRequestFor(sheet, bboxLonLat) { * sheet is an extra, so a failure loses the companion page, not the map. */ export async function fetchAerial({ sheet, bboxLonLat, fetchImpl = fetch, signal } = {}) { const req = aerialRequestFor(sheet, bboxLonLat); - const res = await fetchImpl(`${req.url}?${new URLSearchParams(req.params)}`, { signal }); - if (!res.ok) throw new Error(`Aerial imagery: the service answered ${res.status}.`); - const bytes = new Uint8Array(await res.arrayBuffer()); + const got = await fetchWithDeadline(`${req.url}?${new URLSearchParams(req.params)}`, { + fetchImpl, signal, timeoutMs: SOURCES.usdaNaip.timeoutMs, read: (res) => res.arrayBuffer(), + }); + const bytes = new Uint8Array(orThrow(got, got.httpStatus ? `Aerial imagery: the service answered ${got.httpStatus}.` : undefined)); if (!bytes.length) throw new Error('Aerial imagery: the service returned an empty image.'); return { id: sheet.id, bytes, width: req.width, height: req.height, format: 'jpg' }; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-aqi.js b/themes/headlessmode/static/js/quiet-corners-maps/source-aqi.js index 6411fec..250f813 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-aqi.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-aqi.js @@ -16,6 +16,7 @@ // National AQI Forecast Updated Nightly", boilerplate from another service, and matching it // would be the aurora layer's mistake run backwards. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.epaAirNowAqi; export const SERVICE = SRC.serviceUrl; @@ -106,10 +107,9 @@ export function quietText() { /** Asked for only when a reader asks, and never throws the map. */ export async function fetchAqi(bbox, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(aqiQueryUrl(bbox), { signal }); - return res.ok ? await res.json() : null; - } catch (e) { return null; } + let url; try { url = aqiQueryUrl(bbox); } catch { return null; } // a bad area is "no answer" too + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; } /** Every string this source can put in front of a reader, in one place, for the test. */ diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-aurora.js b/themes/headlessmode/static/js/quiet-corners-maps/source-aurora.js index 478d5d7..5f473f6 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-aurora.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-aurora.js @@ -17,6 +17,7 @@ // 65N. So nothing fetches it on first render, and "nothing to show" is the normal // answer rather than a failure to report. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; export const AURORA_SOURCE = SOURCES.swpcAurora; export const SERVICE = AURORA_SOURCE.serviceUrl; @@ -95,13 +96,8 @@ export function quietText() { /** Fetched only when a reader asks for it -- ~900 KB for the whole globe. Never throws * the map: a service that will not answer leaves the rest of the page alone. */ export async function fetchAurora({ fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(SERVICE, { signal }); - if (!res.ok) return null; - return await res.json(); - } catch (e) { - return null; - } + const got = await fetchWithDeadline(SERVICE, { fetchImpl, signal, timeoutMs: AURORA_SOURCE.timeoutMs }); + return got.ok ? got.body : null; } /** Every string this layer can put in front of a reader, in one place, so a test can diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-blm-rec-sites.js b/themes/headlessmode/static/js/quiet-corners-maps/source-blm-rec-sites.js index 4c53422..7be09b6 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-blm-rec-sites.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-blm-rec-sites.js @@ -117,7 +117,7 @@ export async function fetchBlmRecSites({ bboxLonLat, fetchImpl = fetch, signal, inSR: '4326', outSR: '4326', spatialRel: 'esriSpatialRelIntersects', outFields: FIELDS.join(','), returnGeometry: 'true', }, - normalize: normalizeBlmSite, fetchImpl, signal, pageSize, + normalize: normalizeBlmSite, fetchImpl, signal, timeoutMs: SOURCES.blmRecSites.timeoutMs, pageSize, }); const { features, ...rest } = page; return { sites: features, ...rest }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-county-lines.js b/themes/headlessmode/static/js/quiet-corners-maps/source-county-lines.js index d56afa1..7529ed3 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-county-lines.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-county-lines.js @@ -22,6 +22,7 @@ // of its own. It also obliges no access notice -- it makes no claim about who may be // on the ground, only about which county records that ground. import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { SOURCES } from './source-registry.js'; import { SERVICE } from './source-federal-land.js'; export const LAYER_ID = 'countyLines'; @@ -83,6 +84,7 @@ export async function fetchCountyLines({ bboxLonLat, fetchImpl = fetch, signal, }, fetchImpl, signal, + timeoutMs: SOURCES.usgsGovUnits.timeoutMs, pageSize, }); return { ...page, skipped: null }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-ecoregions.js b/themes/headlessmode/static/js/quiet-corners-maps/source-ecoregions.js index 7b7a6e1..7082c23 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-ecoregions.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-ecoregions.js @@ -20,6 +20,9 @@ // to domestic copyright protection under 17 U.S.C. § 105." Redistributing this artifact // from our own hosting is what that sentence permits; TERMS_URL is the page it is on. +import { fetchWithDeadline } from './request.js'; +import { DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; + export const LAYER_ID = 'epaEcoregions'; /** Same-origin, so no host reaches the policy. Fetched once, when a reader asks. */ export const EXPORT_URL = '/data/quiet-corners/ecoregions-l3.json'; @@ -124,11 +127,9 @@ export function areaReport(regions, point) { /** One national file, fetched when a reader asks and held. A pan is not a new question when * the answer is the whole country. Never throws the map. */ export async function loadEcoregions({ fetchImpl = fetch, url = EXPORT_URL, signal } = {}) { - try { - const res = await fetchImpl(url, { signal }); - if (!res.ok) return null; - return readExport(await res.json()); - } catch (e) { return null; } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS }); + if (!got.ok) return null; + try { return readExport(got.body); } catch (e) { return null; } } /** Every string this source can put in front of a reader, in one place, for the test. */ diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-elevation.js b/themes/headlessmode/static/js/quiet-corners-maps/source-elevation.js index 782e971..64693fd 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-elevation.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-elevation.js @@ -17,6 +17,8 @@ // built on that would have printed nothing while appearing to work. The tint is // blocked on a spike, not shipped broken. See the plan's §4 note. import { measureLength } from './measure.js'; +import { fetchWithDeadline, orThrow } from './request.js'; +import { SOURCES } from './source-registry.js'; const SERVICE = 'https://elevation.nationalmap.gov/arcgis/rest/services/3DEPElevation/ImageServer'; @@ -82,9 +84,10 @@ export async function fetchElevations({ points, fetchImpl = fetch, signal } = {} returnFirstValueOnly: 'true', f: 'json', }); - const res = await fetchImpl(`${SERVICE}/getSamples`, { method: 'POST', body, signal }); - if (!res.ok) throw new Error(`Elevation: the service answered ${res.status}.`); - const json = await res.json(); + const got = await fetchWithDeadline(`${SERVICE}/getSamples`, { + fetchImpl, signal, timeoutMs: SOURCES.usgs3dep.timeoutMs, init: { method: 'POST', body }, + }); + const json = orThrow(got, got.httpStatus ? `Elevation: the service answered ${got.httpStatus}.` : undefined); if (json.error) throw new Error(`Elevation: ${json.error.message || 'the service refused the query.'}`); // getSamples may return fewer samples than points asked for (NoData is // omitted, not nulled), so place each by its own index rather than by order. diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-federal-land.js b/themes/headlessmode/static/js/quiet-corners-maps/source-federal-land.js index df8da1e..fa44129 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-federal-land.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-federal-land.js @@ -17,6 +17,7 @@ // lines too — a National Forest proclamation boundary in particular encloses // private inholdings that were never federal. import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { SOURCES } from './source-registry.js'; export const SERVICE = 'https://carto.nationalmap.gov/arcgis/rest/services/govunits/MapServer'; @@ -108,6 +109,7 @@ export async function fetchFederalLand({ family, bboxLonLat, fetchImpl = fetch, }, fetchImpl, signal, + timeoutMs: SOURCES.usgsGovUnits.timeoutMs, pageSize, }); return { ...page, skipped: null }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-fire.js b/themes/headlessmode/static/js/quiet-corners-maps/source-fire.js index c0e9f63..df6cd46 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-fire.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-fire.js @@ -21,6 +21,7 @@ // contained -- yet 56 of 188 measured 100 per cent and 38 carried null. // `attr_ContainmentDateTime` was null on ALL 188 and is never read. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { serviceError, envelope, viewReader } from './arcgis-view.js'; const SRC = SOURCES.nifcFirePerimeters; @@ -184,10 +185,9 @@ export function fireLayer(L) { /** Asked for only when a reader asks, and never throws the map. */ export async function fetchFire(bbox, { fetchImpl = fetch, signal, widthPx } = {}) { - try { - const res = await fetchImpl(fireQueryUrl(bbox, widthPx), { signal }); - return res.ok ? await res.json() : null; - } catch { return null; } + let url; try { url = fireQueryUrl(bbox, widthPx); } catch { return null; } // a bad area is "no answer" too + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; } /** One bbox question at a time, waiting out a drag — the same protocol as the other bounded surfaces, diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-flood-zones.js b/themes/headlessmode/static/js/quiet-corners-maps/source-flood-zones.js index 8e9abe8..4ae1140 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-flood-zones.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-flood-zones.js @@ -21,6 +21,7 @@ // `features` is simply absent, so an adapter that trusts `res.ok` reports a flood plain as // empty ground. Every body goes through readError first. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.femaNfhl; const LAYER = (id) => SRC.layers.find((l) => l.id === id); @@ -252,10 +253,8 @@ export function floodZonesLayer(L) { } const getJson = async (req, fetchImpl, signal) => { - try { - const res = await fetchImpl(queryUrl(req), { signal }); - return res.ok ? await res.json() : null; - } catch (e) { return null; } + const got = await fetchWithDeadline(queryUrl(req), { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; }; /** One view's worth of questions, in the order that makes the expensive one conditional: diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-gbif.js b/themes/headlessmode/static/js/quiet-corners-maps/source-gbif.js index 784b452..e27e554 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-gbif.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-gbif.js @@ -19,6 +19,7 @@ // // Every reader-facing string is built here so gbif.test.mjs can scan the lot. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.gbifOccurrence; export const SERVICE = SRC.serviceUrl; @@ -239,10 +240,8 @@ export function gbifLayer(L) { } export async function fetchJson(url, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(url, { signal }); - return res.ok ? await res.json() : null; - } catch (e) { return null; } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; } /** One cell at a time, and only for the view the reader stopped on. The same shape as the AQI diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-hillshade.js b/themes/headlessmode/static/js/quiet-corners-maps/source-hillshade.js index 754fba6..54262fe 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-hillshade.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-hillshade.js @@ -14,8 +14,7 @@ // // Cancellation/staleness follows source-terrain-surface.js's `live` closure pattern (an // in-flight request for a view/light the reader has left is dropped, never drawn); the request -// timeout follows source-terrain-names.js's `fetchJson` 20s AbortController pattern -// (REQUEST_POLICY.timeoutMs). No automatic retry loop: a slow/failed request reports its +// timeout is request.js's deadline at the source's registry `timeoutMs`. No automatic retry loop: a slow/failed request reports its // status and stops. // // "Sun now" is a pure function of lat/lon/date -- quiet-corners-sky/sun-moon.js exports @@ -26,7 +25,8 @@ // // The one sentence this layer must never be read as contradicting: hillshade is not a // cast-shadow, visibility, or passability calculation. -import { SOURCES, REQUEST_POLICY } from './source-registry.js'; +import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { SYMBOLS } from './legend.js'; const SRC = SOURCES.usgs3depHillshade; @@ -93,32 +93,19 @@ export function hillshadeImageUrl(request) { /** A generation-guarded, timeout-guarded image fetcher: a response for a bounds/light the * reader has already left is reported `stale`, never drawn, and a request past `timeoutMs` - * (default `REQUEST_POLICY.timeoutMs`, the same 20s used across this directory) aborts and - * reports `timeout`. No automatic retry -- a caller that wants another attempt asks again. */ -export function createHillshadeFetcher({ fetchImpl = fetch, timeoutMs = REQUEST_POLICY.timeoutMs } = {}) { + * (default the source's registry `timeoutMs`) aborts and reports `timeout`. No automatic + * retry -- a caller that wants another attempt asks again. */ +export function createHillshadeFetcher({ fetchImpl = fetch, timeoutMs = SRC.timeoutMs } = {}) { let generation = 0; async function fetchImage(request, { signal } = {}) { generation += 1; const myGeneration = generation; - const controller = new AbortController(); - const onAbort = () => controller.abort(); - if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort); } - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetchImpl(hillshadeImageUrl(request), { signal: controller.signal }); - if (generation !== myGeneration) return { status: 'stale' }; - if (!res.ok) return { status: 'error', error: `HTTP ${res.status}` }; - const blob = await res.blob(); - if (generation !== myGeneration) return { status: 'stale' }; - return { status: 'ready', blob }; - } catch (err) { - if (generation !== myGeneration) return { status: 'stale' }; - const timedOut = err?.name === 'AbortError' && !(signal && signal.aborted); - return { status: timedOut ? 'timeout' : 'error', error: err?.message || String(err) }; - } finally { - clearTimeout(timer); - if (signal) signal.removeEventListener('abort', onAbort); - } + const got = await fetchWithDeadline(hillshadeImageUrl(request), { + fetchImpl, signal, timeoutMs, read: (res) => res.blob(), + }); + if (generation !== myGeneration) return { status: 'stale' }; + if (got.ok) return { status: 'ready', blob: got.body }; + return { status: got.status === 'timeout' ? 'timeout' : 'error', error: got.error }; } return { fetchImage }; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-land-boundaries.js b/themes/headlessmode/static/js/quiet-corners-maps/source-land-boundaries.js index 68ad5ad..e5013ce 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-land-boundaries.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-land-boundaries.js @@ -18,6 +18,7 @@ // catalogue, which was measured against the live services. import { layerFor, whereFor, outFieldsFor, isBrowserFetchable, isSemanticallyUniform, isDrawable } from './state-sources.js'; import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; // The notice a drawn boundary obliges lives with the symbols it attaches to. export { LAND_COVERAGE_SEMANTICS } from './legend.js'; @@ -165,6 +166,7 @@ export async function fetchLandBoundaries({ state, family, bboxLonLat, fetchImpl }, fetchImpl, signal, + timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, pageSize: Math.min(pageSize, layer.maxRecordCount || PAGE_CAP), }); return { ...page, skipped: null }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-land-cover.js b/themes/headlessmode/static/js/quiet-corners-maps/source-land-cover.js index bbe6179..b203949 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-land-cover.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-land-cover.js @@ -41,6 +41,7 @@ // Wisconsin; its colour is measured off the legend swatch, its NUMBER is read off the // legend's order, and it says so rather than passing as known. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; export const PRODUCTS = Object.freeze({ landCover: SOURCES.nlcdLandCover, canopy: SOURCES.nlcdTreeCanopy }); export const PRODUCT_IDS = Object.freeze(Object.keys(PRODUCTS)); @@ -91,6 +92,7 @@ export const LAND_COVER_CLASSES = Object.freeze({ /** The service echoes the request Origin and sets `access-control-allow-credentials: true` * rather than `*`. It is keyless and anonymous and nothing here has an account with it, so * every request says so explicitly instead of relying on a default. */ +const timeoutFor = (request) => (PRODUCTS[request && request.product] || PRODUCTS.landCover).timeoutMs; export const FETCH_OPTIONS = Object.freeze({ credentials: 'omit' }); /** The year, or a throw. Nothing in this file can build a request without going through it. */ @@ -323,19 +325,19 @@ export function readerStrings() { * URL: `img-src` already allows blob:, so the whole pair costs the policy one connect-src * host -- 24 characters -- instead of 48. Never throws the map. */ export async function fetchSurface(request, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(surfaceImageUrl(request), { ...FETCH_OPTIONS, signal }); - return res && res.ok ? await res.blob() : null; - } catch (e) { return null; } + const got = await fetchWithDeadline(surfaceImageUrl(request), { + fetchImpl, signal, timeoutMs: timeoutFor(request), init: FETCH_OPTIONS, read: (res) => res.blob(), + }); + return got.ok ? got.body : null; } /** A point read, as JSON. `res.ok` is deliberately not the test for success -- the caller * reads the body, because this service states its errors inside a 200. */ export async function fetchPoint(request, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(pointUrl(request), { ...FETCH_OPTIONS, signal }); - return await res.json(); - } catch (e) { return null; } + const got = await fetchWithDeadline(pointUrl(request), { + fetchImpl, signal, timeoutMs: timeoutFor(request), init: FETCH_OPTIONS, anyStatus: true, + }); + return got.body ?? null; } /** A Leaflet image overlay re-exported for the view it is asked about, the same shape as the diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-mvum.js b/themes/headlessmode/static/js/quiet-corners-maps/source-mvum.js index 0cfa161..61d977f 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-mvum.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-mvum.js @@ -1,7 +1,8 @@ /* Quiet Corners Field Maps — USFS Motor Vehicle Use Map adapter. Pure: bounded envelope queries, paging while * the service flags `exceededTransferLimit`, a count cross-check, vehicle/date normalization, honest coverage - * (complete / partial / unavailable / unknown). Imports the registry only; network, clock and sleep are injected. */ + * (complete / partial / unavailable / unknown). Imports the registry and request.js only; network, clock and sleep are injected. */ import { SOURCES, REQUEST_POLICY, FAILURE } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; export const LAYERS = Object.freeze({ roads: 1, trails: 2 }); export const OUT_FIELDS = Object.freeze([ @@ -88,25 +89,22 @@ async function requestJson(url, { fetchImpl, signal, timeoutMs, retries, sleep } for (let attempt = 0; attempt <= retries; attempt += 1) { if (signal && signal.aborted) return { ok: false, error: 'aborted' }; if (attempt > 0) await sleep(REQUEST_POLICY.retryBackoffMs[Math.min(attempt - 1, REQUEST_POLICY.retryBackoffMs.length - 1)]); - const ctl = new AbortController(); const onAbort = () => ctl.abort(); - if (signal) signal.addEventListener('abort', onAbort, { once: true }); - const timer = setTimeout(onAbort, timeoutMs); - try { - const res = await fetchImpl(url, { signal: ctl.signal }); - if (!res.ok) { last = `HTTP ${res.status}`; if (res.status === 429 || res.status >= 500) continue; return { ok: false, error: last }; } - let body; try { body = await res.json(); } catch { return { ok: false, error: 'malformed JSON' }; } - if (body && body.error) return { ok: false, error: `service error ${body.error.code ?? ''}`.trim() }; - return { ok: true, body: body || {} }; - } catch (e) { - if (signal && signal.aborted) return { ok: false, error: 'aborted' }; - last = ctl.signal.aborted ? `timeout after ${timeoutMs} ms` : `network error: ${e && e.message}`; - } finally { clearTimeout(timer); if (signal) signal.removeEventListener('abort', onAbort); } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs }); + if (got.status === 'aborted') return { ok: false, error: 'aborted' }; + if (!got.ok) { + if (got.httpStatus) { last = `HTTP ${got.httpStatus}`; if (got.httpStatus === 429 || got.httpStatus >= 500) continue; return { ok: false, error: last }; } + if (got.malformed) return { ok: false, error: 'malformed JSON' }; + last = got.status === 'timeout' ? `timeout after ${timeoutMs} ms` : `network error: ${got.error}`; + continue; + } + if (got.body && got.body.error) return { ok: false, error: `service error ${got.body.error.code ?? ''}`.trim() }; + return { ok: true, body: got.body || {} }; } return { ok: false, error: `${last} after ${retries + 1} attempts` }; } export async function fetchLayer({ layer, bboxLonLat, fetchImpl = globalThis.fetch, signal, pageSize = 1000, - timeoutMs = REQUEST_POLICY.timeoutMs, retries = REQUEST_POLICY.retries, onProgress, now = () => new Date().toISOString(), sleep = defaultSleep }) { + timeoutMs = SOURCES.usfsMvum.timeoutMs, retries = REQUEST_POLICY.retries, onProgress, now = () => new Date().toISOString(), sleep = defaultSleep }) { const layerId = LAYERS[layer] ?? layer; const layerKey = LAYER_KEY[layerId]; const opts = { fetchImpl, signal, timeoutMs, retries, sleep }; const warnings = []; const features = []; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js index 3e4bef1..9f2b158 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-alerts.js @@ -22,7 +22,7 @@ // product is in force -- never that the ground is free of hazards. Every reader-facing string // is built here so scripts/qcm/nws-alerts.test.mjs can scan the lot. import { SOURCES } from './source-registry.js'; -import { withDeadline } from './request.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.nwsAlerts; export const SERVICE = SRC.serviceUrl; @@ -170,15 +170,16 @@ export const UNREADABLE_TEXT = 'The National Weather Service alert feed answered /** One ask. A non-200 is a FAILURE, not an empty answer: the API answers 400 for a query * parameter it does not recognise, and rendering that as "nothing in force" is the one lie * this state is able to tell. */ -export async function fetchAlerts({ lat, lon, fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(alertsUrl(lat, lon), { headers: requestHeaders(), signal }); - if (res.ok) return { status: 'ok', httpStatus: res.status, body: await res.json() }; - const problem = await res.json().catch(() => ({})); - return { status: 'failed', httpStatus: res.status, detail: problem.detail || problem.title || '' }; - } catch (e) { - return { status: 'failed', httpStatus: null, detail: String((e && e.message) || e) }; +export async function fetchAlerts({ lat, lon, fetchImpl = fetch, signal, timeoutMs = SRC.timeoutMs } = {}) { + const got = await fetchWithDeadline(alertsUrl(lat, lon), { + fetchImpl, signal, timeoutMs, init: { headers: requestHeaders() }, anyStatus: true, + }); + if (got.ok) return { status: 'ok', httpStatus: got.httpStatus, body: got.body }; + if (got.httpStatus !== undefined) { + const problem = (got.body && typeof got.body === 'object') ? got.body : {}; + return { status: 'failed', httpStatus: got.httpStatus, detail: problem.detail || problem.title || '' }; } + return { status: 'failed', httpStatus: null, detail: got.error }; } // QUX-09b (D5): the registry's per-source deadline, as source-weather.js's loader carries -- a hung @@ -189,12 +190,15 @@ export const DEFAULT_TIMEOUT_MS = SRC.timeoutMs; * showing it puts A's warnings under B's name. The previous ask is aborted, and the page * drops any answer for a place it has moved off. */ let live = null; -export async function alertsFor({ lat, lon, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { +export async function alertsFor({ lat, lon, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, signal } = {}) { if (live) live.abort(); const ctl = (live = new AbortController()); - const deadline = withDeadline(ctl.signal, timeoutMs); + const onAbort = () => ctl.abort(); + if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); } let got; - try { got = await fetchAlerts({ lat, lon, fetchImpl, signal: deadline.signal }); } finally { deadline.dispose(); } + try { got = await fetchAlerts({ lat, lon, fetchImpl, signal: ctl.signal, timeoutMs }); } finally { + if (signal) signal.removeEventListener('abort', onAbort); + } if (live === ctl) live = null; return got; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-radar.js b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-radar.js index c092620..800ba7a 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-nws-radar.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-nws-radar.js @@ -20,6 +20,7 @@ // already answers "what will it do"; this answers "what is falling now". // `readerStrings()` exists so the test can scan the lot for that promise. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.nwsRadar; @@ -98,15 +99,10 @@ export function frameVintage(validEndMs, nowMs = Date.now()) { /** The service's own rolling window, read off its description. Never throws the map: * an unknown age is a state the row can show, not a reason to lose the layer. */ export async function fetchFrameTime({ fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(`${SERVICE}?f=json`, { signal }); - if (!res.ok) return null; - const body = await res.json(); - const extent = body && body.timeInfo && body.timeInfo.timeExtent; - return Array.isArray(extent) && extent.length === 2 ? Number(extent[1]) : null; - } catch (e) { - return null; - } + const got = await fetchWithDeadline(`${SERVICE}?f=json`, { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + if (!got.ok) return null; + const extent = got.body && got.body.timeInfo && got.body.timeInfo.timeExtent; + return Array.isArray(extent) && extent.length === 2 ? Number(extent[1]) : null; } /** Every string this layer can put in front of a reader, in one place, so the test diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-padus.js b/themes/headlessmode/static/js/quiet-corners-maps/source-padus.js index d99145e..70054e2 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-padus.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-padus.js @@ -24,6 +24,7 @@ // 3,033,604. Nothing overlaps; the polygons run outside the box. Per unit, or not at all. // * `UK` is a fourth answer, not a shade of `OA` and not silence. import { SOURCES, FAILURE } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.padusPublicAccess; const LAYER = SRC.layers[0]; @@ -151,10 +152,9 @@ export function accessReport(body, retrievedOn = null) { /** Asked for only when a reader asks, and never throws the map. `res.ok` is deliberately * not consulted: this service answers a bad request 200. `accessReport` reads the body. */ export async function fetchAccess(bbox, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(padusQueryUrl(bbox), { signal }); - return await res.json(); - } catch (e) { return null; } + let url; try { url = padusQueryUrl(bbox); } catch { return null; } // a bad area is "no answer" too + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: SRC.timeoutMs, anyStatus: true }); + return got.body ?? null; } /** Every string this source can put in front of a reader, in one place, for the test. */ diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-rec-sites.js b/themes/headlessmode/static/js/quiet-corners-maps/source-rec-sites.js index 6042182..1bbc69a 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-rec-sites.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-rec-sites.js @@ -17,6 +17,7 @@ // nothing at all and the layer silently disappears. Verified against the // service's own schema, not from memory. import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; const SERVICE = 'https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_RecInfraRecreationSites_02/MapServer/0'; const FIELDS = ['site_name', 'site_type', 'fee_charged', 'seasonal_operational_status', 'open_season', 'total_capacity']; @@ -128,6 +129,7 @@ export async function fetchRecSites({ bboxLonLat, fetchImpl = fetch, signal, pag normalize: normalizeSite, fetchImpl, signal, + timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, pageSize, }); const { features, ...rest } = page; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js b/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js index 35a4465..31db1fe 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-registry.js @@ -199,8 +199,9 @@ const CALTRANS_COVERAGE_AREAS = Object.freeze([ /** The deadline for one request to a source that has not cited a slower measurement. * Per source, not one number for the app: the GNIS names path is measured at 31 s. - * Only the modules moved onto request.js read it so far (GNIS names, quad index, the NWS - * forecast and alerts loaders); the rest still run at REQUEST_POLICY.timeoutMs until PR-B. */ + * Every module in this directory except main.js reads its own source's value through + * request.js (scripts/qcm/no-raw-fetch.test.mjs holds that); main.js joins them in PR-B2. + * `REQUEST_POLICY.timeoutMs` is no longer a request deadline. */ export const DEFAULT_SOURCE_TIMEOUT_MS = 10000; const withTimeouts = (sources) => Object.fromEntries(Object.entries(sources) .map(([key, src]) => [key, { timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, ...src }])); @@ -424,6 +425,10 @@ export const SOURCES = deepFreeze(withTimeouts({ ], spatialReference: null, limits: { maxRecordCount: 2000, partialPageFlag: 'exceededTransferLimit' }, + // Keeps the 20 s every page of these layers was read against before the deadline moved + // per source; a flat 10 s would end a federal-land or county page that has always answered. + timeoutMs: 20000, + timeoutMeasured: '20 s per page (the shared pager default the federal-land measurements were taken against)', pagination: true, cors: 'star', attribution: 'U. S. Geological Survey - National Geospatial Program, Government Units.', @@ -1705,13 +1710,17 @@ export const SOURCES = deepFreeze(withTimeouts({ maxAllowableOffsetDegrees: 0.0001, // Refuse rather than page. 1,759 records measured in ONE desktop view over // Horicon, and 29,608 in a degree square -- ~90 minutes of wall clock against - // a 20 s timeout. Four pages is what fits REQUEST_POLICY. + // a 20 s timeout. Four pages is what fits this source's own `timeoutMs`. recordBudgetPerExtent: 4000, // The PAINTED route returns a fully transparent PNG, HTTP 200, no error, above // 1:100,000 -- blank at Leaflet z12, paints at z13. Recorded so nobody adds // `export` to img-src and ships a blank picture that reads as "no wetlands". paintedMinZoom: 13, }, + // 20 s, not the 10 s default: paging params re-cost a query 20x (source-wetlands.js), so a + // budget-sized page runs past 10 s. Not re-measured here; this is the value the budget was sized to. + timeoutMs: 20000, + timeoutMeasured: '8.71 s unpaged, 20x that with paging params (2026-09-15, source-wetlands.js)', pagination: true, cors: 'reflects-origin', attribution: 'U.S. Fish and Wildlife Service, National Wetlands Inventory, ' diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-road-basic.js b/themes/headlessmode/static/js/quiet-corners-maps/source-road-basic.js index fac67bd..7165727 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-road-basic.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-road-basic.js @@ -6,7 +6,7 @@ // keyed on route id. Measured 2026-09-06 over the Standing Indian quad: 30 of 31 // MVUM roads matched (97%), levels 2-5 and four surface types present. An // unmatched road keeps its plain width -- drawn, never dropped. -import { SOURCES } from './source-registry.js'; +import { SOURCES, DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; const SERVICE = 'https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_RoadBasic_01/MapServer/0'; @@ -121,6 +121,7 @@ export async function fetchRoadQualityReport({ bboxLonLat, fetchImpl = fetch, si normalize: (one) => one, fetchImpl, signal, + timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, pageSize, }); const byId = normalizeRoadQuality(page.features); diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-road-names.js b/themes/headlessmode/static/js/quiet-corners-maps/source-road-names.js index 6048b86..2d793bd 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-road-names.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-road-names.js @@ -14,6 +14,7 @@ // the caller ignores it (the same convention source-hillshade.js uses). import { SOURCES } from './source-registry.js'; import { serviceError, envelope } from './arcgis-view.js'; +import { fetchWithDeadline } from './request.js'; import { layersForZoom, groupRoads, roadLabelOrder, ROAD_CLASSES } from './road-names.js'; const SRC = SOURCES.usgsRoadNames; @@ -62,17 +63,14 @@ const cacheKey = (b, zoom) => `${round(b.minLon)},${round(b.minLat)},${round(b.m /** One layer's answer: {features, exceeded} or {failed}. Never throws, except to say aborted. */ async function askLayer({ layer, bbox, zoom, fetchImpl, signal }) { - try { - const res = await fetchImpl(buildRoadQuery({ layer, bbox, zoom }), { signal }); - if (!res.ok) return { failed: `HTTP ${res.status}` }; - const body = await res.json(); - const refused = serviceError(body); - if (refused) return { failed: refused }; - if (!Array.isArray(body?.features)) return { failed: 'the service answered without a feature list.' }; - return { features: body.features.map((f) => ({ ...f, layer })), exceeded: body.exceededTransferLimit === true }; - } catch (err) { - return signal.aborted ? { aborted: true } : { failed: err?.message || String(err) }; - } + const got = await fetchWithDeadline(buildRoadQuery({ layer, bbox, zoom }), { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + if (got.status === 'aborted') return { aborted: true }; + if (!got.ok) return { failed: got.httpStatus ? `HTTP ${got.httpStatus}` : got.error }; + const body = got.body; + const refused = serviceError(body); + if (refused) return { failed: refused }; + if (!Array.isArray(body?.features)) return { failed: 'the service answered without a feature list.' }; + return { features: body.features.map((f) => ({ ...f, layer })), exceeded: body.exceededTransferLimit === true }; } /** A fetcher with its own in-flight slot and cache. The module's `fetchRoadNames` is one shared diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-smoke.js b/themes/headlessmode/static/js/quiet-corners-maps/source-smoke.js index 0ca2d17..bdde940 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-smoke.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-smoke.js @@ -18,6 +18,7 @@ // the day has not been analysed, NOT that the sky is clear -- and the two are told apart by a // 1 KB `max(Start)` statistics query rather than by assumption. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { serviceError, envelope, viewReader } from './arcgis-view.js'; const SRC = SOURCES.noaaHmsSmoke; @@ -196,13 +197,13 @@ export function smokeLayer(L) { /** Asked for only when a reader asks, and never throws the map. */ async function getJson(url, fetchImpl, signal) { - try { - const res = await fetchImpl(url, { signal }); - return res.ok ? await res.json() : null; - } catch { return null; } + const got = await fetchWithDeadline(url, { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; } -export const fetchSmoke = (bbox, { fetchImpl = fetch, signal } = {}) => - getJson(smokeQueryUrl(bbox), fetchImpl, signal); +export const fetchSmoke = async (bbox, { fetchImpl = fetch, signal } = {}) => { + let url; try { url = smokeQueryUrl(bbox); } catch { return null; } // a bad area is "no answer" too + return getJson(url, fetchImpl, signal); +}; /** One bbox question at a time, waiting out a drag. The day-state question rides with it, and only * when the view was empty, so the row never mixes one view's plumes with another's day-state. */ diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-stream-names.js b/themes/headlessmode/static/js/quiet-corners-maps/source-stream-names.js index 3a62626..d6705b5 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-stream-names.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-stream-names.js @@ -10,7 +10,7 @@ // Returns {status, features, omittedKinds} like source-terrain-names.js fetchBounded: // ready every name asked was answered capped more names (or reaches) than one answer carries // empty below the lowest zoom: no request timeout / error / aborted the fetchJson shapes -import { SOURCES, REQUEST_POLICY } from './source-registry.js'; +import { SOURCES } from './source-registry.js'; import { KINDS } from './terrain-names.js'; import { fetchJson } from './source-terrain-names.js'; import { envelope } from './arcgis-view.js'; @@ -150,7 +150,7 @@ const failure = (result, signal) => { /** Named rivers and creeks in a view at a zoom: the longest names per kind, one record each, * anchored on the reach in view. */ -export async function fetchStreamNames(bounds, { zoom, fetch: fetchImpl = globalThis.fetch, signal, timeoutMs = REQUEST_POLICY.timeoutMs } = {}) { +export async function fetchStreamNames(bounds, { zoom, fetch: fetchImpl = globalThis.fetch, signal, timeoutMs = SRC.timeoutMs } = {}) { checkBbox(bounds); const layer = nhdLayerForZoom(zoom); if (!layer) return { status: 'empty', features: [], omittedKinds: [] }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-surface-ownership.js b/themes/headlessmode/static/js/quiet-corners-maps/source-surface-ownership.js index ca860bb..8452218 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-surface-ownership.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-surface-ownership.js @@ -17,6 +17,7 @@ // requested, nor is `comments`, nor the tax fields. What is asked for is the // record id, the owner class, the record status and the forest's name. import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { SOURCES } from './source-registry.js'; export const SERVICE = 'https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_SurfaceOwnership_01/MapServer'; export const LAYER_NUMBER = 0; @@ -72,6 +73,7 @@ export async function fetchSurfaceOwnership({ bboxLonLat, fetchImpl = fetch, sig }, fetchImpl, signal, + timeoutMs: SOURCES.usfsSurfaceOwnership.timeoutMs, pageSize, }); return { ...page, skipped: null }; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-surface.js b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-surface.js index 437a073..0c43527 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-surface.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain-surface.js @@ -36,6 +36,7 @@ // tan(degrees) x 100 -- 45 degrees is 100 per cent -- so the two are never interchangeable // and every number here carries its unit in the same string. import { SOURCES } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; const SRC = SOURCES.usgs3depSurface; @@ -253,17 +254,13 @@ export function coverageText({ covered, missing }) { * layer costs the CSP nothing -- and it is the only route that can read the alpha channel * the coverage answer lives in. Never throws the map. */ export async function fetchSlopeImage(request, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(slopeImageUrl(request), { signal }); - return res.ok ? await res.blob() : null; - } catch (e) { return null; } + const got = await fetchWithDeadline(slopeImageUrl(request), { fetchImpl, signal, timeoutMs: SRC.timeoutMs, read: (res) => res.blob() }); + return got.ok ? got.body : null; } export async function fetchPoint(lonLat, surface, { fetchImpl = fetch, signal } = {}) { - try { - const res = await fetchImpl(pointUrl(pointRequestFor(lonLat, surface)), { signal }); - return res.ok ? await res.json() : null; - } catch (e) { return null; } + const got = await fetchWithDeadline(pointUrl(pointRequestFor(lonLat, surface)), { fetchImpl, signal, timeoutMs: SRC.timeoutMs }); + return got.ok ? got.body : null; } /** A Leaflet image overlay whose picture is re-exported for the view it is asked about, the diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain.js b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain.js index f9add2d..b977af4 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-terrain.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-terrain.js @@ -16,6 +16,9 @@ // That check is not ceremony -- 3DEP's exportImage once returned a BYTE-IDENTICAL // image for two different states, and a source that answers every request with the // same picture is worse than one that errors. +import { fetchWithDeadline, orThrow } from './request.js'; +import { SOURCES } from './source-registry.js'; + const SERVICE = 'https://elevation.nationalmap.gov/arcgis/rest/services/3DEPElevation/ImageServer'; /** The named raster function. Not a chain: the service renders it server-side. */ @@ -54,9 +57,10 @@ export function terrainRequestFor(sheet, bboxLonLat) { * sheet is an extra, so a failure loses the companion page, not the map. */ export async function fetchTerrain({ sheet, bboxLonLat, fetchImpl = fetch, signal } = {}) { const req = terrainRequestFor(sheet, bboxLonLat); - const res = await fetchImpl(`${req.url}?${new URLSearchParams(req.params)}`, { signal }); - if (!res.ok) throw new Error(`Shaded relief: the service answered ${res.status}.`); - const bytes = new Uint8Array(await res.arrayBuffer()); + const got = await fetchWithDeadline(`${req.url}?${new URLSearchParams(req.params)}`, { + fetchImpl, signal, timeoutMs: SOURCES.usgs3dep.timeoutMs, read: (res) => res.arrayBuffer(), + }); + const bytes = new Uint8Array(orThrow(got, got.httpStatus ? `Shaded relief: the service answered ${got.httpStatus}.` : undefined)); if (!bytes.length) throw new Error('Shaded relief: the service returned an empty image.'); return { id: sheet.id, bytes, width: req.width, height: req.height, format: 'png' }; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-trails.js b/themes/headlessmode/static/js/quiet-corners-maps/source-trails.js index de965a4..be2472a 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-trails.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-trails.js @@ -12,6 +12,7 @@ // inventory, republished. 7 are `NST - Appalachian`. 6 of the 63 carry NO // allowed-use flags at all, which is the case the honesty rule below exists for. import { pageThrough, PAGE_CAP } from './arcgis-pager.js'; +import { DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; const SERVICE = 'https://carto.nationalmap.gov/arcgis/rest/services/transportation/MapServer/37'; const USE_FIELDS = { hike: 'hikerpedestrian', bike: 'bicycle', horse: 'packsaddle', motor: 'motorcycle' }; @@ -92,6 +93,7 @@ export async function fetchTrails({ bboxLonLat, fetchImpl = fetch, signal, pageS normalize: (raw) => { const t = normalizeTrail(raw); return t.paths.length ? t : null; }, fetchImpl, signal, + timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS, pageSize, }); return { diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-usgs-topo.js b/themes/headlessmode/static/js/quiet-corners-maps/source-usgs-topo.js index 9142d71..3ce26c1 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-usgs-topo.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-usgs-topo.js @@ -4,6 +4,7 @@ // top-left — the service's {z}/{y}/{x} addressing is the standard XYZ grid with y and x swapped in the path. import { inverse } from './geometry.js'; import { SOURCES, REQUEST_POLICY, FAILURE } from './source-registry.js'; +import { fetchWithDeadline } from './request.js'; import { mercatorPixel, TILE_SIZE, EARTH_RADIUS } from './raster-placement.js'; export { TILE_SIZE, EARTH_RADIUS }; @@ -89,22 +90,16 @@ const ABORTED = { ok: false, status: null, bytes: null, error: 'aborted' }; /** Fetch unique tiles with bounded concurrency, per-request timeout, retries on network/5xx/429 only, and cancel via `signal`. */ export async function fetchTiles(tiles, { - fetchImpl = globalThis.fetch, signal, concurrency = REQUEST_POLICY.maxConcurrent, timeoutMs = REQUEST_POLICY.timeoutMs, + fetchImpl = globalThis.fetch, signal, concurrency = REQUEST_POLICY.maxConcurrent, timeoutMs = TOPO.timeoutMs, retries = REQUEST_POLICY.retries, backoffMs = REQUEST_POLICY.retryBackoffMs, onProgress, } = {}) { const queue = [...new Map(tiles.map((t) => [t.key, t])).values()], results = new Map(), requested = queue.length; let received = 0, retriedCount = 0, next = 0; const attempt = async (tile) => { - const ctrl = new AbortController(), timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs} ms`)), timeoutMs); - const onAbort = () => ctrl.abort(signal.reason); - signal?.addEventListener('abort', onAbort, { once: true }); - try { - const res = await fetchImpl(tile.url, { signal: ctrl.signal }); - if (!res.ok) return { ok: false, status: res.status, bytes: null, error: `HTTP ${res.status}` }; - return { ok: true, status: res.status, bytes: await res.arrayBuffer(), error: null }; - } catch (err) { - return { ok: false, status: null, bytes: null, error: String(err?.message ?? err) }; - } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); } + const got = await fetchWithDeadline(tile.url, { fetchImpl, signal, timeoutMs, read: (res) => res.arrayBuffer() }); + if (got.ok) return { ok: true, status: got.httpStatus, bytes: got.body, error: null }; + if (got.httpStatus) return { ok: false, status: got.httpStatus, bytes: null, error: `HTTP ${got.httpStatus}` }; + return { ok: false, status: null, bytes: null, error: got.status === 'timeout' ? `timeout after ${timeoutMs} ms` : got.error }; }; const retryable = (r) => r.status === null || r.status === 429 || r.status >= 500; const worker = async () => { diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js b/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js index 1cb24dd..3803e3d 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-weather.js @@ -4,7 +4,7 @@ // content/quiet-corners-maps.md. TWO STEPS: `/points/{lat},{lon}` maps a lat/lon to an office and grid cell and hands back the // forecast URL, used AS GIVEN -- hand-building `/gridpoints/BMX/96,76/...` hard-codes a grid the office may renumber. import { SOURCES } from './source-registry.js'; -import { withDeadline } from './request.js'; +import { withDeadline, fetchWithDeadline, orThrow } from './request.js'; const API = SOURCES.nwsForecast.origin; // The refresh intervals are the service's own cache-control, not taste: `max-age=3600` on the hourly product, `max-age=85988` on /points -- a grid cell never moves. @@ -77,11 +77,15 @@ export function normalizeHourly(json = {}) { // A 404 `application/problem+json` is the API holding nothing for this point: a state to // show, not an error to throw. Every other status is a fault, and loud. async function getJson(url, { fetchImpl, signal }) { - const res = await fetchImpl(url, { headers: requestHeaders(), signal }); - if (res.ok) return { json: await res.json() }; - if (res.status !== 404) throw new Error(`NWS answered ${res.status} for ${url}.`); - const problem = await res.json().catch(() => ({})); - return { status: NO_FORECAST, detail: problem.detail || problem.title || '', coverage: COVERAGE }; + const got = await fetchWithDeadline(url, { + fetchImpl, signal, timeoutMs: SOURCES.nwsForecast.timeoutMs, init: { headers: requestHeaders() }, anyStatus: true, + }); + if (got.ok) return { json: got.body }; + if (got.httpStatus === 404) { + const problem = (got.body && typeof got.body === 'object') ? got.body : {}; + return { status: NO_FORECAST, detail: problem.detail || problem.title || '', coverage: COVERAGE }; + } + return orThrow(got, got.httpStatus ? `NWS answered ${got.httpStatus} for ${url}.` : undefined); } // The gridpoints product, the OTHER shape on this origin -- `/gridpoints/{office}/{x},{y}`, named by diff --git a/themes/headlessmode/static/js/quiet-corners-maps/source-wetlands.js b/themes/headlessmode/static/js/quiet-corners-maps/source-wetlands.js index a3a3253..2c22663 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/source-wetlands.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/source-wetlands.js @@ -3,10 +3,10 @@ // THE BINDING CONSTRAINT IS REQUEST TIME, NOT RECORD COUNT. Measured 2026-09-15: // merely SENDING resultOffset/resultRecordCount re-costs a query 20x, even at // offset 0 -- a Louisiana 1-degree extent took 8.71 s unpaged and 176.74 s with -// paging params, against REQUEST_POLICY.timeoutMs of 20,000. Thirty pages of that +// paging params, against this source's `timeoutMs` of 20,000. Thirty pages of that // is ninety minutes for one degree square. So this layer asks for a COUNT first and // REFUSES above `recordBudgetPerExtent`, and that refusal is the layer rather than a -// polish item: it is the only thing keeping the requests inside their own policy. +// polish item: it is the only thing keeping the requests inside their own deadline. // Adding `orderByFields` made it worse still -- that variant never returned inside a // 180 s cutoff -- and paging is stable without one (overlap 0, union exactly 1,759), // so no query here sorts. @@ -27,6 +27,7 @@ // empty answer honest. All 105 No_Data parts are Alaska and the Pacific territories. import { SOURCES } from './source-registry.js'; import { pageThrough } from './arcgis-pager.js'; +import { fetchWithDeadline } from './request.js'; import { WETLAND_SEMANTICS } from './legend.js'; const SRC = SOURCES.fwsNwiWetlands; @@ -210,10 +211,10 @@ export function normalizeWetland(raw) { } const post = async (q, fetchImpl, signal) => { - try { - const res = await fetchImpl(q.url, { method: 'POST', body: new URLSearchParams(q.params), signal }); - return res.ok ? await res.json() : null; - } catch { return null; } + const got = await fetchWithDeadline(q.url, { + fetchImpl, signal, timeoutMs: SRC.timeoutMs, init: { method: 'POST', body: new URLSearchParams(q.params) }, + }); + return got.ok ? got.body : null; }; /** Count, then decide, then fetch. The order is the design. @@ -232,7 +233,7 @@ export async function fetchWetlands({ bboxLonLat, fetchImpl = fetch, signal } = const q = featureQuery(bboxLonLat); const page = await pageThrough({ url: q.url, label: 'The National Wetlands Inventory', params: q.params, - normalize: normalizeWetland, fetchImpl, signal, pageSize: PAGE_SIZE, maxPages: MAX_PAGES, + normalize: normalizeWetland, fetchImpl, signal, timeoutMs: SRC.timeoutMs, pageSize: PAGE_SIZE, maxPages: MAX_PAGES, }); const partial = page.coverage !== 'complete'; // Two point queries at the box's centre, asked TOGETHER and only after the budget