Rate Limiting Geofence & Tile Endpoints

Protect expensive PostGIS geofence, ST_DWithin radius, and vector-tile endpoints from abuse and accidental overload. Choose an algorithm, pick a keying strategy, and enforce Redis-backed limits in FastAPI with correct 429 and Retry-After responses.

← 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.

Three enforcement layers for spatial rate limitingA request flows top to bottom through three defensive layers. The edge or CDN sheds volumetric floods with coarse per-IP tile limits. FastAPI middleware backed by Redis enforces per-API-key and cost-weighted business limits and returns 429 with Retry-After. PostgreSQL statement_timeout kills any runaway spatial query that slipped through. Only surviving requests reach PostGIS.Incoming requestLayer 1 · Edge / CDNCoarse per-IP tile limits · absorbs volumetric floods before the originCloudflare Worker or CDN rule · cheapest place to drop traffic429flood shedLayer 2 · FastAPI middleware + RedisPer-API-key / per-tenant sliding window · cost-weighted token bucketPrices each request by bbox area or radius · atomic Lua checkSets RateLimit-* headers · returns 429 + Retry-After429Retry-AfterLayer 3 · PostgreSQL statement_timeoutBackstop · kills a runaway ST_DWithin / ST_Union that slipped throughProtects the connection pool from a single pathological query57014canceled

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.

ComponentMinimum versionWhy it matters
Python3.11+asyncio.TaskGroup, faster asyncio scheduling
FastAPI0.110+Stable BaseHTTPMiddleware, lifespan events
Starlette0.36+ASGI middleware contract used below
redis-py5.0+Native async client (redis.asyncio), server-side Lua via EVALSHA
Redis server7.0+Reliable EVAL/EVALSHA, PEXPIRE millisecond TTLs
PostgreSQL14+Per-transaction SET LOCAL statement_timeout
PostGIS3.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.

AlgorithmBurst behaviourMemory per keyAccuracy at window edgeCost weightingBest fit for spatial routes
Fixed windowAllows 2× burst at boundary1 counterPoor (double-count at edge)AwkwardCoarse edge tile caps where precision is not critical
Sliding window (log)Smooth, exactO(N) sorted setExactNatural (weighted ZADD)Per-key limits on geofence and tile routes
Sliding window (counter)Smooth, approximate2 countersGood (weighted interpolation)AwkwardHigh-cardinality keys where memory matters
Token bucketAllows controlled bursts2 fieldsExactNative — deduct N tokensCost-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 classExampleTypical DB timeLimit (per API key)
Metadata / lookupGET /layers, GET /features/{id}< 5 ms200 req/s
Bounding-box readGET /features?bbox=...10–50 ms50 req/s
Geofence / radiusGET /within?lng&lat&radius20 ms – 3 s10 req/s
Vector tileGET /tiles/{z}/{x}/{y}.mvt30–800 ms25 req/s (lower at z ≤ 8)
Aggregation / exportGET /stats/coverage1–30 s2 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 Authorization header 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 response

Wire 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 429

Inspect 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: 1

Unit 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"]) >= 1

Failure Modes & Edge Cases

  1. 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.

  2. prepared statement does not exist after 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. Keep statement_cache_size=0 as covered in connection pooling & PgBouncer setup.

  3. 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.

  4. Retry-After computed from wall clock, not the window. Returning a fixed Retry-After: 60 when 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).

  5. Returning 503 instead of 429. 503 Service Unavailable signals a server fault; many HTTP clients retry it immediately and aggressively, amplifying the overload. Reserve 503 strictly for the fail-closed limiter-unavailable case, and use 429 for actual limit hits.

  6. Keying tiles on API key when they are anonymous. Public basemap tiles usually carry no key, so client_key falls back to IP. A single corporate NAT then shares one bucket across thousands of users and gets throttled unfairly. For public tiles, key on IP + bbox cell or move the limit to the edge where per-colo budgets are wider.

  7. No database backstop. If you rely solely on the counter and a bug lets one request through, a 30-second ST_Union still holds a connection. SET LOCAL statement_timeout guarantees an upper bound regardless of limiter state.

  8. 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 PEXPIRE on 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.


← Back to Securing Geospatial APIs