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.

Where tile and feature requests actually land Two rows, tiles and features, each showing what proportion is absorbed by the cache, the renderer and the database, and how variable the response size is. Tile requests absorbed by the cache renderer database response size is uniform and small, so a request count is a fair proxy for cost Feature requests cache straight to the database — each query is unique response size ranges from kilobytes to gigabytes with the filter, so a request count is not a proxy for cost consequence: features need their own budget, and the budget should count more than requests

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
One minute of budget, spent three different ways Three query shapes drawn against the same sixty-unit budget, showing how many of each a tenant may issue before the budget is exhausted. A 60-unit minute, spent three ways bounded, properties selected 60 queries 1 unit each hits only, no geometry 60 queries 1 unit each — counting is cheap unbounded, all properties 2 queries 21 units each — the shape you want callers to avoid the budget is a cost budget, not a call count

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.

Two limits, one before authentication and one after A filter chain showing a coarse address-keyed limit, then authentication, then the tenant-keyed limit, with what each stage can know. coarse limit keyed on the address rejects before any cost authentication validates the token, emits the tenant header tenant-keyed limit the policy that matters, with the cost addend Declaring the rate-limit filter before the authentication filter is the usual mistake: the tenant header does not exist yet, so every request falls into one shared descriptor and the per-tenant policy silently becomes a global one.

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.

Up one level: API Gateway, Rate Limiting & Quota Enforcement.