retry-request
retry-request 1.0.0

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.

Contents

Contents

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.

offline-aware exponential backoff abortable no retry by default fully typed
NetworkConnection.init() comes first

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#

sh
npm i @dmytromykhailiuk/retry-request
Requirements

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.

tsmain.ts — once, at startup
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:

ts
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.

Why not fall back to a plain retry?

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.

ts
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. 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.

tsthe loop, in words
// 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#

ts
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#

maxRetriesnumber

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:

tssync.ts
// 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#

retryBaseDelaynumber

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.

exponentialBackoffboolean

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#

maxDelaynumber

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, ….

There is always a ceiling

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.

retryOnlyOnConnectionFailureboolean

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.

ignoreConnectionForFirstAttemptboolean

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.

tsa POST worth retrying, and only just
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,
  },
);
A connection failure is not a promise that nothing happened

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#

shouldRetry(error, attempt) => boolean | Promise<boolean>

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.

onRetry(info) => void

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.

tshttp.ts
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#

signalAbortSignal

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.

ts
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:

tsretry.ts
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:

ts
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:

ts
// 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:

ts
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:

ts
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.