DRV1650-LAZY-LOCALE-IMPORT-RETRY. net::ERR_NETWORK_CHANGED killed an embedded UPG-100's load, and the card asks for the lazy locale/module loads to retry like retryableAsyncComponent does. ★ THOSE URLs DO NOT EXIST IN PRODUCTION. The console named module URLs — locale JSON with ?import, useOptionalT.js, useShadowStyles.js, useMapTheme.js. The build is IIFE with inlineDynamicImports (vite.config.js:216-220), so every one of them is inside the single bundle and is never fetched separately. They are dev-server module requests. There is no loader to wrap and nothing a reader could hit, so the card cannot be done as written, and saying so is most of its value. ★ THE FAILURE CLASS IS REAL ONE LAYER DOWN, and nobody had covered it. A built bundle still makes runtime requests — the boundaries geojson (twice, two composables), the people-group list, prayer statistics — and NOT ONE of them retried. The same wifi switch that killed the Driver's dev load takes out whichever of those is in flight, and the feature is simply ABSENT: an empty map, a missing country outline, a statistic that never arrives, with nothing a reader can act on. That is the same defect he reported, at the layer that ships. retryFetch is the fetch-shaped sibling of retryableAsyncComponent and deliberately shares its policy — 3 attempts, growing backoff — because it survives the same event, and two answers to "how long is a blip" would be worse than one. It retries TRANSPORT failures only. A 404 or a 500 RESOLVES; retrying those would hide real breakage and hammer a struggling server, so the response is handed back untouched. An abort is a decision, not a blip, and is never retried. check-retry-fetch asserts the behaviour AND that the shared data paths use it. Its "persistently dead endpoint still fails" arm originally only checked that it threw — which an UNBOUNDED retry loop also does, eventually. Caught by this gate's own negative test; it now counts the attempts, because the property is bounded give-up and the bound is the number. Negative-tested: retrying an abort, changing the default attempt count, and reverting a data path to bare fetch each turn it red. NOT wired: usePosterPrints, useCsvExport, PeopleGroupDetail and the token service also fetch at runtime. They belong to other surfaces or have their own failure handling, and I am not editing them blind — the list is in the report.
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. The Driver 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
|
|
* the Driver's 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
|