Building fallback chains for routing APIs

When a primary routing API inside a federated mesh degrades — latency creep, payload rejection, or an upstream circuit-breaker trip — the failure does not stay local: route resolution propagates asymmetrically across spatial tile boundaries and vector schema contracts, and every consumer holding a sub-200ms SLA pays for it. This page is a focused operational procedure for wiring a deterministic, multi-tier fallback chain (Primary → Secondary → Static Cache) at the Envoy/Istio ingress so that partial mesh degradation never becomes a cascading outage. It sits under the Fallback Chains for Geocoding Services reference within the broader Federated Ownership & Routing Architecture, and it assumes the ingress route table it extends was already wired according to Mapping API gateways to distributed GIS endpoints. Get the timeout budgets and idempotency semantics right and a failed primary fails over silently, without duplicate spatial computation or malformed geometry reaching a consumer.

Three-tier routing fallback cascade at the Istio ingress An ingress request carrying an x-idempotency-key and an EPSG:4326 bounding box reaches a single Istio VirtualService that enforces a 1.5 second global timeout and a 0.5 second per-try timeout. In steady state it routes to the routing-primary cluster at weight 100. On a 5xx response, timeout, or open circuit breaker it cascades to the routing-secondary cluster, and on a secondary schema violation or 503 it falls to the static-cache cluster as a last resort. A Redis dedup gate keyed on x-idempotency-key sits on each fallback hop so a tier never re-runs a spatial computation an earlier tier already started. Every tier normalizes its result to EPSG:4326 before returning to the consumer. Ingress request x-idempotency-key EPSG:4326 bbox Istio VirtualService timeout 1.5s perTry 0.5s evaluates fallback eligibility per hop weight 100 Tier 1 · routing-primary steady-state · weight 100 resolves route corridor normalizes → EPSG:4326 Tier 2 · routing-secondary weight 0 → shifted on incident schema-validated reply normalizes → EPSG:4326 Tier 3 · static-cache last resort · pre-aggregated corridor served from cache normalizes → EPSG:4326 on 5xx · timeout · circuit-open Redis dedup gate x-idempotency-key on schema-violation · 503 Redis dedup gate x-idempotency-key

Prerequisites

Requirement Value / Assumption Notes
Service mesh Istio >= 1.20, Envoy v3 xDS API VirtualService + DestinationRule support
Orchestration kubectl + istioctl against the geospatial-mesh namespace Config dump + proxy-status access
Inspection tools jq, curl Reads Envoy route config and /stats/prometheus
CRS contract EPSG:4326 ingress normalization; EPSG:3857 for tile-aligned corridors All tiers return the same canonical CRS
Routing headers x-idempotency-key, x-fallback-triggered, x-fallback-reason, x-domain-boundary Authoritative fallback-state vector
Dedup store Redis cluster reachable as redis-idem.internal:6379 TTL-bounded idempotency cache
Access role mesh-routing-admin (RBAC) Required to mutate route tables and outlier detection
Telemetry Prometheus scraping istio_requests_total and envoy_cluster_* Drives fallback-activation alerting

Step-by-Step Implementation

Fallback chains are enforced at the gateway, never inside application code, so a degraded upstream is shed before it consumes the request budget. Each step below is verifiable with a diagnostic command before you proceed.

1. Define the tiered route table with timeout guardrails

The VirtualService establishes a three-tier hierarchy with a strict global timeout and a perTryTimeout that is deliberately smaller, so the gateway can evaluate fallback eligibility before the budget is exhausted. The primary carries weight: 100 in steady state; weight is shifted at runtime when an incident is declared. This aligns with the routing precedence defined across the Federated Ownership & Routing Architecture and prevents timeout propagation across domain boundaries.

yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: routing-api-fallback-chain
  namespace: geospatial-mesh
spec:
  hosts: ["routing-api.mesh.internal"]
  http:
  - match:
    - uri:
        prefix: /v2/route
    route:
    - destination:
        host: routing-primary
        subset: v1
      weight: 100
    - destination:
        host: routing-secondary
        subset: v1
      weight: 0
    timeout: 1.5s
    retries:
      attempts: 1
      perTryTimeout: 0.5s          # MUST stay < global timeout
      retryOn: 5xx,reset,connect-failure,envoy-ratelimited
    headers:
      request:
        add:
          x-fallback-triggered: "false"
          x-fallback-reason: "none"
      response:
        add:
          x-mesh-routing-tier: "primary"

If perTryTimeout >= timeout, the single retry attempt consumes the whole budget and the gateway has no time to cascade to the secondary before the request expires — the most common silent misconfiguration in this pattern.

Verify: confirm the weighted clusters loaded exactly as committed.

bash
istioctl proxy-config route <gateway-pod> \
  -n geospatial-mesh \
  --name routing-api-fallback-chain \
  -o json | \
  jq '.virtualHosts[].routes[].route.weightedClusters.clusters[] | {name, weight}'

