Rate Limiting OGC Requests With a Python Reverse Proxy

Parse SERVICE and REQUEST, reject anything over a pixel or feature cap with an OGC ServiceException, charge a token bucket keyed on identity, coalesce identical in-flight requests onto one upstream call, and forward the rest. Every check is cheaper than the work it prevents, which is the whole reason the gate sits in front of the renderer rather than inside it.

The Core Challenge: The Expensive Decision Happens Too Late

GeoServer evaluates its own security and limits after it has accepted the connection, parsed the request and begun planning work. That is fine for authorisation, which is a policy question, and wrong for cost, which is a capacity question — by the time the renderer decides a request is too large, it has already committed resources to finding out.

Five controls, all cheaper than the work they prevent A five-row grid of gate controls. A pixel cap bounds one map request's memory and costs parsing two integers; a feature cap bounds a feature request's row count; a token bucket bounds request rate per identity at the cost of a counter lookup; a concurrency gate bounds simultaneous renders with a semaphore; and request coalescing eliminates duplicate work with a key and a shared future. Control Bounds Cost to enforce Pixel cap one GetMap's memory parse two integers Feature cap one GetFeature's rows read one parameter Token bucket requests per identity one counter lookup Concurrency gate simultaneous renders a semaphore Coalescing duplicate work a key and a future

A gate in front changes the economics. Rejecting a hundred-megapixel GetMap costs parsing two integers; serving it costs several hundred megabytes of heap and seconds of a renderer thread. The same asymmetry holds for every control in the table: the enforcement cost is a constant, and the prevented cost scales with the request. That is why this belongs at the edge even though the service could technically do it.

Production-Ready Code

from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass, field

import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route

UPSTREAM = "http://geoserver:8080/geoserver"
MAX_PIXELS = 4_194_304          # 2048 x 2048
MAX_FEATURES = 10_000
MAX_CONCURRENT_RENDERS = 8

EXCEPTION_XML = (
    '<?xml version="1.0"?>'
    '<ServiceExceptionReport version="1.3.0" '
    'xmlns="http://www.opengis.net/ogc">'
    '<ServiceException code="{code}">{message}</ServiceException>'
    '</ServiceExceptionReport>')

def service_exception(message: str, code: str = "InvalidParameterValue",
                      status: int = 400) -> Response:
    """Reject in the client's own protocol, not with a bare HTTP error.

    An OGC client parses a ServiceExceptionReport and can surface the
    reason; a plain 400 with an HTML body is opaque to it.
    """
    return Response(EXCEPTION_XML.format(code=code, message=message),
                    status_code=status, media_type="application/vnd.ogc.se_xml")

@dataclass
class TokenBucket:
    """Classic bucket: `rate` tokens per second, burst up to `capacity`."""
    rate: float
    capacity: float
    tokens: float = field(init=False)
    updated: float = field(init=False)

    def __post_init__(self) -> None:
        self.tokens = self.capacity
        self.updated = time.monotonic()

    def take(self, amount: float = 1.0) -> float:
        """Return 0 when allowed, else the seconds until it would be."""
        now = time.monotonic()
        self.tokens = min(self.capacity,
                          self.tokens + (now - self.updated) * self.rate)
        self.updated = now
        if self.tokens >= amount:
            self.tokens -= amount
            return 0.0
        return (amount - self.tokens) / self.rate

