Ctrl K

FastAPI Error Handling and API Responses

A Docker based FastAPI project that uses custom exceptions, global exception handlers, validation error shaping, and consistent API response contracts.

This setup creates a focused FastAPI error handling project. It separates success responses from error responses, uses custom exception classes for domain errors, handles validation errors consistently, and returns predictable JSON shapes for client applications.

Project structure

Create a separate project folder for the error handling example. This project keeps exceptions, handlers, schemas, and routes in separate files.

08-error-handling-responses/
  app/
    __init__.py
    exceptions.py
    handlers.py
    main.py
    schemas.py
    store.py
  requirements.txt
  Dockerfile
  compose.yaml
mkdir 08-error-handling-responses
cd 08-error-handling-responses
mkdir app
touch app/__init__.py

Create response schemas

Create the response schema file at app/schemas.py. These schemas document the normal API response shape and the error response shape.

app/schemas.py
from pydantic import BaseModel, Field


class UserCreate(BaseModel):
    username: str = Field(min_length=1, max_length=50)
    email: str = Field(min_length=3, max_length=120)


class UserUpdate(BaseModel):
    username: str | None = Field(default=None, min_length=1, max_length=50)
    email: str | None = Field(default=None, min_length=3, max_length=120)


class UserResponse(BaseModel):
    id: int
    username: str
    email: str


class ErrorResponse(BaseModel):
    error_code: str
    message: str
    details: dict | list | None = None


class SuccessResponse(BaseModel):
    message: str

ErrorResponse gives the frontend a stable shape. The client can display message, inspect error_code, and optionally use details for field level validation feedback.

Create custom exceptions

Create the custom exception file at app/exceptions.py. These classes represent domain errors that the route layer can raise without repeating HTTP response details everywhere.

app/exceptions.py
class AppError(Exception):
    status_code = 500
    error_code = "internal_error"
    message = "Internal server error"

    def __init__(self, message=None, details=None):
        self.message = message or self.message
        self.details = details


class UserNotFoundError(AppError):
    status_code = 404
    error_code = "user_not_found"
    message = "User not found"


class UserAlreadyExistsError(AppError):
    status_code = 409
    error_code = "user_already_exists"
    message = "Username or email already exists"


class ForbiddenActionError(AppError):
    status_code = 403
    error_code = "forbidden_action"
    message = "This action is not allowed"


class InvalidOperationError(AppError):
    status_code = 400
    error_code = "invalid_operation"
    message = "Invalid operation"

The custom exceptions keep business meaning separate from the HTTP formatting. The handler later converts these exceptions into JSON responses.

Create an in-memory store

Create the store file at app/store.py. This keeps the example focused on error handling instead of database setup.

app/store.py
users = {}
next_user_id = 1


def create_user(payload):
    global next_user_id

    user = {
        "id": next_user_id,
        "username": payload.username,
        "email": payload.email,
    }

    users[next_user_id] = user
    next_user_id += 1

    return user


def list_users():
    return list(users.values())


def get_user(user_id):
    return users.get(user_id)


def update_user(user_id, update_data):
    user = users.get(user_id)

    if not user:
        return None

    user.update(update_data)

    return user


def delete_user(user_id):
    return users.pop(user_id, None)


def username_or_email_exists(username, email):
    return any(
        user["username"] == username or user["email"] == email
        for user in users.values()
    )

A real application would replace this file with SQLAlchemy queries, but the error handling pattern stays the same.

Create exception handlers

Create the exception handler file at app/handlers.py. These handlers convert custom exceptions, HTTP exceptions, validation errors, and unexpected exceptions into predictable JSON responses.

app/handlers.py
from fastapi import Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.exceptions import AppError


async def app_error_handler(_request: Request, exc: AppError):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error_code": exc.error_code,
            "message": exc.message,
            "details": exc.details,
        },
    )