2. Bound the primary with outlier detection

The DestinationRule ejects an unhealthy primary endpoint with circuit-breaker thresholds, so consecutive 5xx responses pull the endpoint out of rotation rather than letting the gateway keep paying the per-try timeout. Connection-pool ceilings cap the blast radius of a saturated upstream.

yaml
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: routing-primary-circuit-breaker
  namespace: geospatial-mesh
spec:
  host: routing-primary
  trafficPolicy:
    outlierDetection:
      consecutive5xxErrors: 3
      interval: 10s
      baseEjectionTime: 30s
      maxEjectionPercent: 25
    connectionPool:
      tcp:
        maxConnections: 1000
      http:
        h2UpgradePolicy: DEFAULT
        http1MaxPendingRequests: 500
        http2MaxRequests: 1000

Verify: confirm the breaker gauges are exported per cluster.

bash
istioctl proxy-config endpoint <gateway-pod> -n geospatial-mesh \
  --cluster "outbound|443||routing-primary.geospatial-mesh.svc.cluster.local" \
  -o json | jq '.[].healthStatus'

3. Enforce idempotent fallback execution

A fallback hop must not re-run a spatial computation that the primary already started. Inject x-idempotency-key: <sha256(payload+timestamp_window)> at ingress; the secondary cluster validates the key against the Redis dedup store before resolving a route, returning the cached corridor on a hit. Payloads crossing into an adjacent spatial domain carry x-domain-boundary: true, which triggers the Cross-Domain Routing Strategies bypass so the fallback returns a pre-aggregated corridor instead of re-running a heavy spatial join. Any secondary response that violates the producer contract is rejected — not propagated — and routed to the static cache tier, keeping fallback aligned with Schema Contracts for Vector/Tile Data.

Verify: replay an identical payload during a forced fallback and confirm the second request hits cache, not the upstream.

bash
KEY=$(printf '%s' "from=-1.20,52.60|to=-0.98,52.81|crs=EPSG:4326|tw=2026-06-26T10" | sha256sum | cut -d' ' -f1)
for i in 1 2; do
  curl -s -D - -o /dev/null \
    -H "x-idempotency-key: $KEY" \
    -H "x-domain-boundary: true" \
    "http://routing-api.mesh.internal/v2/route" | grep -i 'x-cache'
done   # request 2 should report x-cache: HIT

4. Wire telemetry to fallback-activation thresholds

Fallback that exceeds 12% of total routing requests over a 5-minute window is an incident signal, not noise. Export the retry/timeout counters and the per-tier request rate to Prometheus, then alert on the activation ratio and the retry-to-timeout ratio that precedes upstream saturation.

promql
# Retry-to-timeout ratio (> 0.35 indicates upstream saturation)
rate(envoy_cluster_upstream_rq_retry_total[5m])
  / rate(envoy_cluster_upstream_rq_timeout_total[5m])

# Fallback activation rate (> 0.12 triggers P2)
sum(rate(istio_requests_total{response_code="200",
  request_protocol="http",
  destination_service_name="routing-secondary"}[5m]))
/ sum(rate(istio_requests_total{destination_service_name=~"routing-.*"}[5m]))

Verify: pull the raw retry/timeout counters straight from the gateway sidecar.

bash
kubectl exec <gateway-pod> -n geospatial-mesh -c istio-proxy -- \
  curl -s localhost:15000/stats/prometheus | \
  grep -E 'envoy_cluster_upstream_rq_(retry|timeout)_total'

5. Validate the chain before promotion

A fallback chain that has never been exercised is a fallback chain that does not work. Gate promotion on chaos injection: use Envoy fault injection to return 5xx and connect-failure at 15% volume, then inject artificial latency (500ms, 800ms, 1.2s) at the primary and confirm the gateway aborts at the 1.5s global timeout and resolves through the secondary within 0.8s. Topology changes that move a fallback target must arrive through the Domain Sync Protocols for Spatial Data rather than manual edits, so the route table and tenant entitlements move atomically.

bash
# Force 25% of primary traffic to the static cache during a drill
kubectl patch virtualservice routing-api-fallback-chain -n geospatial-mesh --type merge -p \
  '{"spec":{"http":[{"route":[{"destination":{"host":"routing-primary","subset":"v1"},"weight":75},
   {"destination":{"host":"static-cache-cluster","subset":"v1"},"weight":25}]}]}}'
istioctl proxy-status   # confirm config propagated to every gateway pod

Configuration Reference

