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.

The ordered degradation ladder and the condition that advances each rung Five states. PRIMARY runs A star on the canonical graph and advances to RECONCILED on a NetworkXNoPath or NodeNotFound error. RECONCILED snaps drifted endpoints to the nearest node and re-solves, advancing to VERTICAL if the graph is still disconnected. VERTICAL reweights vertical edges when mechanical transport is offline, advancing to SEMANTIC if the graph is fragmented beyond geometric recovery. SEMANTIC emits landmark turn-by-turn instructions, and only if nothing navigable can be produced does it reach HELD, where the session is kept open and an alert is raised. Four recoveries before anything is admitted as a failure NoPath / NodeNotFound still disconnected fragmented nothing navigable PRIMARY A* on the canonical graph RECONCILED snap endpoints, re-solve VERTICAL reweight vertical edges SEMANTIC landmark instructions HELD session kept, alert raised

Each rung loosens exactly one guarantee. Reconciliation gives up endpoint precision, vertical fallback gives up the preferred transport, semantic gives up geometry — and only the last rung gives up the answer.

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)
Three failure policies compared on what the user and the dashboard actually see A comparison of three policies. Fail-closed returns HTTP 500, shows the user a spinner, never produces an unsafe route, exposes only an error rate, and drops the session. Fail-open returns the nearest node unchecked, can show the user a wrong route including one via stairs for a wheelchair user, looks healthy in the metrics, and keeps the session. Graceful degradation returns the best tier that succeeded, shows a longer or coarser route, still enforces the accessibility profile, reports the tier distribution per route, and keeps the session. Two of these policies look fine on a status page Property Fail-closed Fail-open Graceful degradation Response on error △ HTTP 500 △ nearest node, unchecked ● best tier that succeeded User sees △ a spinner △ a wrong route ● a longer or coarser route Wheelchair safety ● no unsafe route △ may route via stairs ● profile still enforced Observability △ error rate only △ looks healthy ● tier distribution per route Session survives △ no ● yes ● yes Fail-open is the dangerous one: it is indistinguishable from success in the metrics.

Fail-open hides in the dashboards. It answers every request with a 200, so the only signal that anything is wrong arrives as a support ticket — which is why the tier a route came from is emitted as a metric, not just logged.

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:

Share of production routes served by each degradation tier over one week A bar chart of the share of routes served by each tier over one week. The primary tier serves 97.1 percent. Reconciliation serves 2.1 percent, vertical fallback 0.6 percent, semantic instructions 0.15 percent, and only 0.05 percent of requests reach the held state. The distribution is steeply skewed, so a shift of even a tenth of a percent between tiers is a visible signal that something upstream has changed. Tier share is a better health signal than error rate 0 25 50 75 100 1 2 3 4 5 1 primary 2 reconciled 3 vertical 4 semantic 5 held share of routes served (%)

This distribution is the alert. A jump in tier 2 means endpoints are drifting — a positioning problem; a jump in tier 3 means lifts are down. The absolute error rate would show neither.

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.

This page is part of the Fallback Routing Architectures guide within the Indoor Mapping Architecture & Standards reference.