Choosing Retry Budgets and Max Attempts for Webhook Delivery

“How many times should we retry?” is the wrong first question. The number that actually matters is the total retry window — how long an event is allowed to stay undelivered before you give up and hand it to a human or a replay job. Once that window is fixed, the attempt count falls out of the backoff curve arithmetically, and the remaining decision is a capacity one: what fraction of the traffic you send to a given endpoint is allowed to be retries. This page sits under Exponential Backoff Algorithms and takes the retry loop built in implementing exponential backoff in Python webhook handlers as given; here we decide the policy constants that loop is configured with.

The failure this prevents is specific and common: a dispatcher configured with max_attempts=10 and a 300-second ceiling keeps hammering a permanently broken endpoint for forty minutes per event. At a thousand failing events, the retry traffic dwarfs the live traffic, the worker pool is fully occupied replaying doomed deliveries, and healthy endpoints starve behind them. A retry budget is what stops a partial outage from consuming the whole dispatcher.

Prerequisites

Step 1: Fix the total retry window before the attempt count

The window is the promise you make to the consumer: “if we accept your event, we will keep trying to deliver it for up to N minutes.” Different event classes deserve different promises. A payment.captured event that drives revenue recognition is worth hours of persistence; a presence.updated event is worthless ninety seconds after it was generated, and retrying it past that point actively harms you by delivering stale state.

Event class Target window Typical attempts Why this window
Financial / ledger 24 hours 12–14 Downstream reconciliation is manual and expensive; late is far better than lost
Provisioning / account state 1–2 hours 8–9 Covers a routine consumer deploy or database failover without human involvement
Notification / email trigger 15 minutes 6–7 Value decays fast; a stale notification confuses the end user
Ephemeral / presence 60 seconds 3–4 The next event supersedes this one; persistence is pure waste
Analytics / telemetry 5 minutes 5–6 Batch replay from source is cheaper than prolonged retrying

Longer windows are not free. Every additional minute widens the duplicate-delivery exposure (a consumer that timed out after successfully processing will see the event again), extends how long queue storage is held, and raises the odds that the payload is semantically stale on arrival. Pick the shortest window that satisfies the contract.

Retry policy profiles compared Fast-fail, balanced and patient retry profiles scored across retry window, attempt count, duplicate exposure and storage held per event. Three retry profiles scored on the same axes Criterion Fast fail Balanced Patient Retry window 2 minutes 1 hour 24 hours Max attempts 4 8 13 Duplicate exposure low moderate high Storage held per event seconds minutes hours A longer window buys delivery success and pays for it in duplicates and storage.
Patience is a trade, not a virtue: each step up the window ladder costs duplicate exposure and held storage.

Step 2: Derive max attempts from the backoff curve

With the window fixed, max_attempts is no longer a judgement call. Walk the cumulative delay of your schedule and stop at the last attempt that still starts inside the window. The subtlety is jitter: under full jitter the expected delay is half the exponential ceiling, so a schedule that looks like it spans 62 seconds actually spans about 31 on average. Compute against the expected delay, then sanity-check against the worst case.

from dataclasses import dataclass


@dataclass(frozen=True)
class BackoffPolicy:
    base: float = 2.0        # seconds before the first retry
    factor: float = 2.0      # exponential growth per attempt
    cap: float = 300.0       # per-delay ceiling in seconds

    def ceiling(self, retry_index: int) -> float:
        """Un-jittered delay before retry number `retry_index` (0-indexed)."""
        return min(self.cap, self.base * (self.factor ** retry_index))


