9 min read 5 sections

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.

Bounded degradation ladder for a real-time location API, with measured degrade and recovery triggers Four stacked service tiers from strongest to weakest — NOMINAL, DEAD_RECKONING, CACHED_TOPOLOGY, SHED — each labelled with the accuracy and latency it promises. Down arrows on the left carry the measured signal that demotes a tier; dashed up arrows on the right carry the signal that promotes it back toward full service. API degradation ladder — demote on a measured signal, promote back up accuracy promise weakens fix age > 2 s · 3 upstream fails ε > ε_SLA · breaker open · RSS > 80% queue > 80% / 60 s · dropout > 30% fresh fix · HDOP < 2 fix returns · RSS < 80% queue drains < ceiling NOMINAL Full accuracy · P99 < 8 ms · live point-in-polygon DEAD_RECKONING ε(Δt) ≤ ε_SLA · P99 < 8 ms · extrapolate last vector CACHED_TOPOLOGY Last-known zone · boundary validation skipped SHED 503 + Retry-After · dead-letter non-critical

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.

python
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.

python
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 dt from 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.

python
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.

python
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.