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.
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>;
// 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
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.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.You'll wrap a flaky async function so that when it fails it tries again after a pause — and so that any single attempt that hangs for too long is cut loose and retried.
Your app calls an API. Most of the time it works; sometimes the request fails for no lasting reason — a dropped connection, a server hiccup, or a request that just hangs and never answers. Failing the whole operation on the first stumble is a poor experience when a second try would have worked. You want a wrapper that quietly tries again a few times, spaces the tries out so it doesn't hammer a struggling server, and refuses to wait forever on any one of them. You're building resilientFetchWrapper(fn, options).
Think of each call as one attempt, and an attempt has three possible endings. It succeeds, so you're done — resolve with the value. It fails but you still have retries left, so you wait backoff(attempt) ms and try again. Or it fails with no retries left, so you give up and reject with that last error. The timeout, when set, is a stopwatch on each attempt: if the attempt runs past the deadline, you treat it as a failure and move on.
The obvious version loops and, on failure, retries immediately with no pause:
async function resilientNaive(fn, retries = 3) {
for (let i = 0; i <= retries; i++) {
try {
return await fn(); // first success returns here
} catch (err) {
// swallow it and loop again — right away
}
}
throw new Error('all attempts failed');
}
Two things are broken. First, it retries instantly — against an overloaded server, back-to-back requests make things worse, which is exactly why the backoff pause exists. Second, if fn returns a promise that never settles (a hung connection), await fn() waits forever: the loop is stuck on the first attempt and the retries never happen. It also throws a generic message instead of the real reason the last attempt failed.
function resilientFetchWrapper(fn, options = {}) {
const { retries = 3, backoff = (attempt) => attempt * 10, timeout } = options;
// Run fn once. With a timeout, race the real call against a timer so a
// hung call loses the race after `timeout` ms and becomes a failure.
function attemptOnce(args) {
const call = Promise.resolve().then(() => fn(...args));
if (timeout == null) return call; // no timeout: just run fn
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('attempt timed out after ' + timeout + 'ms')),
timeout,
);
call.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}
return async function wrapped(...args) {
let lastError;
// attempt 1 is the first call; `retries` more are allowed after it.
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
try {
return await attemptOnce(args); // first success wins — stop here
} catch (error) {
lastError = error; // remember it in case this was the final try
if (attempt <= retries) {
// not the last attempt: wait, then loop for the next one
const wait = backoff(attempt);
await new Promise((resolve) => setTimeout(resolve, wait));
}
}
}
throw lastError; // every attempt failed — reject with the LAST error
};
}
module.exports = { resilientFetchWrapper };
The loop runs retries + 1 times: one initial attempt plus the retries. attemptOnce isolates a single try. Promise.resolve().then(() => fn(...args)) normalizes fn so that even a synchronous throw becomes a rejection you can catch, and when timeout is set it wraps that call in a race against a setTimeout. Whichever settles first — the real result or the timer's rejection — decides the attempt, and clearTimeout stops the timer once the call wins so it can't fire late. Back in the loop, a success returns straight away; a failure is stored in lastError, and if retries remain you wait backoff(attempt) ms and go around again. Fall out of the loop and every attempt has failed, so you reject with the last error you saw.
The race is the piece that makes this resilient rather than merely retrying. Without it, a single request that hangs blocks every retry behind it. With it, each attempt has a fresh deadline, and a stuck call is abandoned so the next attempt can start.
Take resilientFetchWrapper(fn, { retries: 2, backoff: (a) => a * 10, timeout: 30 }) where fn hangs on the first call, rejects on the second, and resolves on the third:
fn() returns a promise that never settles. The 30ms timer wins the race and rejects with a timeout error, which becomes lastError. Attempt 1 still has retries left, so wait backoff(1), which is 10ms.fn() rejects with an Error('503'). Now lastError is the 503. Attempt 2 still has retries left, so wait backoff(2), which is 20ms.fn() resolves with the data. return hands it straight back and the loop ends.Three attempts (1 initial plus 2 retries), waits of 10ms then 20ms, and the hung first attempt did not stall the whole thing because the timeout cut it loose. Had the third attempt also failed, attempt would be 3 — not within the retry budget of 2 — so there is no more waiting: the loop exits and rejects with that final error.
backoff(attempt) wait before each retry is the whole point; don't drop it for a tighter loop.await fn() waits forever. Racing each attempt against a timer is what keeps the wrapper moving.retries — retries is the number of retries, not total attempts. retries: 3 means 4 calls (1 plus 3). Loop retries + 1 times, not retries.clearTimeout — if the real call wins the race, clear the pending timer; otherwise it fires later, rejecting an already-settled attempt or leaking a live timer.AbortController signal into fn lets you actually cancel the in-flight request when it times out.shouldRetry(error) predicate lets you retry a 503 but fail fast on a 400, since retrying a bad request never helps.backoff value spreads out many clients that would otherwise retry in lockstep and re-overload the server at the same instant.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.
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>;
// 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
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.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.You'll wrap a flaky async function so that when it fails it tries again after a pause — and so that any single attempt that hangs for too long is cut loose and retried.
Your app calls an API. Most of the time it works; sometimes the request fails for no lasting reason — a dropped connection, a server hiccup, or a request that just hangs and never answers. Failing the whole operation on the first stumble is a poor experience when a second try would have worked. You want a wrapper that quietly tries again a few times, spaces the tries out so it doesn't hammer a struggling server, and refuses to wait forever on any one of them. You're building resilientFetchWrapper(fn, options).
Think of each call as one attempt, and an attempt has three possible endings. It succeeds, so you're done — resolve with the value. It fails but you still have retries left, so you wait backoff(attempt) ms and try again. Or it fails with no retries left, so you give up and reject with that last error. The timeout, when set, is a stopwatch on each attempt: if the attempt runs past the deadline, you treat it as a failure and move on.
The obvious version loops and, on failure, retries immediately with no pause:
async function resilientNaive(fn, retries = 3) {
for (let i = 0; i <= retries; i++) {
try {
return await fn(); // first success returns here
} catch (err) {
// swallow it and loop again — right away
}
}
throw new Error('all attempts failed');
}
Two things are broken. First, it retries instantly — against an overloaded server, back-to-back requests make things worse, which is exactly why the backoff pause exists. Second, if fn returns a promise that never settles (a hung connection), await fn() waits forever: the loop is stuck on the first attempt and the retries never happen. It also throws a generic message instead of the real reason the last attempt failed.
function resilientFetchWrapper(fn, options = {}) {
const { retries = 3, backoff = (attempt) => attempt * 10, timeout } = options;
// Run fn once. With a timeout, race the real call against a timer so a
// hung call loses the race after `timeout` ms and becomes a failure.
function attemptOnce(args) {
const call = Promise.resolve().then(() => fn(...args));
if (timeout == null) return call; // no timeout: just run fn
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('attempt timed out after ' + timeout + 'ms')),
timeout,
);
call.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}
return async function wrapped(...args) {
let lastError;
// attempt 1 is the first call; `retries` more are allowed after it.
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
try {
return await attemptOnce(args); // first success wins — stop here
} catch (error) {
lastError = error; // remember it in case this was the final try
if (attempt <= retries) {
// not the last attempt: wait, then loop for the next one
const wait = backoff(attempt);
await new Promise((resolve) => setTimeout(resolve, wait));
}
}
}
throw lastError; // every attempt failed — reject with the LAST error
};
}
module.exports = { resilientFetchWrapper };
The loop runs retries + 1 times: one initial attempt plus the retries. attemptOnce isolates a single try. Promise.resolve().then(() => fn(...args)) normalizes fn so that even a synchronous throw becomes a rejection you can catch, and when timeout is set it wraps that call in a race against a setTimeout. Whichever settles first — the real result or the timer's rejection — decides the attempt, and clearTimeout stops the timer once the call wins so it can't fire late. Back in the loop, a success returns straight away; a failure is stored in lastError, and if retries remain you wait backoff(attempt) ms and go around again. Fall out of the loop and every attempt has failed, so you reject with the last error you saw.
The race is the piece that makes this resilient rather than merely retrying. Without it, a single request that hangs blocks every retry behind it. With it, each attempt has a fresh deadline, and a stuck call is abandoned so the next attempt can start.
Take resilientFetchWrapper(fn, { retries: 2, backoff: (a) => a * 10, timeout: 30 }) where fn hangs on the first call, rejects on the second, and resolves on the third:
fn() returns a promise that never settles. The 30ms timer wins the race and rejects with a timeout error, which becomes lastError. Attempt 1 still has retries left, so wait backoff(1), which is 10ms.fn() rejects with an Error('503'). Now lastError is the 503. Attempt 2 still has retries left, so wait backoff(2), which is 20ms.fn() resolves with the data. return hands it straight back and the loop ends.Three attempts (1 initial plus 2 retries), waits of 10ms then 20ms, and the hung first attempt did not stall the whole thing because the timeout cut it loose. Had the third attempt also failed, attempt would be 3 — not within the retry budget of 2 — so there is no more waiting: the loop exits and rejects with that final error.
backoff(attempt) wait before each retry is the whole point; don't drop it for a tighter loop.await fn() waits forever. Racing each attempt against a timer is what keeps the wrapper moving.retries — retries is the number of retries, not total attempts. retries: 3 means 4 calls (1 plus 3). Loop retries + 1 times, not retries.clearTimeout — if the real call wins the race, clear the pending timer; otherwise it fires later, rejecting an already-settled attempt or leaking a live timer.AbortController signal into fn lets you actually cancel the in-flight request when it times out.shouldRetry(error) predicate lets you retry a 503 but fail fast on a 400, since retrying a bad request never helps.backoff value spreads out many clients that would otherwise retry in lockstep and re-overload the server at the same instant.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.