def plan_attempts(
    policy: BackoffPolicy,
    window_seconds: float,
    mean_jitter: float = 0.5,   # 0.5 for full jitter, 0.75 for equal jitter, 1.0 for none
    hard_cap: int = 16,
) -> tuple[int, float, list[float]]:
    """Return (max_attempts, expected_span_seconds, cumulative_offsets)."""
    attempts = 1                 # the first delivery fires immediately
    elapsed = 0.0
    offsets = [0.0]
    while attempts < hard_cap:
        nxt = policy.ceiling(attempts - 1) * mean_jitter
        if elapsed + nxt > window_seconds:
            break
        elapsed += nxt
        offsets.append(round(elapsed, 1))
        attempts += 1
    return attempts, elapsed, offsets


if __name__ == "__main__":
    policy = BackoffPolicy(base=2.0, factor=2.0, cap=300.0)
    for label, window in [("notification", 900), ("provisioning", 7200), ("ledger", 86400)]:
        attempts, span, offsets = plan_attempts(policy, window)
        print(f"{label:<14} window={window:>6}s attempts={attempts:>2} "
              f"expected_span={span:>8.1f}s offsets={offsets}")

Running this against a 15-minute notification window yields seven attempts spanning roughly 700 expected seconds; the eighth would start after the window closes, so it is dropped rather than allowed to run past the promise. Two rules keep the result honest. First, always cap the loop (hard_cap) so a misconfigured window cannot produce a fifty-attempt schedule. Second, persist the derived offsets alongside the event: an operator debugging a delivery should be able to see the schedule the event was admitted under, not the schedule the currently deployed config would produce.

Attempts laid out against the retry window Six attempts fit inside the target delivery window; the seventh would start after the window closes and is replaced by a dead-letter hand-off. target delivery window window ends (60 s) try 1 0s try 2 1s try 3 3s try 4 7s try 5 15s try 6 31s try 7 63s time dead-letter queue
The attempt count is whatever fits: try 7 starts after the window closes, so the schedule ends at six and hands the event off instead.

Step 3: Cap retries as a share of endpoint capacity

Per-event limits bound one event’s cost. They say nothing about aggregate cost, and aggregate cost is what takes a dispatcher down. If a large consumer starts returning 503 for every request, every in-flight event for that consumer enters its retry schedule simultaneously, and the retry traffic can exceed the original traffic several times over.

The fix is a retry budget: a hard rule that retried requests to an endpoint may not exceed a fixed fraction — 10% is a good starting point — of total attempts to that endpoint over a rolling window. When the endpoint is healthy, retries are rare and the budget is never touched. When the endpoint is broken, the budget is exhausted within seconds and further retries are refused immediately, which converts a retry storm into a clean, fast stream of dead-lettered events.

import time

import redis.asyncio as aioredis


class RetryBudget:
    """Refuses retries once they exceed `ratio` of total attempts per endpoint."""

    def __init__(
        self,
        redis: aioredis.Redis,
        ratio: float = 0.10,
        window_seconds: int = 60,
        min_sample: int = 50,
    ) -> None:
        self.redis = redis
        self.ratio = ratio
        self.window = window_seconds
        self.min_sample = min_sample

    def _keys(self, endpoint_id: str) -> tuple[str, str]:
        bucket = int(time.time()) // self.window
        return (
            f"rb:{endpoint_id}:{bucket}:total",
            f"rb:{endpoint_id}:{bucket}:retry",
        )

    async def record(self, endpoint_id: str, *, is_retry: bool) -> None:
        total_key, retry_key = self._keys(endpoint_id)
        ttl = self.window * 3
        async with self.redis.pipeline(transaction=True) as pipe:
            pipe.incr(total_key)
            pipe.expire(total_key, ttl)
            if is_retry:
                pipe.incr(retry_key)
                pipe.expire(retry_key, ttl)
            await pipe.execute()

    async def allows_retry(self, endpoint_id: str) -> bool:
        total_key, retry_key = self._keys(endpoint_id)
        total_raw, retry_raw = await self.redis.mget(total_key, retry_key)
        total = int(total_raw or 0)
        retries = int(retry_raw or 0)
        if total < self.min_sample:
            return True          # too little signal; do not throttle on noise
        return retries < total * self.ratio

