Exponential Backoff & Jitter for Spatial Webhooks

Exponential backoff with decorrelated jitter is the correct retry timing for redelivering failed spatial webhook events: it spreads retry attempts across time so a recovering receiver is not hit by a synchronised thundering herd, and — paired with an idempotency key derived from the geometry — it guarantees a retried write never double-inserts a feature.

This topic sits under Queue Management, Retries & Delivery Guarantees, the section covering how spatial event pipelines move payloads reliably from producer to consumer. Retry timing is the first line of defence against transient failure; when retries are exhausted, events fall through to Dead-Letter Queues for Spatial Payloads, and the overall correctness contract is set by Delivery Guarantees & Event Ordering.


Prerequisites

Confirm your stack meets this baseline before wiring retries into a spatial webhook dispatcher. Check off each item as you verify it:


Why Constant and Linear Retry Fail at Scale

A single client retrying a single failed delivery at a fixed one-second interval is harmless. The danger is correlation. When a shared downstream — a tile server, a PostGIS writer, a routing engine — has a brief outage, every producer that was mid-delivery fails at nearly the same instant. If they all retry on the same fixed schedule, they retry together, in a synchronised wave. The receiver comes back up, is immediately hit by the entire backlog at once, tips over again, and the cycle repeats. This is the classic thundering-herd retry storm, and constant or linear backoff actively creates it because it preserves the alignment of the original failure.

Spatial workloads make this worse in three specific ways. First, individual requests are expensive: a webhook carrying a MultiPolygon triggers geometry validation, a spatial join, and often a tile invalidation, so a retry storm consumes far more receiver CPU per request than a plain JSON ping. Second, payloads are large — a dense boundary geometry is orders of magnitude bigger than a status event — so synchronised retries saturate bandwidth as well as CPU. Third, spatial events are frequently bursty by nature: a fleet of sensors crossing a region boundary emits correlated events, so the population that fails together is already clustered.

Exponential backoff breaks the synchronisation over time by making each successive delay grow multiplicatively (base · 2^attempt), which thins the retry rate as an outage lengthens. But pure exponential backoff alone does not break synchronisation between clients: a thousand producers that all failed at t=0 and all use identical exponential delays still retry in lockstep, just at exponentially spaced instants. The fix is jitter — randomising each delay so the herd disperses across the interval instead of stacking on its edge.


Architecture Blueprint

The retry path is a small state machine wrapped around a single HTTP POST. Every failure is classified before a delay is ever computed, every retry re-claims an idempotency key, and exhaustion has exactly one destination.

Jittered retry state machine for spatial webhook delivery Flow from an outbound GeoJSON POST through failure classification: terminal 4xx and invalid geometry exit immediately to the dead-letter queue, retryable 5xx and timeout compute a decorrelated-jitter delay under a retry budget and loop back to the POST, and success acknowledges. POST GeoJSON idempotency key Classify outcome? 2xx → ACK 4xx / invalid geometry Dead-Letter Queue terminal + budget-exhausted 5xx / timeout Budget token left? no → shed Sleep delay decorrelated jitter min(cap, U(base, prev·3)) retry attempt n+1

Layer breakdown:

  1. Dispatch — the payload is POSTed as GeoJSON under a stable idempotency key. The key is derived once and reused for every retry of the same event, so the receiver can recognise a redelivery.
  2. Classification — the outcome is sorted into three buckets before any timing logic runs: success acknowledges and exits; a terminal fault (4xx or invalid geometry) goes straight to the dead-letter queue; a retryable fault (5xx, timeout, 429) proceeds to timing.
  3. Budget gate — a shared token bucket decides whether the fleet can afford this retry. If the retry-to-success ratio is already too high, the event is shed to the dead-letter queue instead of amplifying an outage.
  4. Jittered delay — a decorrelated-jitter delay, capped at a ceiling, is slept before looping back to dispatch for the next attempt.

Step-by-Step Implementation

Step 1 — Classify the Failure Before Scheduling Anything