async def http_error_handler(_request: Request, exc: StarletteHTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error_code": "http_error",
            "message": exc.detail,
            "details": None,
        },
    )


async def validation_error_handler(_request: Request, exc: RequestValidationError):
    field_errors = []

    for error in exc.errors():
        field_errors.append(
            {
                "field": ".".join(str(item) for item in error["loc"]),
                "message": error["msg"],
                "type": error["type"],
            }
        )

    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={
            "error_code": "validation_error",
            "message": "Request validation failed",
            "details": field_errors,
        },
    )


async def unhandled_error_handler(_request: Request, _exc: Exception):
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={
            "error_code": "internal_error",
            "message": "Internal server error",
            "details": None,
        },
    )

The unhandled exception handler avoids leaking internal stack traces to the client. Server logs should still capture the real traceback in a production setup.

Create the FastAPI app

Create the main application file at app/main.py. This file registers the exception handlers and defines routes that raise domain errors when something fails.

app/main.py
from fastapi import FastAPI, HTTPException, Response, status
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

from app import store
from app.exceptions import (
    AppError,
    ForbiddenActionError,
    InvalidOperationError,
    UserAlreadyExistsError,
    UserNotFoundError,
)
from app.handlers import (
    app_error_handler,
    http_error_handler,
    unhandled_error_handler,
    validation_error_handler,
)
from app.schemas import ErrorResponse, SuccessResponse, UserCreate, UserResponse, UserUpdate

app = FastAPI(title="08 Error Handling Responses")

app.add_exception_handler(AppError, app_error_handler)
app.add_exception_handler(StarletteHTTPException, http_error_handler)
app.add_exception_handler(RequestValidationError, validation_error_handler)
app.add_exception_handler(Exception, unhandled_error_handler)


@app.get("/")
def home():
    return {
        "message": "FastAPI error handling example"
    }


@app.post(
    "/users",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
    responses={
        409: {
            "model": ErrorResponse
        },
        422: {
            "model": ErrorResponse
        },
    },
)
def create_user(payload: UserCreate):
    if store.username_or_email_exists(payload.username, payload.email):
        raise UserAlreadyExistsError()

    return store.create_user(payload)


@app.get("/users", response_model=list[UserResponse])
def list_users():
    return store.list_users()


@app.get(
    "/users/{user_id}",
    response_model=UserResponse,
    responses={
        404: {
            "model": ErrorResponse
        },
    },
)
def get_user(user_id: int):
    user = store.get_user(user_id)

    if not user:
        raise UserNotFoundError()

    return user


@app.patch(
    "/users/{user_id}",
    response_model=UserResponse,
    responses={
        400: {
            "model": ErrorResponse
        },
        404: {
            "model": ErrorResponse
        },
        422: {
            "model": ErrorResponse
        },
    },
)
def update_user(user_id: int, payload: UserUpdate):
    update_data = payload.model_dump(exclude_unset=True)

    if not update_data:
        raise InvalidOperationError(
            message="At least one field must be provided",
        )

    user = store.update_user(user_id, update_data)

    if not user:
        raise UserNotFoundError()

    return user


@app.delete(
    "/users/{user_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    responses={
        403: {
            "model": ErrorResponse
        },
        404: {
            "model": ErrorResponse
        },
    },
)
def delete_user(user_id: int):
    if user_id == 1:
        raise ForbiddenActionError(
            message="The first user cannot be deleted in this example",
        )

    user = store.delete_user(user_id)

    if not user:
        raise UserNotFoundError()

    return Response(status_code=status.HTTP_204_NO_CONTENT)


@app.get(
    "/manual-http-error",
    responses={
        418: {
            "model": ErrorResponse
        },
    },
)
def manual_http_error():
    raise HTTPException(
        status_code=418,
        detail="Manual HTTP exception example",
    )


@app.get(
    "/crash",
    responses={
        500: {
            "model": ErrorResponse
        },
    },
)
def crash():
    raise RuntimeError("Simulated unexpected crash")