The min_sample guard matters more than it looks. Without it, a low-traffic endpoint that sees two requests and retries one is instantly at a 50% retry ratio and gets locked out of retrying at all — exactly backwards, because a quiet endpoint’s retries cost nothing. Below the sample floor, always allow the retry.

Budget accounting is deliberately global per endpoint rather than per dispatcher replica. Ten replicas each enforcing a local 10% budget enforce nothing collectively; Redis is what makes the fraction mean what it says. This is the same shared-state requirement that drives applying backpressure to webhook consumers toward centralized counters.

Step 4: Separate retryable outcomes from terminal ones

Budget spent on a request that can never succeed is budget stolen from a request that could. A 422 Unprocessable Entity means the consumer parsed your payload and rejected it on its merits — the identical payload will be rejected identically in thirty seconds and in thirty minutes. Retrying it burns an attempt, a slot in the worker pool, and a slice of the endpoint’s retry budget for zero probability of success.

Outcome Class Retry? Handling
408, 425, 429, 500, 502, 503, 504 Transient server-side Yes Full backoff schedule; honour Retry-After when present
400, 409, 413, 415, 422 Payload rejected on merit No Dead-letter immediately with the response body attached
401, 403 Credential or signature failure Once One retry after a forced secret refresh, then terminal
404, 410 Endpoint gone No Dead-letter and flag the subscription for auto-disable
Connect timeout, read timeout, connection reset Transport Yes Retryable, but count toward the same budget
TLS verification failure, DNS NXDOMAIN Configuration No Terminal; alert the endpoint owner rather than retrying

Encode this as data, not as scattered if statements in the dispatch path:

from enum import Enum

import httpx


class Outcome(str, Enum):
    SUCCESS = "success"
    RETRYABLE = "retryable"
    TERMINAL = "terminal"


RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504, 507, 509})
TERMINAL_STATUS = frozenset({400, 403, 404, 405, 406, 409, 410, 411, 413, 414, 415, 422, 451})

RETRYABLE_EXC = (
    httpx.ConnectTimeout,
    httpx.ReadTimeout,
    httpx.WriteTimeout,
    httpx.PoolTimeout,
    httpx.ConnectError,
    httpx.RemoteProtocolError,
)
TERMINAL_EXC = (
    httpx.UnsupportedProtocol,
    httpx.InvalidURL,
    httpx.TooManyRedirects,
)


def classify_status(status: int) -> Outcome:
    if 200 <= status < 300:
        return Outcome.SUCCESS
    if status in RETRYABLE_STATUS:
        return Outcome.RETRYABLE
    if status in TERMINAL_STATUS:
        return Outcome.TERMINAL
    if 500 <= status < 600:
        return Outcome.RETRYABLE      # unknown 5xx: assume transient
    return Outcome.TERMINAL           # unknown 4xx: assume our fault, do not hammer


def classify_exception(exc: BaseException) -> Outcome:
    if isinstance(exc, RETRYABLE_EXC):
        return Outcome.RETRYABLE
    if isinstance(exc, TERMINAL_EXC):
        return Outcome.TERMINAL
    return Outcome.TERMINAL           # unrecognised failures are never retried blind

The 401/403 row is the one worth arguing about. Treat it as retryable exactly once, and only after force-refreshing the signing material — a rotation that briefly left both sides disagreeing is the realistic cause. Beyond that single retry it is terminal, because repeatedly presenting a rejected credential looks like a credential-stuffing attempt from the consumer’s side. A 410 Gone should not merely be terminal for that event; it is the strongest possible signal for auto-disabling failing webhook endpoints.

Step 5: Stop retrying and dead-letter with a reason

Four independent conditions can end a retry chain, and an operator reading a dead-letter record needs to know which one fired. “Delivery failed” is not an actionable log line; “retry_budget_exhausted after 2 attempts” tells you the endpoint is broadly down, while “max_attempts_exhausted after 8 attempts” tells you this one event is unlucky.