Timing logic must never run for a failure that will never succeed. A 422 for a self-intersecting polygon is deterministic: it fails identically on attempt one and attempt fifty. Retrying it burns the budget and delays the moment a human sees it in the dead-letter queue.

python
from dataclasses import dataclass
from enum import Enum, auto
import aiohttp


class Outcome(Enum):
    SUCCESS = auto()
    RETRYABLE = auto()   # transient: 5xx, timeout, connection reset, 429
    TERMINAL = auto()    # permanent: 4xx, invalid geometry — send to DLQ


# 4xx codes that must never be retried; a retry cannot change the result.
NON_RETRYABLE_STATUS = {400, 401, 403, 404, 409, 410, 422}
# 5xx and 429 are worth another attempt after a backoff delay.
RETRYABLE_STATUS = {429, 500, 502, 503, 504}


def classify_response(status: int) -> Outcome:
    if 200 <= status < 300:
        return Outcome.SUCCESS
    if status in NON_RETRYABLE_STATUS:
        return Outcome.TERMINAL
    if status in RETRYABLE_STATUS or 500 <= status < 600:
        return Outcome.RETRYABLE
    # Unknown 4xx: treat as terminal so we fail fast rather than loop.
    return Outcome.TERMINAL


def classify_exception(exc: Exception) -> Outcome:
    # Transport faults are transient by nature and safe to retry.
    if isinstance(exc, (aiohttp.ClientConnectionError,
                        aiohttp.ServerTimeoutError,
                        TimeoutError)):
        return Outcome.RETRYABLE
    return Outcome.TERMINAL

Validate geometry topology before the POST so that an invalid ring is classified as terminal locally, never sent over the wire only to bounce back as a 422. The topology check is the same shapely gate used across the pipeline; the CRS must carry its EPSG code (payloads default to EPSG:4326 (WGS84) per RFC 7946).

python
from shapely.geometry import shape
from shapely.validation import explain_validity


def is_terminal_geometry(feature: dict) -> str | None:
    """Return a reason string if the geometry can never succeed, else None."""
    geom = shape(feature["geometry"])
    if geom.is_empty:
        return "empty geometry"
    if not geom.is_valid:
        return f"invalid topology: {explain_validity(geom)}"
    return None

Step 2 — Compute the Delay with Decorrelated Jitter

The three jitter strategies differ in how they draw the random delay for attempt n, given a base, a cap, and (for decorrelated) the previous delay:

  • Full jitterdelay = uniform(0, min(cap, base · 2^n)). Widest spread, lowest collision probability, but can retry almost immediately after a failure.
  • Equal jitterhalf = min(cap, base · 2^n) / 2; delay = half + uniform(0, half). Guarantees a minimum wait, so no retry lands too early, at the cost of a narrower spread.
  • Decorrelated jitterdelay = min(cap, uniform(base, prev · 3)). The delay random-walks upward from the previous value, combining a rising floor with a wide ceiling. This is the recommended default.
python
import random


def full_jitter(attempt: int, base: float, cap: float) -> float:
    return random.uniform(0, min(cap, base * (2 ** attempt)))


def equal_jitter(attempt: int, base: float, cap: float) -> float:
    ceiling = min(cap, base * (2 ** attempt))
    half = ceiling / 2
    return half + random.uniform(0, half)


def decorrelated_jitter(prev_delay: float, base: float, cap: float) -> float:
    """Random-walk the delay upward; independent of an attempt counter."""
    return min(cap, random.uniform(base, prev_delay * 3))

The SVG below compares the delay envelopes the three strategies produce across attempts. All grow, but the shaded spread — the range a given attempt can land in — is what disperses the herd.

Retry-time spread of the three jitter variants Three horizontal tracks over an attempt axis. No-jitter exponential shows single points doubling each attempt. Full jitter shows wide bars from zero up to the exponential ceiling. Equal jitter shows bars covering the upper half of each ceiling. Decorrelated jitter shows a rising staircase band that widens with each attempt. retry attempt → delay spread 1 2 3 4 5 no jitter (lockstep) full = U(0, ceiling) equal = ceiling/2 + U(0, ceiling/2) decorrelated band

