Quieting 4xx noise in FastAPI + Logfire without losing your 500s
If you’ve wired up Pydantic Logfire on a FastAPI service, you’ve probably noticed your exception tracker filling up with things that aren’t actually exceptional: a 404 for an ID that doesn’t exist, a 409 for a duplicate, a 422 for a malformed body. Every one of those is a client mistake, not your service falling over — but they show up in your error dashboard looking exactly like one.
I ran into this recently and wrote up the fix as a gist.
The setup that looks fine but isn’t
A normal FastAPI handler turns a domain error into an HTTP error the idiomatic way:
@router.get("/{device_id}")async def get_device(device_id: int, svc: DeviceService = Depends(get_device_service)): try: return await svc.get_device(device_id) except DeviceNotFoundError: raise HTTPException(status_code=404, detail=f"Device with id {device_id} not found")This is correct HTTP. It is also, under Logfire’s default instrumentation, a recorded exception — stack trace and all — every single time it fires. Do that across every 404 a client fishes for, and your genuine 500s end up buried in noise you can’t afford to ignore.
Why @app.exception_handler doesn’t help
The instinct is to reach for a custom exception handler. It won’t fix this, and the reason is ordering, not logic. Logfire’s instrumentation wraps the endpoint call roughly like this:
try: yield # your endpoint runs hereexcept Exception as exc: root_span.record_exception(exc) # records everything that escapes raiseThat wrapper runs inside FastAPI’s endpoint execution, before Starlette’s exception-handling middleware ever sees the error. By the time your @app.exception_handler builds a clean response, the exception has already escaped the endpoint and been recorded. The handler can change the response. It can’t un-record the exception.
The only thing that stays quiet is returning a response instead of raising one — because then nothing escapes the endpoint for the instrumentation to catch in the first place.
The fix: intercept the seam once
Rewriting every handler to return instead of raise works, but it’s invasive and throws away a clean idiom. The gist instead wraps the endpoint call once, in a custom APIRoute, converting client errors (< 500) into returned responses while letting server errors propagate untouched:
import functoolsfrom fastapi import HTTPExceptionfrom fastapi.responses import JSONResponsefrom fastapi.routing import APIRoute
class QuietClientErrorRoute(APIRoute): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) endpoint = self.dependant.call
@functools.wraps(endpoint) async def quiet_endpoint(*a, **kw): try: return await endpoint(*a, **kw) except HTTPException as exc: if exc.status_code >= 500: raise return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers, )
self.dependant.call = quiet_endpointWired in at construction:
router = APIRouter(route_class=QuietClientErrorRoute)The response your client receives is byte-for-byte the same as FastAPI’s default — same status code, same {"detail": ...} body, same headers — so there’s no contract change. Nothing else about your handlers needs to move.
The parts worth remembering
- Set
route_classat construction, not after.include_routerbakes in each sub-router’s own route class, so setting it on the parent app later does nothing. - This covers endpoint bodies, not dependencies. An
HTTPExceptionraised inside aDepends(...)— auth checks, typically — is recorded on a separate span and isn’t touched by this. Same idea applies if you want to quiet those too. - The
>= 500line is a judgment call, not a law. If something like429or423feels operationally interesting to you, carve it out.
Why this is worth the ten minutes
HTTP already tells you whose problem an error is: 4xx means the caller should fix the request, 5xx means you should fix the server. Instrumentation that flattens both into “exception” throws that signal away, and it’s an easy thing to get wrong even in code you’ve reviewed carefully — the failure mode here isn’t a bug in the handler, it’s a gap between two libraries’ assumptions about who sees an error first. Worth checking for if you’re instrumenting a FastAPI service with any tracer that hooks the same way.