---
title: Retries, timeouts and budgets
description: What the SDK retries on its own and what it never retries, how long it waits between attempts, and the five options that bound a call - retries, timeout, deadline, maxRequests and signal.
sidebar:
  label: Retries and timeouts
related:
  - /docs/rate-limits
  - /docs/api/errors
  - /docs/api/hooks
---

Every method of the SDK is a read, and a read is safe to repeat. So retries are on by default: a failure that might pass is tried again, with a growing wait, before you see an error. You do not write a retry loop. The one exception is a [`raw`](/docs/api/raw) request that is not a known read, which is possible only with `allowWrites: true`: it is sent once.

This page covers what happens after a request fails. Keeping requests inside the vendor's limit in the first place is the [rate limits](/docs/rate-limits) page.

## What happens by default

```ts title="backoff.ts"
import { createOutbound } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "./fake-vendor.ts";
import { instantClock } from "./instant-clock.ts";

// Two failures, then an empty 200.
const vendor = fakeVendor(503, 503, 200);

const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key" }),
  fetch: vendor.fetch,
  hooks: {
    onRetry: (event) => {
      console.log(
        `attempt ${event.attempt} failed (${event.reason}), next in ${event.delayMs} ms`
      );
    },
  },
  runtime: instantClock(),
});

// `raw.get` hands back the response body as it is. Here that is `null`.
const body = await outbound.raw.get("/campaigns");

console.log("answer:", body, "after", vendor.count(), "requests");
```

```text title="Output"
attempt 1 failed (instantly responded 503), next in 500 ms
attempt 2 failed (instantly responded 503), next in 1000 ms
answer: null after 3 requests
```

Two attempts failed with a 503, the SDK waited 500 ms and then 1,000 ms, and the third attempt answered. The caller saw one successful call.

### How the samples fail

Nothing on this page reaches a vendor. Each sample constructs a real Instantly adapter with a made-up key and passes two things to `createOutbound`:

- **`fetch`**: a stand-in for the vendor's server. It answers with a status code and an empty body, never a vendor payload.
- **`runtime`**: a clock on which a wait ends at once, with the randomness fixed. The waits shown are the ones the SDK asked for. No real time passed.

```ts title="fake-vendor.ts"
// A stand-in for the vendor's server, passed as `fetch`. It answers each
// request with the next entry and repeats the last one. An answer is a status
// code with an empty body, never a vendor payload.
export interface WithHeaders {
  readonly headers: Record<string, string>;
  readonly status: number;
}

export type Answer = number | "hang" | "offline" | WithHeaders;

const isStatus = (answer: number | WithHeaders): answer is number =>
  Number.isInteger(answer);

export interface FakeVendor {
  readonly fetch: typeof fetch;
  /** How many requests arrived. */
  readonly count: () => number;
}

// Like the real fetch: it settles only when the caller's signal aborts.
const hang = (signal: AbortSignal | null | undefined): Promise<Response> =>
  // oxlint-disable-next-line promise/avoid-new -- wraps an abort event.
  new Promise((_resolve, reject) => {
    signal?.addEventListener("abort", () => {
      reject(new DOMException("The operation was aborted", "AbortError"));
    });
  });

export const fakeVendor = (...answers: readonly Answer[]): FakeVendor => {
  let requests = 0;

  return {
    count: () => requests,
    fetch: (_input, init) => {
      const answer = answers[Math.min(requests, answers.length - 1)] ?? 200;

      requests += 1;

      if (answer === "hang") {
        return hang(init?.signal);
      }

      if (answer === "offline") {
        return Promise.reject(new TypeError("fetch failed"));
      }

      return Promise.resolve(
        isStatus(answer)
          ? new Response(null, { status: answer })
          : new Response(null, answer)
      );
    },
  };
};
```

