FastAPI request ids in structlog contextvars, tested with capture_logs: working versions (tested 2026-10-15)
Pure ASGI middleware binds a request id into structlog contextvars, echoes it in an X-Request-ID header, and is tested offline with TestClient and structlog.testing.capture_logs.
passed5 passed · 0 failed2026-10-15 13:34 UTC- Status
- passed
- Last passed
- 2026-10-15 13:34 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("structlog-fastapi-request-id") (install), or see them on GitHub.
fastapi==0.143.1 structlog==26.1.0 httpx==0.28.1 pytest==9.1.1
import re
import uuid
import structlog
from fastapi import FastAPI
from structlog.contextvars import bind_contextvars, clear_contextvars, merge_contextvars
structlog.configure(processors=[merge_contextvars, structlog.processors.JSONRenderer()])
log = structlog.get_logger()
SAFE_ID = re.compile(r"[A-Za-z0-9-]{1,64}")
class RequestIdMiddleware:
"""Pure ASGI middleware: bindings happen in the same task as the endpoint,
and the header is added on http.response.start."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
# Never trust the raw header: reject anything that could break logs or headers.
incoming = dict(scope["headers"]).get(b"x-request-id", b"").decode("latin-1")
rid = incoming if SAFE_ID.fullmatch(incoming) else uuid.uuid4().hex
async def send_with_header(message):
if message["type"] == "http.response.start":
message.setdefault("headers", []).append((b"x-request-id", rid.encode()))
await send(message)
bind_contextvars(request_id=rid)
try:
await self.app(scope, receive, send_with_header)
finally:
# Without this, ids leak into whatever runs next on the same context.
clear_contextvars()
app = FastAPI()
app.add_middleware(RequestIdMiddleware)
@app.get("/hello")
async def hello():
log.info("hello_called")
return {"ok": True}
@app.get("/boom")
async def boom():
log.info("about_to_fail")
raise RuntimeError("boom")
import re
from fastapi.testclient import TestClient
from structlog.contextvars import merge_contextvars
from structlog.testing import capture_logs
from app import app
# raise_server_exceptions=False so unhandled errors come back as 500 responses.
client = TestClient(app, raise_server_exceptions=False)
def test_echoes_incoming_request_id():
r = client.get("/hello", headers={"X-Request-ID": "abc-123"})
assert r.status_code == 200
assert r.headers["x-request-id"] == "abc-123"
def test_generates_id_when_missing():
r = client.get("/hello")
assert re.fullmatch(r"[0-9a-f]{32}", r.headers["x-request-id"])
def test_replaces_unsafe_incoming_id():
r = client.get("/hello", headers={"X-Request-ID": "bad id with spaces"})
assert re.fullmatch(r"[0-9a-f]{32}", r.headers["x-request-id"])
def test_logs_carry_request_id():
# capture_logs swaps the processor chain, so merge_contextvars must be passed back in.
with capture_logs(processors=[merge_contextvars]) as logs:
client.get("/hello", headers={"X-Request-ID": "req-1"})
hello = [e for e in logs if e["event"] == "hello_called"]
assert hello[0]["request_id"] == "req-1"
def test_context_cleared_after_error():
with capture_logs(processors=[merge_contextvars]) as logs:
r1 = client.get("/boom", headers={"X-Request-ID": "fail-1"})
r2 = client.get("/hello")
assert r1.status_code == 500
hello = [e for e in logs if e["event"] == "hello_called"]
assert hello[0]["request_id"] == r2.headers["x-request-id"] != "fail-1"
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
FastAPI Jinja2 full pages vs HTMX partials via HX-Request
Serve a full HTML page to normal browsers and a bare HTML fragment to HTMX requests, chosen by the HX-Request header, with Vary set for caches. Tested offline with the sync TestClient.