Rate Limiting Spatial API Traffic
Protecting shared OGC API and tile endpoints with token-bucket and quota limits applied per consumer and per domain at the ingress, so one heavy tile-pyramid crawl can never starve the interactive map next to it. In a federated geospatial estate the load profile is deeply uneven: a single client requesting a deep zoom level can fan out into thousands of tile reads in seconds, while a feature query returns once and stops. Without fairness controls at the edge, that asymmetry turns a legitimate bulk consumer into an accidental denial-of-service against every other tenant sharing the raster domain. This page sits within the Federated Ownership & Routing Architecture and treats rate limiting as a first-class routing concern — a decision the Envoy or Istio ingress makes before a request reaches a backend, using the same tenant identity that API Gateway Mapping for GIS Services resolves. It is the natural quota companion to the workload-isolation discipline behind Async Execution for Heavy Spatial Queries.
Figure — Each consumer draws from its own token bucket at ingress; a request with tokens available is admitted, an empty bucket returns 429 with Retry-After.
Architectural Boundaries & Design Rationale
Rate limiting belongs at the ingress, before routing dispatches a request to an owning domain, because that is the only place with a complete view of a consumer’s cross-domain load. If each backend enforced its own quota in isolation, a consumer could still exhaust shared infrastructure — the tile cache, the connection pool, the object-store egress — by spreading requests across domains that never compare notes. Placing the token bucket at the edge, keyed by API key and tenant, means the decision is made once with full context and the backend is protected from load it would otherwise have to absorb and reject itself. This is the same edge where the API Gateway Mapping for GIS Services already resolves tenant identity, so the limiter reuses that identity rather than re-deriving it.
The token-bucket model fits spatial traffic better than a fixed window because spatial load is bursty by nature. A user panning a map generates a short burst of tile reads, then idles; a fixed per-second window either rejects the legitimate burst or is set so high it never protects anything. A token bucket allows a bounded burst (the bucket capacity) while capping the sustained rate (the refill rate), which matches interactive map behaviour precisely. The pattern also lets deeper zoom levels cost more tokens than shallow ones, so a tile-pyramid crawl into level 18 drains its bucket far faster than a level-6 overview — encoding the real backend cost of the request into the fairness decision. The finest-grained version of this weighting is worked through in Token Bucket Limits for OGC API Endpoints.
Enforcement can be local or global, and the choice is a boundary decision:
- Local (per-proxy) limits — each ingress replica holds its own bucket; cheap, no external dependency, but the effective limit multiplies by replica count.
- Global (shared) limits — a rate-limit service backed by Redis holds one authoritative bucket per consumer across all replicas; exact fairness, at the cost of a lookup per request.
- Tiered composition — a cheap local limit sheds obvious abuse before it reaches the global service, which enforces the precise per-tenant quota.
Because the limit is evaluated per consumer, a saturated bulk crawler is throttled without touching the interactive consumer beside it — the same isolation principle that keeps heavy joins off the low-latency path in Async Execution for Heavy Spatial Queries.
Specification & Contract Reference
A rate-limited endpoint publishes an explicit contract: what a token costs, how fast the bucket refills, and exactly what a rejected consumer receives so it can back off correctly. The surface below is what the ingress enforces before dispatch.
| Field / Parameter | Scope | Required | Constraint / Default |
|---|---|---|---|
| Bucket key | Descriptor | Yes | (tenant, api_key, domain) tuple; missing key → anonymous low quota |
max_tokens |
Bucket capacity | Yes | Burst ceiling; e.g. 600 for tile reads |
tokens_per_fill |
Refill amount | Yes | Sustained-rate numerator |
fill_interval |
Refill period | Yes | Sustained-rate denominator; e.g. 60s |
| Token cost | Per request | Yes | 1 default; scales with tile zoom depth |
429 status |
Reject response | Yes | Returned at edge; backend never contacted |
Retry-After |
Reject header | Yes | Seconds until the bucket has ≥ 1 token |
X-RateLimit-Remaining |
Response header | Recommended | Tokens left in the consumer’s bucket |
| Fail mode | Limiter outage | Yes | fail-open for reads by default; configurable per domain |
Two semantics are non-negotiable. First, a rejected request returns 429 Too Many Requests with a Retry-After header — never a 503 and never a silent drop — so a well-behaved consumer knows precisely how long to wait and does not hammer the endpoint with immediate retries. Second, the limiter’s fail mode is explicit: if the global rate-limit service is unreachable, read endpoints fail open (serve rather than block) by default to preserve availability, while write or high-cost endpoints may fail closed; the choice is configured per domain rather than left implicit. These pair directly with the identity checks in Zero-Trust Security for Spatial Endpoints: identity says who the consumer is, the quota says how much that consumer may draw.
Production Implementation
Rate limits are configured declaratively and shipped through GitOps so a consumer’s quota is versioned alongside the routes it governs. The example below is an Envoy local rate-limit filter that admits a bounded burst of tile requests per consumer and rejects the rest with a correct 429 and Retry-After. The 429 semantics and the Retry-After header are called out explicitly, because a rejected spatial consumer that cannot compute a backoff will retry immediately and amplify the very saturation the limiter exists to prevent.
# Envoy local rate limit on the tile route: 600-token burst, refilling 100
# tokens every 6s (~1000/min sustained). Rejections return 429 + Retry-After so
# a bulk crawler backs off deterministically instead of hot-looping the edge.
name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: tile_ratelimit
token_bucket:
max_tokens: 600 # burst ceiling per consumer
tokens_per_fill: 100 # refill amount ...
fill_interval: 6s # ... every 6s -> sustained ~1000/min
filter_enabled:
default_value: { numerator: 100, denominator: HUNDRED }
filter_enforced:
default_value: { numerator: 100, denominator: HUNDRED }
response_headers_to_add:
- append_action: OVERWRITE_IF_EXISTS_OR_ADD
header: { key: "x-ratelimit-policy", value: "tile-per-consumer" }
status:
code: TooManyRequests # emit 429, not 503, on bucket exhaustion
local_rate_limit_per_downstream_connection: false
For exact cross-replica fairness, the same policy is expressed as a global limit backed by a shared Redis-based rate-limit service, so a consumer’s bucket is authoritative no matter which ingress replica it hits. The Istio EnvoyFilter below attaches the global rate-limit action and keys the descriptor on the consumer’s API key and the owning domain.
# Istio EnvoyFilter: attach a GLOBAL rate-limit descriptor keyed on the
# consumer API key + spatial domain, so the limit is exact across all replicas.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: tile-global-ratelimit
namespace: gateway
spec:
configPatches:
- applyTo: VIRTUAL_HOST
match:
context: GATEWAY
patch:
operation: MERGE
value:
rate_limits:
- actions:
- request_headers:
header_name: "x-api-key"
descriptor_key: "consumer"
- request_headers:
header_name: "x-spatial-domain"
descriptor_key: "domain"
Both manifests are policy-as-code: reviewed, promoted through CI that validates the descriptors against a staging mesh with synthetic tile-pyramid load, and rolled out only after a canary confirms legitimate interactive consumers stay under their limit while a simulated crawl is correctly shed with 429s.
Diagnostic Runbook
When a spatial consumer reports throttling — or a backend is still overloaded despite a configured limit — the fault is almost always the descriptor key, the token cost, or the fail mode, not the network. Work the steps in order.
- Read the reject response. Confirm rejections are
429with aRetry-Afterheader, not503. A503means the limiter is failing closed or the backend is genuinely down — a different problem entirely. - Confirm the descriptor key. Verify the request carries the
x-api-keyandx-spatial-domainheaders the descriptor expects. A missing key collapses every consumer into one shared anonymous bucket, so one crawler throttles everyone. - Inspect the bucket counters. Read the ingress rate-limit stats and confirm the consumer’s bucket is actually draining as expected; a bucket that never empties points to a token cost of zero or a filter that is enabled but not enforced.
- Check local-vs-global effective limit. For local limits, multiply the configured rate by the replica count — the effective ceiling may be far higher than intended. Switch to the global service if exact fairness is required.
- Verify tile-weight accounting. If deep-zoom crawls still saturate the backend, confirm the per-request token cost scales with zoom depth; a flat cost of
1lets a level-18 crawl drain the same as a level-6 overview. - Validate the fail mode. Take the global rate-limit service offline in staging and confirm read endpoints fail open and write endpoints fail closed exactly as configured, so an outage never silently removes all protection.
- Correlate with the trace. Tie a sampled
429to itstrace_idand confirm the backend was never contacted — a rejected request must be shed at the edge, not after it consumed a connection.
SLA Targets & Performance Baselines
The limiter must add negligible, predictable overhead while holding fairness under bursty spatial load. The budgets below are what the rate-limit layer must meet.
| Metric | Target | Alert Threshold | Remediation Action |
|---|---|---|---|
| Rate-limit decision latency | < 2ms p99 (local) |
> 8ms p99 for 5m |
Prefer local tier; check Redis round-trip for global |
| Global limiter availability | > 99.99% |
< 99.95% |
Fail-open reads; scale rate-limit service replicas |
429 correctness |
100% carry Retry-After |
any 429 without header |
Fix filter status/header config |
| False-throttle rate | < 0.1% of legit traffic |
> 1% for 5m |
Raise burst ceiling; re-check descriptor key |
| Backend saturation under crawl | < 70% pool utilization |
> 90% sustained |
Increase deep-zoom token cost; tighten sustained rate |
| Quota sync drift (global) | < 1s |
> 10s |
Reconcile Redis; check rate-limit service GC pauses |
Holding these baselines depends on keeping the hot path local where exact fairness is not required and reserving the global Redis-backed service for tenants whose quotas must be enforced to the token. Retry-After values should reflect the real refill interval so consumers back off by exactly the right amount rather than guessing.
Governance & Compliance Notes
Quotas are contractual, so the ingress treats every limit as an auditable artifact. Each consumer’s max_tokens, refill rate, and per-zoom token cost is committed to source control and promoted only after CI validates it against synthetic load, giving a reviewer a precise record of what each tenant was entitled to draw at any point in time. Throttle events — the consumer, the descriptor, the Retry-After issued — are written to append-only, tenant-partitioned streams and carry the W3C Trace-Context injected at ingress, so a disputed throttle can be replayed against the exact policy version in force. This keeps the quota surface aligned with the governed data products described in Metadata Cataloging for Raster/Vector, where a published tile product carries its consumption limits as part of its documented SLA.
Fairness itself is a governance guarantee. Because limits are per consumer and per domain, no single tenant’s bulk workload can degrade another’s contracted availability, and the explicit fail mode ensures a limiter outage degrades predictably rather than silently removing all protection or blocking every read. Where a tenant negotiates a higher quota, the change is a reviewed, versioned bump to its bucket descriptor — never an ad-hoc edit — so the entitlement history is as auditable as the schema and identity contracts enforced at the same edge.
Related
- Up to the parent: Federated Ownership & Routing Architecture
- Token Bucket Limits for OGC API Endpoints — the concrete per-key, zoom-weighted bucket configuration
- API Gateway Mapping for GIS Services — the ingress that resolves the tenant identity the limiter keys on
- Async Execution for Heavy Spatial Queries — isolating heavy workloads so they never starve interactive traffic
- Zero-Trust Security for Spatial Endpoints — identity control that pairs with quota at the same edge