The remaining runtime fetches retry, except the one where a miss is the plan

The follow-up dx-shepherd queued: wire retryFetch into the library fetches with no
failure handling, and skip any that handle failure deliberately. Four qualified, one did
not, and the distinction is the work.

WIRED, because each already handles an HTTP error and NOT a transport one:
  usePosterPrints  the poster HTML — checks .ok, so a 404 is deliberate; a network throw
                   went uncaught and the poster simply failed
  useSharedMapToken the token service — its .catch degrades to '' by design, but the
                   result is MEMOISED one-fetch-per-URL-per-page, so a single blip meant
                   no token for the whole page and a tokenless Mapbox map
  useCsvExport     the bulk list — .ok checked, transport throw blanks the export
  PeopleGroupDetail the detail API — .ok checked, transport throw blanks the panel
In every one the deliberate degradation is unchanged; it now simply survives a blip
first. retryFetch returns a non-ok RESPONSE untouched, so no .ok path moves.

NOT WIRED, and named in the gate rather than silently skipped: usePosterPrints' probe for
an OPTIONAL pre-rendered .webp. A miss is the EXPECTED path there — it falls through to
generating the poster — so retrying would delay the fallback three times over for an
asset that is legitimately absent. That is failure handling that means something, which
is exactly what the instruction said to leave alone.

The gate now covers all eight sites and carries the exemption as a COUNT for that one
file, so a second bare fetch appearing there still fails. Negative-tested: reverting any
wired site to bare fetch turns it red.
This commit is contained in:
Doxa Maps 2026-09-28 18:21:31 +00:00
parent 838df76c14
commit 6c0f1f3040
5 changed files with 22 additions and 7 deletions

View file

@ -67,16 +67,27 @@ const realFetch = globalThis.fetch
globalThis.fetch = realFetch
// ── and the shared data paths actually use it ────────────────────────────────────────────────────
// ★ ONE SITE IS DELIBERATELY EXEMPT AND IS NAMED, not silently skipped. usePosterPrints probes for an
// OPTIONAL pre-rendered .webp and falls through to generating the poster when it is absent — a miss is
// the expected path, so retrying would delay the fallback for an asset that is legitimately not there.
// Everything else in that file, and every other runtime request, must retry.
const EXEMPT = new Map([['library/composables/usePosterPrints.js', 1]])
for (const rel of [
'library/composables/useMapLayers.js',
'library/composables/useCountryOutline.js',
'library/lists/peopleGroupList.js',
'library/composables/usePrayerStatistics.js',
'library/composables/usePosterPrints.js',
'library/composables/useSharedMapToken.js',
'library/composables/useCsvExport.js',
'library/components/PeopleGroupDetail.vue',
]) {
const src = readFileSync(join(ROOT, rel), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
if (/(?<![A-Za-z.])fetch\s*\(/.test(src)) {
fails.push(`${rel} still calls bare fetch() for a runtime request — a transport blip there leaves the\n`
+ ' feature silently absent. Use retryFetch().')
const bare = (src.match(/(?<![A-Za-z.])fetch\s*\(/g) || []).length
const allowed = EXEMPT.get(rel) || 0
if (bare > allowed) {
fails.push(`${rel} has ${bare} bare fetch() call(s) for a runtime request, ${allowed} allowed — a transport\n`
+ ' blip there leaves the feature silently absent. Use retryFetch().')
}
}

View file

@ -108,6 +108,7 @@
</template>
<script setup>
import { retryFetch } from '@map/composables/retryFetch.js'
import { computed, ref, watch, inject } from 'vue';
import { markPrayedLocally } from '../composables/usePrayedLocally.js';
import { useI18n } from 'vue-i18n';
@ -239,7 +240,7 @@ async function fetchDetail(slugVal) {
// click and routed to the slow WP origin, making images load 10+ seconds.
// Live counts (prayer / committed / adopted) come from /list which IS
// cache-busted in DataSourceManager — so freshness stays intact.
const res = await fetch(`${baseUrl}/api/people-groups/detail/${encodeURIComponent(slugVal)}?lang=${encodeURIComponent(locale.value)}`);
const res = await retryFetch(`${baseUrl}/api/people-groups/detail/${encodeURIComponent(slugVal)}?lang=${encodeURIComponent(locale.value)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
detailData.value = await res.json();
} catch (err) {

View file

@ -25,6 +25,7 @@
* The result is cached for the session; if the fetch fails we still export the loaded record
* (24 fields) rather than producing nothing — degraded, never broken.
*/
import { retryFetch } from './retryFetch.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { inject } from 'vue'
import sourcesConfig from '@map/api/sources.json'
@ -145,7 +146,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const lang = src.queryParams?.lang || 'en'
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
const res = await fetch(url, { cache: 'no-store' })
const res = await retryFetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
// Only the groups of this page's people-group LIST (library/lists/) are exported.

View file

@ -24,6 +24,7 @@
// is the mechanism the Driver asked for by name: "not supposed to open up a new
// browser, but should open up the print dialog directly in the browser".
import { retryFetch } from './retryFetch.js'
import { ref } from 'vue';
import { openPrintFrame } from './printInPlace.js';
@ -112,7 +113,7 @@ export function usePosterPrints(opts = {}) {
// NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are
// not shipped by default; the HTML is the SOURCE and is smaller than the image it makes.
set(item, kind, 'render', 'generating the poster', 45);
const htmlRes = await fetch(`${base}/posters/${id}.html`);
const htmlRes = await retryFetch(`${base}/posters/${id}.html`);
if (!htmlRes.ok) throw new Error(`poster not found (${htmlRes.status})`);
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js');
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 });

View file

@ -37,6 +37,7 @@
* every tile request. It does not widen exposure — it stops a second element on the SAME page from
* being unable to use what that page was already given. Nothing is persisted or sent anywhere.
*/
import { retryFetch } from './retryFetch.js'
import { ref, onBeforeUnmount } from 'vue'
const KEY = '__doxaMapToken' // the value, on window, so every bundle sees one copy
@ -131,7 +132,7 @@ export function fetchMapToken(url) {
if (typeof window === 'undefined' || !/^https?:\/\//i.test(String(url || ''))) return Promise.resolve('')
const cache = (window.__doxaMapTokenFetches = window.__doxaMapTokenFetches || {})
if (cache[url]) return cache[url]
const p = fetch(url)
const p = retryFetch(url)
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return (r.headers.get('content-type') || '').includes('json') ? r.json() : r.text() })
.then((d) => {
const tk = String((typeof d === 'string' ? d : d && (d.token || d.tk || d.accessToken)) || '').trim()