Implementing Exponential Backoff with Jitter in Python

To add exponential backoff with jitter in Python, seed your sleep interval at base, then on each retry draw a uniform random delay between base and the previous sleep times three, clamp it to cap, and stop at max_attempts — the AWS “decorrelated jitter” recipe. Only wrap idempotent-safe operations in the loop. This spreads retries across time so a fleet of webhook senders never re-POSTs in lockstep after a shared outage.

This page sits within Exponential Backoff & Jitter for Spatial Webhooks, part of the broader Queue Management, Retries & Delivery Guarantees architecture — the reference section for delivering spatial events reliably under failure.


When to use this pattern

Reach for decorrelated-jitter backoff when:

  • You re-POST GeoJSON payloads to a downstream endpoint that returns transient failures (429 Too Many Requests, 503 Service Unavailable, connection resets) and you want retries that recover fast without synchronizing across senders.
  • Many workers or shards retry against the same endpoint, so uncoordinated but time-spread retries matter — jitter is what breaks the herd.
  • The delivery is idempotent-safe: the receiver deduplicates by key, or the write is naturally idempotent (an upsert keyed on feature ID).

It is not the right tool when the operation mutates spatial state non-idempotently — appending a geometry, incrementing a counter, or inserting a row without a unique key. Retrying those double-applies the change. Make the operation idempotent first (see the sibling patterns below), then retry.


Why retries without jitter cause storms

Plain exponential backoff doubles a fixed delay: 1s, 2s, 4s, 8s. If a downstream tile service drops for thirty seconds, every sender that failed at the same moment computes the same deterministic schedule and retries at the same instants. The recovering endpoint is hit by synchronized waves — a retry storm — that can knock it back over just as it comes up.

Jitter randomizes each delay so the retries scatter across the window. Of the three AWS-documented variants — full jitter, equal jitter, and decorrelated jitter — decorrelated jitter gives the best throughput under contention because each delay is derived from the previous delay rather than from a fixed exponent, letting the backoff both grow and occasionally shrink to probe for recovery.

Retry storm without jitter versus spread retries with decorrelated jitter Two horizontal timelines. The top timeline shows four senders all retrying at the same three instants, forming tall synchronized spikes. The bottom timeline shows the same senders with decorrelated jitter, their retries scattered evenly across the window with no overlap. Without jitter — synchronized storm With decorrelated jitter — spread time →

Complete runnable implementation

The helper below is self-contained. It re-POSTs a GeoJSON payload with aiohttp, computes a decorrelated-jitter delay each attempt, honours Retry-After, and refuses to retry unless you explicitly mark the call idempotent-safe. It records observation_time once from wall-clock at ingestion and measures backoff with a monotonic clock so an NTP correction mid-retry can never produce a negative sleep.

python
import asyncio
import random
import time
from dataclasses import dataclass, field
from typing import Any, Optional

import aiohttp


# Transient status codes worth retrying. 4xx other than 429 are caller errors
# and must NOT be retried — the payload will fail identically every time.
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})


@dataclass
class RetryConfig:
    base: float = 0.5          # seconds — first-attempt floor delay
    cap: float = 30.0          # seconds — hard ceiling on any single sleep
    max_attempts: int = 6      # total tries including the first
    multiplier: float = 3.0    # decorrelated-jitter growth factor
    respect_retry_after: bool = True


class NonRetryableError(Exception):
    """Raised for a 4xx (except 429): retrying cannot help."""


def _decorrelated_sleep(prev: float, cfg: RetryConfig) -> float:
    """
    AWS-style decorrelated jitter.

    sleep = min(cap, uniform(base, prev * multiplier))

    The delay is drawn from a widening band anchored on the PREVIOUS sleep,
    so it grows on average but can shrink to probe for early recovery. The
    randomness is what breaks synchronized retry storms across many senders.
    """
    upper = min(cfg.cap, prev * cfg.multiplier)
    # uniform() needs lo <= hi; base may exceed a tiny prev on attempt one.
    lo, hi = min(cfg.base, upper), max(cfg.base, upper)
    return random.uniform(lo, hi)


def _parse_retry_after(header: Optional[str]) -> Optional[float]:
    """Retry-After is delta-seconds here; HTTP-date form is left to the caller."""
    if not header:
        return None
    try:
        return max(0.0, float(header))
    except ValueError:
        return None


