v0.1.0 · zero dependencies

Retries that know when to stop.

Wrap any async function or fetch in a retry loop that makes you declare idempotency, enforces a deadline and a shared budget, honours Retry-After, and reports every decision.

npm install --save-dev retrypilot
network → 503 → okHTTP 200recovered
  • 0 msattempt-failed

    attempt 1 failed: connect ECONNRESET

  • 0 msretry-scheduled

    retry in 50 ms (backoff 50 ms)

  • 50 msattempt-failed

    attempt 2 failed: HTTP 503

  • 50 msretry-scheduled

    retry in 100 ms (backoff 100 ms)

  • 150 msoperation-completed

    succeeded

Real events from retry() on a fake clock. Every decision is reported.

0runtime dependencies
Node ≥18ESM, ships its own types
4limits enforced together
7failure reasons, each named

Limits

A retry happens only if every limit allows it.

Retries help with transient faults only, and they add load to a system that may already be struggling. Every default here is deliberately conservative.

Per call

Attempts and a deadline.

maxAttempts bounds the work of one call. totalTimeoutMs covers every attempt and every wait, so a huge attempt limit cannot outlive it.

A wait that would end after the deadline is never scheduled.

Shared

A budget for everyone.

Each retry spends a token and each success earns a little back. When the bucket is empty, retries stop for every call that shares it.

The first attempt is never charged.

Always

Cancellation is final.

Aborting interrupts a wait on the spot, aborts the in-flight attempt, and guarantees no further attempt starts.

RetryError.reason === "aborted"

What you get

Safe by default, readable when it goes wrong.

  • You declare whether it is safe

    retry() throws unless you pass idempotency. A non-idempotent operation is never retried, even on a network error, because the server may already have done the work.

  • Four limits, enforced together

    Attempts, a total deadline, a shared budget and cancellation all apply. The first to say no ends the loop and names itself in RetryError.reason.

  • A budget shared across calls

    maxAttempts bounds one call. A RetryBudget bounds all of them, so an outage cannot multiply your traffic by the attempt limit.

  • Retry-After, within reason

    A server delay is a floor on the wait. If it asks for more than maxServerDelayMs, the retry is abandoned instead of silently shortened.

  • Events without secrets

    No bodies or headers. The fetch wrapper strips query strings, fragments and URL credentials, and swallows errors thrown by your onEvent handler.

  • Works with the fetch you have

    Wrap globalThis.fetch, undici, an instrumented fetch or a test double. Zero dependencies, ESM, and no Node imports outside retrypilot/node.

Workflow

Declare it. Limit it. Observe it.

Retries cannot fix a persistent failure. They can amplify load during an outage, which is why the budget, jitter and a deadline are part of the model and not extras.

Declare

Say whether repeating is safe. Pick attempts, a deadline and an optional shared budget.

  1. 1
    retry(op, { idempotency: "idempotent" })

Limit

After each failure the checks run in a fixed order, and the budget is charged last.

  1. 2
    maxAttempts · totalTimeoutMs · budget

Observe

Every decision becomes an event, and a failure becomes a RetryError with a reason.

  1. 3
    onEvent(e) · RetryError.reason

Read what it cannot do →

How it works