```ts title="instant-clock.ts"
import type { Runtime } from "outbound-sdk";

// A clock for the samples: a wait of any length ends at once and moves the
// clock forward by that length. Backoff, throttle waits and timeouts then
// run in no real time, and `random` is fixed so the delays are the same on
// every run. Pass it as `runtime`. It is meant for tests, not production.
export const instantClock = (): Pick<Runtime, "now" | "random" | "timer"> => {
  let now = Date.now();

  return {
    now: () => now,
    random: () => 0.5,
    timer: (ms, onFire) => {
      const handle = setTimeout(() => {
        now += ms;
        onFire();
      }, 0);

      return () => {
        clearTimeout(handle);
      };
    },
  };
};
```

The samples call `outbound.raw.get`, which returns the response body untouched, because an empty body is not a campaign. Retries work the same on every read. Both options are also how you test your own code against a failing vendor: see [test without a key](/guides/test-without-a-key).

## What is retried

```ts title="gives-up.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "./fake-vendor.ts";
import type { Answer } from "./fake-vendor.ts";
import { instantClock } from "./instant-clock.ts";

const attempt = async (label: string, answer: Answer): Promise<void> => {
  const vendor = fakeVendor(answer);
  const delays: number[] = [];

  const outbound = createOutbound({
    adapter: instantly({ apiKey: "example-key" }),
    fetch: vendor.fetch,
    hooks: {
      onRetry: (event) => {
        delays.push(event.delayMs);
      },
    },
    runtime: instantClock(),
  });

  try {
    await outbound.raw.get("/campaigns");
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(
        `${label}: ${vendor.count()} request(s), ${error.code},`,
        `permanent ${error.permanent}, waits`,
        delays
      );
    }
  }
};

// Retried: the default is four retries after the first attempt.
await attempt("500", 500);

await attempt("503", 503);

await attempt("408", 408);

await attempt("no connection", "offline");

await attempt("429 with Retry-After: 20", {
  headers: { "retry-after": "20" },
  status: 429,
});

// Never retried.
await attempt("400", 400);

await attempt("401", 401);

await attempt("403", 403);

await attempt("404", 404);

await attempt("422", 422);
```

```text title="Output"
500: 5 request(s), Provider, permanent false, waits [ 500, 1000, 2000, 4000 ]
503: 5 request(s), Provider, permanent false, waits [ 500, 1000, 2000, 4000 ]
408: 5 request(s), Provider, permanent false, waits [ 500, 1000, 2000, 4000 ]
no connection: 5 request(s), Provider, permanent false, waits [ 500, 1000, 2000, 4000 ]
429 with Retry-After: 20: 5 request(s), RateLimited, permanent false, waits [ 20000, 20000, 20000, 20000 ]
400: 1 request(s), Invalid, permanent true, waits []
401: 1 request(s), Unauthorized, permanent true, waits []
403: 1 request(s), Unauthorized, permanent true, waits []
404: 1 request(s), NotFound, permanent true, waits []
422: 1 request(s), Invalid, permanent true, waits []
```

| Failure | Retried | Error when retries run out |
| --- | --- | --- |
| HTTP 500, 502, 503, 504 | yes | `Provider` |
| HTTP 408 | yes | `Provider` |
| HTTP 429 | yes, after the wait the vendor asks for | `RateLimited` |
| No connection, or any other network failure | yes | `Provider` |
| No answer within `timeout` | yes | `Provider`, with `timedOut: true` |
| HTTP 400, 422 and other 4xx | never | `Invalid` |
| HTTP 401, 403 | never | `Unauthorized` |
| HTTP 404 | never | `NotFound` |
| Your `signal` aborted the call | never | `Provider`, with `aborted: true` |
| A refusal by the SDK: `Invalid` input, an `Unsupported` read | never: no request was sent |  |

With the default of four retries, a failing call is five requests. A walk with `listAll` retries each page on its own.

## How long it waits

The wait before retry number _n_ is a random time between 0 and the smaller of 60 seconds and 1 second doubled _n - 1_ times. That is exponential backoff with full jitter: the ceiling doubles each time, and the randomness keeps many clients that failed together from retrying together.

| Before retry | Ceiling | In the samples, where the random number is always 0.5 |
| --- | --- | --- |
| 1 | 1 s | 500 ms |
| 2 | 2 s | 1,000 ms |
| 3 | 4 s | 2,000 ms |
| 4 | 8 s | 4,000 ms |
| 7 and later | 60 s | 30,000 ms |