async def post_geojson_with_backoff(
    session: aiohttp.ClientSession,
    url: str,
    payload: dict[str, Any],
    *,
    idempotency_key: str,
    cfg: RetryConfig = RetryConfig(),
) -> dict[str, Any]:
    """
    Re-POST a GeoJSON webhook payload with decorrelated-jitter backoff.

    `idempotency_key` is REQUIRED and non-optional by design: the receiver must
    be able to deduplicate, because at-least-once retries can deliver the same
    geometry write more than once. Never call this for a non-idempotent write
    that lacks a dedupe key.

    Raises NonRetryableError on a permanent 4xx, or the last transport error
    after max_attempts is exhausted (drain to a dead-letter queue there).
    """
    headers = {
        "Content-Type": "application/geo+json",
        "Idempotency-Key": idempotency_key,
    }
    prev = cfg.base
    last_exc: Optional[Exception] = None

    for attempt in range(1, cfg.max_attempts + 1):
        try:
            async with session.post(url, json=payload, headers=headers) as resp:
                if resp.status < 400:
                    return await resp.json()
                if resp.status not in RETRYABLE_STATUS:
                    body = await resp.text()
                    raise NonRetryableError(f"{resp.status}: {body[:200]}")
                # Retryable failure — prefer the server's Retry-After if present.
                server_delay = (
                    _parse_retry_after(resp.headers.get("Retry-After"))
                    if cfg.respect_retry_after else None
                )
                last_exc = aiohttp.ClientResponseError(
                    resp.request_info, resp.history, status=resp.status,
                )
        except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as exc:
            server_delay = None
            last_exc = exc

        if attempt == cfg.max_attempts:
            break  # exhausted — let the caller dead-letter the payload

        delay = server_delay if server_delay is not None else _decorrelated_sleep(prev, cfg)
        delay = min(delay, cfg.cap)
        prev = delay
        await asyncio.sleep(delay)

    assert last_exc is not None
    raise last_exc


