Outbound

Retries & backoff

Retry strategies, circuit breakers, and auto-disable for persistently failing endpoints.

View as Markdown

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

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

ExponentialBackoff (default)

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:

Attempt123456789
Delay before it5s5m30m2h5h10h1d2d3d

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.

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

LinearBackoff

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

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

Predictable, slower escalation.

Custom

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

The dispatcher decides from the response status:

ResponseOutcome
2xxSuccess — done.
408, 429Failed, 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 4xxFailed permanently — the request itself is wrong, so retrying is wasted work. No further attempts.
5xx, network error, timeoutFailed, retryable on the strategy's schedule.

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.

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

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.

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

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:

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

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

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" },
});

On this page