Throttling WFS GetFeature with Envoy Rate Limits
This guide applies per-tenant, cost-weighted rate limits to WFS GetFeature traffic using Envoy and a global rate-limit service, so that feature extraction is bounded independently of tile traffic. It sits under API Gateway, Rate Limiting & Quota Enforcement, part of the Core Portal Architecture & Security Boundaries framework.
Prerequisites
- Envoy 1.27 or newer as the ingress proxy, with access to edit its listener and route configuration.
- A global rate-limit service reachable over gRPC, backed by Redis. Envoy’s own reference implementation is the usual choice.
- An authentication filter ahead of the rate-limit filter that emits a tenant header, as set up in configuring a Keycloak realm for GeoNode SSO.
- A WFS endpoint behind Envoy, and a staging route to test against.
Why feature traffic needs its own limit
Tile requests and feature requests consume different resources, and a single limit sized for one is wrong for the other. Tiles are absorbed by a cache and, on a miss, cost renderer CPU. Feature queries usually bypass the cache entirely — they carry filters, bounding boxes and property selections that make each response unique — so every one of them reaches the spatial database. A tenant issuing feature queries at the rate you happily permit for tiles is issuing database queries at that rate.
Step-by-step implementation
1. Derive rate-limit descriptors from the request
Envoy builds a list of descriptors per request and asks the rate-limit service whether they are within budget. The descriptors are where the policy lives: include the tenant so budgets are per tenant, and include the operation so features and tiles draw on separate budgets.
# envoy: route configuration for the OGC virtual host
route_config:
name: ogc
virtual_hosts:
- name: ogc
domains: ["portal.example.gov"]
rate_limits:
# Feature reads: one descriptor pair, tenant plus operation.
- actions:
- request_headers:
header_name: "x-tenant-id"
descriptor_key: "tenant"
- request_headers:
header_name: "x-ogc-operation" # set by a lua filter from ?request=
descriptor_key: "operation"
# Skip this policy when the header is absent rather than limiting globally.
limit:
dynamic_metadata:
metadata_key:
key: envoy.filters.http.ratelimit
routes:
- match: { prefix: "/geoserver/wfs" }
route:
cluster: geoserver
timeout: 120s # feature queries are legitimately slow
retry_policy:
num_retries: 0 # never retry an expensive query automatically
Setting num_retries: 0 on this route matters as much as the limit itself. A retried feature query doubles the database work for a request the client has often already abandoned, and it is a common reason a throttled tenant still manages to saturate the backend.
2. Express the budgets in the rate-limit service
The service configuration is where each tenant’s allowance is stated. Keep the descriptor tree shallow — tenant, then operation — so the policy stays readable and one lookup answers it.
# ratelimit config: ogc.yaml
domain: ogc
descriptors:
- key: tenant
value: tenant-highways # contractual bulk consumer
descriptors:
- key: operation
value: GetFeature
rate_limit: { unit: minute, requests_per_unit: 240 }
- key: operation
value: Transaction
rate_limit: { unit: minute, requests_per_unit: 60 }
- key: tenant
value: tenant-planning
descriptors:
- key: operation
value: GetFeature
rate_limit: { unit: minute, requests_per_unit: 60 }
# Default for any tenant without a specific agreement.
- key: tenant
descriptors:
- key: operation
value: GetFeature
rate_limit: { unit: minute, requests_per_unit: 30 }
- key: operation
value: Transaction
rate_limit: { unit: minute, requests_per_unit: 10 }
3. Charge expensive queries more than one unit
Envoy can consume more than one token per request through hits_addend, which is how a request count becomes a cost count. Compute the addend from the properties that actually drive cost — an absent count limit, a large bounding box, a request for all properties — and a single unbounded extraction consumes a meaningful share of the minute’s budget rather than one thirtieth of it.
-- envoy lua filter: charge feature queries by their shape
function envoy_on_request(handle)
local q = handle:streamInfo():dynamicMetadata()
local path = handle:headers():get(":path") or ""
local addend = 1
-- No count/maxFeatures cap: this may return the whole layer.
if not string.find(string.lower(path), "count=") and
not string.find(string.lower(path), "maxfeatures=") then
addend = addend + 20
end
-- resultType=hits is cheap; a full geometry response is not.
if string.find(string.lower(path), "resulttype=hits") then
addend = 1
end
-- Property selection reduces serialisation cost substantially.
if string.find(string.lower(path), "propertyname=") then
addend = math.max(1, addend - 5)
end
handle:headers():add("x-ratelimit-hits-addend", tostring(addend))
end
4. Return a useful rejection
Envoy’s default 429 body is empty. Replace it with something a client can act on, and make sure the response advertises the remaining budget so a careful client never reaches the limit.
http_filters:
- name: envoy.filters.http.ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
domain: ogc
failure_mode_deny: false # a rate-limiter outage must not take the portal down
enable_x_ratelimit_headers: DRAFT_VERSION_03
rate_limited_as_resource_exhausted: false
timeout: 0.05s
rate_limit_service:
grpc_service:
envoy_grpc: { cluster_name: ratelimit }
transport_api_version: V3
failure_mode_deny: false is a deliberate choice worth stating: if the rate-limit service is unreachable, requests pass unlimited rather than being rejected. For a public portal that is the right default — an outage of an auxiliary control should not become an outage of the service — but it means the rate-limit service needs its own alert, since its failure is otherwise invisible.
Where the limit sits relative to authentication
Filter order decides whether the limit is cheap and whether it can be keyed on a tenant at all. Envoy evaluates filters in the order they are declared, and the rate-limit filter can only use a header that an earlier filter has produced.
Verify the order from the running configuration rather than from the file, since a merged or templated configuration can reorder filters in ways the source does not make obvious.
Verification
# 1. The rate-limit service answers, and Envoy is configured to ask it
curl -s http://envoy-admin:9901/stats | grep -E 'ratelimit\.(ok|over_limit|error)'
# expect: ratelimit.ok climbing during traffic, ratelimit.error at 0
# 2. A bounded query is permitted at the expected rate
for i in $(seq 1 40); do
curl -s -o /dev/null -w '%{http_code} ' -H "Authorization: Bearer $TOKEN" \
"https://portal.example.gov/geoserver/wfs?service=WFS&version=2.0.0&request=GetFeature&typeNames=parcels&count=100&propertyName=id,geom"
done; echo
# expect: all 200 within a standard tenant's per-minute allowance
# 3. An unbounded query exhausts the budget in a handful of calls
for i in $(seq 1 5); do
curl -s -o /dev/null -w '%{http_code} ' -H "Authorization: Bearer $TOKEN" \
"https://portal.example.gov/geoserver/wfs?service=WFS&version=2.0.0&request=GetFeature&typeNames=parcels"
done; echo
# expect: 200 200 200 429 429 — the addend is being applied
# 4. Budget headers are present so a client can pace itself
curl -sI -H "Authorization: Bearer $TOKEN" \
"https://portal.example.gov/geoserver/wfs?service=WFS&version=2.0.0&request=GetCapabilities" \
| grep -i ratelimit
# expect: ratelimit-limit, ratelimit-remaining, ratelimit-reset
# 5. Tile traffic is unaffected by an exhausted feature budget
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
"https://portal.example.gov/geoserver/wms?service=WMS&request=GetMap&layers=base&bbox=0,0,1,1&width=256&height=256&format=image/png"
# expect: 200 — separate descriptors, separate budgets
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Every request is limited under one shared budget | The tenant header is missing, so all requests share a descriptor with no value | Make the tenant header required on this route; log its absence |
| Limits have no effect at all | Descriptor keys in the route do not match the service configuration | Compare the names exactly; a typo silently produces no match |
| The portal returns 429 for everything after a Redis restart | failure_mode_deny left at true while the service was unavailable |
Set it to false and alert on ratelimit.error instead |
| Unbounded queries still pass freely | The addend header is not wired to hits_addend, so every request costs one |
Confirm the filter order: the Lua filter must run before the rate-limit filter |
| Slow feature queries time out at the proxy while the database keeps working | Route timeout shorter than the query, with no cancellation | Raise the route timeout for feature paths and cap the query server-side |
| A tenant reports intermittent 429s at a steady request rate | Fixed-window boundary effects at the service | Use a shorter unit with a proportional allowance, smoothing the boundary |
| Budget headers absent from responses | enable_x_ratelimit_headers not set |
Enable the draft headers so clients can self-pace |
FAQ
Why not simply cap the number of features a query may return?
Do both — they solve different halves. A server-side feature cap bounds the response, which protects serialisation and transfer, and it is the right default. It does not bound the work needed to find those features: a filter over an unindexed attribute across a national dataset can scan everything and then return ten rows. The rate limit bounds how often such a query may be issued.
Is a global rate-limit service worth the extra component?
It is when the gateway runs as more than one instance, which is almost always. Per-instance limits divide the intended allowance by however many instances happen to be running, so the effective limit changes whenever the deployment scales — including during an incident, in the wrong direction.
How should the cost addend be chosen?
From the properties that predict cost, not from an attempt at precision. Absent count limits, absent property selection and large extents are strong predictors and are all visible in the request. Getting the relative order right matters; getting the absolute values right does not, because the budget is a policy choice rather than a measurement.
What about clients that cannot handle a 429?
They exist, particularly older desktop GIS, and the mitigation is to keep limits high enough that a correct client never reaches them and to give known integrations their own tier. Where a client genuinely cannot cope, a slower response is often kinder than a rejection — but queueing expensive queries has its own cost, so use it sparingly and never for write operations.
Should Transaction requests share the read budget?
No. Writes are rare, expensive, and consequential, and a runaway editing client that exhausts the read budget takes the public map down with it. Give transactions their own small allowance, and keep it small enough that an accidental loop is bounded well before it becomes an incident.
Related
- Enforcing Per-Tenant WMS Request Quotas with Nginx — the same policy at the edge for tile traffic.
- Shielding GeoServer from Oversized BBOX Requests — rejecting by request shape rather than by rate.
- Optimizing PostgreSQL/PostGIS Connection Limits — the database ceiling these limits exist to protect.
Up one level: API Gateway, Rate Limiting & Quota Enforcement.