Step 3 — An Async Retry Decorator Around an aiohttp POST

The decorator wraps any coroutine that performs one delivery attempt. It uses decorrelated jitter, classifies every outcome, respects a Retry-After header when present, and raises a dedicated exception when the ladder is exhausted so the caller can route to a dead-letter queue.

python
import asyncio
import functools
import logging
import random
from typing import Awaitable, Callable

logger = logging.getLogger(__name__)


class RetriesExhausted(Exception):
    """All attempts consumed; the caller should dead-letter the event."""


class TerminalFailure(Exception):
    """Non-retryable outcome; fail fast and dead-letter immediately."""


def retry_decorrelated(
    max_attempts: int = 6,
    base: float = 0.5,     # seconds; first-attempt floor
    cap: float = 30.0,     # seconds; ceiling for any single delay
):
    def wrapper(fn: Callable[..., Awaitable]):
        @functools.wraps(fn)
        async def inner(*args, **kwargs):
            prev = base
            for attempt in range(1, max_attempts + 1):
                try:
                    return await fn(*args, **kwargs)
                except TerminalFailure:
                    raise  # never retry a permanent fault
                except Exception as exc:
                    if attempt == max_attempts:
                        raise RetriesExhausted(str(exc)) from exc
                    # Decorrelated jitter: walk upward from the last delay.
                    delay = min(cap, random.uniform(base, prev * 3))
                    prev = delay
                    logger.warning(
                        "attempt %d/%d failed (%s); sleeping %.2fs",
                        attempt, max_attempts, exc, delay,
                    )
                    await asyncio.sleep(delay)
        return inner
    return wrapper


@retry_decorrelated(max_attempts=6, base=0.5, cap=30.0)
async def deliver_geojson(
    session: aiohttp.ClientSession,
    url: str,
    feature: dict,
    idempotency_key: str,
) -> None:
    """Single delivery attempt. Raises to signal the decorator to retry."""
    reason = is_terminal_geometry(feature)
    if reason is not None:
        raise TerminalFailure(f"unshippable geometry: {reason}")

    headers = {
        "Content-Type": "application/geo+json",
        # The same key on every retry lets the receiver dedupe redeliveries.
        "Idempotency-Key": idempotency_key,
    }
    timeout = aiohttp.ClientTimeout(total=10)
    try:
        async with session.post(url, json=feature, headers=headers,
                                timeout=timeout) as resp:
            outcome = classify_response(resp.status)
            if outcome is Outcome.SUCCESS:
                return
            if outcome is Outcome.TERMINAL:
                raise TerminalFailure(f"HTTP {resp.status}")
            # Retryable: honour Retry-After if the server sent one.
            retry_after = resp.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                await asyncio.sleep(min(float(retry_after), 30.0))
            raise RuntimeError(f"retryable HTTP {resp.status}")
    except (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError,
            TimeoutError) as exc:
        raise RuntimeError(f"transport fault: {exc}") from exc

Step 4 — Pair Retries with Idempotency So a Geometry Write Cannot Double-Insert

This is the non-negotiable rule of retries: a retry fires exactly when the client cannot know whether the first attempt succeeded. A read timeout can occur after the receiver has already inserted the polygon and committed the transaction — the write landed, but the acknowledgement never made it back. If the retry re-POSTs blindly, the receiver inserts the feature a second time: duplicate geometry, double-counted area, a corrupted spatial join downstream.

The idempotency key sent in Step 3 is what makes the retry a no-op instead of a duplicate. On the receiver side, that key is derived deterministically from the normalised geometry and claimed atomically before the write, exactly as described in Cache-Backed Idempotency Checks within the broader discipline of Idempotency & Spatial Deduplication. Retries and idempotency are two halves of one mechanism: retries provide liveness (the event eventually lands), idempotency provides safety (it lands at most once).

python
import hashlib
import json