class Gate:
    def __init__(self) -> None:
        self.buckets: dict[str, TokenBucket] = {}
        self.renders = asyncio.Semaphore(MAX_CONCURRENT_RENDERS)
        self.in_flight: dict[str, asyncio.Future] = {}
        self.client = httpx.AsyncClient(base_url=UPSTREAM, timeout=120.0)

    def bucket_for(self, identity: str) -> TokenBucket:
        if identity not in self.buckets:
            self.buckets[identity] = TokenBucket(rate=20.0, capacity=100.0)
        return self.buckets[identity]

    @staticmethod
    def identity(request: Request) -> str:
        """Prefer an authenticated identity; fall back to the peer address.

        Keying on address alone throttles an entire corporate NAT as one
        client, which is why the authenticated key is preferred.
        """
        auth = request.headers.get("authorization")
        if auth:
            return f"auth:{auth[:32]}"
        forwarded = request.headers.get("x-forwarded-for", "")
        return f"ip:{forwarded.split(',')[0].strip() or request.client.host}"

    @staticmethod
    def check_cost(params: dict[str, str]) -> Response | None:
        """Bound one request before anything is allocated upstream."""
        operation = params.get("request", "").lower()
        if operation in {"getmap", "gettile"}:
            try:
                pixels = int(params.get("width", "0")) * int(params.get("height", "0"))
            except ValueError:
                return service_exception("WIDTH and HEIGHT must be integers")
            if pixels <= 0:
                return service_exception("WIDTH and HEIGHT are required")
            if pixels > MAX_PIXELS:
                return service_exception(
                    f"requested image of {pixels:,} pixels exceeds the "
                    f"maximum of {MAX_PIXELS:,}")
        if operation == "getfeature":
            requested = params.get("count") or params.get("maxfeatures")
            if requested and int(requested) > MAX_FEATURES:
                return service_exception(
                    f"count of {requested} exceeds the maximum of {MAX_FEATURES}; "
                    f"page with startIndex instead")
            if not requested:
                params["count"] = str(MAX_FEATURES)      # cap the unbounded case
        return None

    @staticmethod
    def cache_key(params: dict[str, str]) -> str | None:
        """Identical renders coalesce onto one upstream request."""
        if params.get("request", "").lower() not in {"getmap", "gettile"}:
            return None
        return "|".join(f"{k}={params[k]}" for k in sorted(params))

    async def handle(self, request: Request) -> Response:
        params = {k.lower(): v for k, v in request.query_params.items()}

        rejection = self.check_cost(params)
        if rejection is not None:
            return rejection

        wait = self.bucket_for(self.identity(request)).take()
        if wait > 0:
            return service_exception(
                f"rate limit exceeded; retry in {wait:.1f}s",
                code="TooManyRequests", status=429)

        key = self.cache_key(params)
        if key and key in self.in_flight:
            body, media = await asyncio.shield(self.in_flight[key])
            return Response(body, media_type=media,
                            headers={"x-gate": "coalesced"})

        future: asyncio.Future = asyncio.get_running_loop().create_future()
        if key:
            self.in_flight[key] = future
        try:
            async with self.renders:
                upstream = await self.client.get(request.url.path, params=params)
            result = (upstream.content,
                      upstream.headers.get("content-type", "application/octet-stream"))
            if key and not future.done():
                future.set_result(result)
            return Response(result[0], status_code=upstream.status_code,
                            media_type=result[1])
        except Exception as exc:
            if key and not future.done():
                future.set_exception(exc)
            return service_exception("upstream service unavailable",
                                     code="ServiceUnavailable", status=502)
        finally:
            if key:
                self.in_flight.pop(key, None)

gate = Gate()
app = Starlette(routes=[
    Route("/{path:path}", gate.handle, methods=["GET"]),
])

Step-by-Step Walkthrough

Cheapest check first. The cost cap runs before the rate limit because it needs no shared state and rejects the most expensive requests. The rate limit runs before coalescing because a client already over its budget should not be given a free ride on someone else’s in-flight render.

Five checks, in increasing cost order A five-stage gate pipeline ordered so the cheapest rejection happens first. The operation is identified from the service and request parameters; the request cost is capped and an oversized request rejected immediately; a token bucket is charged per identity and an exhausted bucket yields a 429; identical in-flight requests are coalesced onto one upstream call; and only what survives all four is proxied. Parse the operation SERVICE + REQUEST Cap the cost pixels or features Charge the bucket per identity Coalesce identical in flight Proxy upstream only what survived cheap reject early 429 if empty share the result

Reject in the client’s protocol. An OGC client parses a ServiceExceptionReport and can show the user why the request failed. A bare HTTP 400 with an HTML body is opaque to it, and typically surfaces as “the layer could not be loaded”. Returning the exception document costs nothing and turns a mystery into a message.

An unbounded GetFeature is capped rather than rejected. A request with no count is not malformed — it is asking for everything, which is legitimate on a small layer and ruinous on a large one. Injecting the maximum turns it into a bounded first page, which a well-behaved client will then page through using the links or startIndex.

Coalescing is where a tile stampede dies. When a popular tile expires, dozens of clients request it simultaneously and each triggers an identical render. Keying on the sorted parameter set and sharing one future means the first request renders and the rest await its result — the single highest-value control here for a tile workload.

