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.
56 lines
2.9 KiB
JavaScript
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
|