def idempotency_key_for(feature: dict, device_id: str, event_type: str,
                        precision: int = 7) -> str:
    """
    Deterministic key over the rounded geometry plus a business identifier.
    The SAME event always yields the SAME key, so a retried POST is recognised
    as a redelivery on the receiver and does not double-insert. Coordinates are
    rounded (~1.1 cm at the equator, EPSG:4326) to absorb float drift.
    """
    def _round(coords):
        if coords and isinstance(coords[0], (int, float)):
            return [round(c, precision) for c in coords]
        return [_round(part) for part in coords]

    canonical = {
        "type": feature["geometry"]["type"],
        "coordinates": _round(feature["geometry"]["coordinates"]),
    }
    blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
    composite = f"{device_id}:{event_type}:{blob}"
    return "idem:v1:" + hashlib.sha256(composite.encode()).hexdigest()

Step 5 — Enforce a Retry Budget Across the Fleet

Per-event backoff disperses a herd in time, but it does not cap total retry load. During a long outage, every event still climbs its full ladder, and a fleet of thousands multiplies that into a sustained flood aimed at an already-failing receiver. A retry budget bounds retries as a fraction of successful traffic — a token bucket refilled from successes and drained by retries. When the bucket is empty, retries are shed straight to the dead-letter queue.

python
import time


class RetryBudget:
    """
    Token-bucket retry budget shared across the dispatcher. Every success adds
    `ratio` tokens (e.g. 0.1 → retries may add at most ~10% extra load); every
    retry costs 1 token. When empty, retries are refused and the event is
    dead-lettered instead of amplifying a downstream outage.
    """
    def __init__(self, ratio: float = 0.1, ceiling: float = 100.0,
                 min_per_sec: float = 1.0):
        self.ratio = ratio
        self.ceiling = ceiling
        self.min_per_sec = min_per_sec
        self.tokens = ceiling
        self._last = time.monotonic()

    def _leak(self) -> None:
        now = time.monotonic()
        self.tokens = min(self.ceiling,
                          self.tokens + self.min_per_sec * (now - self._last))
        self._last = now

    def record_success(self) -> None:
        self._leak()
        self.tokens = min(self.ceiling, self.tokens + self.ratio)

    def try_retry(self) -> bool:
        self._leak()
        if self.tokens >= 1.0:
            self.tokens -= 1.0
            return True
        return False  # budget exhausted → caller must dead-letter

Sizing the ratio and the ceiling against each provider’s published retry window and error-rate SLA is its own discipline, covered in Tuning Retry Budgets for Webhook Provider SLAs.


Delivery Guarantees: What Retries Actually Buy You

Retries move a pipeline from best-effort toward at-least-once delivery — with a bounded budget, every event is either acknowledged, dead-lettered, or shed, and none is silently lost. At-least-once is the honest ceiling for a network with timeouts: the client can always be forced to retry after a lost acknowledgement, so duplicates are unavoidable at the transport layer. What upgrades the observable behaviour to effectively-once is the idempotency key from Step 4 — duplicates still arrive, but the receiver collapses them, so a feature is written at most once regardless of how many times it is delivered.

True exactly-once across a network boundary requires a distributed transaction coordinator and is rarely justified for spatial ingestion given its latency cost. The pragmatic contract for almost every spatial webhook fleet is therefore at-least-once delivery plus idempotent writes. Ordering is a separate axis — retries reorder events by construction, since a retried event lands after later events that succeeded first — and reconciling that with spatial correctness is the subject of Delivery Guarantees & Event Ordering. When the retry ladder and the budget are both exhausted, the event must land somewhere durable and inspectable: Dead-Letter Queues for Spatial Payloads.


Verification

A retry policy is only correct if its delay sequence stays inside the bounds you designed. This test asserts that decorrelated jitter never drops below the base floor, never exceeds the cap, and — over many trials — actually disperses rather than collapsing onto a single value.

python
import statistics


def decorrelated_sequence(attempts: int, base: float, cap: float,
                          rng_seed: int) -> list[float]:
    rnd = random.Random(rng_seed)
    prev, out = base, []
    for _ in range(attempts):
        delay = min(cap, rnd.uniform(base, prev * 3))
        out.append(delay)
        prev = delay
    return out


def test_backoff_sequence_bounds():
    base, cap = 0.5, 30.0
    for seed in range(500):
        seq = decorrelated_sequence(8, base, cap, seed)
        # Bound 1: no delay is below the base floor.
        assert min(seq) >= base - 1e-9, f"delay below base: {min(seq)}"
        # Bound 2: no delay exceeds the cap.
        assert max(seq) <= cap + 1e-9, f"delay above cap: {max(seq)}"

    # Bound 3: across seeds, attempt-1 delays are dispersed, not constant.
    first_delays = [decorrelated_sequence(1, base, cap, s)[0]
                    for s in range(500)]
    assert statistics.pstdev(first_delays) > 0.1, "jitter is not dispersing"
    print("backoff bounds hold; jitter disperses the herd")


if __name__ == "__main__":
    test_backoff_sequence_bounds()

Run it with pytest -q or directly with python. The third assertion is the one that catches a subtly broken jitter implementation: a delay function that grows correctly but forgets to randomise will pass the bound checks yet still cause a thundering herd, and only the dispersion assertion exposes it. A worked, benchmarked version of this harness — with side-by-side timing of all three variants — lives in Implementing Exponential Backoff with Jitter in Python.


Troubleshooting

Symptom Likely spatial cause Fix
Receiver recovers, then immediately falls over again No jitter — all producers retry in lockstep after the outage Switch from plain exponential to decorrelated jitter; confirm the dispersion test passes
Duplicate polygons appear after a delivery timeout Retry re-POSTed a write that had already committed, with no idempotency key Send a stable Idempotency-Key derived from normalised geometry; claim it atomically before the write
A single malformed geometry retries forever Invalid-geometry 422 classified as retryable instead of terminal Run the shapely topology check locally; map 4xx and invalid geometry to TerminalFailure
Retry load spikes and never subsides during an outage No retry budget — every event climbs its full ladder simultaneously Add the token-bucket RetryBudget; shed to the dead-letter queue when empty
Retries fire far faster than expected Retry-After header from a 429 ignored Honour Retry-After and clamp it to the cap before sleeping
Delays occasionally exceed the intended ceiling cap applied to the base but not to the jittered result Wrap the final draw in min(cap, …); assert the upper bound in the verification test
Duplicate keys collide across distinct events Idempotency key omits the business identifier, keying on geometry alone Include device_id and event_type in the composite before hashing

FAQ

Why do retries require idempotency for spatial payloads?

A retry happens precisely when the client is unsure the first attempt succeeded — a timeout or dropped connection can occur after the receiver has already written the geometry. Without an idempotency key, the retried POST re-inserts the same feature, producing duplicate polygons, double-counted areas, and corrupted spatial joins. Deriving a deterministic key from the normalised geometry and claiming it atomically before the write makes the retry a no-op instead of a duplicate.

Which jitter variant should I use for spatial webhook retries?

Decorrelated jitter is the best default for most spatial webhook fleets. Full jitter minimises collisions but can retry too eagerly right after a failure; equal jitter guarantees a minimum wait but clusters more tightly; decorrelated jitter walks the delay upward from the previous value, giving both wide spread and a rising floor. AWS load tests found full and decorrelated jitter complete a contended workload in the fewest total calls.

How do I tell a retryable failure from a non-retryable one?

Retry on transient transport and server faults: connection errors, read timeouts, and 5xx responses such as 502, 503, and 504, plus 429 (respecting Retry-After). Do not retry client faults: 400, 401, 403, 404, 409, 422, and any invalid-geometry rejection. A self-intersecting polygon will fail identically on every attempt, so retrying it only wastes the retry budget and delays routing it to a dead-letter queue for inspection.

What is a retry budget and why does it matter at fleet scale?

A retry budget caps retries as a fraction of successful requests — for example, no more than 10 percent additional load from retries. Without a budget, a downstream outage causes every event to exhaust its full retry ladder simultaneously, multiplying load exactly when the receiver is already failing. A token-bucket budget sheds retries once the ratio is exceeded, sending events straight to the dead-letter queue instead of amplifying the outage.


Explore this section