So four retries add at most 15 seconds of waiting, and half that on average.

When the vendor says how long to wait, with a `Retry-After` header or a field in its response body, the SDK waits at least that long. The 429 in the output above asked for 20 seconds and got it each time. A 429 also slows the instance down for a while afterwards: see [rate limits](/docs/rate-limits).

## The five options

`OperationOptions`. Each can be set on `createOutbound` as the default for every call, and again on any one call.

| Option | Type | Default | Bounds |
| --- | --- | --- | --- |
| `retries` | `number`, or `{ max, backoff? }` | `4` | How many times a failed request is tried again |
| `timeout` | milliseconds | `30000` | One attempt |
| `deadline` | milliseconds | none | The whole call: every attempt, every backoff and every wait for the rate budget |
| `maxRequests` | `number` | none | How many vendor requests the call may send |
| `signal` | `AbortSignal` | none | Cancels the call from outside |

```ts title="budgets.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import type { OutboundOptions } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "./fake-vendor.ts";
import type { Answer } from "./fake-vendor.ts";
import { instantClock } from "./instant-clock.ts";

const attempt = async (
  label: string,
  answer: Answer,
  options: Partial<OutboundOptions>
): Promise<void> => {
  const vendor = fakeVendor(answer);

  const outbound = createOutbound({
    adapter: instantly({ apiKey: "example-key" }),
    fetch: vendor.fetch,
    runtime: instantClock(),
    ...options,
  });

  try {
    await outbound.raw.get("/campaigns");
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(`${label}: ${vendor.count()} request(s), ${error.code}`);
      console.log(`  ${error.message}`);
      console.log(
        `  timedOut ${error.timedOut}, aborted ${error.aborted}, permanent ${error.permanent}`
      );
    }
  }
};

// The vendor never answers. Each attempt is cut off after `timeout`.
await attempt("timeout", "hang", { retries: 1, timeout: 5000 });

// 500 ms of backoff fits in the deadline. The next 1,000 ms does not.
await attempt("deadline", 503, { deadline: 1200 });

// The third request is never sent.
await attempt("maxRequests", 503, { maxRequests: 2 });

// No retries at all.
await attempt("retries: 0", 503, { retries: 0 });

// A signal that is already aborted sends nothing.
await attempt("signal", 503, { signal: AbortSignal.abort() });
```

```text title="Output"
timeout: 2 request(s), Provider
  instantly did not answer within 5000 ms
  timedOut true, aborted false, permanent false
deadline: 2 request(s), Provider
  instantly responded 503
  timedOut true, aborted false, permanent false
maxRequests: 2 request(s), RateLimited
  The call used its budget of 2 request(s) to instantly
  timedOut false, aborted false, permanent false
retries: 0: 1 request(s), Provider
  instantly responded 503
  timedOut false, aborted false, permanent false
signal: 0 request(s), Provider
  The call was aborted
  timedOut false, aborted true, permanent true
```

### `timeout`

How long one attempt may take. An attempt that gets no answer in time is cut off and counts as a failure, which is retried. The clock restarts for each attempt, so `timeout: 5000` with four retries can take 25 seconds plus the backoff. When the last attempt also times out, the error is `Provider` with `timedOut: true`.

### `deadline`

How long the whole call may take. The SDK checks it before each wait: if the next backoff, or the wait for the rate budget, would end after the deadline, it stops there and throws instead of waiting.

- Stopped before a retry, the error is the last failure (`Provider`, or `RateLimited` after a 429) with `timedOut: true`.
- Stopped while waiting for the rate budget, the error is `RateLimited` with `timedOut: true`.

A deadline does not cut off an attempt already in flight. That is `timeout`. Set both: `deadline` for how long you are willing to wait in all, `timeout` for how long one request may hang. On a walk with `listAll`, the deadline covers the whole walk from its first page.

### `maxRequests`

The most vendor requests one call may send, retries included. When the count is reached the next request is not sent, and the call fails with `RateLimited` and the message shown above. It exists so one question cannot spend a vendor quota: an insight that reads every campaign, or a retry storm.

On a walk with `listAll` and on `getMany`, it covers all the pages or records together. `meta.requests` on a [result](/docs/api/results) is what a call spent, and [`outbound.estimate`](/docs/api#methods) is what it would spend.

### `signal`

An `AbortSignal` stops a call at once: the request in flight is cancelled, a backoff or a rate-budget wait ends, and nothing is retried. The error is `Provider` with `aborted: true` and `permanent: true`. A signal that is already aborted sends no request at all.

Use `AbortSignal.timeout(ms)` for a hard wall-clock limit that does cut off an attempt in flight, which `deadline` does not.

### `retries`

A number is how many retries follow the first attempt. `0` turns retries off. An object is a policy:

| Field | Type | Meaning |
| --- | --- | --- |
| `max` | `number` | Retries after the first attempt. |
| `backoff` | `(attempt, random) => milliseconds` | The wait before retry number `attempt`, counted from 1. `random` is a number from 0 up to 1. Defaults to `fullJitterBackoff`. |

`fullJitterBackoff` is exported, so a policy can build on it. A vendor's `Retry-After` still wins when it is longer than what your `backoff` returns.

## On the constructor or on one call

```ts title="per-call.ts"
import { createOutbound, fullJitterBackoff, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";

import { fakeVendor } from "./fake-vendor.ts";
import { instantClock } from "./instant-clock.ts";

const vendor = fakeVendor(503);
const waits: number[] = [];

// Defaults for every call on the instance.
const outbound = createOutbound({
  adapter: instantly({ apiKey: "example-key" }),
  fetch: vendor.fetch,
  hooks: {
    onRetry: (event) => {
      waits.push(event.delayMs);
    },
  },
  retries: 2,
  runtime: instantClock(),
  timeout: 10_000,
});

const run = async <Result>(
  label: string,
  call: () => Promise<Result>
): Promise<void> => {
  const before = vendor.count();

  waits.length = 0;

  try {
    await call();
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(`${label}: ${vendor.count() - before} request(s), waits`, [
        ...waits,
      ]);
    }
  }
};

await run("constructor default", () => outbound.raw.get("/campaigns"));

// One call can set its own.
await run("retries: 0", () => outbound.raw.get("/campaigns", { retries: 0 }));

// A policy: how many retries, and how long to wait before each.
await run("fixed 250 ms", () =>
  outbound.raw.get("/campaigns", {
    retries: { backoff: () => 250, max: 3 },
  })
);

// The default backoff, for a policy that only changes `max`.
console.log(
  "default backoff at random 0.5:",
  [1, 2, 3, 4, 5, 6, 7, 8]
    .map((attempt) => fullJitterBackoff(attempt, 0.5))
    .join(", ")
);
```

```text title="Output"
constructor default: 3 request(s), waits [ 500, 1000 ]
retries: 0: 1 request(s), waits []
fixed 250 ms: 4 request(s), waits [ 250, 250, 250 ]
default backoff at random 0.5: 500, 1000, 2000, 4000, 8000, 16000, 30000, 30000
```

A value on a call replaces the instance's default for that call. The others keep their defaults. Every method that reads takes these options: on `list` and `listAll` they sit beside the filters, on `get` and the handle methods they are the last argument.

## Watching it happen

A retried call that succeeds looks like any other to the caller. To see retries, use the [hooks](/docs/api/hooks): `onRetry` fires before each wait with the attempt, the delay and the reason, and `onRequest` fires for each attempt with its status. `meta.requests` on the result counts the attempts too.

## What to do when an error reaches you

By then the SDK has retried what could be retried. `error.permanent` says whether trying again later could work:

- **`permanent: false`** (`Provider`, `RateLimited`): the vendor is down, slow or limiting. Try again later, after `error.retryAfterMs` when it is set. The [failover](/docs/plugins/failover) plugin can serve the last good answer meanwhile.
- **`permanent: true`**: the same call will fail the same way. Fix the key, the id or the input.

See [errors](/docs/api/errors) for every field.
