Designing Graceful Degradation for Routing Failures
Graceful degradation is the discipline that keeps an indoor navigation session alive when the routing graph, the network, or the positioning fix fails, and it is the operational core of the Fallback Routing Architectures reference: instead of one routing call that either works or throws, you build a ladder of progressively weaker but always-safe tiers, each guarded by a health check, that a resolver walks until one answers.
What “Graceful Degradation” and “Fail-Closed” Mean
Graceful degradation means a system that loses capability in defined steps rather than collapsing all at once. For an indoor router, the fullest capability is a fresh, live routing graph reflecting real-time closures; the weakest usable capability is a static, pre-baked emergency-egress map showing only the exits. Between them sit intermediate tiers — a last-known-good cached graph, for instance. Each tier is strictly less capable than the one above it and strictly more likely to be available, and the resolver’s job is to serve the highest tier that currently passes its health check.
“Fail-closed” is the rule that decides what happens when every tier a user might want is unavailable. A fail-open system, faced with a missing graph, returns something anyway — a blank canvas, a straight line through walls, a stale route that ignores a live closure — and that is worse than useless because it looks authoritative while being wrong. A fail-closed system refuses to invent an answer: it degrades to the most conservative safe state it can still vouch for (the emergency-egress layer, and past that an explicit “routing unavailable”), and it never presents guidance it cannot stand behind. In a building, a confident wrong turn can walk someone toward a hazard, so fail-closed is not a preference here — it is a safety property.
Minimal Working Example
The resolver below walks a tier ladder from the live graph down to a safe “unavailable” state. Each tier pairs a loader with a health check; the resolver serves the first tier whose health check passes and whose loader returns a graph, logging which tier it served so degradation is observable. Nothing throws to the caller — the terminal tier is always a valid, if minimal, response.
import logging
from dataclasses import dataclass
from typing import Callable, Optional
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)
RoutingGraph = dict # opaque graph payload for this example
Loader = Callable[[], Optional[RoutingGraph]]
HealthCheck = Callable[[], bool]
@dataclass(frozen=True)
class Tier:
name: str
load: Loader # returns a graph or None
healthy: HealthCheck # cheap signal: is this tier usable right now?
def resolve_graph(ladder: list[Tier]) -> tuple[str, RoutingGraph]:
"""Serve the highest healthy tier; fail closed to a safe state, never raise to the caller."""
for tier in ladder:
try:
if not tier.healthy():
logger.warning("tier '%s' failed health check, descending", tier.name)
continue
graph = tier.load()
except (OSError, TimeoutError) as exc:
logger.warning("tier '%s' load error: %s, descending", tier.name, exc)
continue
if graph is not None:
logger.info("served routing graph from tier '%s'", tier.name)
return tier.name, graph
# Terminal, fail-closed state: always available, always safe, never a lie.
logger.error("all tiers exhausted; serving emergency-egress-only safe state")
return "unavailable", {"mode": "egress_only", "edges": []}
LADDER = [
Tier("live", load_live_graph, lambda: cdn_reachable() and fix_confident()),
Tier("cache", load_last_known_good, lambda: cache_fresh_within(minutes=30)),
Tier("egress", load_static_egress, lambda: True), # pre-baked, always healthy
]
served_tier, graph = resolve_graph(LADDER)
The ordering is the design. live is tried first because it is the most capable, but its health check is the strictest — it requires both a reachable CDN and a confident positioning fix. cache requires only that the last-known-good graph is recent enough. egress is always healthy because it is a static asset baked into the client, so the ladder can never fall through it — and only past it does the resolver reach the explicit "unavailable" safe state. No tier throws to the caller; the worst case is a minimal but honest response.
Degradation Ladder Reference
| Tier / parameter | Type | Example | Role |
|---|---|---|---|
live |
tier | fresh graph from CDN | Highest capability; real-time closures; strictest health check |
cache |
tier | last-known-good graph | Survives a network drop; bounded by staleness window |
egress |
tier | pre-baked static exits | Always healthy; the fail-closed floor above “unavailable” |
unavailable |
terminal | {"mode": "egress_only"} |
Explicit safe state; honest “no routing”, never a blank lie |
healthy() |
HealthCheck |
CDN reachable + fix confident | Cheap boolean gate that admits or skips a tier |
| staleness window | int (min) |
30 |
Max age before the cache tier is considered unhealthy |
| load timeout | float (s) |
1.5 |
Bound on each loader so a hung fetch descends instead of blocking |
| policy | str |
fail_closed |
Whether an exhausted ladder degrades safe (closed) or invents (open) |
Common Errors & Fixes
Failing open — a blank canvas or an invented route. The resolver caught an exception and returned None or an empty graph straight to the renderer, which drew nothing or a straight line through walls. That is a confident lie. Ensure the ladder always terminates in a real, safe payload and that the caller treats the served-tier name as load-bearing, not decorative:
served, graph = resolve_graph(LADDER)
if served == "unavailable":
ui.show_egress_only_banner() # honest: "live routing is down, exits shown"
# never: if graph is None: render_blank() -> that is failing open
No health check, so a dead tier is served anyway. Omitting tier.healthy() and relying only on the loader throwing means a tier that returns a stale graph without error is served as if fresh — a cached route that ignores a live corridor closure. Gate every tier on an explicit, cheap health signal (CDN reachability, cache age, positioning confidence) so an unhealthy-but-not-erroring tier is skipped before its loader ever runs.
Unbounded retry blocking the descent. A loader that retries a hung network fetch forever never lets the resolver fall to the next tier, so the user stares at a spinner instead of getting the cached graph. Bound every loader with a timeout and treat the timeout as “descend”, never “retry in place”:
# WRONG: retry loop with no ceiling blocks the whole ladder.
# while True: g = fetch(); ...
# RIGHT: one bounded attempt; a timeout descends to the next tier.
graph = fetch_with_timeout(seconds=1.5) # raises TimeoutError -> resolver descends
Integration Point
This resolver is the mechanism behind the tiered state machine described in Fallback Routing Architectures — that section frames the policy (which tiers exist and why each loosens its guarantee), and this reference supplies the engine (a health-gated ladder walk that fails closed). Downstream, the same fail-closed ladder is what a client consumes: the SDK Integration Patterns degradation contract descends from the latest synced graph to a last-known-good cache to a pre-baked emergency-egress layer using exactly this structure, so the server-side resolver and the on-device ladder share one shape. Whichever graph library serves the live tier — the trade-off weighed in NetworkX vs. igraph for large routing graphs — the degradation ladder wraps it unchanged, because the ladder cares about tier health, not the container underneath.
Frequently Asked Questions
What is the difference between failing open and failing closed for routing?
Failing open means returning something when the real answer is unavailable — a blank map, a straight line, or a stale route that ignores a live closure — which looks authoritative while being wrong and can walk a user toward a hazard. Failing closed means refusing to invent guidance: the system degrades to the most conservative state it can still vouch for, such as an emergency-egress-only layer, and past that shows an explicit “routing unavailable” rather than a fabricated path. In a physical building where a wrong turn has consequences, fail-closed is a safety property, not a preference.
Why gate each tier with a health check instead of just catching exceptions?
Because the most dangerous failure is the one that does not throw. A cached graph that is thirty minutes stale loads without error, so an exception-only resolver serves it as if fresh, ignoring a corridor that closed five minutes ago. A cheap explicit health check — cache age, CDN reachability, positioning confidence — lets the resolver skip a tier that is technically loadable but not trustworthy, descending to a tier it can actually stand behind. Exceptions catch hard failures; health checks catch silent staleness.
How do I stop a hung fetch on one tier from blocking the whole ladder?
Bound every loader with a timeout and treat a timeout as a signal to descend, never to retry in place. An unbounded retry loop on a hung network fetch pins the resolver on the top tier forever, so the user waits on a spinner instead of receiving the cached graph that was one tier down and instantly available. One bounded attempt per tier keeps the whole ladder responsive: the worst case is that the resolver walks all the way to the pre-baked egress layer quickly, rather than stalling on a dead live tier.
Related
- Fallback Routing Architectures — the tiered state machine whose policy this resolver implements.
- SDK Integration Patterns for Indoor Maps — the on-device degradation ladder that consumes this same fail-closed structure.
- NetworkX vs. igraph for Large Indoor Routing Graphs — the graph library that serves the live tier this ladder wraps.
This page is part of the Fallback Routing Architectures guide within the Indoor Mapping Architecture & Standards reference.