Field / Header Scope Required value Effect
timeout VirtualService route 1.5s Global per-request budget across all tiers
retries.perTryTimeout VirtualService retry 0.5s Must stay strictly below timeout to allow cascade
retries.retryOn VirtualService retry 5xx,reset,connect-failure,envoy-ratelimited Conditions that arm the next tier
x-idempotency-key Request header SHA-256(payload+timestamp_window) Redis-cached dedup across fallback hops
x-domain-boundary Request header true | false Diverts cross-domain payloads to pre-aggregated corridors
x-fallback-reason Request header none | timeout | 5xx | schema Pins which tier and cause triggered fallback
outlierDetection.consecutive5xxErrors DestinationRule 3 Errors before an endpoint is ejected
outlierDetection.baseEjectionTime DestinationRule 30s Minimum ejection duration; scales per re-ejection
outlierDetection.maxEjectionPercent DestinationRule 25 Ceiling on simultaneously ejected endpoints
connectionPool.http.http2MaxRequests DestinationRule 1000 Concurrent request ceiling before backpressure

Common Failure Modes & Fixes

Read the x-fallback-reason header and the per-tier metrics together — the pair localizes the failure to a specific hop.

Fallback never fires — the request just times out at the client. Root cause: perTryTimeout >= timeout, so the lone retry consumes the whole budget and the gateway never reaches the secondary. Fix: set perTryTimeout: 0.5s against the 1.5s global budget and re-dump the route — istioctl proxy-config route <gateway-pod> -n geospatial-mesh -o json | jq '..|.perTryTimeout? // empty'.

Fallback activation rate climbs above 12% (5m). Root cause: the primary is shedding 5xx faster than consecutive5xxErrors ejects it, usually from connection-pool exhaustion. Fix: confirm http2MaxRequests / http1MaxPendingRequests headroom, scale the primary horizontally, and verify outlier detection is actually ejecting — escalate to platform-engineering on-call as a P2.

Secondary returns malformed geometry and consumers degrade silently. Root cause: schema drift between primary and secondary (coordinate order flip, nullable attribute, mismatched tile matrix set such as ogc-tiles/v1.2.0-crs:EPSG:4326-res:10m). Fix: enforce strict content-type negotiation at the fallback boundary so a contract violation is rejected to the static cache, then roll back the secondary deployment per Schema Contracts for Vector/Tile Data.

Duplicate route corridors / double compute during fallover. Root cause: the x-idempotency-key is absent or its timestamp_window is too coarse, so the secondary re-runs a job the primary already materialized. Fix: inject the key at ingress, narrow the window to 1–5 minute buckets, and confirm from, to, and crs are all inside the digest before the TTL is set.

Cross-domain timeout exceeds 2s under fallback. Root cause: the fallback path is running a synchronous spatial join across a domain boundary instead of bypassing it. Fix: set x-domain-boundary: true to engage the pre-aggregated corridor path, and offload genuinely heavy joins to the async worker described in Optimizing async execution for spatial joins.

When degradation passes 25% activation (5m), declare a P1: shift weight: 0 on routing-primary and weight: 100 on static-cache-cluster, disable the heavy spatial query endpoints, and close the incident with a blameless review of timeout calibration and breaker thresholds.

FAQ

Why must perTryTimeout be smaller than the global timeout?

The global timeout is the budget for the entire request, including any fallback hop. If a single attempt is allowed to consume the whole budget, the gateway has no remaining time to evaluate fallback eligibility and dispatch to the secondary cluster, so the request just expires at the client. Keeping perTryTimeout (e.g. 0.5s) well under the global timeout (e.g. 1.5s) reserves headroom for at least one cascade before the budget is exhausted.

How do I stop a fallback hop from re-running a spatial computation the primary already started?

Inject an x-idempotency-key derived from SHA-256(payload + timestamp_window) at ingress and have the secondary cluster check it against the Redis dedup store before resolving a route. A key already present returns the cached corridor with x-cache: HIT, so the fallback returns the same geometry the primary was computing instead of duplicating the join. Always include the origin, destination, and CRS in the digest so distinct queries cannot collide on one key.

What activation rate should trigger an incident versus a warning?

Treat sustained fallback above 12% of total routing requests over a 5-minute window as a P2 — the primary is degrading faster than outlier detection can mask it. Above 25% over the same window, declare a P1 and shift full weight to the static cache. A single transient spike that clears inside one scrape interval is expected during deploys and does not warrant paging.

How do I keep a secondary cluster from emitting incompatible geometry?

Enforce strict schema validation at the fallback boundary, not just at the primary. The secondary must satisfy the same producer–consumer contract — coordinate order, attribute nullability, tile matrix set, and CRS normalization to EPSG:4326. Reject any response that violates the contract straight to the static cache tier rather than letting malformed geometry reach a consumer, and version the contract so a secondary rollback is a single declarative change.

Can I test the chain without a real upstream outage?

Yes — that is the only safe way to trust it. Use Envoy fault injection to return 5xx and connect-failure at a controlled volume, inject graduated latency (500ms/800ms/1.2s) at the primary, and assert that the gateway aborts at the global timeout and resolves through the secondary within budget. Gate production promotion on these chaos checks plus an idempotency replay that confirms no duplicate-compute log lines.