gods-eye-view/src/data/retryableLoad.js

86 lines
3.3 KiB
JavaScript

/**
* @file One-shot loader that caches success and rate-limits failure.
*
* The bundled data packs (Natural Earth regions, neighborhood polygons) are
* loaded once per session and memoized. Memoizing the *promise* is the cheap
* way to do that, but it silently turns one transient failure into a
* session-long outage: every later call re-returns the same rejection, and
* the callers — which `.catch(() => null)` — report it to the user as "no
* such region" rather than "the data pack didn't load".
*
* This keeps the memoization and drops it on rejection, so a later caller
* retries. Retries are NOT immediate: a voice/analyst caller in a loop would
* otherwise hammer a permanently broken pack once per spoken query. A failed
* loader serves its recorded rejection until a cooldown elapses, doubling per
* consecutive failure up to a ceiling. Concurrent callers share one in-flight
* load; success is cached permanently.
*
* @module data/retryableLoad
*/
/** @const {number} Ms before a first retry is allowed. */
export const RETRY_COOLDOWN_MS = 5_000;
/** @const {number} Ms ceiling for the doubling backoff. */
export const RETRY_COOLDOWN_MAX_MS = 300_000;
/**
* Wrap a loader so its result is memoized but its failures are rate-limited.
*
* @template T
* @param {() => (Promise<T>|T)} load - Runs at most once per successful load.
* @param {Object} [options]
* @param {number} [options.cooldownMs] - Backoff after the first failure.
* @param {number} [options.maxCooldownMs] - Ceiling for the doubling backoff.
* @param {() => number} [options.now] - Clock seam for tests.
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
*/
export function createRetryableLoader(load, {
cooldownMs = RETRY_COOLDOWN_MS,
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
now = () => Date.now(),
} = {}) {
/** @type {Promise<T>|null} */
let inflight = null;
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
let failure = null;
/** Explicit flag: a loader may reject with a falsy value (null, 0, ''). */
let hasFailure = false;
let failedAt = 0;
let consecutiveFailures = 0;
return function loadOnce() {
if (inflight) return inflight;
if (hasFailure) {
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
// Replay the recorded reason rather than re-running a loader that is
// probably still broken. Callers see identical behavior either way.
if (now() - failedAt < wait) return Promise.reject(failure);
}
// Normalizes a synchronous throw into a rejection, so a broken loader
// cannot escape the retry contract.
const attempt = (async () => load())();
inflight = attempt;
attempt.then(
() => {
// Success is permanent: `inflight` stays set and holds the value.
if (inflight === attempt) {
failure = null;
hasFailure = false;
consecutiveFailures = 0;
}
},
(error) => {
// Only clear our own attempt: a retry may already have replaced it.
if (inflight === attempt) {
inflight = null;
failure = error;
hasFailure = true;
failedAt = now();
consecutiveFailures += 1;
}
},
);
return attempt;
};
}