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.

yaml
# 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.

bash
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.

yaml
# 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
lua
-- 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.

bash
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.

yaml
# 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.

bash
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.

bash
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.

bash
# 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.