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.
Project structure
Section titled “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.yamlmkdir 08-error-handling-responsescd 08-error-handling-responsesmkdir apptouch app/__init__.pyCreate response schemas
Section titled “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.pyfrom 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: strErrorResponse 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
Section titled “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.pyclass 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
Section titled “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.pyusers = {}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
Section titled “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.pyfrom fastapi import Request, statusfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom 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
Section titled “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.pyfrom fastapi import FastAPI, HTTPException, Response, statusfrom fastapi.exceptions import RequestValidationErrorfrom starlette.exceptions import HTTPException as StarletteHTTPException
from app import storefrom 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
Section titled “Add dependencies”Create the dependency file at requirements.txt. This example only needs FastAPI and Uvicorn.
requirements.txtfastapiuvicorn[standard]Create the Dockerfile
Section titled “Create the Dockerfile”Create the Dockerfile at the project root. The container owns the Python environment and runs the FastAPI app with Uvicorn.
DockerfileFROM python:3.12-slim
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"ENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --upgrade pipRUN 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
Section titled “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.yamlservices: backend: build: . container_name: fastapi-08-error-handling-responses ports: - "8000:8000" volumes: - .:/appRun the project
Section titled “Run the project”Run Docker Compose from inside the project folder.
docker compose up --buildOpen the API docs page to inspect the success and error response models.
http://localhost:8000/docsTest success response
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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 failureMental model
Section titled “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 consistentlyStop the project
Section titled “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 terminalCTRL + C
# Or run this from another terminaldocker compose down