Graceful Degradation Strategies for Location APIs
A real-time location API publishes a spatiotemporal contract — a coordinate of bounded error, delivered inside a bounded latency window — and production routinely violates the inputs that contract depends on: GNSS multipath, cellular handover, upstream geocoder throttling, and spatial-compute memory pressure. The wrong response is binary availability, where a degraded input flips the whole service to a 5xx and downstream consumers lose state continuity. The right response is graceful degradation: the API stays up, narrows what it promises, and signals the narrowed promise explicitly so callers can adjust their own SLA enforcement. This page is the API-surface companion to Fallback Routing for GPS Dropouts — the parent topic decides when the internal router fails over; this deep-dive decides what the public endpoint returns while it does, and how to bound the error it ships. It operates inside the per-event contract defined in Core Architecture & Latency Constraints, where every stage owns a slice of a fixed latency budget.
The reason a degradation strategy is non-optional: a location endpoint that returns a stale coordinate with no quality annotation is more dangerous than one that returns an error, because a billing geofence or a dispatch decision will act on it as if it were fresh. Degradation that preserves continuity must therefore ship two things together — a best-effort position and an honest confidence bound.
Concept & Specification
Graceful degradation is a finite ladder of service tiers, each with a strictly weaker accuracy promise and a strictly cheaper compute cost. A tier is entered on a measurable signal — never a wall-clock heuristic — so the same input replays to the same tier and post-incident reconciliation is deterministic.
The governing quantity is the position-error bound the API is willing to ship. Under dead-reckoning interpolation, error grows from the last validated fix as a function of dropout duration :
where is the last fix accuracy, $v$ the last validated speed, the heading uncertainty in radians, and the worst-case unmodelled acceleration. The API holds a tier only while stays inside the contractual bound ; the dwell ceiling is the that solves . The decision to admit or shed a request follows from the effective latency
where is the term that runs away first when an upstream stalls — which is why shedding targets the queue, not the kernel.
The parameters that move the tier transitions:
| Tier | Trigger signal | Promise | Compute path |
|---|---|---|---|
NOMINAL |
HDOP < 2, fresh fix, queue < 50% | Full accuracy, P99 < 8 ms | Live point-in-polygon evaluation on traffic-weighted graph |
DEAD_RECKONING |
Fix age > 2 s or 3 consecutive upstream failures | , P99 < 8 ms | Extrapolate from last velocity vector, cached topology |
CACHED_TOPOLOGY |
Geocoder breaker open, RSS > 80% | Last-known zone, no fresh boundary | Direct-to-cache, validation skipped |
SHED |
Queue depth > 80% for 60 s, dropout rate > 30% | 503 + Retry-After, dead-letter |
Reject non-critical enrichment |
Step-by-Step Implementation
Prerequisites: Python 3.11+, shapely>=2.0 (GEOS-backed predicates), numpy>=1.24, an asyncio ingestion loop. Coordinate fixes arrive as (lat, lon, t, v, heading, hdop) tuples; the fallback routing graph is pre-loaded into memory at startup so a tier change never pays cold-start latency.
1. Make the tier a first-class, measurable state. Encode the ladder as an enum and key every transition off a metric, so the controller is auditable and replayable.
from __future__ import annotations
import enum
import math
from dataclasses import dataclass
class Tier(enum.IntEnum):
NOMINAL = 0
DEAD_RECKONING = 1
CACHED_TOPOLOGY = 2
SHED = 3
@dataclass(slots=True)
class Fix:
lat: float
lon: float
t: float # epoch seconds of the fix
v: float # m/s, last validated speed
heading: float # radians
hdop: float # horizontal dilution of precision
def error_bound(fix: Fix, dt: float, sigma_theta: float, a_max: float) -> float:
"""Worst-case dead-reckoning error in metres after dt seconds of dropout."""
eps0 = 5.0 * fix.hdop # last-fix accuracy proxy
return eps0 + fix.v * sigma_theta * dt + 0.5 * a_max * dt * dt
2. Drive the transition from signals, never from a timer alone. The controller folds fix age, upstream health, and queue pressure into one tier decision and refuses to ship a position whose error bound has blown the SLA.
def select_tier(
fix: Fix,
now: float,
*,
failures: int,
queue_fill: float, # 0..1
breaker_open: bool,
eps_sla: float = 30.0, # metres
sigma_theta: float = 0.08,
a_max: float = 3.0,
) -> Tier:
if queue_fill > 0.80 or failures > 10:
return Tier.SHED
if breaker_open:
return Tier.CACHED_TOPOLOGY
dt = now - fix.t
if dt > 2.0 or failures >= 3:
# only stay in dead-reckoning while the error bound holds
if error_bound(fix, dt, sigma_theta, a_max) <= eps_sla:
return Tier.DEAD_RECKONING
return Tier.CACHED_TOPOLOGY
return Tier.NOMINAL
Gotcha: compute
dtfrom the fix timestamp, not from request arrival. Clock skew between the device and the gateway otherwise makes a fresh fix look stale and demotes a healthy stream into dead reckoning.
3. Extrapolate, and always annotate the confidence. Dead reckoning projects the last vector forward; the response carries the error bound so callers degrade their own logic instead of trusting a synthetic point blindly.
def extrapolate(fix: Fix, now: float) -> dict[str, float | str]:
dt = now - fix.t
# equirectangular step is fine for sub-minute dropout windows
dx = fix.v * dt * math.sin(fix.heading)
dy = fix.v * dt * math.cos(fix.heading)
dlat = dy / 111_320.0
dlon = dx / (111_320.0 * math.cos(math.radians(fix.lat)))
return {
"lat": fix.lat + dlat,
"lon": fix.lon + dlon,
"accuracy_m": error_bound(fix, dt, 0.08, 3.0),
"quality": "dead_reckoned", # never label this 'gps'
}
4. Shed load at the gateway, not at the kernel. When the queue saturates, reject low-value enrichment requests with a Retry-After so clients back off; this protects for the critical ENTER/EXIT path.
import asyncio
async def admit(queue: asyncio.Queue, critical: bool) -> bool:
fill = queue.qsize() / (queue.maxsize or 1)
if fill > 0.80 and not critical:
return False # caller gets 503 + Retry-After
return True
5. Wrap the upstream geocoder in a circuit breaker. Count consecutive failures, open the breaker on threshold, and probe with a single half-open request before closing — so a flapping provider never reopens the path under full load. Drive the breaker state into select_tier rather than letting each call retry inline and inflate the tail.
Benchmark / Verification
Figures below are a 4-core gateway, CPython 3.11, a 200-vertex municipal zone, NumPy-backed coordinate batches, sustained 50k events/sec offered load, perf_counter_ns timing, gen-2 GC frozen after warm-up, and a fault injector that drops the upstream geocoder for 30 s mid-run.
| Scenario | P50 | P95 | P99 | Sustained | Error band |
|---|---|---|---|---|---|
| Nominal, healthy upstream | 0.9 ms | 3.1 ms | 7.4 ms | ~96k/s | ±8 m |
| Upstream drop, no degradation (before) | 1.1 ms | 240 ms | 1900 ms | ~6k/s | timeouts → 5xx |
| Upstream drop, dead-reckoning tier (after) | 1.0 ms | 3.4 ms | 7.9 ms | ~92k/s | ±12 m @ 2 s |
| Memory pressure, cached-topology tier | 0.7 ms | 2.6 ms | 6.1 ms | ~99k/s | last-zone only |
| Surge + shed, critical path only | 1.2 ms | 3.8 ms | 8.0 ms | ~88k/s (critical) | ±8 m |
The before/after story is rows two and three: without a degradation ladder, an upstream stall converts directly into a 1900 ms P99 and a collapse to ~6k/s as inline retries pile into the queue; with the dead-reckoning tier the endpoint holds P99 under 8 ms and sustains ~92k/s, trading roughly 4 m of accuracy for full continuity. The cached-topology row shows the cheaper tier is actually faster because it skips boundary validation — the deliberate trade is precision, not speed. A regression gate worth wiring into CI: assert that under injected upstream loss the dead-reckoning P99 stays under 8 ms and the response carries quality != "gps". Both catch the two real regressions — an inline retry that leaks back onto the loop, and a synthetic point shipped without its confidence band.
Failure Modes & Edge Cases
A stale fix re-promoted as fresh. If select_tier reads request arrival time instead of the fix timestamp, a recovering stream looks current and the API ships a dead-reckoned point labelled gps. Always carry the original fix t end to end and recompute dt at emit time.
Error bound grows unbounded. Dead reckoning is only valid while ; past that the controller must drop to CACHED_TOPOLOGY, not keep extrapolating. A vehicle that turns during a tunnel transit will diverge quadratically in , so the dwell ceiling is short — seconds, not minutes.
NaN and out-of-range fixes. A NaN latitude flows through shapely.contains as False silently, so a corrupt GPS sample reads as a legitimate “outside”. Reject with np.isfinite at ingest and count rejects as a sensor-fault metric, not a containment miss — the same input hygiene used in point-in-polygon evaluation.
GC pressure masquerading as upstream latency. Millisecond P99 outliers during a dropout storm are usually a gen-2 collection landing on trajectory-buffer churn, not geocoder slowness. Correlate gc.get_stats() collection counts with spike timestamps; if they align, call gc.freeze() after warm-up and pool the state vectors so the hot path stays allocation-free, the heap discipline detailed in memory-constrained spatial processing.
Breaker flap under recovery. A geocoder that recovers then immediately fails again will thrash the breaker if it closes on the first success. Require N consecutive half-open successes before closing, and keep the tier in CACHED_TOPOLOGY until then, so the public latency never inherits the provider’s instability.
Surge converts a stall into an OOM. When offered load exceeds the measured ceiling, an unbounded queue turns a compute stall into a memory-exhaustion crash. Bound the ingest queue, shed non-critical enrichment at 80% fill, and dead-letter overflow so the service degrades to coarse accuracy rather than failing entirely.
Related
- Fallback Routing for GPS Dropouts — the parent topic: the fallback state machine and reconciliation runbook this API surface sits on top of.
- Reducing P99 Latency in Python Geofence Services — the tail-latency tuning that keeps the nominal tier inside its 8 ms budget.
- Benchmarking Spatial Containment in Async Python — how to measure the compute term each degradation tier trades against.
- Up one level: Core Architecture & Latency Constraints — the per-event latency budget every tier must fit inside.