Mapping API gateways to distributed GIS endpoints

Routing failures in a federated geospatial estate almost never come from network partitioning — they come from misaligned ingress resolution, where a request lands on the wrong owning backend or fans out across domains that should never have been touched. This page is a focused operational procedure for wiring an Envoy ingress to bounded vector, raster, and geocoding endpoints using deterministic header-based routing, strict schema validation at the edge, and bounded circuit breakers. It sits under the API Gateway Mapping for GIS Services reference within the broader Federated Ownership & Routing Architecture, and it assumes the domain boundaries it routes to were already drawn according to Spatial Domain Boundary Design. Get the mapping right and you eliminate cross-domain fan-out latency, enforce ownership boundaries, and hold predictable p99 response times under heavy spatial query loads.

Ingress routing topology for distributed GIS endpoints A client request carrying spatial headers and an idempotency key reaches a single Envoy ingress gateway on port 8080. The gateway evaluates the X-Spatial-Domain, X-Query-Weight, and X-Vector-Schema headers against its v3 route table, then dispatches to one of three bounded upstreams: the cadastral tile cluster for domain cadastral with a 2.5 second timeout, the async spatial worker cluster for heavy query weight with a 15 second timeout, and the standard geocode resolver. Each upstream edge passes through its own circuit breaker, shown as an open-switch glyph, with the cadastral cluster bounded at 500 connections and 300 pending, the async cluster at 200 connections and 100 pending, and the geocode resolver on a bounded pool. Ingress request + spatial headers + Idempotency-Key EPSG:4326 bbox Envoy ingress :8080 · v3 route table evaluates headers X-Spatial-Domain X-Query-Weight X-Vector-Schema 2.5s 15.0s Cadastral tile cluster X-Spatial-Domain: cadastral breaker 500 conn / 300 pend Async spatial worker X-Query-Weight: heavy breaker 200 conn / 100 pend Geocode resolver /v1/geocode/ · standard breaker · bounded pool circuit breakers

Prerequisites

Requirement Value / Assumption Notes
Gateway Envoy >= 1.27 (v3 xDS API) Static bootstrap or control-plane-managed
Orchestration kubectl against the geospatial-mesh namespace Rolling restart + config dump access
Inspection tools curl, jq Reads the admin :15000/config_dump endpoint
CRS contract EPSG:4326 ingress normalization; EPSG:3857 for tile delivery bbox validated before dispatch
Routing headers X-Spatial-Domain, X-Vector-Schema, X-Query-Weight, Idempotency-Key Authoritative routing vector
Access role mesh-routing-admin (RBAC) Required to mutate route tables
Dedup store Redis cluster reachable as redis-idem.internal:6379 TTL-bounded idempotency cache

Step-by-Step Implementation

Route resolution must decouple ingress evaluation from backend dispatch. Spatial context headers serve as the authoritative routing vector, and each step below is verifiable with a diagnostic command before you proceed to the next.

1. Define the deterministic route table

The following Envoy RouteConfiguration enforces header-based spatial routing, exact-match domain selection, and bounded circuit breakers. It is structured for Envoy v3 API compliance and idempotent deployment through a GitOps pipeline. Heavy payloads (X-Query-Weight: heavy) are split off to an asynchronous worker so they never contend with low-latency tile traffic — the same isolation principle described in Async Execution for Heavy Spatial Queries.

What each match key can and cannot express for a GIS endpointFour candidate route match keys compared. A URL path can express the endpoint but not the CRS or tile matrix set, and changing it breaks every bookmarked client. A hostname per domain works but multiplies certificates and DNS records. A query parameter is expressive but is frequently stripped by caches and proxies, which makes routing depend on intermediaries. A request header carries domain, CRS and matrix set together, survives caching, and is the only key that lets one path serve many domains without ambiguity.Expresses CRSSurvives cachesVerdictURL pathnoyesendpoint onlyHostnamenoyescertificate sprawlQuery parameteryesoften strippeddepends on proxiesRequest headeryesyesthe routing key

yaml
static_resources:
  listeners:
  - name: gis_ingress
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: gis_mesh
          access_log:
          - name: envoy.access_loggers.file
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
              path: /var/log/envoy/gis_access.log
              log_format:
                text_format: "[%START_TIME%] %REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL% %RESPONSE_CODE% %RESPONSE_FLAGS% %UPSTREAM_HOST% %REQ(X-SPATIAL-DOMAIN)% %REQ(X-QUERY-WEIGHT)%\n"
          route_config:
            name: spatial_routing
            virtual_hosts:
            - name: tile_vector_domains
              domains: ["*"]
              routes:
              - match:
                  prefix: "/v1/tiles/"
                  headers:
                  - name: "X-Spatial-Domain"
                    string_match:
                      exact: "cadastral"
                route:
                  cluster: cadastral_tile_cluster
                  timeout: 2.5s
                  retry_policy:
                    retry_on: "5xx,reset,connect-failure"
                    num_retries: 1
              - match:
                  prefix: "/v1/geocode/"
                  headers:
                  - name: "X-Query-Weight"
                    string_match:
                      safe_regex:
                        regex: "^heavy$"
                route:
                  cluster: async_spatial_worker_cluster
                  timeout: 15.0s
                  retry_policy:
                    retry_on: "5xx"
                    num_retries: 0
  clusters:
  - name: cadastral_tile_cluster
    connect_timeout: 1.0s
    circuit_breakers:
      thresholds:
      - max_connections: 500
        max_pending_requests: 300
        max_requests: 800
        max_retries: 2
    load_assignment:
      cluster_name: cadastral_tile_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: cadastral-gis.internal
                port_value: 443
  - name: async_spatial_worker_cluster
    connect_timeout: 2.0s
    circuit_breakers:
      thresholds:
      - max_connections: 200
        max_pending_requests: 100
        max_requests: 400
        max_retries: 0
    load_assignment:
      cluster_name: async_spatial_worker_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: spatial-async-worker.internal
                port_value: 8443