Identity beats address. Keying the bucket on an authentication header when present means a shared corporate NAT is not throttled as one client, which is the usual complaint about address-based limiting.

Verification

Confirm each control fires and returns the right thing:

# over the pixel cap
curl -s "http://gate:8000/wms?SERVICE=WMS&REQUEST=GetMap&WIDTH=10000&HEIGHT=10000" | head -3
<?xml version="1.0"?><ServiceExceptionReport version="1.3.0"
 xmlns="http://www.opengis.net/ogc"><ServiceException
 code="InvalidParameterValue">requested image of 100,000,000 pixels exceeds
 the maximum of 4,194,304</ServiceException></ServiceExceptionReport>
import asyncio, httpx

async def stampede(url, n=25):
    async with httpx.AsyncClient() as client:
        responses = await asyncio.gather(*(client.get(url) for _ in range(n)))
    coalesced = sum(1 for r in responses if r.headers.get("x-gate") == "coalesced")
    print(f"{n} identical requests -> {n - coalesced} upstream, {coalesced} coalesced")

asyncio.run(stampede("http://gate:8000/wms?SERVICE=WMS&REQUEST=GetMap"
                     "&LAYERS=basemap&WIDTH=512&HEIGHT=512&BBOX=0,0,1,1"
                     "&CRS=EPSG:3857&FORMAT=image/png"))
25 identical requests -> 1 upstream, 24 coalesced

Gotchas & Edge Cases

In-process buckets do not survive scaling out. Two gate instances each grant the full rate, so the effective limit doubles. Redis-backed counters fix it at the cost of a round trip per request; for many deployments halving the per-instance rate is an adequate approximation and is worth choosing deliberately rather than by accident.

Four rejections, four different meanings A decision diamond mapping gate rejections to response codes. A request over the pixel cap is a client error and is best returned as an OGC ServiceException with a 400; an exhausted token bucket is a 429 with a Retry-After header; a full concurrency gate is a transient 503 with Retry-After; and an upstream failure is a 502 that should not be disguised as a client error. Which response code should the gate return? 400 + ServiceException the request is malformed for this service 429 + Retry-After the client should back off 503 + Retry-After transient, not the client's fault 502 do not disguise it as the client's problem over the pixel cap bucket empty concurrency full upstream error

POST requests need body parsing. WFS transactions and OGC API - Features search requests carry their parameters in the body, so a gate that only inspects query strings passes them through uncapped. Either parse the body — which means buffering it — or route POST to a stricter path that requires authentication regardless.

Do not coalesce non-idempotent requests. The cache key deliberately returns None for anything that is not a map or tile request. Coalescing two Transaction requests because their parameters match would apply one write and report success for both.

A bucket per identity leaks memory. The dictionary above grows without bound across distinct clients. A time-based eviction, or an LRU cap, belongs in anything long-lived — the same discipline the streaming ingest guide applies to deduplication sets.

Set Retry-After. A 429 or 503 without it leaves a client guessing, and the usual guess is to retry immediately. The bucket already computes the wait, so returning it costs nothing.

Frequently Asked Questions

Why not use nginx rate limiting instead?

For a pure request-rate limit, do — it is faster and battle-tested. What nginx cannot easily do is understand OGC semantics: computing a pixel count from WIDTH and HEIGHT, injecting a feature cap into an unbounded GetFeature, coalescing on a normalised parameter set, or replying with a ServiceExceptionReport. A small application gate handles those, and nginx can still sit in front of it.

Should the cap return 400 or 403?

400 with an OGC ServiceException. The request is malformed for this service’s declared limits, which is a client error in the same family as an unknown layer name. 403 implies a permissions problem and sends the client’s operator looking for credentials that were never the issue.

How large should the token bucket be?

Set the rate from sustainable throughput and the capacity from a plausible burst. A map viewer opening fires a screenful of tiles at once, so a capacity of roughly one screen of tiles with a refill rate matching steady browsing lets normal use through and still bounds a scripted crawl.

Does coalescing risk serving stale results?

Only within the lifetime of a single upstream request, which is the point — the coalesced clients receive exactly what the one upstream call returned, at the moment they would otherwise each have triggered an identical call. It is a correctness-preserving optimisation for idempotent reads and must never be applied to writes.


Back to Security and Access Control for Spatial Services

Related