Custom Error Responses · learncode.live

The Need for a Unified Error Format

Without a standardised error structure, clients must parse different shapes for every endpoint. A unified format simplifies client-side error handling:

{
  "error": {
    "code": "ITEM_NOT_FOUND",
    "message": "The requested item was not found",
    "details": {"item_id": 42}
  }
}

Error Detail Schema with Pydantic

Define a base error schema using Pydantic:

from pydantic import BaseModel
from typing import Any, Optional


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


class ErrorResponse(BaseModel):
    error: ErrorDetail

Custom Exception Classes

Create a hierarchy of domain-specific exceptions:

class AppException(Exception):
    def __init__(
        self,
        code: str,
        message: str,
        status_code: int = 500,
        details: dict[str, Any] | None = None,
    ):
        self.code = code
        self.message = message
        self.status_code = status_code
        self.details = details or {}


class NotFoundError(AppException):
    def __init__(self, entity: str, entity_id: Any):
        super().__init__(
            code="NOT_FOUND",
            message=f"{entity} with id '{entity_id}' not found",
            status_code=404,
            details={"entity": entity, "entity_id": str(entity_id)},
        )


class BadRequestError(AppException):
    def __init__(self, message: str, details: dict[str, Any] | None = None):
        super().__init__(
            code="BAD_REQUEST",
            message=message,
            status_code=400,
            details=details,
        )


class UnauthorizedError(AppException):
    def __init__(self, message: str = "Authentication required"):
        super().__init__(
            code="UNAUTHORIZED",
            message=message,
            status_code=401,
        )


class ForbiddenError(AppException):
    def __init__(self, message: str = "Insufficient permissions"):
        super().__init__(
            code="FORBIDDEN",
            message=message,
            status_code=403,
        )


class ConflictError(AppException):
    def __init__(self, message: str, details: dict[str, Any] | None = None):
        super().__init__(
            code="CONFLICT",
            message=message,
            status_code=409,
            details=details,
        )

Global Exception Handler for AppException

Register a single handler to catch all AppException subclasses:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()


@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content=ErrorResponse(
            error=ErrorDetail(
                code=exc.code,
                message=exc.message,
                details=exc.details,
            )
        ).model_dump(),
    )

Using Custom Exceptions in Endpoints

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    if user_id <= 0:
        raise BadRequestError("Invalid user ID", details={"user_id": user_id})

    user = fake_db.get(user_id)
    if user is None:
        raise NotFoundError("User", user_id)

    return user


@app.post("/users")
async def create_user(data: dict):
    if data.get("email") in existing_emails:
        raise ConflictError(
            "Email already registered",
            details={"email": data["email"]},
        )
    return {"id": 123, **data}

Exception Handler for 500s

Catch all unhandled exceptions so the API never leaks internal details:

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    # Log the full traceback internally
    logger.exception("Unhandled exception")
    return JSONResponse(
        status_code=500,
        content=ErrorResponse(
            error=ErrorDetail(
                code="INTERNAL_ERROR",
                message="An unexpected error occurred",
            )
        ).model_dump(),
    )

Register it last so more specific handlers take precedence.

Overriding the Default Validation Error

FastAPI returns Pydantic validation errors as a list. Override to use your unified format:

from fastapi.exceptions import RequestValidationError


@app.exception_handler(RequestValidationError)
async def validation_error_handler(
    request: Request, exc: RequestValidationError
):
    errors = exc.errors()
    message = "; ".join(
        f"{'.'.join(str(l) for l in e['loc'])}: {e['msg']}"
        for e in errors
    )
    return JSONResponse(
        status_code=422,
        content=ErrorResponse(
            error=ErrorDetail(
                code="VALIDATION_ERROR",
                message=message,
                details={"errors": errors},
            )
        ).model_dump(),
    )

Putting It All Together

app = FastAPI()


@app.exception_handler(AppException)
async def handle_app_exception(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content=ErrorResponse(
            error=ErrorDetail(
                code=exc.code, message=exc.message, details=exc.details
            )
        ).model_dump(),
    )


@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content=ErrorResponse(
            error=ErrorDetail(
                code="VALIDATION_ERROR",
                message="Request validation failed",
                details={"fields": exc.errors()},
            )
        ).model_dump(),
    )


@app.exception_handler(Exception)
async def handle_unhandled(request: Request, exc: Exception):
    logger.exception("Unhandled exception")
    return JSONResponse(
        status_code=500,
        content=ErrorResponse(
            error=ErrorDetail(
                code="INTERNAL_ERROR",
                message="An unexpected error occurred",
            )
        ).model_dump(),
    )

Key Takeaways

  • A unified error format makes client-side error handling predictable.
  • Domain-specific exception classes (NotFoundError, BadRequestError, etc.) keep code readable and reusable.
  • Use a base AppException with a single global handler instead of one handler per exception.
  • Never leak stack traces - catch all unhandled exceptions and return a generic 500 response.
  • Override RequestValidationError to convert Pydantic’s default format into your unified schema.
  • Register exception handlers in order of specificity; the most specific match wins.
Courses