retry-request
One function that runs a request again when it fails — with exponential backoff, an abort signal, and one thing a hand-written loop cannot have: it knows whether the network is actually there.
A plain retry loop treats every failure the same. Offline, that is the worst possible behaviour: the four attempts you budgeted for a flaky server are spent in eight seconds on requests that never left the device, and the call fails while the user is still walking towards the lift. Retries are for failures that might not repeat, and a failure that repeats every time until the connection is back is not one of them.
So this loop asks
NetworkConnection
first. An attempt does not start while the network is down — the
call parks, costing nothing, and starts the moment
the connection is verified back. A backoff already in progress
ends early when the connection returns, because a
reconnection is better information than a guess about the future.
And retryOnlyOnConnectionFailure lets you say the thing
you actually mean:
retry a dropped connection, never a server that answered.
This is not an optional integration. Every call reads the network
state from
@dmytromykhailiuk/network-connection, so
NetworkConnection.init() must have run before the
first retryRequest — otherwise the call rejects
immediately with
[retry-request] NetworkConnection.init() must be called before
retryRequest()
and the attempt is never started. See
NetworkConnection is required.
Getting started
Install#
npm i @dmytromykhailiuk/retry-request
One dependency,
@dmytromykhailiuk/network-connection, installed with
it — see below for the one line of
setup it needs. No framework. Ships ESM and CJS with type
declarations for both, and needs nothing from the DOM beyond
setTimeout.
NetworkConnection is required#
The network state is where every interesting decision in this
library comes from, and there is no honest default for it.
navigator.onLine is not one: refresh a PWA while
offline and it still reports true. So the state comes
from a healthcheck request, and that healthcheck has to be
configured — once, at startup, before anything calls
retryRequest.
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
// Any URL your server answers cheaply. A 404 still proves the network is
// reachable — this measures connectivity, not server health.
await NetworkConnection.init("/healthcheck", {
pingInterval: 30_000, // catch the silent drops: Wi-Fi up, no internet
});
Call retryRequest before that has finished and it
rejects at once, before your attempt runs:
await retryRequest(load);
// ✗ Error: [retry-request] NetworkConnection.init() must be called before retryRequest()
// `load` was never called.
The same applies after
NetworkConnection.destroy(): the layer is gone, so the
next call is refused the same way. Calls that were already
in flight when it was destroyed reject too — with the
connection layer's own error — because a call parked waiting for a
reconnection has just lost the only thing that could ever wake it,
and quietly carrying on would mean guessing.
Because the fallback would be a lie in exactly the situation the
library exists for. Without the connection layer, an offline
failure and a server error are indistinguishable, so
retryOnlyOnConnectionFailure would have to guess,
parking would be impossible, and a backoff could never end early.
A call that cannot do those things is not the call you wrote —
better to say so at startup than to silently degrade in
production.
Quick start#
retryRequest takes the work to run and the options, and
resolves with whatever the work resolves with.
import { retryRequest } from "@dmytromykhailiuk/retry-request";
const profile = await retryRequest(
async () => {
const response = await fetch("/api/profile");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
{ maxRetries: 4, retryBaseDelay: 500 },
);
Four retries, waiting 500 ms, 1 s, 2 s and 4 s —
and none of that time is spent while the device is offline. Note the
throw: fetch resolves for a 500 as happily
as for a 200, so a response you consider a failure has to be turned
into one.
maxRetries defaults to 0. Wrapping a
call in retryRequest and passing no options runs it
exactly once — the wrapper is inert until you ask for retries,
which is what makes it safe to introduce into an existing codebase
in one commit.
Concepts
How a call unfolds#
The order is fixed, and every step of it is observable. Knowing it is usually enough to predict exactly what a given set of options will do.
// once, before anything:
// the options are validated, and NetworkConnection must be initialized
// then, for every attempt:
// 1. aborted? reject with signal.reason
// 2. wait until the network is confirmed online
// (skipped for attempt 1 with ignoreConnectionForFirstAttempt)
// 3. run the attempt — resolved? that is the result, done
//
// and when it rejects:
// 4. aborted? reject with signal.reason
// 5. out of budget? reject with the attempt's own error
// 6. retryOnlyOnConnectionFailure and the network is up? reject with it too
// 7. shouldRetry says no? reject with it too
// 8. call onRetry, then wait: the backoff delay, or the connection
// coming back, or an abort — whichever happens first
// 9. back to 1
Two consequences worth stating plainly.
The budget is checked before anything else, so
shouldRetry and onRetry never run for the
failure that ends the call. And
waiting for the network is not an attempt: a call
parked offline for ten minutes has spent none of its retries.
API
retryRequest#
retryRequest<T>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T>
interface RetryOptions {
maxRetries?: number; // default: 0
retryBaseDelay?: number; // default: 500
exponentialBackoff?: boolean; // default: true
retryOnlyOnConnectionFailure?: boolean; // default: false
ignoreConnectionForFirstAttempt?: boolean;// default: false
maxDelay?: number; // default: Infinity
shouldRetry?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
onRetry?: (info: { error: unknown; attempt: number; delay: number }) => void;
signal?: AbortSignal;
}
fn is called with no arguments, every time. It has to
be a function, not a promise: a promise can only be awaited
once, and a retry has to start the work again from the beginning —
so retryRequest(fetch(url)) would retry nothing at all,
while retryRequest(() => fetch(url))
makes a fresh request every round.
Options
The retry budget#
How many extra attempts are allowed after the first
one fails, so the call runs at most
maxRetries + 1 times. Defaults to 0.
Must be a non-negative integer, or Infinity.
maxRetries |
Attempts | Rejects with |
|---|---|---|
0 (default) |
1 | the first error, unchanged |
3 |
up to 4 | the error from the fourth attempt |
Infinity |
unbounded | never — see below |
Retrying forever#
maxRetries: Infinity is explicitly supported: the
budget check can never fail, so the call keeps trying until the work
succeeds, something stops it, or the process ends. It is the right
shape for a background sync that must eventually go through — and it
is a promise that may never settle, so give it a way out:
// Keep trying for as long as the tab lives, but never faster than once a
// minute, and stop the moment the user signs out.
await retryRequest(() => pushPendingChanges(), {
maxRetries: Number.POSITIVE_INFINITY,
retryBaseDelay: 1000,
maxDelay: 60_000,
signal: sessionController.signal,
onRetry: ({ attempt }) => console.warn(`sync attempt ${attempt} failed`),
});
Two habits make an unbounded loop safe:
maxDelay so the wait cannot
grow past anything useful, and either a
signal or a
shouldRetry that can end it. Note
that an unbounded loop is not a busy loop: while the device is
offline it is parked, not spinning.
Delays#
Milliseconds to wait before the first retry, and the base
every later delay is derived from. Defaults to
500. 0 is allowed — the retry then
happens on the next tick.
Doubles the delay after every failure. Defaults to
true; set it to false to wait
retryBaseDelay flat every time.
| After failure | exponentialBackoff: true |
false |
|---|---|---|
| 1st | 500 ms | 500 ms |
| 2nd | 1 s | 500 ms |
| 3rd | 2 s | 500 ms |
| 4th | 4 s | 500 ms |
| 10th | 4 min 16 s | 500 ms |
The waiting is not a plain setTimeout: it is a race
between the delay and the connection coming back. Drop the network
during a four-second backoff, restore it after one, and the next
attempt starts then — the delay was a guess about when things might
work again, and the reconnection settled the question.
maxDelay#
Ceiling for a single delay, in milliseconds — the point where the doubling stops. Unlimited by default.
Doubling grows faster than people expect: the tenth retry of a
500 ms base is over four minutes away, the twentieth is nearly
three days. Anything with more than a handful of retries wants a
ceiling — maxDelay: 30_000 turns
500, 1000, 2000, … into
500, 1000, 2000, …, 30000, 30000, ….
A browser timer holds its delay in a 32-bit integer: anything
above ~24.8 days overflows and fires
immediately, turning a long backoff into a busy loop.
Every delay is clamped to that maximum, so even
maxDelay: Infinity with a hundred failures behind it
cannot produce that bug.
The connection options#
Parking while offline and waking on reconnection are not optional and have no flags — they are what the library is. These two options tune the edges of that behaviour.
Retry only while the network is down. When an attempt fails
and the connection is up, the failure came from the other side
— the error is rethrown on the spot and the remaining budget
is left unspent. Defaults to false.
This is the option that makes a retry loop safe around
non-idempotent work: a POST that reached the
server and came back 500 is not retried, while the same
POST killed by a dying connection is.
Start the first attempt without waiting for the network. The
retries that follow still wait. Defaults to
false.
For work that can succeed offline: a service worker or a cache may answer perfectly well with no connection, and there is no sense parking a request that was never going to touch the network.
await retryRequest(
async () => {
const response = await fetch("/api/orders", { method: "POST", body });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
{
maxRetries: 5,
retryOnlyOnConnectionFailure: true, // never repeat a request the server answered
retryBaseDelay: 1000,
},
);
retryOnlyOnConnectionFailure narrows the retries to
the failures where the request most likely never arrived — but
"most likely" is the honest wording. A request can reach the
server and be committed there while the response dies on the way
back. For anything that must not happen twice, keep the option
and make the endpoint idempotent, with an idempotency key
or a client-generated id.
shouldRetry & onRetry#
The last word before a retry: return false and
the error is rethrown instead. Called with the error and the
number of the attempt that just failed, only while attempts
remain, and awaited when it returns a promise. If it throws,
its error is what the call rejects with.
Called once per retry, after the decision is made and before
the waiting starts, with
{ error, attempt, delay } — where
delay is the number of milliseconds about to be
waited, after the cap. Purely observational: the return value
is ignored, and a throw takes the call down with it.
The common use of shouldRetry is the HTTP status:
retrying a 404 or a 422 just spends time
to arrive at the same answer, while a 429 or a
503 is worth another go.
class HttpError extends Error {
constructor(readonly status: number) {
super(`HTTP ${status}`);
}
}
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
export const getJson = <T>(url: string) =>
retryRequest<T>(
async () => {
const response = await fetch(url);
if (!response.ok) throw new HttpError(response.status);
return response.json();
},
{
maxRetries: 4,
maxDelay: 10_000,
// Anything that is not an HttpError — a parse failure, a dropped
// connection — has no status to judge, so it stays retryable.
shouldRetry: (error) =>
!(error instanceof HttpError) || RETRYABLE.has(error.status),
onRetry: ({ error, attempt, delay }) =>
console.warn(`retry ${attempt} in ${Math.round(delay)}ms`, error),
},
);
Cancellation#
Cancels the whole call — including a backoff in progress and a
wait for the connection to come back. The call rejects with
signal.reason.
A retry loop without a way out is a leak: the component unmounts, the route changes, the user signs out, and something is still waiting to try again. The signal ends all three states a call can be in — parked offline, backing off, or between attempts — and no further attempt is started.
const controller = new AbortController();
const load = retryRequest(
// Passing the signal to fetch as well cancels the request in flight;
// without it, the loop stops but the current attempt runs to its end.
() => fetch(url, { signal: controller.signal }).then((r) => r.json()),
{ maxRetries: 5, signal: controller.signal },
);
onCleanup(() => controller.abort());
An abort always wins. If the signal fires while an attempt is being
torn down, the call rejects with signal.reason rather
than with whatever the dying attempt threw — the cancellation is the
real cause, and the noise on the way out is not worth reporting.
controller.abort() with no argument gives the platform
default, a DOMException named AbortError.
Behaviour
What a call rejects with#
| Situation | Rejection |
|---|---|
| Retries exhausted | the last attempt's own error, never wrapped |
shouldRetry returned false |
that attempt's error |
retryOnlyOnConnectionFailure while online
|
that attempt's error |
shouldRetry or onRetry threw
|
the hook's error |
| The signal aborted | signal.reason |
| An option is out of range |
Error("[retry-request] …"), before any attempt
|
NetworkConnection not initialized |
Error("[retry-request] NetworkConnection.init() …")
|
NetworkConnection.destroy() mid-call |
Error("[network-connection] destroyed while waiting
…")
|
Everything a caller catches today keeps working: the error identity
is preserved, so instanceof checks,
error.status, and error-reporting fingerprints all
behave exactly as they would without the wrapper. Only an abort and
a throwing hook replace it, and both are things you asked for.
The validation errors are thrown before the attempt is ever started,
and always as a rejection rather than a synchronous throw —
retryRequest(…).catch(handler) catches every failure
mode there is.
Recipes#
A default for your whole app. Options are a plain object, so a house style is one spread away — and per-call options still win:
import { type RetryOptions, retryRequest } from "@dmytromykhailiuk/retry-request";
const HOUSE_STYLE: RetryOptions = {
maxRetries: 3,
retryBaseDelay: 400,
maxDelay: 10_000,
};
export const withRetry = <T>(fn: () => Promise<T>, options?: RetryOptions) =>
retryRequest(fn, { ...HOUSE_STYLE, ...options });
A request that has to survive the lift. Read-only, idempotent, and not urgent — park for as long as it takes and never give up:
const settings = await retryRequest(() => getJson("/api/settings"), {
maxRetries: Number.POSITIVE_INFINITY,
maxDelay: 30_000,
signal: pageController.signal,
});
Offline-first reads. Let the first attempt try the cache with no network at all, and only park if that fails:
// cacheFirst answers from the Cache API when it can — no connection needed.
await retryRequest(() => cacheFirst(url, () => fetch(url)), {
maxRetries: 3,
ignoreConnectionForFirstAttempt: true,
});
A timeout per attempt. There is no
timeout option, because it belongs to the request, not
to the loop — AbortSignal.timeout already does it, and
composes:
await retryRequest(
() => fetch(url, { signal: AbortSignal.timeout(5000) }), // per attempt
{ maxRetries: 3, signal: pageController.signal }, // the whole call
);
TypeScript#
The result type comes from the attempt, so nothing needs annotating in the common case:
const user = await retryRequest(async () => ({ id: 1, name: "Ada" }));
// ^? { id: number; name: string }
const raw = await retryRequest<User>(() => fetch(url).then((r) => r.json()));
// ^? User — the explicit parameter types an otherwise `any` json()
The error handed to shouldRetry and
onRetry is typed unknown, because that is
what a catch gives you and JavaScript can throw
anything. Narrow it — error instanceof HttpError — and
the type follows.
RetryOptions is exported for wrappers that pass options
through, and every field on it is optional, so a partial object is
already a valid RetryOptions.
Exports#
Functions
retryRequest
Types
RetryOptions
That is the entire surface. The delay helper, the backoff maths and
the abort plumbing stay internal — there is nothing to configure
about them that RetryOptions does not already cover.