Resilient Fetch WrapperLoading saved progress…

Resilient Fetch Wrapper

A resilient fetch wrapper retries a failing async call a few times, pausing between attempts, and abandons any single attempt that hangs past a timeout — so a transient network blip recovers on its own instead of surfacing as an error. It sits between your app code and a flaky network: one unreliable fn goes in, a steadier version comes out.

You implement resilientFetchWrapper(fn, options). It returns a new async function that forwards its arguments to fn. If fn rejects, it retries up to retries more times, waiting backoff(attempt) milliseconds before each retry. When a timeout is given, every attempt races fn against a timer, and a timed-out attempt counts as a failure that can be retried like any other.

Signature

function resilientFetchWrapper<A extends any[], R>(
  fn: (...args: A) => Promise<R>,
  options?: {
    retries?: number; // extra attempts after the first (default 3)
    backoff?: (attempt: number) => number; // ms to wait before retry N (default attempt * 10)
    timeout?: number; // per-attempt deadline in ms (optional)
  },
): (...args: A) => Promise<R>;

Examples

// Flaky endpoint: retry up to 3 times, waiting 10ms, 20ms, 30ms between tries.
const get = resilientFetchWrapper((url) => fetch(url).then((r) => r.json()));
const data = await get('/api/user'); // resolves as soon as one attempt succeeds
// Give each attempt a 50ms deadline; a hung attempt times out and is retried.
const call = resilientFetchWrapper(loadProfile, {
  retries: 2,
  backoff: (attempt) => attempt * 100, // wait 100ms, then 200ms
  timeout: 50,
});
await call(userId); // rejects with the LAST error if all 3 attempts fail

Notes

  • retries is the retry count — the number of extra tries after the first call, so retries: 3 allows up to 4 attempts total (1 initial plus 3 retries).
  • backoff is a function of the attempt number — it receives the 1-based number of the attempt that just failed and returns the milliseconds to wait before the next one. The default (attempt) => attempt * 10 grows the wait linearly.
  • A first-try success never waits — if the very first call resolves, return its value right away, with no delay and no further calls to fn.
  • timeout is per attempt, not total — each attempt gets its own fresh deadline; a timeout is just a failure, and a failure can be retried.
  • Reject with the last error — after every attempt fails, reject with the error from the final attempt, not the first one.

FAQ

What is the difference between retries and total attempts?
retries counts the extra tries after the first call, so retries: 3 allows up to 4 attempts (1 initial + 3 retries).
Does the timeout cancel the underlying request?
No. The timeout stops the wrapper from waiting on a hung attempt and moves on, but the original fn promise keeps running in the background — pass an AbortSignal into fn if you need to truly cancel it.
Which error do you get when every attempt fails?
The wrapper rejects with the error from the last attempt, so you see the most recent failure reason rather than the first.
Loading editor…