Token Bucket Limits for OGC API Endpoints
Configuring a concrete, per-API-key token bucket for OGC API Tiles and Features so that a bounded burst is admitted, a sustained rate is capped, and a deep tile-pyramid crawl costs proportionally more tokens than a shallow overview read. This is the hands-on configuration behind the Rate Limiting Spatial API Traffic pattern inside the Federated Ownership & Routing Architecture: you attach an Envoy token-bucket filter keyed on the consumer’s API key, weight each request by its OGC API Tiles zoom level, and prove with curl that a client sees 200s while it has tokens and a clean 429 with Retry-After the moment its bucket empties. It reuses the tenant identity resolved by the API Gateway Mapping for GIS Services edge.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Gateway | Envoy >= 1.27 (v3 xDS API) |
Local or global rate-limit filter support |
| Global store | Redis reachable as redis-ratelimit.internal:6379 |
Only needed for exact cross-replica fairness |
| Orchestration | kubectl against the gateway namespace |
Config dump + rolling restart access |
| Inspection tools | curl, jq |
Reads admin :15000/stats and response headers |
| Consumer key | X-API-Key header on every request |
Descriptor key; missing → shared anonymous bucket |
| OGC API surface | Tiles /tiles/{tileMatrixSet}/{z}/{y}/{x}, Features /collections/*/items |
z drives token weight |
| CRS contract | EPSG:3857 for tiles, EPSG:4326 for feature bbox |
Unchanged by rate limiting |
| Access role | mesh-routing-admin (RBAC) |
Required to mutate rate-limit config |
Step-by-Step Implementation
Configure the bucket first, then layer zoom-weighting, then verify the 429 boundary with real requests. Each step ends with a verification command so you never advance on an unverified change.
1. Attach a per-API-key token bucket to the OGC API route
Define an Envoy local rate-limit filter whose descriptor is keyed on X-API-Key, so each consumer draws from its own bucket. The bucket admits a 600-token burst and refills 100 tokens every 6s for a sustained ceiling near 1000/min — matched to interactive tile reads, where isolating a bulk crawler is the goal, the same principle as Async Execution for Heavy Spatial Queries.
# ogc-tiles route with a per-consumer token bucket. Rejections emit 429, and
# the descriptor keys on X-API-Key so one crawler cannot drain another's bucket.
name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: ogc_tiles_rl
token_bucket:
max_tokens: 600
tokens_per_fill: 100
fill_interval: 6s
filter_enabled: { default_value: { numerator: 100, denominator: HUNDRED } }
filter_enforced: { default_value: { numerator: 100, denominator: HUNDRED } }
descriptors:
- entries:
- key: consumer
value: per-api-key
token_bucket: # per-consumer override of the default bucket
max_tokens: 600
tokens_per_fill: 100
fill_interval: 6s
status: { code: TooManyRequests }
Verify: confirm the filter loaded and the bucket is defined as committed.
curl -s localhost:15000/config_dump | \
jq '.. | select(.stat_prefix? == "ogc_tiles_rl") | .token_bucket'
# expect max_tokens 600, tokens_per_fill 100, fill_interval 6s
2. Weight requests by tile-pyramid depth
A flat cost lets a deep-zoom crawl drain the same as an overview, which does not reflect backend cost. Assign a higher token cost to deeper OGC API Tiles zoom levels by matching the {z} path segment and applying a hits_addend, so a level-18 tile consumes more of the bucket than a level-6 tile.
# EnvoyFilter: charge deeper zoom levels more tokens. z >= 15 costs 4 tokens,
# z 10-14 costs 2, shallower costs the default 1 — encoding real tile cost.
rate_limits:
- actions:
- request_headers: { header_name: "x-api-key", descriptor_key: "consumer" }
hits_addend:
format: "%DYNAMIC_METADATA(envoy.lua:tile_cost)%" # 1, 2, or 4 by zoom
-- envoy.lua: derive a token cost from the {z} segment of the OGC Tiles path.
function envoy_on_request(handle)
local path = handle:headers():get(":path")
local z = tonumber(path:match("/tiles/[^/]+/(%d+)/")) or 0
local cost = 1
if z >= 15 then cost = 4 elseif z >= 10 then cost = 2 end
handle:streamInfo():dynamicMetadata():set("envoy.lua", "tile_cost", cost)
end
Verify: a deep-zoom request must decrement the bucket faster than a shallow one.
curl -s localhost:15000/stats | grep 'ogc_tiles_rl.*remaining_tokens'
# note the value, request one z=18 tile, then re-read: it should drop by ~4
3. Return a correct 429 with Retry-After on exhaustion
When the bucket empties, the client must receive 429 and a Retry-After telling it exactly how long until a token refills, so it backs off deterministically instead of hot-looping. Add the header to the rate-limited response.
# On a rate-limited response, add Retry-After = refill interval (6s) so the
# consumer waits the right amount before retrying, per the parent pattern.
response_headers_to_add:
- append_action: OVERWRITE_IF_EXISTS_OR_ADD
header: { key: "retry-after", value: "6" }
- append_action: OVERWRITE_IF_EXISTS_OR_ADD
header: { key: "x-ratelimit-policy", value: "ogc-tiles-per-key" }
Verify: drain the bucket and confirm the reject carries 429 and Retry-After.
for i in $(seq 1 700); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-API-Key: consumer-a" \
"http://localhost:8080/tiles/WebMercatorQuad/6/20/33" ; \
done | sort | uniq -c
# expect a block of 200 then 429 once ~600 tokens are spent
curl -s -D - -o /dev/null -H "X-API-Key: consumer-a" \
"http://localhost:8080/tiles/WebMercatorQuad/6/20/33" | grep -i 'retry-after'
# expect: retry-after: 6
4. Roll out and confirm per-key isolation
Apply the config and prove that draining one consumer’s bucket does not throttle another — the core fairness guarantee. A second API key must still receive 200s while the first is being rejected.
kubectl rollout restart deploy/gis-ingress-gateway -n gateway
kubectl rollout status deploy/gis-ingress-gateway -n gateway --timeout=120s
Verify: consumer B is unaffected while consumer A is throttled.
# after consumer-a is exhausted from step 3:
curl -s -o /dev/null -w "A=%{http_code}\n" -H "X-API-Key: consumer-a" \
"http://localhost:8080/tiles/WebMercatorQuad/6/20/33" # expect 429
curl -s -o /dev/null -w "B=%{http_code}\n" -H "X-API-Key: consumer-b" \
"http://localhost:8080/tiles/WebMercatorQuad/6/20/33" # expect 200
Configuration Reference
| Field | Scope | Required value | Effect |
|---|---|---|---|
token_bucket.max_tokens |
Bucket | 600 |
Burst ceiling per consumer |
token_bucket.tokens_per_fill |
Bucket | 100 |
Tokens restored each interval |
token_bucket.fill_interval |
Bucket | 6s |
Refill period; sets sustained rate ~1000/min |
descriptors[].entries.key |
Descriptor | consumer |
Keys the bucket to X-API-Key |
hits_addend |
Route rate limit | 1 | 2 | 4 by zoom |
Deep-zoom tiles cost more tokens |
status.code |
Filter | TooManyRequests |
Emit 429, not 503, on exhaustion |
retry-after header |
Response | 6 (= fill_interval) |
Tells the client when to retry |
X-API-Key |
Request header | Consumer identity | Missing → shared anonymous bucket |
Common Failure Modes & Fixes
Every consumer shares one bucket — one crawler throttles all clients.
Root cause: requests arrive without X-API-Key, so the descriptor collapses to a single anonymous key. Fix: require the header at the gateway and reject unauthenticated calls before the limiter — confirm with curl -s localhost:15000/config_dump | jq '..|.descriptors? // empty'.
Deep-zoom crawl still saturates the tile backend despite a limit.
Root cause: hits_addend is not wired, so every request costs 1 regardless of zoom and a level-18 crawl drains like an overview. Fix: confirm the Lua metadata is set and referenced — curl -s localhost:15000/stats | grep ogc_tiles_rl.*over_limit should climb faster under deep-zoom load.
Rejections return 503 instead of 429.
Root cause: status.code is unset (defaults vary) or the limiter is failing closed on a global-store outage. Fix: set status: { code: TooManyRequests } explicitly and confirm the read route’s fail mode is fail-open for the global limiter.
429 returned but no Retry-After, so clients retry immediately.
Root cause: response_headers_to_add was omitted, so consumers cannot compute a backoff and hot-loop the edge. Fix: add the retry-after header equal to fill_interval and verify with curl -s -D -.
Local limit lets far more traffic through than configured.
Root cause: the limit is per-proxy and multiplies by replica count. Fix: divide max_tokens by replica count for a rough local cap, or move to the global Redis-backed rate-limit service for exact fairness.
FAQ
How do I pick max_tokens and fill_interval for an OGC API Tiles endpoint?
Set fill_interval and tokens_per_fill from the sustained rate a single consumer should hold — here 100 tokens per 6s is roughly 1000/min — then set max_tokens to the largest legitimate burst, such as a full viewport of tiles on a fast pan. Too small a max_tokens rejects normal interactive panning; too large a value stops protecting the backend. Tune max_tokens against a synthetic viewport-load test, not a guess.
Why weight tokens by zoom level instead of counting requests equally?
A single deep-zoom tile can be far more expensive to generate and serve than an overview tile, and a crawl into level 18 fans out into vastly more tiles per map area. Charging 4 tokens for z >= 15 and 2 for z 10-14 makes the bucket drain in proportion to real backend cost, so a deep crawl is throttled long before it saturates the tile engine while shallow interactive reads stay cheap.
Should the Retry-After value always equal the fill interval?
It should reflect the actual time until at least one token — including the request’s token cost — is available. For a flat 1-token cost that is one fill_interval; for a 4-token deep-zoom request that was rejected, a correct Retry-After may be longer. Returning at least the fill_interval is a safe floor that prevents immediate hot-loop retries.
Local or global token buckets for OGC API endpoints?
Use local buckets when approximate per-proxy fairness is acceptable and you want zero external dependency on the hot path — remembering the effective limit multiplies by replica count. Use the global Redis-backed service when a consumer’s quota must be enforced exactly across every ingress replica, accepting one Redis round-trip per request. A common setup is a cheap local limit to shed obvious abuse, backed by a global limit for precise per-key quotas.
Does token-bucket rate limiting change the CRS or bbox handling of the request?
No. The token bucket operates on the request’s API key and path (for zoom weighting) only; it never inspects or rewrites the geometry payload. EPSG:3857 tile delivery and EPSG:4326 feature bbox validation happen in the endpoint’s own contract enforcement, exactly as they would without any limiter attached.
Related
- Rate Limiting Spatial API Traffic — the parent pattern this configuration implements
- API Gateway Mapping for GIS Services — the ingress that resolves the API key the bucket is keyed on
- Async Execution for Heavy Spatial Queries — isolating heavy workloads that a token bucket alone cannot absorb
- Zero-Trust Security for Spatial Endpoints — the identity control that pairs with per-key quotas
- Federated Ownership & Routing Architecture — up one level to the routing architecture overview