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.
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.
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.
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"]),
])
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.
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.
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
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.
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.
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.
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.
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.
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