Skip to content

services retry

Eugene Lazutkin edited this page Jul 3, 2026 · 4 revisions

Retry

io.retry (attached by default) retries failed requests with exponential backoff, but only when it is safe to do so: idempotent verbs retry automatically, while a bare POST does not.

import io from 'double-meh';

const data = await io.get('https://example.com/r', null, {retry: 3});
// a 5xx / 429 / network error is retried up to 3 times, then the last result stands

Retries are off unless the request passes retry: true for the default budget (io.retry.retries, 2), a number for that many retries, or a config object (see below). What counts as a failure is a network error, any 5xx, or 429.

The retry option

  • retry: true — retry with the default budget (io.retry.retries).
  • retry: n — up to n retries.
  • retry: {...} — a config object:
    • retries — maximum retry attempts (default io.retry.retries, 2).
    • initDelay — first backoff delay in ms (default io.retry.initDelay, 100). Use initDelay: 0 for immediate retries.
    • forcetrue overrides the verb-safety gate below.
    • nextDelay(delay, attempt, options) — per-request delay policy (default io.retry.nextDelay).
    • continueRetries(response, attempt, options) — a predicate deciding whether to keep going after a response.
await io.get('https://example.com/r', null, {retry: {retries: 3, initDelay: 200}});

Verb-aware safety

GET, HEAD, OPTIONS, PUT, and DELETE are idempotent and retry automatically. PATCH retries only with ifMatch or as: 'merge-patch'. A bare POST is not retried; it must carry an idempotencyKey or If-None-Match: * (ifNoneMatch: '*') to unlock retry. Pass retry: {force: true} to retry regardless, or retry: false (or no retry at all) to disable it.

// bare POST: not retried even with a retry budget
await io.post('https://example.com/p', {a: 1}, {retry: 3, ignoreBadStatus: true});

// POST with an idempotency key: retried, same key reused across attempts
await io.post('https://example.com/p', {a: 1}, {retry: 1, idempotencyKey: true});

// force: retried regardless of the verb
await io.post('https://example.com/p', {a: 1}, {retry: {retries: 2, force: true}});

Honours Retry-After

When a failed response carries a Retry-After header (delta-seconds or an HTTP date), that delay is used for the next attempt instead of the backoff timer, clamped to io.retry.maxDelay (default 30 s).

await io.get('https://example.com/rate-limited', null, {retry: 2});
// a 429 with `Retry-After: 5` waits ~5s before retrying

Polling with continueRetries

When continueRetries is present it replaces the built-in status check: after each response the predicate decides whether to try again (a thrown network error still consumes the retries budget). With retries: 0 the loop is driven solely by the predicate — the polling mode.

// poll until the job leaves 202 Accepted
const job = await io.get('https://example.com/job/7', null, {
  retry: {retries: 0, continueRetries: response => response.status === 202}
});

Retried DELETE 404 becomes 204

If a DELETE succeeds on the server but its response is lost and a retry then returns 404, the resource is already gone — so a 404 on any attempt after the first is rewritten to 204 No Content.

const env = await io.full.delete('https://example.com/d', null, {retry: 3});
env.status; // 204 when a retried DELETE hits 404

nextDelay

io.retry.nextDelay(delay, attempt, options) computes the next backoff from the current one; the default doubles it and caps at 5s. Replace it to change the curve instance-wide, or pass nextDelay in the retry config for one request.

io.retry.nextDelay = delay => Math.min(delay * 3, 10_000); // instance-wide
await io.get(url, null, {retry: {retries: 3, nextDelay: () => 1000}}); // per request

An aborted request is never retried — the abort propagates immediately. Requests with a streaming (ReadableStream) body are not retried either, since the body cannot be replayed.

Each retry attempt emits a 'retry' event with {attempt, response, error} — see Events.

Attaching

The main entry attaches the retry service for you. io.retry.detach() removes it; io.retry.attach() reinstalls it. Both maintain io.retry.isActive and return io. Instance-wide defaults live on io.retry: {retries: 2, initDelay: 100, maxDelay: 30000, nextDelay}.

io.retry.detach();
io.retry.attach();

See also

Clone this wiki locally