npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 a retryRequest made 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-request

Setup

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

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