Why FastAPI geolocation middleware is the wrong tool
Search for FastAPI geolocation middleware and you'll get the same answer every time: subclass BaseHTTPMiddleware , call an IP lookup API, stash the result on request.state.geo . It works. It's also the wrong tool, and the reason is boring rather than clever. Middleware runs before routing. It cannot know which endpoint matched, so it fires on everything: /health , /metrics , /docs , /openapi.json , your static mounts, and every CORS preflight OPTIONS . You've just put a network call in front of your liveness probe. A dependency runs after routing, only where you declare it. That one ordering difference fixes the problem and brings typing and testability along with it. TL;DR - BaseHTTPMiddleware runs on every request including health checks and docs routes.Depends() runs only on routes that ask for it. - Fix client IP resolution first. request.client.host is your load balancer, andX-Forwarded-For is caller-controlled unless you configure Uvicorn to trust it. - Return a Pydantic model from the dependency so the route signature shows what it gets and your editor can autocomplete it. - Keep geolocation and risk as two separate dependencies. Cheap routes take geo, sensitive routes take both. - Fail open on lookup errors, cache in Redis, and swap the whole thing out in tests with app.dependency_overrides . You'll end with two composable dependencies, GeoContext and RiskProfile , that any route can pull in by adding one parameter. Roughly 120 lines total, Redis-cached, and testable without mocking HTTP. Why FastAPI geolocation middleware is the wrong tool The routing-order problem is the one that actually bites. Starlette runs middleware in the ASGI stack before the router resolves a path, so a geolocation middleware has no way to say "skip this for /health ". You end up with a hand-maintained path prefix blocklist inside the middleware, which drifts the moment someone adds a route. Latency is the part people feel in production. A Kubernetes liveness probe hits /health every few seconds. If your middleware makes an outbound API call on every request, that probe now depends on a third-party network round trip. When the upstream gets slow, probes time out, and your orchestrator restarts a pod that was perfectly healthy. I've watched a team spend most of a day on that one. Then there's typing. request.state.geo is a bag with no schema. Your editor can't complete it, your reviewer can't see it in the function signature, and a typo in request.state.geo.country_code2 fails at runtime in whichever route nobody tested. BaseHTTPMiddleware also has its own reputation for edge-case behaviour around exceptions, streaming responses, and background tasks. Plenty of people run it happily. It's still more machinery than this job needs. What a dependency gives you instead FastAPI's dependency injection system gives you four things, in the order you'll care about them. Per-route opt-in, so /health stays a pure function. A typed return value that shows up in the signature. Automatic caching within a single request, so declaring the same dependency twice doesn't call the API twice. And dependency_overrides , which makes testing a three-line fixture instead of a mock HTTP layer. The FastAPI dependency docs cover the mechanics well. What they don't cover is the case for using them where your instinct says middleware, which is most of what follows. Get the client IP right first Everything downstream is worthless if you geolocate the wrong address. request.client.host gives you the peer that opened the TCP connection. Behind nginx, an ALB, Cloudflare, or Railway's Envoy layer, that peer is the proxy, and you'll cheerfully look up your own infrastructure for every visitor. The fix is not to parse X-Forwarded-For in your application code. That header is whatever the caller typed unless something upstream has overwritten it, so trusting the leftmost entry hands any user the ability to claim any country they like. This is the single most common mistake in the geolocation middleware examples floating around. Let Uvicorn do it. Its proxy header settings rewrite request.client.host from the forwarded headers, but only for peers you explicitly trust: uvicorn app.main:app \ --proxy-headers \ --forwarded-allow-ips="10.0.0.0/8" The default for --forwarded-allow-ips is 127.0.0.1 , which is not what you want the moment your proxy lives on another host. Set it to your load balancer's actual range and nothing else. With that in place, request.client.host is trustworthy and the dependency stays short: # app/deps/client_ip.py import ipaddress from fastapi import Request def get_client_ip(request: Request) -> str | None: """The caller's public IP, or None when we don't have one worth looking up.""" client = request.client if client is None: # Some ASGI transports (including parts of the test client) omit this. return None try: parsed = ipaddress.ip_address(client.host) except ValueError: return None # Private, loopback and reserved addresses have no public geolocation. # Returning None here saves a pointless round trip on every local request. if parsed.is_private or parsed.is_loopback or parsed.is_reserved: return None return client.host Returning None rather than raising is deliberate. A missing IP is a normal condition, not an error, and the callers downstream all handle None already. Running it locally Every request from your laptop arrives as 127.0.0.1 , so get_client_ip returns None and the geo dependency short-circuits before it ever calls out. That's the behaviour you want, and it means local development costs nothing. When you do want real data in development, hardcode a test IP behind an environment flag rather than pointing the lookup at a private address. IP geolocation APIs reject private and bogon ranges, and you'll spend twenty minutes debugging an error response that was correct all along. The geolocation dependency One call, one model. Here's the request shape: curl -s 'https://api.ipgeolocation.io/v3/ipgeo?apiKey=API_KEY&ip=91.128.103.196' And the full response, which is worth reading once before you decide what to keep: { "ip": "91.128.103.196", "location": { "continent_code": "EU", "continent_name": "Europe", "country_code2": "SE", "country_code3": "SWE", "country_name": "Sweden", "country_name_official": "Kingdom of Sweden", "country_capital": "Stockholm", "state_prov": "Stockholms lΓ€n", "state_code": "SE-AB", "district": "Stockholm", "city": "Stockholm", "zipcode": "164 40", "latitude": "59.40510", "longitude": "17.95510", "is_eu": true, "country_flag": "https://ipgeolocation.io/static/flags/se_64.png", "geoname_id": "9972319", "country_emoji": "πΈπͺ" }, "country_metadata": { "calling_code": "+46", "tld": ".se", "languages": ["sv-SE", "se", "sma", "fi-SE"] }, "currency": { "code": "SEK", "name": "Swedish Krona", "symbol": "kr" }, "asn": { "as_number": "AS1257", "organization": "Tele2 Sverige AB", "country": "SE" }, "time_zone": { "name": "Europe/Stockholm", "offset": 1, "offset_with_dst": 2, "current_time": "2026-09-07 16:55:30.494+0200", "current_time_unix": 1788792930.494, "current_tz_abbreviation": "CEST", "current_tz_full_name": "Central European Summer Time", "is_dst": true } } Two things in there will trip you up. latitude and longitude are strings, not floats, so cast them before any arithmetic. And country_metadata.languages is an array, not the comma-separated string that several other providers return. If you're porting code from another API, that's where it breaks. I'm using ipgeolocation.io for these examples because the geolocation and threat endpoints share a response envelope, which keeps the two dependencies below nearly identical. IPGeolocation, ipinfo, ip-api, MaxMind GeoIP2, IPLocate and IP2Location all hand back roughly the same country-level payload, so swap in whichever is already in your stack. Only the parsing function changes. Now the dependency: # app/deps/geo.py import logging import os from typing import Annotated import httpx from fastapi import Depends, Request from pydantic import BaseModel from app.deps.client_ip import get_client_ip logger = logging.getLogger(name) class GeoContext(BaseModel): ip: str country_code: str | None = None country_name: str | None = None city: str | None = None is_eu: bool = False currency_code: str | None = None asn_organization: str | None = None timezone: str | None = None async def get_geo( request: Request, ip: Annotated[str | None, Depends(get_client_ip)], ) -> GeoContext | None: if ip is None: return None api_key = os.environ.get("IPGEO_API_KEY") if not api_key: logger.warning("IPGEO_API_KEY is not set, skipping geolocation") return None try: response = await request.app.state.http.get( "/ipgeo", params={"apiKey": api_key, "ip": ip} ) response.raise_for_status() payload = response.json() except (httpx.HTTPError, ValueError) as exc: # Fail open. A geo lookup should never be why a page returns 500. logger.warning("Geolocation lookup failed for %s: %s", ip, exc) return None location = payload.get("location") or {} return GeoContext( ip=payload.get("ip", ip), country_code=location.get("country_code2"), country_name=location.get("country_name"), city=location.get("city"), is_eu=bool(location.get("is_eu", False)), currency_code=(payload.get("currency") or {}).get("code"), asn_organization=(payload.get("asn") or {}).get("organization"), timezone=(payload.get("time_zone") or {}).get("name"), ) The or {} on every nested access is not paranoia. Responses vary by key configuration, and payload["location"]["city"] on an IP with no city resolution is a KeyError in production at 3am. Fail open or fail closed The code above fails open: if the lookup breaks, get_geo returns None and the route carries on with whatever default it has. For pricing, currency, or language, that's obviously right. Nobody should see a 500 because a third-party API had a bad minute. Fail closed is the correct choice in exactly one situation, which is when the lookup is a control rather than a decoration. If y
Comments
No comments yet. Start the discussion.