python 130 lines · 3 tabs

Consistent JSON Error Responses in FastAPI With a Custom Exception Handler

Shared by codesnips Jul 2026
3 tabs
from typing import Any, Optional

from fastapi.responses import JSONResponse
from pydantic import BaseModel


class ErrorDetail(BaseModel):
    code: str
    message: str
    details: Optional[Any] = None


class ErrorBody(BaseModel):
    error: ErrorDetail


class AppError(Exception):
    status_code: int = 500
    code: str = "internal_error"

    def __init__(self, message: str, details: Optional[Any] = None):
        super().__init__(message)
        self.message = message
        self.details = details

    def to_response(self) -> JSONResponse:
        body = ErrorBody(
            error=ErrorDetail(code=self.code, message=self.message, details=self.details)
        )
        return JSONResponse(status_code=self.status_code, content=body.model_dump())


class NotFoundError(AppError):
    status_code = 404
    code = "not_found"


class ConflictError(AppError):
    status_code = 409
    code = "conflict"


class UnauthorizedError(AppError):
    status_code = 401
    code = "unauthorized"
3 files · python Explain with highlit

This snippet shows how a FastAPI service can guarantee that every error leaves the application in the same JSON shape, so clients never have to parse ad-hoc payloads. The core idea is to funnel all failures through a single custom exception type and one registered handler, rather than scattering HTTPException and JSONResponse calls across route functions.

In errors.py, AppError is a base exception carrying a machine-readable code, a human message, an HTTP status_code, and optional details. Concrete subclasses like NotFoundError and ConflictError simply pin the status and code, which keeps the vocabulary of failures small and centralized. ErrorBody is a Pydantic model describing the wire format, so the contract is documented and appears in the OpenAPI schema instead of living only in the developer's head. The to_response helper renders any AppError into that envelope under a top-level error key.

In handlers.py, register_error_handlers wires three handlers onto the app. The AppError handler is the happy path: it logs at warning level and returns the structured body. A second handler catches Starlette's built-in HTTPException and Pydantic's RequestValidationError, normalizing them into the same envelope so framework-raised errors don't leak a different shape. The final catch-all handler for Exception logs the full traceback with exc_info and returns a generic 500 that never exposes internal details to callers — a deliberate trade-off favoring safety over debuggability on the client side.

In main.py, the app is assembled and handlers are registered before routes. The get_user route demonstrates the intended style: business code just raise NotFoundError(...) or ConflictError(...) and trusts the handler to format everything. This inverts the usual pattern where each endpoint builds its own responses.

The main pitfall is ordering and coverage: the Exception handler must be registered so it truly catches everything unhandled, and validation errors must be intercepted or FastAPI's default 422 shape will slip through. The payoff is a stable, versionable error contract, cleaner route code, and one place to add fields like request IDs later.


Related snips

Share this code

Here's the card — post it anywhere.

Consistent JSON Error Responses in FastAPI With a Custom Exception Handler — share card
Link copied