async def main() -> None:
    # observation_time is captured ONCE at ingestion (wall clock) and travels
    # unchanged through every retry so downstream ordering stays stable.
    feature = {
        "type": "Feature",
        "properties": {
            "sensor_id": "SN-42",
            "observation_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        },
        "geometry": {"type": "Point", "coordinates": [-73.965355, 40.782865]},
    }
    async with aiohttp.ClientSession(
        timeout=aiohttp.ClientTimeout(total=10)
    ) as session:
        result = await post_geojson_with_backoff(
            session,
            "https://example.test/ingest",
            feature,
            idempotency_key="evt-9f8a92b1",
            cfg=RetryConfig(base=0.5, cap=20.0, max_attempts=5),
        )
        print(result)


if __name__ == "__main__":
    asyncio.run(main())

The coordinates above are EPSG:4326 (WGS84) as required by RFC 7946, and the application/geo+json media type signals the payload type to the receiver.


Parameter reference

Parameter Type Meaning / spatial constraint Default
base float (s) Floor delay for the first retry; also the lower bound of every jitter draw. Keep ≥ your P50 endpoint latency so retries don’t pile on a slow-but-alive service 0.5
cap float (s) Hard ceiling on any single sleep. Bounds worst-case tail latency; must stay below the webhook provider’s delivery deadline 30.0
max_attempts int Total tries including the first. Beyond this, drain the GeoJSON payload to a dead-letter queue rather than looping forever 6
multiplier float Decorrelated-jitter growth factor (AWS uses 3). Higher spreads faster but overshoots cap sooner 3.0
respect_retry_after bool When True, a server Retry-After on 429/503 overrides the computed jitter for that attempt True

Gotchas and spatial edge cases

  1. Retrying without jitter causes storms. A deterministic 1-2-4-8s schedule makes every sender that failed together retry together. The band-based decorrelated formula above is the fix — never ship fixed-interval retries against a shared endpoint.

  2. Retrying non-idempotent geometry writes double-applies them. If the receiver appends the feature or bumps a version on each POST, a retry after a successful-but-timed-out delivery writes the geometry twice. Always attach an idempotency key and let the consumer deduplicate; the required idempotency_key argument above is deliberate friction to force this.

  3. Ignoring Retry-After fights the server. A 429 or 503 often tells you exactly when to come back. Overriding that with a shorter jittered delay just earns another 429. Honour the header when present, and clamp it to a sane maximum so a hostile or buggy Retry-After: 86400 can’t wedge your worker.

  4. Using the wall clock for backoff timing. Measure sleeps with a monotonic clock (time.monotonic, loop.time) — an NTP step during a retry can move the system clock backwards and yield a negative or huge delay. Reserve wall-clock time for the event’s observation_time, which must be captured once at ingestion and carried unchanged so downstream ordering and out-of-order handling stay correct.

  5. cap above the provider deadline is a silent data-loss bug. If your webhook provider considers a delivery failed after 60s but your cap lets a single sleep reach 90s, the provider gives up mid-backoff and the spatial event is lost. Keep cap × worst-case remaining attempts inside the delivery window, and size the two together — tuning retry budgets for webhook provider SLAs covers that math.

  6. Retrying 4xx caller errors. A 400/422 from a malformed geometry (self-intersecting polygon, wrong CRS) fails identically on every attempt. Only the codes in RETRYABLE_STATUS should re-enter the loop; everything else raises immediately and heads to inspection.


Verification

This pytest confirms the delay sequence stays within [base, cap] on every attempt and that the jitter actually varies (two runs are not identical). Seeding random makes the bounds check deterministic without fixing the values to a single sample.

python
import random
import pytest

from your_module import RetryConfig, _decorrelated_sleep  # adjust import path


def _delay_sequence(cfg: RetryConfig, seed: int) -> list[float]:
    """Reproduce the delays the helper would sleep for, without any I/O."""
    random.seed(seed)
    delays, prev = [], cfg.base
    for _ in range(cfg.max_attempts - 1):   # last attempt never sleeps
        d = min(_decorrelated_sleep(prev, cfg), cfg.cap)
        delays.append(d)
        prev = d
    return delays


def test_delays_stay_within_bounds():
    cfg = RetryConfig(base=0.5, cap=20.0, max_attempts=8)
    for seed in range(200):                 # sample many jitter draws
        for d in _delay_sequence(cfg, seed):
            assert cfg.base <= d <= cfg.cap  # never below base, never above cap


def test_cap_is_never_exceeded_even_with_large_multiplier():
    cfg = RetryConfig(base=1.0, cap=5.0, multiplier=10.0, max_attempts=12)
    assert all(d <= cfg.cap for d in _delay_sequence(cfg, seed=7))


def test_jitter_actually_varies():
    cfg = RetryConfig(base=0.5, cap=20.0, max_attempts=8)
    # Different seeds must produce different sequences, or there is no jitter.
    assert _delay_sequence(cfg, 1) != _delay_sequence(cfg, 2)


def test_first_delay_respects_base_floor():
    cfg = RetryConfig(base=2.0, cap=30.0, max_attempts=4)
    first = _delay_sequence(cfg, seed=0)[0]
    assert first >= cfg.base

FAQ

Why add jitter instead of plain exponential backoff?

Plain exponential backoff makes every failed client wait the same deterministic interval, so after a shared outage they all retry at the same instant and hammer the recovering endpoint in synchronized waves — a retry storm. Randomized (jittered) delays spread those retries across the window, smoothing load and improving overall recovery time.

Is it safe to retry a GeoJSON webhook POST?

Only if the operation is idempotent. Re-POSTing a payload that appends a feature or increments state will double-apply the geometry write on a retry. Attach a deterministic idempotency key so the receiver can deduplicate, and never retry non-idempotent writes blindly.

How should I handle a Retry-After header?

When a 429 or 503 response carries a Retry-After header, treat it as authoritative and sleep for that server-specified duration instead of your computed jitter for that attempt. The server knows its own recovery schedule better than your local backoff curve — just clamp the value so a pathological header can’t wedge the worker.

What clock should I record for observation_time versus retry timing?

Use two different clocks. The event’s observation_time is a wall-clock timestamp captured once at ingestion and carried unchanged through every retry, so downstream ordering stays correct. Retry delays should be measured with a monotonic clock (time.monotonic / loop.time) that never jumps when NTP adjusts the system clock.