doxa-maps/library/composables/retryFetch.js
Doxa Maps d3cdc2c3bc Two gates the branch backlog turned red
Seven Driver citations came back with the merged branches. Each keeps its
engineering reason and its date, and loses the speaker: the overlap report, the
poster's legend rule, the network-change defect, the chart's provenance.

check:plural-forms crashed rather than failed. It built its locale list by
listing the catalogue directory, so a README.md moved in beside the locale
folders became a 'locale', passed the exists check because a file does exist,
and crashed readdirSync on a non-directory. It now filters to directories. The
gate on development has the same line and only survives because that README is
not there yet -- so this is a fix for development, not a merge artefact.

Both found by running the full 103-gate chain; the shortened chain the merge
produced ran neither.
2026-09-29 14:55:35 +00:00

56 lines
2.9 KiB
JavaScript

/**
* retryFetch.js — fetch(), with the same transient-blip retry the lazy components already get.
*
* ★ THE DEFECT AND WHAT IT IS NOT. A reader hit net::ERR_NETWORK_CHANGED on an embedded UPG-100 and
* the load died. The URLs in his console were module URLs — locale JSON with ?import, useOptionalT.js,
* useShadowStyles.js, useMapTheme.js — which look like lazy loads that need retrying. They are not: the
* production build is IIFE with inlineDynamicImports (vite.config.js:216-220), so every one of those
* modules is INSIDE the single bundle and is never fetched separately. Those failures are dev-server
* module requests and cannot happen for a reader.
*
* ★ BUT THE FAILURE CLASS IS REAL IN PRODUCTION, one layer down. A built bundle still makes runtime
* requests — the boundaries geojson, the people-group list, the prayer statistics, a detail lookup, a
* token service — and not one of them retried. The same wifi switch, VPN flap or sleep-wake that killed
* a dev load takes out whichever of those happened to be in flight, and the feature is simply
* absent: an empty map, a missing outline, a statistic that never arrives. No error a reader can act on.
*
* So this is the fetch-shaped sibling of retryableAsyncComponent, deliberately with the SAME policy —
* 3 attempts, growing backoff — because it is the same event being survived and two different answers
* to "how long is a blip" would be worse than one.
*
* ★ IT RETRIES TRANSPORT FAILURES ONLY. A rejected fetch() is a network-layer failure; a 404 or a 500
* RESOLVES, and retrying those would hide real breakage and hammer a struggling server. A 5xx is
* arguably transient, but it is the server saying something, and this helper deliberately does not
* second-guess it — the caller sees the response it would have seen.
*/
const DEFAULT_ATTEMPTS = 3
const DEFAULT_BACKOFF = (attempt) => attempt * 500
/**
* @param {string|Request} input
* @param {RequestInit} [init]
* @param {Object} [opts]
* @param {number} [opts.attempts] total tries, including the first
* @param {function} [opts.backoff] (attempt) => ms
* @param {AbortSignal} [opts.signal] abort ends it immediately, without further attempts
* @returns {Promise<Response>} the response, or the LAST transport error if every attempt failed
*/
export async function retryFetch(input, init = undefined, { attempts = DEFAULT_ATTEMPTS, backoff = DEFAULT_BACKOFF, signal = null } = {}) {
let lastError
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
try {
return await fetch(input, init)
} catch (err) {
// An ABORT is a decision, not a blip: never retry one.
if (err?.name === 'AbortError' || signal?.aborted) throw err
lastError = err
if (attempt >= attempts) break
await new Promise((r) => setTimeout(r, backoff(attempt)))
}
}
throw lastError
}
export default retryFetch