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.
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.
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.
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.
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.
Verify: the same key replayed inside the TTL should hit cache, not the upstream.
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.
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.
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.
Related
- API Gateway Mapping for GIS Services — the parent reference for ingress-to-domain mapping.
- Cross-Domain Routing Strategies — how requests fan out once a domain is resolved.
- Optimizing Async Execution for Spatial Joins — the worker side of the heavy-query path.
- Building Fallback Chains for Routing APIs — degradation paths when an upstream domain is unavailable.
- Federated Ownership & Routing Architecture — up one level to the routing architecture overview.