Skip to content

FastAPI Error Handling and API Responses

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.

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
Terminal window
mkdir 08-error-handling-responses
cd 08-error-handling-responses
mkdir app
touch app/__init__.py

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 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 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 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 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.

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

requirements.txt
fastapi
uvicorn[standard]

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 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 Docker Compose from inside the project folder.

Terminal window
docker compose up --build

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

http://localhost:8000/docs

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"
}

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
}

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
}

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"
}
]
}

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
}

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
}

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
}

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

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 running container from the active Docker Compose terminal or shut it down from another terminal.

Terminal window
# Press this in the running terminal
CTRL + C
# Or run this from another terminal
docker compose down