FASTAPI · OBSERVABILITY

Quieting 4xx noise in FastAPI + Logfire without losing your 500s

·3 min read

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 here
except Exception as exc:
root_span.record_exception(exc) # records everything that escapes
raise

That 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 functools
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from 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_endpoint

Wired 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_class at construction, not after. include_router bakes 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 HTTPException raised inside a Depends(...) — 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 >= 500 line is a judgment call, not a law. If something like 429 or 423 feels 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.