The responses argument improves the generated OpenAPI documentation by showing which error response model can be returned from each route.

Add dependencies

Create the dependency file at requirements.txt. This example only needs FastAPI and Uvicorn.

requirements.txt
fastapi
uvicorn[standard]

Create the Dockerfile

Create the Dockerfile at the project root. The container owns the Python environment and runs the FastAPI app with Uvicorn.

Dockerfile
FROM python:3.12-slim

WORKDIR /app

RUN python -m venv /opt/venv

ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY requirements.txt .

RUN pip install --upgrade pip
RUN pip install -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Create the Compose file

Create the Docker Compose file at compose.yaml. The source folder is mounted into the container so code changes reload during development.

compose.yaml
services:
  backend:
    build: .
    container_name: fastapi-08-error-handling-responses
    ports:
      - "8000:8000"
    volumes:
      - .:/app

Run the project

Run Docker Compose from inside the project folder.

docker compose up --build

Open the API docs page to inspect the success and error response models.

http://localhost:8000/docs

Test success response

Create a user through POST /users. The successful response follows UserResponse.

{
  "username": "baris",
  "email": "baris@example.com"
}
{
  "id": 1,
  "username": "baris",
  "email": "baris@example.com"
}

Test conflict error

Send the same user again through POST /users. The custom UserAlreadyExistsError becomes a 409 response.

{
  "error_code": "user_already_exists",
  "message": "Username or email already exists",
  "details": null
}

Test not found error

Open a missing user. The custom UserNotFoundError becomes a 404 response.

http://localhost:8000/users/999
{
  "error_code": "user_not_found",
  "message": "User not found",
  "details": null
}

Test validation error

Send an invalid request body. FastAPI raises RequestValidationError before the route logic runs, and the custom validation handler returns a consistent shape.

{
  "username": "",
  "email": ""
}
{
  "error_code": "validation_error",
  "message": "Request validation failed",
  "details": [
    {
      "field": "body.username",
      "message": "String should have at least 1 character",
      "type": "string_too_short"
    },
    {
      "field": "body.email",
      "message": "String should have at least 3 characters",
      "type": "string_too_short"
    }
  ]
}

Test forbidden error

Delete user 1. This example blocks deleting the first user and returns a 403 error.

DELETE /users/1
{
  "error_code": "forbidden_action",
  "message": "The first user cannot be deleted in this example",
  "details": null
}

Test manual HTTP exception

Open the manual HTTP error route. The global HTTP exception handler converts normal HTTPException output into the same error response shape.

http://localhost:8000/manual-http-error
{
  "error_code": "http_error",
  "message": "Manual HTTP exception example",
  "details": null
}

Test unexpected error

Open the crash route. The global unhandled error handler returns a generic 500 response without exposing the internal exception message.

http://localhost:8000/crash
{
  "error_code": "internal_error",
  "message": "Internal server error",
  "details": null
}

Error response rules

A good API should make error responses predictable. Frontend clients should not need to handle many unrelated error shapes.

Use 400
  request is valid JSON but operation is invalid

Use 401
  authentication is missing or invalid

Use 403
  authenticated user is not allowed

Use 404
  requested resource does not exist

Use 409
  request conflicts with existing state

Use 422
  request body, path, or query validation failed

Use 500
  unexpected server failure

Mental model

The route layer should raise meaningful errors. The handler layer should format those errors. This keeps route code clean and makes API clients easier to build.

Route function
  raises domain exception

Custom exception
  stores status_code, error_code, message, details

Global handler
  converts exception into JSONResponse

ErrorResponse
  documents the response shape

Frontend client
  reads error_code and message consistently

Stop the project

Stop the running container from the active Docker Compose terminal or shut it down from another terminal.

# Press this in the running terminal
CTRL + C

# Or run this from another terminal
docker compose down