import time
from dataclasses import dataclass


@dataclass
class DeliveryState:
    event_id: str
    endpoint_id: str
    attempt: int                 # 1-based; the number just completed
    first_attempted_at: float    # time.monotonic() of attempt 1


async def stop_reason(
    state: DeliveryState,
    outcome: Outcome,
    budget: RetryBudget,
    *,
    max_attempts: int,
    window_seconds: float,
) -> str | None:
    """Return None to retry, or a machine-readable reason to stop."""
    if outcome is Outcome.SUCCESS:
        return "delivered"
    if outcome is Outcome.TERMINAL:
        return "terminal_response"
    if state.attempt >= max_attempts:
        return "max_attempts_exhausted"
    if time.monotonic() - state.first_attempted_at >= window_seconds:
        return "retry_window_elapsed"
    if not await budget.allows_retry(state.endpoint_id):
        return "retry_budget_exhausted"
    return None


async def finish_delivery(state, outcome, budget, dlq, *, max_attempts, window_seconds):
    reason = await stop_reason(
        state, outcome, budget,
        max_attempts=max_attempts, window_seconds=window_seconds,
    )
    if reason is None:
        await budget.record(state.endpoint_id, is_retry=True)
        return "retry_scheduled"
    if reason != "delivered":
        await dlq.publish({
            "event_id": state.event_id,
            "endpoint_id": state.endpoint_id,
            "attempts": state.attempt,
            "elapsed_seconds": round(time.monotonic() - state.first_attempted_at, 2),
            "stop_reason": reason,
        })
    return reason

Evaluate the gates in this order deliberately: terminal responses first (cheapest and most decisive), then the per-event limits, then the shared budget check, which is the only one that costs a network round trip. Export stop_reason as a Prometheus label on your dead-letter counter — the ratio between retry_budget_exhausted and max_attempts_exhausted is the single most useful signal for telling “one endpoint is down” apart from “delivery is degraded everywhere”. Events that land in the queue with a reason attached are also far easier to triage when you come to replaying events from a dead-letter queue.

Stop conditions evaluated in order Four gates are checked in sequence: terminal status, attempt cap, elapsed window and retry budget; the first yes ends the chain in the dead-letter queue. Evaluate in order; the first yes ends the retry chain terminal status code? attempt ≥ max_attempts? elapsed ≥ retry window? retry budget exhausted? no no no no retry with backoff dead-letter queue tagged with stop_reason yes yes yes yes
Ordering the gates from cheapest to most expensive means the shared budget lookup only runs for deliveries that survived every local check.

Verification and testing

The policy layer is pure logic and deserves real tests — a miscalculated attempt count is invisible until an incident. Test the arithmetic, the budget ratio, and the classification table separately.

import asyncio

import fakeredis.aioredis
import pytest


def test_attempts_fit_inside_the_window():
    policy = BackoffPolicy(base=2.0, factor=2.0, cap=300.0)
    attempts, span, offsets = plan_attempts(policy, window_seconds=900)
    assert span <= 900, "schedule must not exceed the promised window"
    assert offsets[-1] <= 900
    # The next attempt must genuinely not fit, or we stopped too early.
    next_delay = policy.ceiling(attempts - 1) * 0.5
    assert span + next_delay > 900


def test_cap_shortens_deep_schedules():
    capped = BackoffPolicy(base=2.0, factor=2.0, cap=30.0)
    uncapped = BackoffPolicy(base=2.0, factor=2.0, cap=100_000.0)
    assert plan_attempts(capped, 3600)[0] > plan_attempts(uncapped, 3600)[0]


@pytest.mark.asyncio
async def test_budget_blocks_after_the_ratio_is_exceeded():
    redis = fakeredis.aioredis.FakeRedis()
    budget = RetryBudget(redis, ratio=0.10, window_seconds=60, min_sample=50)
    for _ in range(100):
        await budget.record("ep_42", is_retry=False)
    assert await budget.allows_retry("ep_42") is True
    for _ in range(11):
        await budget.record("ep_42", is_retry=True)
    assert await budget.allows_retry("ep_42") is False


