Retries & backoff
Retry strategies, circuit breakers, and auto-disable for persistently failing endpoints.
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:
| 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.
ExponentialBackoff({ schedule: ["10s", "1m", "10m", "1h"] }); // 4 attemptsLinearBackoff
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:
| 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
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" },
});Read next
- Replay — re-emitting historical messages.
send()and the outbox — what gets retried.