Build1 publisher3 min readPublished
FastAPI middleware puts a third-party lookup in front of your liveness probe
Starlette resolves the route after the middleware stack has already run, so a geolocation middleware fires on /health and /docs as well. A per-route dependency scopes the same lookup to the handlers that actually need it.
The Engineer · Build desk

What happened
- A dev.to post argues the common FastAPI geolocation pattern is the wrong tool, because Starlette runs middleware before the router resolves a path and the middleware cannot tell which endpoint matched.
- A dependency declared with Depends() runs after routing and only on the routes that ask for it. That single ordering difference is what the post's whole case rests on.
- The failure mode the post describes is a liveness probe on /health inheriting a third-party round trip, timing out when the upstream slows, and the orchestrator restarting a healthy pod.
- The alternative it sets out is two composable dependencies, GeoContext and RiskProfile, returning Pydantic models, at roughly 120 lines total and cached in Redis.
- FastAPI's own dependency documentation covers the mechanics, according to the post, but not the argument for reaching for a dependency where instinct reaches for middleware.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Placement in the ASGI stack forecloses per-path exemption, so the only lever left inside the middleware is a prefix blocklist someone has to remember to update with every new route.
- exposure When the application parses the forwarded header itself, anything keyed on country downstream, including pricing, blocking and risk scoring, is set by the caller.
- decision Correct client IP resolution becomes a property of the process launch command, so whoever owns the Dockerfile, unit file or Helm chart now owns it, not the developer writing route handlers.
- cost Per-route opt-in is also per-route work: every handler that needs enrichment gains a parameter, so migration effort scales with how many routes need it.
Middleware sits in the ASGI stack, and the router runs inside it. That is why a `BaseHTTPMiddleware` subclass cannot ask which endpoint matched [1]. There is no hook for "skip this one", so the workaround is a path prefix blocklist maintained by hand inside the middleware, and it drifts the moment someone adds a route [3].
The cost rests on a conditional the post states plainly: the probe inherits a third-party round trip if the middleware makes an outbound API call on every request [4]. A Kubernetes liveness probe hits `/health` every few seconds [5]. At one probe every five seconds, a single pod asks for 17,280 lookups a day before a user arrives [6]. The post recommends a Redis cache [11], and since the probe's source address does not change, nearly all of those should be hits, apart from the first and the first after an eviction or a Redis restart. Failing open covers a lookup that raises [12], and a socket that hangs has not raised yet. The post gives no timeout.
Client IP resolution is the part I would fix first whichever tool you pick. `request.client.host` is the peer that opened the TCP connection, and behind nginx, an ALB, Cloudflare or Railway's Envoy layer that peer is the proxy [7]. `X-Forwarded-For` is whatever the caller typed unless something upstream overwrote it, and the post calls trusting the leftmost entry the single most common mistake in the middleware examples in circulation [8]. Uvicorn will rewrite `request.client.host` from the forwarded headers, but only for peers you name: `--proxy-headers` with `--forwarded-allow-ips="10.0.0.0/8"` [9]. The flag defaults to `127.0.0.1` [10]. An off-host proxy fails that check, no rewrite happens, and every visitor geolocates as your load balancer [17].
On the ergonomics, the case is narrower and cleaner. `request.state.geo` has no schema, so a typo in `request.state.geo.country_code2` fails at runtime in whichever route nobody tested [13]. A dependency returning a Pydantic model puts the shape in the function signature [14]. FastAPI caches a dependency within a single request, so declaring it twice does not call the API twice, and `dependency_overrides` replaces the lookup with a three-line fixture in tests [15]. Splitting geolocation from risk scoring means a cheap route takes only the geo dependency and a sensitive one takes both [16].
Writing about the restart loop, the author said: "I've watched a team spend most of a day on that one." [18]
The ordering fact is a property of Starlette, so it holds in every FastAPI app. What it costs you varies with what the enrichment does: an in-process lookup with no network hop shrinks the probe argument to almost nothing, while the typing and testing arguments stay the same size. The post is not calling the middleware broken. It says plenty of people run `BaseHTTPMiddleware` happily, alongside its reputation for edge-case behaviour around exceptions, streaming responses and background tasks, and settles on it being more machinery than this job needs [2].
What to watch
- Whether Starlette adds a route-aware hook for middleware, which would remove the ordering objection entirely.
- Keeping --forwarded-allow-ips in sync as proxy ranges change across availability zones or CDN edge lists.
- Whether teams that switch report steadier probes, since the evidence for the restart loop is currently one team's incident.