Any operation, any fetch, one budget.

  1. 1

    Wrap an operation

    Forward the attempt's signal so abandoned work is actually cancelled.

    ts
    import { RetryBudget, createRetryPolicy, retry } from "retrypilot";
    
    declare const db: { fetchUser(id: string, options: { signal: AbortSignal }): Promise<{ name: string }> };
    declare const cache: { get(key: string): Promise<string> };
    
    // One call. Declaring idempotency is required: there is no default.
    const user = await retry(({ signal }) => db.fetchUser("42", { signal }), {
      idempotency: "idempotent",
      maxAttempts: 4,
      totalTimeoutMs: 10_000, // the whole call, waits included
      attemptTimeoutMs: 2_000, // each attempt
    });
    
    // Many calls, one policy, one shared budget.
    const budget = new RetryBudget({ capacity: 20, successRefund: 0.1 });
    const policy = createRetryPolicy({ idempotency: "idempotent", maxAttempts: 3, budget });
    
    await policy.execute(() => cache.get("a"));
    await policy.execute(() => cache.get("b"), { maxAttempts: 5 });
    
    export { user };
  2. 2

    Wrap the fetch you use

    GET is retried, POST is sent once unless you opt in, and stream bodies are never replayed.

    ts
    import { createRetryFetch } from "retrypilot/http";
    
    const fetchWithRetry = createRetryFetch(fetch, {
      maxAttempts: 4,
      totalTimeoutMs: 15_000,
      attemptTimeoutMs: 5_000,
    });
    
    // GET is idempotent: retried on 503, 429 and network errors.
    await fetchWithRetry("https://api.example.com/items");
    
    // POST is not: sent once, unless you opt in per request or by header.
    await fetchWithRetry("https://api.example.com/items", { method: "POST", body: "{}" });
    await fetchWithRetry("https://api.example.com/items", { method: "POST", body: "{}", retry: { idempotent: true } });
  3. 3

    Share a budget, watch the events

    One bucket for every call, and a typed event for every decision.

    ts
    import { RetryBudget } from "retrypilot";
    
    // Each retry spends 1 token, each success earns 0.1 back. When the bucket is empty,
    // retries stop for every call that shares it: an outage cannot triple your traffic.
    export const budget = new RetryBudget({ capacity: 20, retryCost: 1, successRefund: 0.1 });
    ts
    import { RetryError, retry } from "retrypilot";
    
    declare const op: () => Promise<string>;
    declare const log: { warn(entry: object): void };
    
    try {
      await retry(op, {
        idempotency: "idempotent",
        name: "load-profile",
        onEvent(e) {
          if (e.type === "attempt-failed") log.warn({ attempt: e.attempt, ms: e.durationMs });
          if (e.type === "retry-scheduled") log.warn({ waitMs: e.delayMs });
          if (e.type === "retry-abandoned") log.warn({ reason: e.reason });
        },
      });
    } catch (error) {
      if (error instanceof RetryError) {
        // 'non-retryable' | 'not-idempotent' | 'max-attempts' | 'deadline-exceeded'
        // | 'budget-exhausted' | 'server-delay-too-long' | 'aborted'
        log.warn({ reason: error.reason, attempts: error.attempts, elapsedMs: error.elapsedMs });
      }
    }

Playground

Script the failures. Watch the decisions.

This runs the retrypilot loop from this repository on a fake clock, so thirty seconds of waiting takes no time. Each line below is what one attempt returns.

SucceededHTTP 200
  • 0 msattempt-started

    attempt 1 started

  • 0 msattempt-failed

    attempt 1 failed: connect ECONNRESET

  • 0 msretry-scheduled

    retry in 50 ms (backoff 50 ms)

  • 50 msattempt-started

    attempt 2 started

  • 50 msattempt-failed

    attempt 2 failed: HTTP 503

  • 50 msretry-scheduled

    retry in 100 ms (backoff 100 ms)

  • 150 msattempt-started

    attempt 3 started

  • 150 msoperation-completed

    succeeded

A dropped connection and a 503, then success. Waits are exponential with full jitter, and every decision is an event. The random source is fixed at 0.5 here so the numbers are repeatable. How the limits interact →

The code behind this panel
ts
import { retry, RetryBudget, RetryError, type Idempotency, type RetryEvent } from "retrypilot";
import { FakeClock } from "retrypilot/testing";

export type Input = {
  /** One attempt per line: `ok`, `network`, `bug`, `503`, or `429 retry-after 2` (seconds). The last line repeats. */
  script: string;
  idempotency: Idempotency;
  maxAttempts: number;
  /** Whole-call deadline in milliseconds. 0 means none. */
  totalTimeoutMs: number;
  /** Shared retry budget capacity. 0 means no budget. */
  budget: number;
};

export type Row = { atMs: number; type: RetryEvent["type"]; text: string };

type Reply = { status: number; retryAfterMs?: number };

const network = () => Object.assign(new Error("connect ECONNRESET"), { code: "ECONNRESET" });

