Implementing At-Least-Once Delivery for GIS Webhooks
Immediately return 200 OK before touching any geometry, persist the raw spatial payload to a durable queue, and enforce Redis-backed idempotency before every write — that is the complete at-least-once contract for a GIS webhook pipeline.
This page is part of Sensor Data Routing Patterns, itself a section of Core Event Fundamentals & Architecture. It focuses on the precise implementation mechanics: key derivation, atomic deduplication, retry budgets, and dead-letter routing — all adapted for the coordinate-precision and CRS constraints that spatial payloads introduce.
When to use this pattern
At-least-once delivery with explicit idempotency enforcement is the right choice when:
- Your upstream cannot guarantee deduplication. Third-party GIS platforms (ArcGIS Online, Mapbox, Felt) resend events on connection timeout without a built-in deduplication protocol, so your receiver owns the duplicate-suppression contract.
- Spatial processing is expensive but not strictly transactional. Writing to PostGIS, updating routing graphs, or invalidating tile caches can tolerate an occasional redundant attempt, but silently processing the same coordinate update twice corrupts spatial indexes and inflates metric counters.
- Network partitions are more likely than message loss. Ephemeral network errors cause retries; at-least-once matches that failure model and avoids the overhead of distributed transactions that exactly-once requires.
When you need true exactly-once semantics — for example, financial transactions tied to a geographic asset — pair this pattern with a two-phase commit or use a broker (Kafka with enable.idempotence=true) that provides exactly-once at the protocol level.
Complete runnable implementation
The following self-contained FastAPI application implements every component: immediate acknowledgment, coordinate-normalized idempotency key derivation, atomic Redis deduplication, bounded exponential backoff with full jitter, and dead-letter routing. Swap the # SPATIAL PROCESSING block for your actual PostGIS write, shapely validation, or tile cache invalidation call.
"""
at_least_once_gis.py — Production at-least-once GIS webhook receiver.
Dependencies: fastapi, uvicorn, redis[asyncio], shapely, pyproj, pydantic
"""
import asyncio
import hashlib
import json
import logging
import math
import random
from typing import Any
import redis.asyncio as aioredis
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from pydantic import BaseModel, field_validator
from shapely.geometry import shape
from shapely.validation import explain_validity
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
IDEMPOTENCY_TTL_SECONDS = 86_400 # 24-hour retention for processed keys
DLQ_TTL_SECONDS = 604_800 # 7-day retention for dead-letter entries
MAX_RETRIES = 5
BASE_BACKOFF_SECONDS = 2.0
COORDINATE_PRECISION = 6 # ~11 cm; sufficient for all GIS use-cases
app = FastAPI(title="GIS Webhook Receiver")
redis_client = aioredis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
logger = logging.getLogger("gis.at_least_once")
# ---------------------------------------------------------------------------
# Payload schema (GeoJSON Feature, strict)
# ---------------------------------------------------------------------------
class GeoJSONFeaturePayload(BaseModel):
"""Minimal GeoJSON Feature schema for webhook ingestion."""
type: str
geometry: dict[str, Any]
properties: dict[str, Any] | None = None
@field_validator("type")
@classmethod
def must_be_feature(cls, v: str) -> str:
if v != "Feature":
raise ValueError(f"Expected GeoJSON 'Feature', got '{v}'")
return v
# ---------------------------------------------------------------------------
# Idempotency key derivation
# ---------------------------------------------------------------------------
def _round_coordinates(obj: Any) -> Any:
"""Recursively round all floats to COORDINATE_PRECISION decimal places.
Floating-point serialization varies across sender libraries — 40.7128 and
40.712800000001 are geographically identical but produce different SHA-256
hashes. Rounding before hashing ensures duplicate payloads yield the same key.
"""
if isinstance(obj, float):
return round(obj, COORDINATE_PRECISION)
if isinstance(obj, list):
return [_round_coordinates(v) for v in obj]
if isinstance(obj, dict):
return {k: _round_coordinates(v) for k, v in sorted(obj.items())}
return obj
def derive_idempotency_key(payload: dict[str, Any], event_id: str | None) -> str:
"""Return a Redis key that uniquely identifies this spatial event.
Priority:
1. X-Event-ID or Idempotency-Key header (sender-assigned, most reliable).
2. SHA-256 hash of the coordinate-normalized, key-sorted payload JSON
(used when the sender provides no event identifier).
"""
if event_id:
return f"idemp:{event_id}"
normalized = json.dumps(_round_coordinates(payload), sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(normalized.encode()).hexdigest()
return f"idemp:{digest}"
# ---------------------------------------------------------------------------
# Backoff strategy
# ---------------------------------------------------------------------------
def full_jitter_backoff(attempt: int) -> float:
"""Full jitter: uniform sample in [0, min(cap, base * 2^attempt)].
Full jitter outperforms equal-jitter and decorrelated jitter at preventing
thundering-herd problems when many retrying workers recover simultaneously
after a PostGIS or network outage.
"""
cap = 60.0
ceiling = min(cap, BASE_BACKOFF_SECONDS * math.pow(2, attempt))
return random.uniform(0.0, ceiling)
# ---------------------------------------------------------------------------
# Geometry validation
# ---------------------------------------------------------------------------
def validate_geometry(geometry_dict: dict[str, Any]) -> None:
"""Raise ValueError for invalid or degenerate geometries before any DB write.
Inserting an invalid polygon into PostGIS can corrupt spatial indexes and
cause ST_Intersects / ST_Contains queries to return wrong results indefinitely.
Validate with shapely before the geometry touches any spatial store.
"""
try:
geom = shape(geometry_dict)
except Exception as exc:
raise ValueError(f"Cannot parse geometry: {exc}") from exc
if not geom.is_valid:
raise ValueError(f"Invalid geometry: {explain_validity(geom)}")
if geom.is_empty:
raise ValueError("Geometry is empty — zero-area or zero-length feature rejected")
# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------
async def process_spatial_event(payload: dict[str, Any], event_id: str | None) -> None:
"""Deduplication + bounded-retry spatial processor.
1. Derive the idempotency key.
2. Atomic SET NX EX — skip immediately if key already exists (duplicate).
3. Validate geometry before any spatial write.
4. Attempt processing with exponential backoff and full jitter.
5. On exhaustion, route to the dead-letter queue and release the key so
manual replay is possible.
"""
key = derive_idempotency_key(payload, event_id)
# Atomic check-and-set: only ONE worker proceeds per unique event
acquired = await redis_client.set(key, "processing", nx=True, ex=IDEMPOTENCY_TTL_SECONDS)
if not acquired:
logger.info("Duplicate spatial event skipped: %s", key)
return
# Validate geometry once, outside the retry loop — malformed geometries
# will not succeed on retry, so fail fast and route directly to DLQ.
try:
validate_geometry(payload.get("geometry", {}))
except ValueError as exc:
logger.error("Geometry validation failed for %s: %s — routing to DLQ", key, exc)
dlq_key = f"dlq:{key}"
dlq_value = json.dumps({"payload": payload, "error": str(exc), "retries": 0})
await redis_client.set(dlq_key, dlq_value, ex=DLQ_TTL_SECONDS)
await redis_client.delete(key)
return
for attempt in range(MAX_RETRIES):
try:
# -------------------------------------------------------------------
# SPATIAL PROCESSING — replace with your actual implementation:
# - asyncpg INSERT to PostGIS with ST_GeomFromGeoJSON
# - Shapely-based topology merge
# - Tile cache invalidation via HTTP to your tile server
# - Routing graph update in Neo4j / pgRouting
# -------------------------------------------------------------------
await asyncio.sleep(0.0) # Remove; placeholder for real async call
logger.info("Spatial event processed successfully: %s", key)
return # Success — idempotency key remains set in Redis as proof
except Exception as exc:
wait = full_jitter_backoff(attempt)
logger.warning(
"Attempt %d/%d failed for %s — retrying in %.2fs. Error: %s",
attempt + 1, MAX_RETRIES, key, wait, exc,
)
await asyncio.sleep(wait)
# Retries exhausted — move to dead-letter queue
logger.error("Event %s exhausted %d retries. Routing to DLQ.", key, MAX_RETRIES)
dlq_key = f"dlq:{key}"
dlq_value = json.dumps({"payload": payload, "retries": MAX_RETRIES})
await redis_client.set(dlq_key, dlq_value, ex=DLQ_TTL_SECONDS)
# Delete processing key so the event can be replayed manually via DLQ consumer
await redis_client.delete(key)
# ---------------------------------------------------------------------------
# HTTP endpoint
# ---------------------------------------------------------------------------
@app.post("/webhooks/gis", status_code=200)
async def receive_gis_webhook(
request: Request,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
"""Accept a GeoJSON Feature webhook and acknowledge before processing.
The immediate 200 OK stops the sender's retry timer. All spatial work
happens in the background, decoupled from the HTTP response path.
"""
try:
raw = await request.json()
payload = GeoJSONFeaturePayload(**raw).model_dump()
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Schema validation failed: {exc}")
# Prefer sender-assigned event identifier for stable deduplication
event_id = (
request.headers.get("X-Event-ID")
or request.headers.get("Idempotency-Key")
)
background_tasks.add_task(process_spatial_event, payload, event_id)
return {"status": "accepted"}
Parameter reference
| Parameter | Type | Default | Spatial constraint |
|---|---|---|---|
IDEMPOTENCY_TTL_SECONDS |
int |
86400 (24 h) |
Must exceed your sender’s maximum retry window. GIS platform SLAs are typically ≤ 4 h. |
DLQ_TTL_SECONDS |
int |
604800 (7 d) |
Long enough for manual replay; trim after audit to cap storage. |
MAX_RETRIES |
int |
5 |
Beyond 7, downstream degradation is usually structural, not transient. |
BASE_BACKOFF_SECONDS |
float |
2.0 |
Minimum mean wait on first retry. Set higher for PostGIS-heavy writes. |
COORDINATE_PRECISION |
int |
6 |
6 decimal degrees ≈ 11 cm. Sufficient for all drone/IoT telemetry use-cases. Do not exceed 8 (float64 precision limit). |
X-Event-ID header |
str |
None | Sender-supplied stable UUID. Preferred over hash-based key; eliminates precision ambiguity entirely. |
Idempotency-Key header |
str |
None | Accepted as a fallback when X-Event-ID is absent. Same semantics. |
Gotchas and spatial edge cases
-
Coordinate precision drift causes missed duplicates. Two payloads representing the same sensor ping may serialize
longitudeas13.404954and13.4049540000001depending on the sender’s JSON library. Without_round_coordinates(), the SHA-256 hash differs and both events are processed, producing a duplicate PostGIS row. Always normalize to 6 decimal places before hashing. -
CRS ambiguity poisons the idempotency key. If one delivery of an event declares
"crs": {"init": "epsg:4326"}and a retry omits the CRS field entirely (a common GeoJSON spec violation), the hash will differ and the duplicate will slip through. Normalize the CRS field to a canonical EPSG:4326 declaration — or strip it, since GeoJSON RFC 7946 mandates WGS 84 — before hashing. -
Ring orientation flips on re-serialization. Some spatial libraries (Turf.js, GDAL) may flip polygon exterior ring winding order between the original event and a retry.
_round_coordinates()does not protect against this because the coordinate values are identical; the list order differs. Normalize ring orientation (right-hand rule per RFC 7946) withshapely.geometry.mapping(orient(shape(geometry), sign=1.0))before computing the hash, and apply this using geometry validation pipelines. -
Atomic
SET NXis not cluster-safe without Redlock. A single Redis instance provides true atomicSET NX EX. Across a Redis Cluster or Sentinel setup,SET NXis node-local — two workers may simultaneously acquire the key on different shards and both proceed. If your Redis deployment is clustered, either hash-tag your idempotency keys to pin them to a single slot ({idemp}:hash) or use a Redlock implementation. See using Redis to cache spatial webhook signatures for a Redlock example. -
Invalid geometries must bypass the retry loop. A self-intersecting polygon will fail
validate_geometry()on every attempt. Retrying it wastes budget and delays DLQ routing. The implementation above validates once before entering the retry loop and routes directly to the DLQ on geometry failure — preserving retries for transient infrastructure errors only. -
DLQ key deletion enables manual replay. Deleting the processing key after DLQ routing is intentional: if an operator fixes the downstream system and wants to replay the event, they must not be blocked by a stale
SET NX. The DLQ entry itself carries full payload and retry count for forensic analysis.
Minimal verification snippet
Run this against a local instance (uvicorn at_least_once_gis:app --reload) with Redis on localhost:6379.
"""
test_at_least_once.py — Verify idempotency and DLQ routing end-to-end.
Run: pytest test_at_least_once.py -v
"""
import asyncio
import hashlib
import json
import pytest
import redis.asyncio as aioredis
from at_least_once_gis import (
COORDINATE_PRECISION,
derive_idempotency_key,
full_jitter_backoff,
validate_geometry,
_round_coordinates,
)
# ---------------------------------------------------------------------------
# Unit: idempotency key stability
# ---------------------------------------------------------------------------
def test_coordinate_rounding_produces_stable_key():
"""Two payloads differing only in float noise must yield the same key."""
payload_a = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [13.404954, 52.520008]},
"properties": {},
}
payload_b = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [13.4049540000001, 52.5200080000001]},
"properties": {},
}
key_a = derive_idempotency_key(payload_a, None)
key_b = derive_idempotency_key(payload_b, None)
assert key_a == key_b, "Coordinate noise must not produce distinct idempotency keys"
def test_event_id_header_takes_priority():
"""When X-Event-ID is provided it overrides the hash-based key."""
payload = {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0, 0]}}
key = derive_idempotency_key(payload, "evt-abc-123")
assert key == "idemp:evt-abc-123"
# ---------------------------------------------------------------------------
# Unit: geometry validation
# ---------------------------------------------------------------------------
def test_valid_point_passes():
validate_geometry({"type": "Point", "coordinates": [13.4050, 52.5200]}) # no exception
def test_self_intersecting_polygon_rejected():
# Bowtie polygon — invalid topology
bowtie = {
"type": "Polygon",
"coordinates": [[[0, 0], [2, 2], [2, 0], [0, 2], [0, 0]]],
}
with pytest.raises(ValueError, match="Invalid geometry"):
validate_geometry(bowtie)
def test_empty_geometry_rejected():
from shapely.wkt import loads
# Pass an empty geometry dict (missing type)
with pytest.raises((ValueError, KeyError)):
validate_geometry({})
# ---------------------------------------------------------------------------
# Unit: backoff stays within bounds
# ---------------------------------------------------------------------------
def test_backoff_never_exceeds_cap():
for attempt in range(20):
assert full_jitter_backoff(attempt) <= 60.0
def test_backoff_is_non_negative():
for attempt in range(10):
assert full_jitter_backoff(attempt) >= 0.0
# ---------------------------------------------------------------------------
# Integration: duplicate suppression via Redis
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_duplicate_event_suppressed():
"""Second call with the same key must be a no-op (key already set)."""
r = aioredis.Redis(host="localhost", port=6379, db=15, decode_responses=True)
key = "idemp:test-dedup-001"
await r.delete(key)
first = await r.set(key, "processing", nx=True, ex=60)
second = await r.set(key, "processing", nx=True, ex=60)
assert first is True, "First acquisition must succeed"
assert second is None, "Duplicate acquisition must be rejected by SET NX"
await r.delete(key)
await r.aclose()
The idempotency and geometry-validation unit tests run without a live Redis instance. Mark the integration test with @pytest.mark.asyncio and ensure pytest-asyncio is installed (pip install pytest-asyncio).
Related
- Sensor Data Routing Patterns — parent section covering queue topology, geographic partitioning, and fan-out strategies for IoT and drone telemetry
- Event Key Generation for Spatial Data — detailed treatment of hash strategies, H3-cell keying, and TTL selection for spatial idempotency stores
- Using Redis to Cache Spatial Webhook Signatures — Redlock and cluster-safe patterns for high-availability deduplication
- Geometry Validation Pipelines — topology checks, ring orientation normalization, and CRS validation before spatial writes
- Core Event Fundamentals & Architecture — the architectural principles underpinning every spatial event pipeline on this site