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.
Cost-Weighted Limits, Because Spatial Requests Are Not Interchangeable
Conventional rate limiting counts requests, which works when requests cost roughly the same. Spatial requests do not: a zoom-3 tile covering a continent and a zoom-18 tile covering a street corner are one request each and differ in server cost by orders of magnitude, and a bbox query over a whole country and one over a city block differ similarly. A limit expressed in requests per second is therefore a limit on nothing in particular — it permits a client to consume an unbounded share of capacity while remaining perfectly compliant.
The correction is to charge requests tokens proportional to their estimated cost, and to make that estimate from properties available before execution. Three inputs cover most of the space: the area of the requested extent, the zoom or resolution requested, and the number of layers or joins involved. A request’s token cost is then a declared function of those, published alongside the limit so consumers can predict their own consumption rather than discovering it.
| Request | Requests-per-second view | Cost-weighted view |
|---|---|---|
| Zoom-18 tile | 1 | 1 token |
| Zoom-10 tile | 1 | 8 tokens |
| Zoom-3 tile | 1 | 64 tokens |
| Point feature lookup | 1 | 1 token |
| Bbox query, city block | 1 | 2 tokens |
| Bbox query, national extent | 1 | 240 tokens |
| Cross-domain join | 1 | Refused — async path |
Reading the table’s last row: some requests should not have a price at all on the synchronous path. Cost weighting is a mechanism for allocating bounded capacity fairly, not a mechanism for admitting unbounded work slowly, and a request whose cost cannot be estimated before execution belongs on the async path regardless of how many tokens the caller holds.
Two implementation points decide whether this is workable. First, the cost function must be cheap and deterministic, computed from the request alone without touching the data — an estimate that requires a query has already spent the resource it was protecting. Second, the cost must be visible in the response, as a header stating tokens charged and tokens remaining. Without it, a client cannot tell an expensive request from a cheap one, cannot pace itself, and will discover the limit by being throttled — which is the outcome the limit exists to make unnecessary.
Refusal semantics matter as much as the limit. A 429 should carry a Retry-After derived from the actual refill schedule rather than a constant, and it should distinguish exhausting a quota from exceeding a burst — the first needs the consumer to slow down over hours, the second only over seconds, and a single undifferentiated response teaches clients to retry at the wrong cadence for one of the two.
Limits should also be attributed to the resolved domain rather than to the estate, which is why rate limiting sits after domain resolution in the routing order. A single shared bucket lets a burst against one domain consume the headroom every other domain depends on, and the resulting throttling appears, to the affected consumers, as an unexplained failure originating somewhere they have no visibility into.
Publishing the cost function alongside the limit is what makes the whole scheme cooperative rather than adversarial. A consumer that can compute a request’s token cost before issuing it will restructure its access pattern to fit — batching, requesting a coarser zoom, or moving to a bulk port — because doing so is now visibly in its own interest. A consumer that can only discover cost by being throttled has no such signal and will simply retry, which is the behaviour the limit was meant to prevent.
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