@watchgold/resilience
v0.1.0
Published
Resilience primitives for data-heavy frontends: safe retries with backoff, request deduplication and coalescing, TTL caching, deploy-skew chunk recovery, and fail-closed data settling for ISR.
Maintainers
Readme
@watchgold/resilience
Resilience primitives for data-heavy frontends: safe retries with backoff, request deduplication and coalescing, TTL caching, deploy-skew chunk recovery, and fail-closed data settling for ISR.
Zero dependencies. Framework-free (works with Next.js, but nothing here imports it). Every helper is dependency-injected (now, reload, the underlying get, the loader) so all of it is testable without timers-in-the-sky or a real network.
Why this exists
Each module encodes a lesson a production market-data platform paid for:
The duplicate-POST retry. A response interceptor replayed any request that timed out or hit a 503 — POSTs included. Any metered AI question slower than the client's 30 s timeout was silently re-sent up to three times: each retry passed the daily-allowance check again, spent model tokens again, and persisted a duplicate record. retry.ts replays only RFC 9110 safe methods (GET/HEAD/OPTIONS); an unsafe request that may already have been processed surfaces its error and lets the user retry deliberately.
The empty render cached by ISR. A page fetched all of its panels with Promise.allSettled and rendered null for anything that rejected. Looks resilient — until the backend was down or cold-starting and every fetch rejected at once: the page rendered fully empty with a 200, and Next.js ISR cached that blank render for the whole revalidate window. Every visitor for the next minute got an empty page, even after the backend recovered. settle.ts keeps the partial tolerance but throws when everything (or anything you mark critical) failed, so a revalidation render fails and the framework keeps serving the last good page instead of caching the outage.
Deploy-skew crashes. After each deploy, tabs opened against the previous build still hold the old chunk manifest. Their next client-side navigation requests chunks whose names changed: the renamed chunk 404s (ChunkLoadError), or a lazy module resolves to undefined (minified React error #130), and the user lands on a dead-end "client-side exception" screen. The cure is one full reload — chunk-recovery.ts detects the skew-shaped errors and hard-reloads once, with a sessionStorage guard so a genuine bug can't cause a reload loop.
The remaining modules are the load-shedding layer that kept the same platform inside upstream rate limits: browser-side GET deduplication (dedup.ts), and the BFF/proxy response cache with per-prefix TTLs and single-flight coalescing (coalesced-cache.ts) that stopped 429s during traffic spikes.
Install
npm i @watchgold/resilienceQuick start
Safe retries with backoff (retry)
import axios from 'axios';
import { attachRetryInterceptor, isRetryableRequestError } from '@watchgold/resilience';
const api = axios.create({ baseURL: '/api', timeout: 30_000 });
// GET/HEAD/OPTIONS retried on network error / 503 / timeout with 2s, 4s, 8s
// backoff. POST/PUT/PATCH/DELETE are NEVER replayed.
const detach = attachRetryInterceptor(api);
// Customize:
attachRetryInterceptor(api, {
maxRetries: 2,
delayMs: (attempt) => attempt * 500,
isRetryable: (err) => isRetryableRequestError(err, { retryStatuses: [503, 429] }),
onRetry: (err, attempt) => console.warn(`retry #${attempt}`, err.code),
});The client is typed structurally (AxiosLikeInstance: callable with a config, plus interceptors.response.use), so axios is a peer in spirit only — any compatible client, or a hand-rolled test double, works.
Request deduplication (dedup)
import { createDedupedGet } from '@watchgold/resilience';
const deduped = createDedupedGet((url) => api.get(url)); // or fetch-based
// A ticker, a chart, and a stats panel all asking for the same series
// during one render → exactly one network call.
const [a, b] = await Promise.all([
deduped.get('/prices/latest'),
deduped.get('/prices/latest'),
]);
deduped.get('/holdings/monthly', 5 * 60_000); // slow-moving data: longer TTL
deduped.invalidate('/prices/latest'); // e.g. after a relevant mutationTTL cache with coalescing, for a BFF/proxy (coalesced-cache)
import {
ttlRules, createCoalescedCache, etagMatches, swrCacheControl,
} from '@watchgold/resilience';
// Per-prefix TTLs in seconds, first match wins; 0 = per-caller, never cached.
const ttlFor = ttlRules(
[
['api/portfolio', 0], // must reflect writes immediately
['api/candles', 30], // near-real-time series
['api/reference', 300], // slow-moving reference data
],
15, // light default for everything else
);
const cache = createCoalescedCache<{ body: string; status: number; etag: string }>({
ttlMsFor: (key) => ttlFor(key) * 1000,
});
// In a route handler:
const entry = await cache.fetchThrough(pathAndQuery, async () => {
const res = await fetch(upstreamUrl);
const body = await res.text();
// 2xx-only caching: error responses are returned to waiters, never stored.
return { value: { body, status: res.status, etag: makeEtag(body) }, cacheable: res.ok };
});
const cacheControl = swrCacheControl(ttlFor(pathAndQuery));
if (ifNoneMatch && etagMatches(ifNoneMatch, entry.etag)) return new Response(null, { status: 304 });Semantics carried over from production:
- Fresh hits are served without touching the upstream.
- Concurrent misses coalesce: one loader runs; everyone shares its result. If it rejects, waiting callers fall through and make their own attempt instead of inheriting the failure.
- TTL 0 bypasses read, write and coalescing — coalescing could answer a read-after-write with a fetch that started pre-write.
- Only
cacheable !== falseresults are stored; purging of expired entries is throttled (once per minute by default). invalidatePrefix('api/reference')after a mutation drops the stale reads.
Deploy-skew chunk recovery (chunk-recovery)
import { isChunkLoadError, reloadForChunkErrorOnce } from '@watchgold/resilience';
// In a global error boundary / error page:
export default function GlobalError({ error }: { error: Error }) {
useEffect(() => {
if (isChunkLoadError(error) && reloadForChunkErrorOnce()) return; // reloading
}, [error]);
return <FallbackScreen />;
}reloadForChunkErrorOnce reloads at most once per 15 s window (tracked in sessionStorage under chunk-recovery:last-reload-at), so a genuine bug can't trap the user in a reload loop. If storage is unavailable (private mode, quota), it reloads anyway — one reload still beats a dead-end error screen.
Fail-closed settling for ISR pages (settle)
import { settleOrThrow } from '@watchgold/resilience';
// In a server component / page with `revalidate`:
const data = await settleOrThrow(
{
prices: fetchPrices(), // the page is meaningless without this
holdings: fetchHoldings(), // best-effort panel
news: fetchNews(), // best-effort panel
},
{ critical: ['prices'], onError: (key, err) => console.error(`[${String(key)}]`, err) },
);
// data.holdings: Holdings | null — render partial pages happily.
// Backend fully down => AggregateError => ISR keeps serving the last good page.Bounded fetches (fetch-timeout)
import { fetchWithTimeout } from '@watchgold/resilience';
const res = await fetchWithTimeout(url, { headers }, { timeoutMs: 10_000 });
// Times out with a `TimeoutError` DOMException; combines with your own
// AbortSignal (init.signal) — whichever aborts first wins.API reference
retry
| Export | Description |
| --- | --- |
| isRetryableRequestError(error, opts?) | true only for safe methods (GET/HEAD/OPTIONS) failing transiently: no response, status in opts.retryStatuses (default [503]), or code === 'ECONNABORTED'. |
| attachRetryInterceptor(client, opts?) | Registers a response interceptor on an AxiosLikeInstance; returns a detach function. Options: maxRetries (3), delayMs(attempt) (2 ** attempt * 1000), isRetryable, onRetry(error, attempt). Replay count is stamped on the request config as __retryCount. |
| RetryableErrorLike, AxiosLikeInstance, RetryInterceptorOptions, IsRetryableOptions | Supporting types. |
dedup
| Export | Description |
| --- | --- |
| createDedupedGet(get, opts?) | Wraps a (url) => Promise<R>; returns { get(url, ttlMs?), invalidate(url?), size() }. Coalesces identical in-flight GETs; caches successes for ttlMs (default defaultTtlMs, 15 000 ms); failures clear the slot and propagate; now injectable. |
chunk-recovery
| Export | Description |
| --- | --- |
| DEFAULT_SKEW_PATTERNS | Case-insensitive fragments identifying stale-build errors (webpack chunk errors, dynamic-import failures, unexpected token '<', React #130). |
| isChunkLoadError(error, extraPatterns?) | Matches error.name + error.message against the default plus extra patterns. |
| reloadForChunkErrorOnce(opts?) | Guarded hard reload. Options: storageKey ('chunk-recovery:last-reload-at'), guardWindowMs (15 000), reload (defaults to window.location.reload). Returns whether a reload was triggered; false during SSR. |
coalesced-cache
| Export | Description |
| --- | --- |
| ttlRules(rules, defaultTtlSeconds) | Ordered [prefix, ttlSeconds] table → (key) => ttlSeconds, startsWith matching, first match wins. |
| createCoalescedCache<V>({ ttlMsFor, now?, purgeIntervalMs? }) | get(key) (fresh-only), fetchThrough(key, loader), invalidatePrefix(prefix), delete(key), clear(), size(). Loader resolves { value, cacheable? }. |
| etagMatches(ifNoneMatchHeader, etag) | RFC 9110 weak comparison; handles W/ prefixes and comma-separated lists. |
| swrCacheControl(ttlSeconds, opts?) | 'private, no-store' for TTL 0 / noStore; otherwise public, max-age=N, stale-while-revalidate=N*factor (factor default 4). |
settle
| Export | Description |
| --- | --- |
| settleOrThrow(tasks, opts?) | Awaits a keyed map of promises; rejections become null. Throws AggregateError when every task rejected (requireAny, default true) or any critical key rejected. onError(key, reason) observes each rejection. |
fetch-timeout
| Export | Description |
| --- | --- |
| fetchWithTimeout(input, init?, opts?) | fetch bounded by AbortSignal.timeout(opts.timeoutMs ?? 10_000); a caller-supplied init.signal is combined (via AbortSignal.any where available, listener merge otherwise). |
SSR safety
reloadForChunkErrorOncereturnsfalsewhen there is nowindow;isChunkLoadErroris pure. Safe to import from server components.createDedupedGetandcreateCoalescedCachehold plain module-level-styleMaps: on a server they are per process (each serverless instance has its own copy — good for load shedding, not a source of truth), and in the browser they are per tab.settleOrThrowandfetchWithTimeoutare isomorphic.fetchWithTimeoutneedsAbortSignal.timeout(Node 18+, all modern browsers);AbortSignal.anyis used when present (Node 20.3+) with a fallback otherwise.- Nothing here touches
document, and no module has import-time side effects (sideEffects: false).
License
MIT
Extracted from the production codebase of WatchGold, a precious-metals market-data platform.