Envoy v3 uses string_match with an exact or safe_regex sub-field for header matching, not the older top-level exact_match / regex_match fields — a config that still uses the legacy fields will silently fail to match and fall through to a 404.

Verify: confirm the route table loaded exactly as committed before sending traffic.

bash
curl -s localhost:15000/config_dump | \
  jq '.configs[] | select(.["@type"] | test("RouteConfiguration")) |
      .. | .routes? // empty'

2. Validate spatial contracts at the edge

Spatial payloads require strict contract enforcement before dispatch. Add a pre-routing validation filter that rejects malformed X-Vector-Schema headers against the OGC-aligned tile/vector specification, and normalizes the bbox to EPSG:4326 so out-of-bounds requests are refused before they consume backend capacity. Edge validation here must agree with Schema Contracts for Vector/Tile Data so a payload that passes the gateway never gets rejected again downstream. Reference the OGC API - Tiles standard for tile matrix set identifiers and CRS validation.

Verify: an out-of-bounds request must be rejected at the gateway, not the backend.

bash
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "X-Spatial-Domain: cadastral" \
  -H "X-Vector-Schema: ogc-tiles/v1.2.0-crs:EPSG:4326-res:10m" \
  "http://localhost:8080/v1/tiles/?bbox=999,999,1000,1000"   # expect 400

3. Enforce idempotent execution

Idempotency is enforced through the Idempotency-Key header, derived from a normalized spatial query hash — SHA-256(bbox + crs + layer_id + timestamp_window). The gateway caches this key in the Redis cluster with a TTL matching the upstream query-execution window. A duplicate request inside the TTL returns the cached payload with 200 OK, preventing redundant compute and upstream saturation. Heavy queries route to async workers with num_retries: 0 so a slow upstream cannot trigger a retry storm — for the worker side of this contract, see Optimizing Async Execution for Spatial Joins.

How the gateway makes a retried write reach the backend exactly onceA client sends a write carrying an idempotency key. The gateway records the key as in-flight, forwards the request, and the backend commits. The response is stored against the key before it is returned. When the client retries after a network failure that lost the first response, the gateway finds the recorded result and replays it without forwarding anything, so the backend commits once regardless of how many times the client asks. The final exchange notes the failure this prevents: without the recorded key, the retry reaches the backend as a second, indistinguishable write.ClientGatewayDomain backendwrite + Idempotency-Keyforwarded oncecommittedRETRY, same keyresponse was lostrecorded result replayed

Verify: the same key replayed inside the TTL should hit cache, not the upstream.

bash
KEY=$(printf '%s' "bbox=-1.2,52.6,-1.0,52.8|crs=EPSG:4326|layer=parcels|tw=2026-06-26T10" | sha256sum | cut -d' ' -f1)
for i in 1 2; do
  curl -s -D - -o /dev/null \
    -H "X-Spatial-Domain: cadastral" \
    -H "Idempotency-Key: $KEY" \
    "http://localhost:8080/v1/tiles/parcels" | grep -i 'x-cache'
done   # request 2 should report x-cache: HIT

4. Wire telemetry to circuit-breaker state

Gateway telemetry must capture routing decisions, circuit-breaker transitions, and upstream failures. Enable Envoy’s upstream_rq_timeout and cluster.circuit_breakers metrics in Prometheus, then alert when circuit_breaker_open exceeds 3 consecutive windows or p99_latency breaches 2.2s on tile endpoints.

Verify: confirm the breaker gauges are exported per cluster.

bash
curl -s localhost:15000/stats/prometheus | \
  grep -E 'envoy_cluster_circuit_breakers_default_(cx|rq)_open'

5. Roll out and reconcile

When a control plane manages xDS, route changes propagate automatically; for static bootstrap configs, perform a rolling restart so no in-flight spatial query is dropped. Topology changes (new owning domains, moved endpoints) should arrive as event-driven signals through the Domain Sync Protocols for Spatial Data rather than manual edits, so route tables and tenant entitlements move atomically.

bash
kubectl rollout restart deploy/gis-ingress-gateway -n geospatial-mesh
kubectl rollout status  deploy/gis-ingress-gateway -n geospatial-mesh --timeout=120s

Configuration Reference

