Skip to content

Repository files navigation

nano-retry

npm version npm downloads license

Tiny retry with exponential backoff, jitter, timeout, and AbortSignal support.

  • Zero dependencies
  • TypeScript-first with full type inference
  • ~220 lines of code (plus JSDoc)
  • ESM and CommonJS support

The Problem

Your OpenAI call hangs. Your Stripe webhook times out. You add p-retry but:

// p-retry: no per-attempt timeout, so one hung request = stuck forever
// p-retry: jitter is OFF by default, so your retries stampede together
// p-retry: you throw AbortError to stop retrying (weird)

The Fix

import { retry } from "nano-retry";

await retry(() => openai.chat.completions.create({ model: "gpt-4", messages }), {
  attemptTimeout: 30000,  // kill hung requests after 30s (p-retry can't do this)
  retryIf: (err) => err.status === 429 || err.status >= 500,  // simple predicate
});
// jitter already on by default - no thundering herd

That's it. Your call retries on failure, times out stuck attempts, and staggers retries automatically.

Why nano-retry?

Feature p-retry async-retry nano-retry
Dependencies 2 1 0
Native TypeScript ❌ (needs @types)
ESM + CJS ESM-only (v6+) CJS only ✅ Both
Per-attempt timeout
Total timeout
AbortSignal
Jitter (default) opt-in opt-in ✅ on by default
Bail mechanism AbortError class bail() callback retryIf predicate
Bundle size (gzip) ~3KB + deps ~2KB + deps 2.3KB

Installation

npm install nano-retry

Requirements: Node.js 16+ or modern browsers (ES2020)

Quick Start

import { retry } from "nano-retry";

// Retries 3 times with exponential backoff (1s, 2s, 4s)
const data = await retry(() => fetch("/api/data"));

// With options
await retry(() => callApi(), {
  retries: 5,                // max 5 retries
  attemptTimeout: 10000,     // 10s max per attempt
  retryIf: (err) => err.status >= 500,  // only retry server errors
  onRetry: (err, ctx) => console.log(`Retry ${ctx.attempt}...`),
});

API Reference

retry(fn, options?): Promise<T>

Retry an async function with exponential backoff.

const result = await retry(
  (attempt) => fetchData(), // attempt is 1-based
  { retries: 3 }
);

Options

Option Type Default Description
retries number 3 Maximum retry attempts (not counting initial)
minTimeout number 1000 Initial delay in ms
maxTimeout number 30000 Maximum delay in ms
factor number 2 Exponential backoff multiplier
jitter boolean true Add ±25% randomization to delays
attemptTimeout number - Timeout for each attempt in ms
totalTimeout number - Max total time for all attempts in ms
signal AbortSignal - Cancel the retry operation
retryIf (error, ctx) => boolean - Return false to stop retrying
onRetry (error, ctx) => void - Called before each retry

RetryContext

The retryIf and onRetry callbacks receive a context object:

interface RetryContext {
  attempt: number;     // Current attempt (1-based)
  retriesLeft: number; // Remaining retries
  elapsed: number;     // Total elapsed time in ms
  nextDelay: number;   // Delay before next retry
}

retryable(fn, options?): WrappedFunction

Create a pre-configured retryable function.

const fetchWithRetry = retryable(
  (url: string) => fetch(url),
  { retries: 3, minTimeout: 500 }
);

const data = await fetchWithRetry("/api/users");

Error Types

AbortError

Thrown when the operation is cancelled via AbortSignal.

import { AbortError } from "nano-retry";

try {
  await retry(fn, { signal: controller.signal });
} catch (error) {
  if (error instanceof AbortError) {
    console.log("Operation was cancelled");
  }
}

TimeoutError

Thrown when attemptTimeout or totalTimeout is exceeded.

import { TimeoutError } from "nano-retry";

try {
  await retry(fn, { attemptTimeout: 5000 });
} catch (error) {
  if (error instanceof TimeoutError) {
    console.log("Operation timed out");
  }
}

Patterns & Recipes

Rate Limit Handling (429)

const result = await retry(
  () => callApi(),
  {
    retries: 5,
    minTimeout: 1000,
    retryIf: (error) => error.status === 429,
  }
);

Retry Only Server Errors

await retry(fetchData, {
  retryIf: (error) => {
    // Retry 5xx and network errors, not 4xx
    if (error.status >= 500) return true;
    if (error.code === "ECONNRESET") return true;
    return false;
  },
});

With Per-Attempt Timeout

// Each attempt has 5 seconds to complete
await retry(
  () => slowOperation(),
  {
    retries: 3,
    attemptTimeout: 5000,
  }
);

With Total Timeout

// Entire operation must complete within 30 seconds
await retry(
  () => fetchData(),
  {
    retries: 10,
    totalTimeout: 30000,
  }
);

Cancellation with AbortController

const controller = new AbortController();

// Cancel after 10 seconds
setTimeout(() => controller.abort(), 10000);

try {
  await retry(fetchData, { signal: controller.signal });
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Cancelled");
  }
}

Logging Retries

await retry(fetchData, {
  onRetry: (error, ctx) => {
    console.log(
      `Attempt ${ctx.attempt} failed: ${error.message}. ` +
      `Retrying in ${ctx.nextDelay}ms (${ctx.retriesLeft} left)`
    );
  },
});

Database with Transient Errors

const RETRYABLE_CODES = new Set(["40001", "40P01", "ECONNRESET"]);

await retry(
  () => db.query("SELECT * FROM users"),
  {
    retries: 3,
    minTimeout: 100,
    retryIf: (error) => RETRYABLE_CODES.has(error.code),
  }
);

TypeScript Usage

Full type inference is supported:

import { retry, RetryOptions, RetryContext } from "nano-retry";

interface User {
  id: string;
  name: string;
}

// Return type is inferred as Promise<User>
const user = await retry(async (): Promise<User> => {
  const res = await fetch("/api/user");
  return res.json();
});

Performance

nano-retry is optimized for the common case (no retries needed):

Operation Time
Direct call (baseline) 0.10 µs
retry() - success 0.22 µs
Overhead 0.12 µs

The library adds minimal overhead when your function succeeds on the first attempt.

Comparison with p-retry

Simpler error handling

p-retry requires importing and throwing a special AbortError:

// p-retry
import pRetry, { AbortError } from "p-retry";

await pRetry(async () => {
  const error = await getError();
  if (error.status === 400) {
    throw new AbortError("Bad request"); // Stop retrying
  }
});

nano-retry uses a simple predicate:

// nano-retry
await retry(getError, {
  retryIf: (error) => error.status !== 400, // false = stop
});

Per-attempt timeout

p-retry doesn't support per-attempt timeouts. You need to handle it yourself:

// p-retry - manual timeout
await pRetry(async () => {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), 5000);
  return fetch(url, { signal: controller.signal });
});

nano-retry has built-in support:

// nano-retry - built-in
await retry(() => fetch(url), { attemptTimeout: 5000 });

Development

npm install
npm run build    # Compile TypeScript (CJS + ESM)
npm test         # Run tests (45 tests)
npm run bench    # Run benchmarks
npm run verify   # Run all checks

License

MIT

About

Tiny retry with exponential backoff, jitter, timeout, and AbortSignal support

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages