Configuring Exponential Backoff for Nominatim Fallback

When a primary geocoder starts to degrade — rising latency, intermittent 503s, sporadic rate-limit rejections — retrying it immediately and in lockstep only synchronizes every caller into a thundering herd that finishes the provider off. This operation, part of Fallback Chains for Geocoding Services within the Federated Ownership & Routing Architecture, configures exponential backoff with full jitter on the primary before failing over to a self-hosted Nominatim tier, sizing the base, cap, and retry count so retries spread out, respect the provider’s rate limit, and stay idempotent so a slow-but-eventual success is never geocoded twice. It works hand in glove with Circuit Breaker Tuning for Geocoding Providers, which ejects a provider the moment backoff stops paying off.

Figure — each retry waits a random interval inside an exponentially growing window, capped, until the retry budget is spent and the request fails over to Nominatim.

Exponential backoff with full jitter before Nominatim failover A timeline of geocoding attempts against the primary. Attempt zero fails. Retry one waits a random delay within a 200 millisecond window, retry two within a 400 millisecond window, retry three within an 800 millisecond window, each capped at two seconds. The actual jittered delay is shown as a shorter bar inside each window. After the third retry the budget is exhausted and the request fails over to the Nominatim fallback tier, which returns a normalized EPSG:4326 result. attempt 0 fail window 200ms retry 1 jitter window 400ms retry 2 jitter window 800ms · cap 2s retry 3 jitter Nominatim tier EPSG:4326 result delay = random(0, min(cap, base·2^n)) full jitter · idempotent retries

Prerequisites

Requirement Value / Assumption Notes
Fallback tier Self-hosted Nominatim reachable as nominatim.mesh.internal Last-resort geocoder; rate-limited
Service mesh Istio >= 1.20, Envoy v3 xDS For gateway-level retry policy
Client library python3 with requests >= 2.31 Reference client-side backoff implementation
CLI tools curl, jq, istioctl Verifies retry headers and route config
CRS contract All tiers normalize to EPSG:4326 lon/lat Coordinate order asserted before return
Idempotency x-idempotency-key: SHA-256(query+crs) Prevents a slow success being geocoded twice
Rate limits Primary 50 rps; Nominatim 1 rps per client Backoff must never exceed either
Access role mesh-routing-admin for gateway policy Zero-trust: clients cannot widen retry budget

Step-by-Step Implementation

Backoff is enforced in two places that must agree: the client library spreads its own retries with full jitter, and the Envoy gateway caps the total retry budget so no client can exceed the provider’s rate limit. Each step verifies before moving on.

1. Implement full-jitter backoff in the client

Full jitter — random(0, min(cap, base * 2**n)) — decorrelates retries across callers far better than equal or “equal jitter” schemes, which is what stops a thundering herd. Cap the window so a late attempt never blocks longer than the request budget.

python
import random, time, hashlib, requests

BASE, CAP, MAX_RETRIES = 0.2, 2.0, 3   # seconds, seconds, count

def geocode(query, crs="EPSG:4326"):
    key = hashlib.sha256(f"{query}|{crs}".encode()).hexdigest()
    headers = {"x-idempotency-key": key}
    for n in range(MAX_RETRIES + 1):
        r = requests.get("http://geocode-primary.mesh.internal/v1/search",
                         params={"q": query, "crs": crs}, headers=headers, timeout=1.0)
        if r.status_code < 500 and r.status_code != 429:
            return r.json()                      # success or a real 4xx
        delay = random.uniform(0, min(CAP, BASE * (2 ** n)))   # full jitter
        time.sleep(delay)
    return nominatim_fallback(query, crs, headers)  # budget exhausted

Verify the delays actually grow and vary run to run:

bash
python3 -c "import random; BASE,CAP=0.2,2.0; \
print([round(random.uniform(0,min(CAP,BASE*2**n)),3) for n in range(4)])"
# windows grow 0.2 -> 0.4 -> 0.8 -> 1.6, jittered, none above 2.0

2. Route the fallback to Nominatim idempotently

The fallback carries the same x-idempotency-key, so if the primary eventually succeeded on a request the client had already abandoned, the dedup gate returns that result instead of geocoding again. This is the same key discipline the routing fallback chain uses.

python
def nominatim_fallback(query, crs, headers):
    r = requests.get("http://nominatim.mesh.internal/search",
                     params={"q": query, "format": "jsonv2"},
                     headers=headers, timeout=3.0)
    r.raise_for_status()
    hit = r.json()[0]
    # Normalize to the mesh CRS contract before returning.
    return {"lon": float(hit["lon"]), "lat": float(hit["lat"]), "crs": crs}

Verify a replayed query does not double-geocode:

bash
KEY=$(printf '%s' "1600 Pennsylvania Ave|EPSG:4326" | sha256sum | cut -d' ' -f1)
for i in 1 2; do
  curl -s -D - -o /dev/null -H "x-idempotency-key: $KEY" \
    "http://geocode-primary.mesh.internal/v1/search?q=1600+Pennsylvania+Ave" | grep -i 'x-cache'
done   # request 2 should report x-cache: HIT

3. Cap the retry budget at the Envoy gateway

Gateway retries are the backstop for clients that misbehave. retryOn arms on the same conditions; perTryTimeout times each attempt; and the total attempts times the per-try timeout must stay inside the global timeout.

yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: geocode-primary-backoff
  namespace: geospatial-mesh
spec:
  hosts: ["geocode-primary.mesh.internal"]
  http:
  - route:
    - destination: { host: geocode-primary, subset: v1 }
    timeout: 4s
    retries:
      attempts: 3
      perTryTimeout: 1s                       # 3 x 1s < 4s global budget
      retryOn: 5xx,reset,connect-failure,retriable-status-codes
      retriableStatusCodes: [429]             # honor rate-limit as retriable
      backoff:
        baseInterval: 0.2s                     # matches client BASE
        maxInterval: 2s                        # matches client CAP

Verify the policy loaded exactly as committed:

bash
istioctl proxy-config route <gateway-pod> -n geospatial-mesh \
  --name geocode-primary-backoff -o json | \
  jq '.virtualHosts[].routes[].route.retryPolicy | {numRetries, retryOn, perTryTimeout}'

4. Keep backoff inside the Nominatim rate limit

Nominatim tolerates roughly 1 rps per client. The failover path must not itself become a herd, so serialize fallbacks behind a token bucket and confirm the effective rate under a burst.

bash
# Fire 10 fallbacks and confirm they land no faster than ~1 rps.
for i in $(seq 1 10); do
  curl -s -o /dev/null -w '%{time_starttransfer}\n' \
    "http://nominatim.mesh.internal/search?q=test$i&format=jsonv2"
done | awk '{print NR": "$1"s"}'

If the inter-arrival gap drops below one second, tighten the client-side bucket before Nominatim starts returning 429.

Configuration Reference

Parameter Scope Value Rationale
BASE / baseInterval client + gateway 0.2s First backoff window; doubles per retry
CAP / maxInterval client + gateway 2s Ceiling so a late retry never blocks the budget
MAX_RETRIES / attempts client + gateway 3 Retry ladder before failover
jitter client full random(0, min(cap, base·2ⁿ)) decorrelates callers
retryOn gateway 5xx,reset,connect-failure,retriable-status-codes Conditions that arm a retry
retriableStatusCodes gateway [429] Treats rate-limit as backoff-worthy, not fatal
perTryTimeout gateway 1s Bounds each attempt; attempts × perTry < timeout
timeout gateway 4s Global budget across all retries + failover
x-idempotency-key header SHA-256(query+crs) Stops a slow success being geocoded twice

Common Failure Modes & Fixes

  • Symptom: the primary recovers, then immediately collapses again. Root cause: fixed-interval retries synchronized every client into a thundering herd. Fix: switch to full jitter (random(0, min(cap, base*2**n))); confirm delays vary run to run with the Step 1 check.
  • Symptom: clients keep hammering a rate-limited primary. Root cause: 429 was treated as a hard failure and never retried with backoff. Fix: add 429 to retriableStatusCodes and honor Retry-After when present.
  • Symptom: the request times out before failover fires. Root cause: attempts × perTryTimeout ≥ timeout, so the budget is spent inside retries. Fix: keep 3 × 1s under the 4s global timeout, leaving headroom for the Nominatim hop.
  • Symptom: an address is geocoded twice — once by a late primary success and once by the fallback. Root cause: missing or query-only idempotency key. Fix: derive the key from SHA-256(query+crs) and check it on both tiers, as in Step 2.
  • Symptom: Nominatim starts returning 429 during an incident. Root cause: every abandoned primary request failed over at once, exceeding 1 rps. Fix: serialize fallbacks behind a token bucket and let the Circuit Breaker Tuning for Geocoding Providers eject the primary so retries stop sooner.

FAQ

Why full jitter instead of a fixed or exponential-but-deterministic delay?

Deterministic backoff makes every client wait the same interval, so they all retry at the same instant and re-synchronize into a herd that keeps a recovering provider down. Full jitter draws each delay uniformly from 0 to the current window, which spreads retries across the whole interval and decorrelates callers. It is the scheme that empirically minimizes both completion time and load on a struggling upstream.

How do base, cap, and max-retries relate to the request timeout?

The worst-case total wait is roughly the sum of the capped windows plus the per-try timeouts. With base=0.2s, cap=2s, and three retries the backoff waits stay small, and the gateway enforces attempts × perTryTimeout < timeout so the budget always leaves room for the Nominatim failover hop. If you widen any of them, re-check that inequality or the request will expire before it ever fails over.

Why must retries carry an idempotency key?

A retry can race a slow original request. If the primary eventually completes work the client already abandoned, and the fallback also geocodes the same address, you get duplicate results and double the provider load. Keying retries on SHA-256(query+crs) and checking that key on every tier means the second execution returns the cached result instead of geocoding again.

Should a 429 from the primary count as a retryable failure?

Yes, but with backoff and preferably honoring Retry-After. A 429 means the provider is rate-limiting you, not that the request is malformed, so an immediate retry only makes it worse. Add 429 to retriableStatusCodes so it arms the exponential-backoff ladder; if the limit persists across the whole budget, fail over to Nominatim rather than retrying indefinitely.