Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

retry-ladder

A tiny zero-dependency retry library that knows the difference between a rate limit, a timeout, and a bug — and only retries the ones worth retrying.

Most hand-rolled retry code is wrong in the same few ways. It retries a 400 or a 401, which will return the same answer forever. It has no jitter, so a whole fleet of clients wakes up at the same instant and re-stampedes a server that was just getting back on its feet. It ignores the Retry-After header the API explicitly sent. It retries a POST that charges a card. And it has no overall deadline, so one slow call can hang for minutes.

retry-ladder gets the classification right by default and stays out of your way.

What it does

  • Retries network errors and 408 / 429 / 500 / 502 / 503 / 504. Nothing else.
  • Never retries other 4xx (400, 401, 403, 404, 409, 422, ...). They won't change.
  • Honors Retry-After (both integer seconds and HTTP-date) when the server sends it.
  • Backs off exponentially with full jitter, so clients don't retry in lock-step.
  • Caps total wall-clock with maxElapsedMs — a hard deadline across all attempts.
  • Forwards your AbortSignal so a cancel stops both the in-flight call and the backoff.
  • Refuses to retry a non-idempotent operation unless you mark it idempotent: true.
  • Ships a fetchRetry helper that wraps global fetch with all of the above.

Pure ESM, a bundled .d.ts, zero runtime dependencies. The whole thing is unit-tested against a fake clock, so the test suite finishes in well under a second with no real waiting.

Install

npm install @velkina/retry-ladder

Needs Node 18+ (for global fetch and AbortSignal). Works in any runtime that has them.

Basic usage

retry(fn, options) runs fn, retries it on transient failures, and returns the result — or throws once the retries or the deadline run out.

import { retry } from '@velkina/retry-ladder';

const data = await retry(
  async ({ attempt, signal }) => {
    const res = await fetch('https://api.example.com/things', { signal });
    if (!res.ok) {
      // Attach the status so retry-ladder can classify it.
      const err = new Error(`HTTP ${res.status}`);
      err.status = res.status;
      throw err;
    }
    return res.json();
  },
  {
    retries: 4,        // up to 4 retries after the first try
    baseMs: 200,       // first backoff window
    maxElapsedMs: 30_000, // give up after 30s total, no matter what
    idempotent: true,  // a GET is safe to repeat
    onRetry({ attempt, delayMs, error }) {
      console.warn(`attempt ${attempt} failed (${error.message}); retrying in ${Math.round(delayMs)}ms`);
    },
  },
);

A 404 or a 401 thrown out of fn is returned to you immediately — no retry, no waiting. A 503 is retried with backoff until it succeeds or the budget runs out.

The idempotency guard

By default, retry-ladder will not retry, even on a retryable failure, unless you tell it the operation is safe to repeat:

// Throws IdempotencyError on a 503 — retrying could double-charge the card.
await retry(() => chargeCard(order), { retries: 3 });

// You added an idempotency key, so repeating is safe. Now it retries.
await retry(() => chargeCard(order, { idempotencyKey }), { retries: 3, idempotent: true });

This is opt-in on purpose. The most expensive retry bug is retrying a write that already half-succeeded.

fetchRetry: drop-in fetch with correct retries

fetchRetry has the same signature as fetch, plus the retry options. It reads Retry-After off the response for you, and infers idempotency from the HTTP method — GET / HEAD / PUT / DELETE / OPTIONS are retried automatically; POST and PATCH are retried only if you pass idempotent: true.

import { fetchRetry } from '@velkina/retry-ladder';

// A 429 with `Retry-After: 2` makes this wait exactly 2 seconds, not the backoff window.
const res = await fetchRetry('https://api.openai.com/v1/models', {
  headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
  retries: 5,
  maxElapsedMs: 60_000,
  onRetry({ attempt, delayMs, retryAfter }) {
    console.warn(`429 — waiting ${Math.round(delayMs)}ms (${retryAfter ? 'Retry-After' : 'backoff'})`);
  },
});

const models = await res.json();

A retryable status you never shake (a 503 that stays down past your budget) comes back as a normal Response, so you handle it like any other. Network errors and the maxElapsedMs deadline throw.

Retrying a POST safely

const res = await fetchRetry('https://api.stripe.com/v1/charges', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${key}`,
    'idempotency-key': idempotencyKey, // makes the retry safe
  },
  body,
  idempotent: true,
  retries: 3,
});

Cancellation

Pass an AbortSignal and a cancel stops everything — the request in flight and any backoff that's currently sleeping. An AbortError is never treated as retryable.

const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);

await fetchRetry('https://api.example.com/slow', { signal: ac.signal, retries: 5 });

How backoff works

The delay before retry n (0-based) is:

window = min(maxDelayMs, baseMs * factor ** n)
delay  = random() * window          // full jitter

Full jitter — picking a random point in [0, window] rather than always sleeping the whole window — is what keeps a fleet of clients from retrying at the same moment and re-overloading a recovering server. It's the "Full Jitter" approach from the AWS Architecture Blog post Exponential Backoff And Jitter by Marc Brooker: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/

When the server sends a Retry-After, that wins over the computed backoff, because the server knows when it'll be ready and you don't.

Options

Option Default What it does
retries 3 Max retries after the first try (so up to 4 calls).
baseMs 200 The first backoff window, in ms.
factor 2 Exponential growth per attempt.
maxDelayMs 30000 Ceiling on any single backoff window.
maxElapsedMs Infinity Hard deadline across all attempts, in ms.
idempotent false Must be true to retry. (fetchRetry infers it from the method.)
signal An AbortSignal that cancels the call and the backoff.
retryableStatuses 408,429,500,502,503,504 Override the retryable HTTP status set.
retryableErrorCodes network codes Override the retryable error-code set (ECONNRESET, ETIMEDOUT, ...).
shouldRetry built-in classifier Custom decision for a thrown error.
shouldRetryResult Opt a successful return value into retry classification.
getRetryAfterMs Pull a server-dictated delay (ms) from a failure. fetchRetry wires this up.
onRetry Called before each backoff: { attempt, error, delayMs, elapsedMs, retryAfter }.
random Math.random RNG in [0,1) for jitter. Injectable for tests.

Errors it can throw

Class When
IdempotencyError A retryable failure on an operation that wasn't marked idempotent.
RetryExhaustedError All retries used up. Has .attempts, .elapsedMs, and the last failure as .cause.
RetryTimeoutError The maxElapsedMs deadline was reached. Same fields as above.
(the original error) A non-retryable failure (e.g. a 400) passes straight through.
AbortError The signal was aborted.

Also exported for building your own logic: isRetryable, extractStatus, parseRetryAfter, retryAfterFromHeaders, backoffWindow, jitteredDelay, DEFAULT_RETRYABLE_STATUSES, DEFAULT_RETRYABLE_ERROR_CODES.

Run the tests

npm test          # node --test, runs against a fake clock — finishes in ~0.2s
node examples/basic.js
node examples/fetch.js

License

MIT. See LICENSE.


Built by Velkina — https://velkina.com

About

A tiny zero-dependency retry library that knows the difference between a rate limit, a timeout, and a bug, and only retries the ones worth retrying.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages