# Retries & backoff



A webhook delivery is at-least-once, which means it sometimes fails and gets retried. The relevant questions are: *how many times*, *with what delay*, and *what happens when an endpoint stays broken*.

## Retry strategies [#retry-strategies]

Postel ships three strategies. Pick one at the outbound config level, or override per endpoint.

### `ExponentialBackoff` (default) [#exponentialbackoff-default]

```ts
import { ExponentialBackoff, InMemoryStorage, Postel } from "@postel/core";

const postel = Postel({
  outbound: {
    storage: InMemoryStorage(), // or your DB adapter
    retryPolicy: ExponentialBackoff({
      maxAttempts: 8,       // default: the schedule length (9)
      jitter:      0.2,     // ± 20%, the default
    }),
  },
});
```

The delay for attempt *n* is read from a fixed schedule, not computed by doubling. The default schedule spans three days:

| Attempt         | 1  | 2  | 3   | 4  | 5  | 6   | 7  | 8  | 9  |
| --------------- | -- | -- | --- | -- | -- | --- | -- | -- | -- |
| Delay before it | 5s | 5m | 30m | 2h | 5h | 10h | 1d | 2d | 3d |

Pass your own `schedule` (an array of durations) to replace it; `maxAttempts` defaults to the schedule length, and attempts past the end of a shorter schedule reuse its last delay. Jitter de-correlates retries across a stampede.

```ts
ExponentialBackoff({ schedule: ["10s", "1m", "10m", "1h"] }); // 4 attempts
```

### `LinearBackoff` [#linearbackoff]

```ts
import { LinearBackoff } from "@postel/core";

LinearBackoff({
  maxAttempts: 5,
  step:        "30s",
});
```

Predictable, slower escalation.

### `Custom` [#custom]

```ts
import { Custom } from "@postel/core";

Custom({
  maxAttempts: 6,
  compute: (attempt) => `${Math.min(3600, attempt ** 2 * 5)}s`,
});
```

For SLAs that don't fit the canned strategies. `compute` receives the number of failed attempts so far and returns the delay before the next one, as a duration (`"30s"`, `"5m"`, or plain milliseconds).

## What counts as a failure [#what-counts-as-a-failure]

The dispatcher decides from the response status:

| Response                      | Outcome                                                                                                                                       |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `2xx`                         | Success — done.                                                                                                                               |
| `408`, `429`                  | Failed, retryable. A `Retry-After` header (seconds or HTTP-date) is honored: when it asks for longer than the strategy's next delay, it wins. |
| Any other `4xx`               | Failed **permanently** — the request itself is wrong, so retrying is wasted work. No further attempts.                                        |
| `5xx`, network error, timeout | Failed, retryable on the strategy's schedule.                                                                                                 |

## Circuit breaker [#circuit-breaker]

Per-endpoint short-circuit when the failure rate crosses a threshold. While the breaker is open, attempts skip the HTTP call and re-enqueue after a cooldown.

```ts
const postel = Postel({
  outbound: {
    storage: InMemoryStorage(), // or your DB adapter
    circuitBreaker: {
      threshold: 0.5,    // 50% failures across the window
      cooldown:  "5m",
    },
  },
});
```

The breaker prevents one misbehaving endpoint from saturating worker capacity. Per-endpoint state lives in the DB so it survives process restarts.

## Auto-disable [#auto-disable]

If an endpoint has been broken long enough that retrying is wasted work, auto-disable removes it from delivery rotation. The endpoint moves to `state: "disabled"`; a human (or an automated reconciliation job) calls `postel.outbound.endpoints.enable(id)` to move it back to `active`.

```ts
const postel = Postel({
  outbound: {
    storage: InMemoryStorage(), // or your DB adapter
    autoDisable: {
      failureRate: 0.9,     // 90% over the window
      window:      "1h",
      minAttempts: 20,
    },
  },
});
```

## Dead-letter [#dead-letter]

Messages that exhaust `maxAttempts` move to `status: "dead-lettered"` (the `dead_letter` database view collects them for ops queries). Replay re-enqueues them:

```ts
await postel.outbound.replay({
  filter: (msg) => (msg as { status: string }).status === "dead-lettered",
  freshWebhookId: false,
});
```

The receiver sees the same `webhook-id` (or a fresh one if you pass `freshWebhookId: true`).

## Per-endpoint overrides [#per-endpoint-overrides]

Endpoints inherit the outbound-level defaults; pass strategy overrides at `endpoints.create` time to differentiate:

```ts
await postel.outbound.endpoints.create({
  url:   "https://strict-customer.example.com/hook",
  types: ["*"],
  retryPolicy:    LinearBackoff({ maxAttempts: 3, step: "10s" }),
  circuitBreaker: { threshold: 0.3, cooldown: "30m" },
});
```

## Read next [#read-next]

* [Replay](/docs/outbound/replay) — re-emitting historical messages.
* [`send()` and the outbox](/docs/outbound/send) — what gets retried.