function reply(line: string): Reply | Error {
  const [word = "ok", , seconds] = line.trim().toLowerCase().split(/\s+/);
  if (word === "network") return network();
  if (word === "bug") return new Error("TypeError in my own code");
  const status = word === "ok" ? 200 : Number(word) || 200;
  return seconds === undefined ? { status } : { status, retryAfterMs: Number(seconds) * 1000 };
}

function describe(e: RetryEvent): string {
  switch (e.type) {
    case "attempt-started":
      return `attempt ${e.attempt} started`;
    case "attempt-failed": {
      const why = e.detail ?? (e.error instanceof Error ? e.error.message : "failed");
      return `attempt ${e.attempt} failed: ${why}`;
    }
    case "retry-scheduled": {
      const server = e.serverDelayMs === undefined ? "" : `, server asked ${e.serverDelayMs} ms`;
      const budget = e.budgetRemaining === undefined ? "" : `, budget left ${e.budgetRemaining}`;
      return `retry in ${e.delayMs} ms (backoff ${e.backoffMs} ms${server}${budget})`;
    }
    case "retry-abandoned":
      return `gave up: ${e.reason}${e.detail ? ` (${e.detail})` : ""}`;
    case "deadline-exceeded":
      return `deadline of ${e.totalTimeoutMs} ms exceeded`;
    case "operation-completed":
      return e.outcome === "success" ? "succeeded" : `failed: ${e.failure?.reason ?? "unknown"}`;
  }
}

/** The real retry loop on a FakeClock, so a thirty-second wait takes no time at all. */
export async function simulate(input: Input) {
  const steps = input.script.split("\n").map((l) => l.trim()).filter(Boolean);
  if (steps.length === 0) steps.push("ok");

  const clock = new FakeClock();
  const rows: Row[] = [];
  const budget = input.budget > 0 ? new RetryBudget({ capacity: input.budget, clock }) : undefined;

  let done = false;
  const settled = retry<Reply>(
    ({ attempt }) => {
      const next = reply(steps[Math.min(attempt, steps.length) - 1] ?? "ok");
      if (next instanceof Error) throw next;
      return next;
    },
    {
      idempotency: input.idempotency,
      maxAttempts: input.maxAttempts,
      ...(input.totalTimeoutMs > 0 ? { totalTimeoutMs: input.totalTimeoutMs } : {}),
      ...(budget ? { budget } : {}),
      classifyResult: (r) =>
        r.status >= 500 || r.status === 429
          ? { retry: true, reason: `HTTP ${r.status}`, ...(r.retryAfterMs === undefined ? {} : { delayMs: r.retryAfterMs }) }
          : undefined,
      onEvent: (e) => void rows.push({ atMs: e.elapsedMs, type: e.type, text: describe(e) }),
      clock,
      random: () => 0.5,
    },
  ).then(
    (value) => ({ ok: true as const, value }),
    (error: unknown) => ({ ok: false as const, error }),
  ).finally(() => {
    done = true;
  });

  for (let i = 0; i < 2400 && !done; i++) await clock.advance(250);
  const outcome = await settled;

  const summary = outcome.ok
    ? `HTTP ${outcome.value.status}`
    : outcome.error instanceof RetryError
      ? `${outcome.error.reason} after ${outcome.error.attempts} attempt${outcome.error.attempts === 1 ? "" : "s"}, ${outcome.error.elapsedMs} ms`
      : String(outcome.error);
  return { rows, ok: outcome.ok, summary, pending: clock.pendingTimers };
}

export type Run = Awaited<ReturnType<typeof simulate>>;

Options

A few knobs cover the rest.

Backoff defaults to 100 ms, doubling, capped at 10 s, with full jitter. Test it without waiting by injecting a FakeClock.

  • idempotencyRequired. idempotent or non-idempotent. The library never guesses.
  • maxAttemptsAttempts including the first. Default 3.
  • totalTimeoutMsWall-clock limit for the whole call, waits included.
  • attemptTimeoutMsLimit for each attempt, clipped to the time remaining.
  • budgetA RetryBudget shared by every call that should draw on the same retries.
  • isRetryableReplace the default classifier. It can also return a server-directed delay.

See every option →

Documentation

Everything is written down.

More from M2Toolkit