@pytest.mark.asyncio
async def test_quiet_endpoints_are_never_throttled():
    redis = fakeredis.aioredis.FakeRedis()
    budget = RetryBudget(redis, ratio=0.10, window_seconds=60, min_sample=50)
    await budget.record("ep_quiet", is_retry=True)
    assert await budget.allows_retry("ep_quiet") is True


def test_terminal_statuses_never_retry():
    for status in (400, 403, 404, 410, 422):
        assert classify_status(status) is Outcome.TERMINAL
    for status in (429, 500, 503, 504):
        assert classify_status(status) is Outcome.RETRYABLE

In staging, confirm the budget end to end by pointing a subscription at an endpoint that returns 503 unconditionally and watching the stop-reason breakdown flip:

# Should show a small number of max_attempts_exhausted, then a flood of
# retry_budget_exhausted once the ratio trips.
curl -s localhost:9090/metrics \
  | grep '^webhook_deadletter_total' \
  | sort -k2 -nr

If every record says max_attempts_exhausted, the budget is not wired into the decision path; if the counter shows retry_budget_exhausted from the very first event, min_sample is too low for that endpoint’s traffic.

Failure modes and gotchas

Frequently Asked Questions

Does a 429 with Retry-After consume the retry budget?

It consumes an attempt, but it belongs in a separate bucket from failure-driven retries. A 429 is the endpoint cooperating — it told you when to come back — while the budget exists to stop you hammering something that is not answering at all, so lumping the two together locks a healthily rate-limited integration out of retrying. Record both classes and let the budget check ignore honoured Retry-After waits, leaving the delivery window to govern how long you persist.

Should one budget cover every event class going to the same endpoint?

Yes, because what you are protecting is a single endpoint's capacity and it does not care which class is consuming it. What you can add is priority in how the shared allowance is spent: as the ratio approaches the limit, refuse retries for ephemeral classes first so ledger events still have room. That requires the class to be on the delivery record at check time, which is another reason the policy constants travel with the event rather than living in dispatcher config.

When the budget refuses a retry, should the event be dead-lettered or parked until budget frees up?

Dead-letter it, tagged with the budget stop reason. Parking events until the ratio recovers builds an invisible second queue whose depth nobody is watching, and it releases as one synchronized burst the moment the endpoint returns. The dead-letter path already has retention, visibility and a replay tool, so a refused retry becomes a deferred decision rather than a lost event.

To lengthen the window, do I raise max_attempts or the delay ceiling?

Raise the ceiling first. Once the ceiling binds each additional attempt only buys that fixed interval, so extending to an hour under a 60-second cap costs roughly sixty attempts, sixty signature computations and sixty delivery-log rows for a single event. A larger cap reaches the same window in a handful of attempts; the trade you accept is coarser recovery, because an endpoint that comes back early in a long gap waits it out.

How should min_sample be set for endpoints with wildly different traffic?

Derive it from the ratio instead of picking a round number: at a 10% budget you need at least ten attempts in the window before one retry means anything, and several times that before the ratio is stable. Sizing it against the median attempts per window for each endpoint is better again, since one constant is either conservative for a busy integration or effectively disables the budget on a quiet one. If a low-traffic endpoint produces enough retries to worry you, that is work for a circuit breaker rather than the budget.

Does the budget protect an endpoint while a backlog drains after an outage?

No, and the gap is worth knowing about. Everything released from a backlog is a first attempt rather than a retry, so it passes the budget untouched and can hit a freshly recovered endpoint harder than the retry storm did. Bound the drain with concurrency limits and rate limiting on the dispatch path; the retry budget only shapes the failure-driven traffic sitting on top of that baseline.