@dmytromykhailiuk/retry-request
v1.0.0
Published
A retry loop that knows whether the network is actually there — exponential backoff, abort support, and retries that park while offline instead of burning attempts. Typed, tested, tiny.
Maintainers
Readme
@dmytromykhailiuk/retry-request
A retry loop that knows whether the network is actually there — exponential backoff, abort support, and attempts that park while offline instead of being spent.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
⚠️
NetworkConnection.init()must be called first. This is a requirement, not an optional integration: every call reads the network state from NetworkConnection Docs, so aretryRequestmade before that setup rejects immediately with[retry-request] NetworkConnection.init() must be called before retryRequest()and never starts your attempt. See Setup.
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. And
retryOnlyOnConnectionFailure lets you say the thing you actually mean: retry a dropped
connection, never a server that answered.
Install
npm i @dmytromykhailiuk/retry-requestSetup
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
});The same applies after NetworkConnection.destroy(): the layer is gone, so the next call is
refused the same way, and calls already in flight reject with the connection layer's own error —
a call parked waiting for a reconnection has just lost the only thing that could ever wake it.
There is no fallback to a plain retry, on purpose. Without the connection layer, an offline failure and a server error are indistinguishable, parking is impossible and a backoff can never end early — better to say so at startup than to silently degrade in production.
Quick start
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.
Nothing is retried by default. maxRetries defaults to 0, so wrapping a call and passing no
options runs it exactly once.
API
retryRequest<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>
interface RetryOptions {
maxRetries?: number; // default: 0 — extra attempts, may be Infinity
retryBaseDelay?: number; // default: 500 — ms before the first retry
exponentialBackoff?: boolean; // default: true — double the delay each failure
retryOnlyOnConnectionFailure?: boolean; // default: false — never retry a real answer
ignoreConnectionForFirstAttempt?: boolean; // default: false — attempt 1 may run offline
maxDelay?: number; // default: Infinity — ceiling for one delay
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 must be a function, not a promise — a promise
can only be awaited once, and a retry starts the work again from the beginning.
How a call unfolds
once: the options are validated, and NetworkConnection must be initialized
per 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
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 1Two 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.
Delays
| After failure | exponentialBackoff: true | false |
| ------------- | -------------------------- | ------- |
| 1st | 500 ms | 500 ms |
| 2nd | 1 s | 500 ms |
| 3rd | 2 s | 500 ms |
| 10th | 4 min 16 s | 500 ms |
Doubling grows faster than people expect, so anything with more than a handful of retries wants
maxDelay — maxDelay: 30_000 turns 500, 1000, 2000, … into 500, 1000, 2000, …, 30000,
30000, …. There is always a ceiling regardless: a browser timer holds its delay in a 32-bit
integer, and above ~24.8 days it overflows and fires immediately — every delay is clamped below
that, so a long backoff can never turn into a busy loop.
maxRetries: Infinity
Explicitly supported: the budget check can never fail, so the call keeps trying until the work
succeeds or something stops it. 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 with maxDelay and a
signal:
await retryRequest(() => pushPendingChanges(), {
maxRetries: Number.POSITIVE_INFINITY,
retryBaseDelay: 1000,
maxDelay: 60_000, // never slower than once a minute
signal: sessionController.signal,
});An unbounded loop is not a busy loop: while the device is offline it is parked, not spinning.
Retrying only what is worth retrying
retryOnlyOnConnectionFailure is what 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.
await retryRequest(() => postOrder(body), {
maxRetries: 5,
retryOnlyOnConnectionFailure: true,
});It 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.
shouldRetry is the per-error version of the same idea:
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
await retryRequest(load, {
maxRetries: 4,
// Anything without a status — a parse failure, a dropped connection —
// has nothing to judge, so it stays retryable.
shouldRetry: (error) =>
!(error instanceof HttpError) || RETRYABLE.has(error.status),
onRetry: ({ attempt, delay }) =>
console.warn(`retry ${attempt} in ${Math.round(delay)}ms`),
});Cancellation
signal ends all three states a call can be in — parked offline, backing off, or between attempts:
const controller = new AbortController();
const load = retryRequest(
// Passing the signal to fetch as well cancels the request in flight.
() => 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.
There is no timeout option — a timeout belongs to the request, not to the loop, and
AbortSignal.timeout(5000) passed to fetch composes with the call-wide signal.
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 …") |
The error identity is preserved, so instanceof checks, error.status and error-reporting
fingerprints behave exactly as they would without the wrapper. Every failure mode is a rejection,
never a synchronous throw.
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 unknown, because that is what a catch gives
you. RetryOptions is exported for wrappers that pass options through, and every field on it is
optional:
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 });Exports
retryRequest · RetryOptions — that is the entire surface.
License
MIT
