← Back to Securing Geospatial APIs
Spatial endpoints break the assumptions that ordinary rate limiters are built on. A /users/{id} lookup costs the same every time, so a flat requests-per-second ceiling protects it perfectly. A geofence query does not: ST_DWithin(geom, :point, :radius) with a 10 metre radius returns in a millisecond, while the same route with a 300 km radius scans a large candidate set, holds a pooled backend connection for seconds, and burns CPU that competes with every other query on the box. Vector-tile generation is worse still — a request for a low-zoom tile over a dense layer can serialise tens of thousands of features. Left unprotected, a handful of these requests will exhaust your connection pool and take down endpoints that have nothing to do with the offending route.
This guide covers why these endpoints need bespoke limits, where to enforce them, which algorithm to choose, how to key the counter, and how to return a correct 429. It sits alongside JWT authentication for spatial scopes and row-level security for multi-tenant PostGIS in the broader Securing Geospatial APIs reference: authentication says who you are, row-level security says what you may see, and rate limiting says how much of the database’s finite spatial capacity you may consume.
Enforcement layers
Rate limiting a spatial API is defence in depth, not a single check. Each layer catches a different class of overload, and the request only reaches the database if it survives all three.
The edge is where you shed brute-force floods — it is the cheapest place to drop a request because it never touches your origin. FastAPI is where business logic lives: per-API-key quotas, per-tenant fairness, and cost-weighted pricing that a CDN cannot compute because it does not understand your geometry. PostgreSQL’s statement_timeout is the last resort that guarantees no single query can hold a connection forever, no matter how the upstream limits are configured.
Prerequisites & Environment
The middleware in this guide targets an async FastAPI stack with Redis as the shared counter store. Redis is mandatory because a limiter that lives in per-process memory does not survive horizontal scaling — two Uvicorn workers each admit the full quota, doubling your effective limit.
| Component | Minimum version | Why it matters |
|---|---|---|
| Python | 3.11+ | asyncio.TaskGroup, faster asyncio scheduling |
| FastAPI | 0.110+ | Stable BaseHTTPMiddleware, lifespan events |
| Starlette | 0.36+ | ASGI middleware contract used below |
| redis-py | 5.0+ | Native async client (redis.asyncio), server-side Lua via EVALSHA |
| Redis server | 7.0+ | Reliable EVAL/EVALSHA, PEXPIRE millisecond TTLs |
| PostgreSQL | 14+ | Per-transaction SET LOCAL statement_timeout |
| PostGIS | 3.3+ | ST_DWithin, ST_Intersects, ST_AsMVT availability |
pip install "fastapi>=0.110" "redis>=5.0" "uvicorn[standard]>=0.29"Confirm the spatial functions you intend to protect actually exist in your image before you tune limits around them — ST_AsMVT for vector tiles arrived in PostGIS 2.4 but ST_AsMVTGeom’s clipping behaviour changed in 3.0:
SELECT proname FROM pg_proc
WHERE proname IN ('st_dwithin', 'st_asmvt', 'st_intersects');Decision matrix: rate-limiting algorithms
Four algorithms dominate. The right one depends on whether you care about burst smoothness, memory footprint, or the ability to price requests unequally — which spatial APIs almost always need.
| Algorithm | Burst behaviour | Memory per key | Accuracy at window edge | Cost weighting | Best fit for spatial routes |
|---|---|---|---|---|---|
| Fixed window | Allows 2× burst at boundary | 1 counter | Poor (double-count at edge) | Awkward | Coarse edge tile caps where precision is not critical |
| Sliding window (log) | Smooth, exact | O(N) sorted set | Exact | Natural (weighted ZADD) | Per-key limits on geofence and tile routes |
| Sliding window (counter) | Smooth, approximate | 2 counters | Good (weighted interpolation) | Awkward | High-cardinality keys where memory matters |
| Token bucket | Allows controlled bursts | 2 fields | Exact | Native — deduct N tokens | Cost-based throttling by bbox area / radius |
For a straightforward per-API-key ceiling on tile and geofence routes, the sliding-window log is the default: it is exact, smooths bursts, and its sorted-set structure makes weighting trivial. The full atomic implementation lives in Redis sliding-window rate limits for spatial endpoints.
When requests are wildly unequal in cost — a 10 m radius versus a 300 km radius on the same route — a flat request count is the wrong unit. A token bucket whose withdrawal is proportional to the estimated query cost prices each request fairly; a large-radius ST_DWithin withdraws many tokens and drains the budget quickly, while cheap point lookups barely move it. That approach is developed in full in cost-based throttling for expensive PostGIS queries.
Step-by-Step Implementation
Step 1: Classify routes by cost and assign budgets
Not every route deserves the same ceiling. Group your endpoints into cost tiers and pick a limit for each. The tiers below are a realistic starting point for a single-tenant plan on a modest replica; halve them for a free tier, multiply for enterprise.
| Route class | Example | Typical DB time | Limit (per API key) |
|---|---|---|---|
| Metadata / lookup | GET /layers, GET /features/{id} | < 5 ms | 200 req/s |
| Bounding-box read | GET /features?bbox=... | 10–50 ms | 50 req/s |
| Geofence / radius | GET /within?lng&lat&radius | 20 ms – 3 s | 10 req/s |
| Vector tile | GET /tiles/{z}/{x}/{y}.mvt | 30–800 ms | 25 req/s (lower at z ≤ 8) |
| Aggregation / export | GET /stats/coverage | 1–30 s | 2 req/min |
The geofence and low-zoom tile classes are the ones that hurt, because their worst case is orders of magnitude slower than their median. A limit chosen for the median lets the worst case run wild; a limit chosen for the worst case throttles legitimate median traffic. This tension is exactly what cost-based throttling resolves — see the bounding-box spatial index queries guide for how to derive a cost from the requested bbox before the query runs.
Step 2: Choose the algorithm and the key
The key decides who shares a budget. Three strategies, from coarse to fine:
- Per API key / tenant — the fairness unit for a paid product. Extract the key from the
Authorizationheader or a validated JWT scope. This is what a paying customer expects to be metered on, and it composes cleanly with the tenant identity already established for row-level security. - Per IP — the only option for anonymous tile traffic. Coarse and easily defeated by rotating IPs, so use it at the edge, not as your only defence.
- Per IP + bbox cell — snap the requested bounding box to a coarse grid (say 0.1°) and key on
ip:cell. This stops a scraper from walking a dense area tile-by-tile at full speed while leaving a normal panning user unaffected.
Step 3: Enforce atomically in middleware
The check-then-set race is the classic rate-limiter bug: two workers read “9 of 10 used”, both admit, and you served 11. The fix is to make the read-decide-write a single atomic Redis operation via a Lua script (which Redis runs without interleaving). The production example below does exactly that; the sliding-window variant is dissected line by line in Redis sliding-window rate limits for spatial endpoints.
Step 4: Return a correct 429
On rejection, return 429 Too Many Requests — never 503, which tells clients the server is broken and invites an immediate retry. Include Retry-After (seconds) plus the RateLimit-* header family so disciplined clients self-throttle before they are throttled.
Step 5: Backstop with statement_timeout
Set a per-transaction timeout so a query that slips past every counter still cannot hold a connection forever:
await conn.execute("SET LOCAL statement_timeout = '3000ms'")A cancelled query surfaces as PostgreSQL error 57014 (canceling statement due to statement timeout); catch it and return 429 or 504 rather than a raw 500.
Production Code Example
A cohesive, copy-runnable FastAPI middleware. It selects a per-route limit, keys on the API key, and enforces a sliding-window limit atomically with a Lua script. On rejection it emits a spec-compliant 429.
# app/ratelimit.py
import time
from redis.asyncio import Redis
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
# Per-route (window_seconds, max_requests). Match your cost tiers from Step 1.
ROUTE_LIMITS: dict[str, tuple[int, int]] = {
"/within": (1, 10), # geofence / radius — expensive, tight limit
"/tiles": (1, 25), # vector tiles
"/features": (1, 50), # bounding-box reads
}
DEFAULT_LIMIT = (1, 200) # metadata / everything else
# Atomic sliding-window log: trim old entries, count, admit-or-reject, then
# only record the hit if admitted. KEYS[1]=zset, ARGV=now_ms, window_ms, limit, member
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window= tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local used = redis.call('ZCARD', key)
if used < limit then
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return {1, limit - used - 1}
end
-- rejected: compute Retry-After from the oldest entry in the window
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_ms = window - (now - tonumber(oldest[2]))
return {0, retry_ms}
"""
def route_class(path: str) -> str:
for prefix in ROUTE_LIMITS:
if path.startswith(prefix):
return prefix
return "__default__"
def client_key(request: Request) -> str:
# Prefer the validated API key; fall back to peer IP for anonymous traffic.
api_key = request.headers.get("x-api-key")
if api_key:
return f"key:{api_key}"
return f"ip:{request.client.host}"
class SpatialRateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, redis: Redis):
super().__init__(app)
self.redis = redis
self._sha: str | None = None
async def _script(self) -> str:
if self._sha is None:
self._sha = await self.redis.script_load(SLIDING_WINDOW_LUA)
return self._sha
async def dispatch(self, request: Request, call_next):
cls = route_class(request.url.path)
window_s, limit = ROUTE_LIMITS.get(cls, DEFAULT_LIMIT)
redis_key = f"rl:{cls}:{client_key(request)}"
now_ms = int(time.time() * 1000)
member = f"{now_ms}-{id(request)}" # unique member so equal timestamps don't collide
try:
sha = await self._script()
allowed, meta = await self.redis.evalsha(
sha, 1, redis_key,
now_ms, window_s * 1000, limit, member,
)
except Exception:
# Fail OPEN for tiles (availability), FAIL CLOSED for expensive routes.
if cls == "/within":
return JSONResponse(
{"detail": "Rate limiter unavailable; try again shortly."},
status_code=503,
)
return await call_next(request)
if allowed == 0:
retry_after = max(1, round(int(meta) / 1000))
return JSONResponse(
{"detail": "Rate limit exceeded.", "retry_after": retry_after,
"limit": limit, "window_seconds": window_s},
status_code=429,
headers={
"Retry-After": str(retry_after),
"RateLimit-Limit": str(limit),
"RateLimit-Remaining": "0",
"RateLimit-Reset": str(retry_after),
},
)
response: Response = await call_next(request)
response.headers["RateLimit-Limit"] = str(limit)
response.headers["RateLimit-Remaining"] = str(max(0, int(meta)))
return responseWire it into the app with a Redis client created in the lifespan handler:
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from redis.asyncio import Redis
from app.ratelimit import SpatialRateLimitMiddleware
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = Redis.from_url("redis://redis:6379/0", decode_responses=True)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
# Middleware needs the client at construction; build it after state is set.
app.add_middleware(SpatialRateLimitMiddleware, redis=Redis.from_url("redis://redis:6379/0", decode_responses=True))The fail-open versus fail-closed decision is a deliberate policy choice. If Redis is unreachable, cheap tile routes fail open (serve the request — availability wins), while the expensive /within geofence route fails closed (reject with 503 — protecting the database wins). Getting this backwards means a Redis outage either takes down your whole map or lets a scraper hammer your most expensive query unmetered.
Verification & Testing
Drive the limit with a curl loop and watch the transition from 200 to 429:
# Fire 15 requests at a route limited to 10/s and print each status code.
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code} " \
-H "x-api-key: test-key-123" \
"http://localhost:8000/within?lng=13.4&lat=52.5&radius=500"
done; echo
# Expected: 200 200 200 200 200 200 200 200 200 200 429 429 429 429 429Inspect the 429 headers to confirm Retry-After is present and sane:
curl -s -D - -o /dev/null \
-H "x-api-key: test-key-123" \
"http://localhost:8000/within?lng=13.4&lat=52.5&radius=500" | grep -i 'ratelimit\|retry-after'
# retry-after: 1
# ratelimit-limit: 10
# ratelimit-remaining: 0
# ratelimit-reset: 1Unit test the middleware against a real or fake Redis (fakeredis supports EVALSHA):
# tests/test_ratelimit.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.asyncio
async def test_geofence_route_rejects_after_limit():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://t") as c:
codes = []
for _ in range(15):
r = await c.get("/within", params={"lng": 13.4, "lat": 52.5, "radius": 500},
headers={"x-api-key": "k"})
codes.append(r.status_code)
assert codes.count(200) == 10
assert codes[-1] == 429
assert int((await c.get("/within", headers={"x-api-key": "k"},
params={"lng": 13.4, "lat": 52.5, "radius": 500})
).headers["retry-after"]) >= 1Failure Modes & Edge Cases
In-memory counters under horizontal scaling. A limiter that stores counts in a Python dict admits the full quota per worker. With four Uvicorn workers a “10 req/s” limit becomes 40 req/s. Always back the counter with shared Redis, and confirm every replica points at the same Redis database.
prepared statement does not existafter a limiter deploy through PgBouncer. Unrelated to the limiter itself, but a Redis outage that trips fail-open floods the pool, surfacing latent transaction-pooling bugs. Keepstatement_cache_size=0as covered in connection pooling & PgBouncer setup.Check-then-set race admits over the limit. Reading the count and writing the increment in two round trips lets concurrent requests both pass the check. The symptom is intermittent over-admission under load that disappears when you serialise the test. Fix: perform the whole decision in one Lua script, as above.
Retry-Aftercomputed from wall clock, not the window. Returning a fixedRetry-After: 60when the window is 1 second makes honest clients back off far too long. Compute it from the oldest entry still inside the window (the Lua script returns exactly this).Returning
503instead of429.503 Service Unavailablesignals a server fault; many HTTP clients retry it immediately and aggressively, amplifying the overload. Reserve503strictly for the fail-closed limiter-unavailable case, and use429for actual limit hits.Keying tiles on API key when they are anonymous. Public basemap tiles usually carry no key, so
client_keyfalls back to IP. A single corporate NAT then shares one bucket across thousands of users and gets throttled unfairly. For public tiles, key onIP + bbox cellor move the limit to the edge where per-colo budgets are wider.No database backstop. If you rely solely on the counter and a bug lets one request through, a 30-second
ST_Unionstill holds a connection.SET LOCAL statement_timeoutguarantees an upper bound regardless of limiter state.Sorted-set memory growth. The sliding-window log stores one member per request in the window. A very high limit (say 100k/min) makes each key large; set
PEXPIREon every write so idle keys evict, and prefer the counter variant for very high-cardinality limits.
Performance Notes
Limiter overhead. The EVALSHA round trip to a co-located Redis adds roughly 0.2–0.5 ms per request on a warm connection — negligible next to a 20 ms bounding-box query and trivial next to a multi-second geofence. Load the script once (SCRIPT LOAD at startup) and call it by SHA; sending the full script body on every request wastes bandwidth and CPU.
Where the limit pays off. The point of the limiter is not the happy path — it is the tail. Without a limit, p99 latency on your geofence route is unbounded because it is set by whoever requests the largest radius. With a cost-weighted limit, a single client cannot monopolise the pool, so p99 stays bounded even under adversarial load. This is the same tail-latency argument that motivates a separate heavy-query connection pool in the connection pooling guide.
Redis sizing. A sliding-window log at 50 req/s over a 1-second window holds ~50 members per key. Ten thousand active keys is roughly 5–15 MB — comfortably in RAM. If you push limits into the thousands-per-window range, switch to the two-counter sliding-window approximation to cap memory at O(1) per key.
Tile caching beats limiting. The cheapest request is the one you never compute. Cache generated tiles at the CDN edge and in Redis so repeat requests for the same z/x/y never reach PostGIS; the rate limiter then only guards genuine cache misses. For confirming that your surviving geofence queries actually use the GiST index, run the plans through query plan analysis & index tuning.
FAQ
Why do spatial endpoints need different rate limits than ordinary REST routes?
A single request can be arbitrarily expensive. An ST_DWithin call with a 200 km radius, or a vector tile at zoom 6 covering a dense polygon layer, can hold a backend connection for several seconds and burn CPU that a point lookup never touches. A flat requests-per-second limit that is comfortable for metadata routes lets a handful of large-radius or low-zoom requests saturate the connection pool. Spatial endpoints therefore need lower request ceilings, or cost-weighted budgets that price each request by its bounding-box area or radius before admitting it.
Where should I enforce the limit: at the edge, in FastAPI, or in PostgreSQL?
Use all three as layers. The CDN or edge worker sheds volumetric floods before they reach your origin and is the right place for coarse per-IP tile limits. FastAPI middleware backed by Redis enforces per-API-key and per-tenant business limits and can price a request by its spatial cost. A PostgreSQL statement_timeout is the backstop that kills a runaway ST_DWithin or ST_Union that slipped through, so one pathological query cannot hold a pooled connection indefinitely.
What should a 429 response for a spatial endpoint contain?
Return HTTP 429 Too Many Requests with a Retry-After header giving the number of seconds until the caller may retry, plus RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers so well-behaved clients can self-throttle. The body should be a small JSON object naming the limit that was hit and the retry delay. Never return 503 for rate limiting: 503 signals server fault and encourages clients to retry immediately, which amplifies the overload you are trying to prevent.
Related
- Redis Sliding-Window Rate Limits for Spatial Endpoints — the atomic sorted-set + Lua limiter, dissected line by line
- Cost-Based Throttling for Expensive PostGIS Queries — price each request by bbox area or radius and deduct from a token budget
- Securing Geospatial APIs — authentication, authorization, and tenant isolation for spatial services
- Connection Pooling & PgBouncer Setup — the pool that rate limiting exists to protect
- Tile Generation & CDN Distribution — cache tiles at the edge so the limiter only guards cache misses
← Back to Securing Geospatial APIs