Field / Header Scope Required value Effect
X-Spatial-Domain Request header Exact owning domain, e.g. cadastral Selects the bounded upstream; case-sensitive exact match
X-Vector-Schema Request header ogc-tiles/v1.2.0-crs:EPSG:4326-res:10m Edge schema validation; rejected if malformed
X-Query-Weight Request header heavy | standard | degraded Diverts heavy payloads to async workers
Idempotency-Key Request header SHA-256(bbox+crs+layer_id+timestamp_window) Redis-cached dedup within TTL
route.timeout Tile route 2.5s Per-request budget for synchronous tiles
route.timeout Async route 15.0s Budget for heavy geocode/worker dispatch
retry_policy.num_retries Async route 0 Prevents retry storms on heavy compute
circuit_breakers.max_connections cadastral_tile_cluster 500 Upper bound on concurrent upstream conns
circuit_breakers.max_pending_requests cadastral_tile_cluster 300 Backpressure threshold before UO rejects
connect_timeout Tile cluster 1.0s Upstream TCP/TLS connect ceiling

Common Failure Modes & Fixes

Watch the exact RESPONSE_FLAGS token in /var/log/envoy/gis_access.log — it pins the failure to a specific layer.

RESPONSE_FLAGS=NR — no route matched (request 404s despite a valid backend). Root cause: X-Spatial-Domain casing or value does not exactly match the string_match.exact value, or the config still uses the legacy exact_match field. Fix: align the header to the route table and re-dump the config — curl -s localhost:15000/config_dump | jq '..|.string_match? // empty'.

RESPONSE_FLAGS=UF — upstream connection failure. Root cause: TLS handshake, health check, or DNS resolution failing for the owning host (e.g. cadastral-gis.internal). Fix: resolve and probe the endpoint directly — getent hosts cadastral-gis.internal && curl -kv https://cadastral-gis.internal:443/healthz — then correct that Envoy cluster’s socket_address.

RESPONSE_FLAGS=UT — upstream timeout (> 2.5s). Root cause: spatial query complexity, index fragmentation, or worker-thread exhaustion. Fix: reclassify the request with X-Query-Weight: heavy so it routes to async_spatial_worker_cluster; for genuinely heavy joins, follow the async materialization pattern instead of widening the synchronous timeout.

RESPONSE_FLAGS=UO — circuit breaker open. Root cause: concurrent requests exceeded max_connections / max_pending_requests. Fix: scale the backend horizontally and shed load with a Retry-After header; only raise max_pending_requests (by ~20%) as a temporary measure while the upstream scales, never as a permanent ceiling bump.

Idempotency-key collisions — distinct queries return a stale cached payload. Root cause: the timestamp_window component is too coarse, so two different queries hash to the same key. Fix: narrow the window granularity (e.g. 5-minute buckets) and confirm bbox + crs + layer_id are all included in the digest before the TTL is set.

When a domain misbehaves at scale, isolate it by injecting X-Spatial-Domain: quarantine to a shadow cluster, validate the upstream contract with a schema-registry diff, and — if the mesh partitions — reroute /v1/geocode/ to a read-only cached replica using the Fallback Chains for Geocoding Services pattern. Every routing change must pass pre-deployment validation against a staging mesh with synthetic spatial payloads, and every incident should close with a blameless review of header-contract drift and breaker-threshold calibration.

FAQ

Why use header-based routing instead of path-only routing for GIS endpoints?

Path prefixes (/v1/tiles/, /v1/geocode/) identify the operation, but a single operation can belong to multiple owning domains. The X-Spatial-Domain header carries ownership explicitly, so the gateway can route cadastral and hydrography tiles to different bounded backends without overlapping path tables. This prevents a misconfigured route from shadowing another domain’s prefix and silently misdirecting traffic.

How do I stop heavy spatial queries from starving low-latency tile traffic?

Tag heavy payloads with X-Query-Weight: heavy and route them to a dedicated async_spatial_worker_cluster with its own circuit-breaker thresholds and num_retries: 0. Because the heavy path has separate connection and pending-request budgets, a saturated join queue cannot exhaust the connection pool serving cadastral tiles.

What timestamp window should the Idempotency-Key hash use?

Match the window to your upstream query-execution time and acceptable staleness — typically 1 to 5 minutes for tile reads. Too coarse a window causes distinct queries to collide on one key (stale payloads); too fine a window defeats deduplication because near-simultaneous retries hash to different keys. Always include bbox, crs, and layer_id alongside the window in the digest.

My routes return 404 even though the backend is healthy — what is wrong?

The most common cause is an Envoy v3 config using the deprecated exact_match / regex_match header fields instead of string_match.exact / string_match.safe_regex. The legacy fields parse without error but never match, so every request falls through to the default 404. Dump the live config and confirm each header matcher uses string_match.

How are new endpoints added without manual route edits?

New owning domains and moved endpoints should arrive as event-driven topology signals through the domain sync layer, which updates the xDS route table atomically. With a control plane managing xDS, Envoy picks up the change without a restart; static bootstrap deployments require a rolling restart so in-flight queries drain cleanly.