[SPOILER ALERT] Watch how a messy error handling system transforms into a clean, unified API in under a minute. Quick Demo Video
APIException: from chaos to clean, predictable API responses.
While working with FastAPI for quite a long time, I kept running into a couple of annoying problems that occurred in almost all projects I started with FastAPI.
Don’t get me wrong because of what I said above; I’m a big FastAPI fan and have been using it for over 4 years in almost all my projects.
It’s still my go-to framework for backend development. But like any tool, there are small pain points that start to pile up over time.
Nothing major at first glance, but these issues caused headaches for frontend integration, documentation, and even debugging.
Let’s break them down one by one.
Problem 1 – Inconsistent Response Formats
Anyone who has spent some time with FastAPI knows that, by default, responses vary depending on the error type:
// Endpoint 1 error
{"error": "User not found"}
// Endpoint 2 validation [422] error
{"detail": [{"loc": ["body", "name"], "msg": "field required"}]}
// Unhandled exception
<html>...HTML error page...</html>
This means:
- 422 Validation errors return Pydantic’s default format
- HTTPExceptions have a different JSON structure
- Unhandled exceptions may even return HTML
- Also, they don’t look great on Swagger. Some aren’t even documented by default.
This is frustrating for API consumers because they need to handle multiple formats for different error types.
Problem 2 – Registering Exception Handlers in Every App is a Pain
If you work on multiple projects at the same time, you quickly realise that setting up everything you’ve already done in previous projects for each new one gets old fast.
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException: {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail},
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
logger.error(f"ValueError: {str(exc)}")
return JSONResponse(
status_code=400,
content={"error": str(exc)},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(f"Validation error: {exc.errors()}")
return JSONResponse(
status_code=422,
content={"error": exc.errors()},
)
For every app you build, you have to:
- Register exception handlers
- Ensure the response format remains consistent across all exceptions
- Repeat the same boilerplate code over and over again
And yes... It's boring, and I hate it.
Problem 3 – The Repetitive Code You Keep Writing in Every Endpoint At Least Once
In many FastAPI projects, I often see (and used to write) error handling like this:
if some_event:
logger.error("User not found")
raise HTTPException(detail="User not found", status_code=404)
I believe that every exception should be logged in most projects, unless you have intentionally configured it not to log a specific error.
The Goal
In short, the goal is to solve the problems described above using fewer lines of code while maintaining the same functionality, adding no extra overhead, and causing no performance issues.
I wanted a solution that would:
- A single unified response schema for all cases (success or exception/error)
- Swagger/OpenAPI docs to reflect all possible responses
- Unhandled exceptions to return clean JSON
- Automatic logging unless you explicitly disable it
Solutions
I've created a Python package called APIException that includes solutions for problems described above and more...
Documentation is clear and suggested.
Installation
You can install it directly via pip:
pip install apiexception
And you can import it:
import api_exception
Once you have it installed, you're good to go.
1) The Solution for Problem - 1: Unified ResponseModel
One of the biggest things I wanted was for every single API response, whether it’s a success or an error, to have the exact same JSON format.
So, here’s what I did: I created a ResponseModel that every single endpoint and every single exception in the app will use.
Why do I think that using ResponseModel is awesome?
When the response format is standardised for both success and error cases, the frontend integration becomes a breeze.
The frontend team always knows the API will return the same structure, so after parsing the JSON, it can simply check the status field:
- If
status is "SUCCESS" → grab the payload from the data field.
- If
status is "FAIL" → check the error_code.
- From there, the frontend can either use its own custom error message or use the
message or description fields returned by the API.
- The
description and message fields can also serve different purposes in success cases (for example, human-readable info or debugging context).
Example: Before & After using ResponseModel in FastAPI router's response_model
Before:
{"error": "User not found"}
{"detail": [{"loc": ["body", "name"], "msg": "field required"}]}
<html>...HTML error page...</html>
After – Error Case (APIException)
{
"data": null,
"status": "FAIL",
"error_code": "USR-404",
"message": "User not found.",
"description": "The user ID does not exist."
}
After – Error Case (Unhandled Exception)
{
"data": null,
"status": "FAIL",
"error_code": "SRV-500",
"message": "Internal Server Error",
"description": "An unexpected error occurred."
}
After – Success Case
{
"data": {
"id": 1,
"name": "John Doe"
},
"status": "SUCCESS",
"error_code": null,
"message": "Operation completed successfully.",
"description": "User gathered successfully."
}
I believe that the above ResponseModel described above is cleaner, more consistent, and makes things easier for both backend and frontend teams. The backend team will always know the error_code, which makes it easier to find bugs from the logs, while the frontend team can handle responses much more efficiently. They no longer need to check if the http_status_code is 20x and then parse the response. Instead, they can simply parse the response once, check the status parameter to determine whether it is a success or error, and then look at either the data parameter or the error_code parameter accordingly.
2) The Solution for Problem - 2: APIException
Another major improvement I wanted was to avoid writing repetitive logging code in every endpoint before raising exceptions.
With APIException, all exceptions are automatically logged unless you explicitly disable logging for a specific error.
This means you can replace code like this:
if not user:
logger.error("User not found")
raise HTTPException(status_code=404, detail="User not found")]
With a much cleaner and shorter version:
from api_exception import APIException, BaseExceptionCode
class CustomExceptionCode(BaseExceptionCode):
USER_NOT_FOUND = ("USR-404", "User not found.", "The user ID does not exist.")
if not user:
raise APIException(ExceptionCode.USER_NOT_FOUND)
Using APIException together with a CustomExceptionCode that extends your BaseExceptionCode not only makes logs easier to read, but also creates a reusable structure for handling repeated exception messages. When you used APIException together with a CustomExceptionCode it automatically provides the error_code, message, and description from there. So define your own custom exception class and make the backend more consistent in terms of logging and code repetition.
When raised, APIException will:
- Automatically log the error with the provided
error_code, message, and description
- Return a unified response format (via
ResponseModel)
- Optionally disable logging for certain exceptions (e.g.,
log=False if you don’t want it logged)
- Optionally you can add extra log message by using
log_message: [str | dict] parameter.
3) The Solution for Problem - 3: One-Time Handler Registration with register_exception_handlers
Instead of adding exception handlers all over the place and duplicating app.add_exception_handler(...) calls,
I wanted a single entry point to set up my API’s exception handling.
That’s exactly what register_exception_handlers does.
When you call it, it:
- Attaches APIException handlers so all your
APIException raises return the same format.
- Optionally adds a fallback middleware to catch all unhandled exceptions (like runtime errors, DB failures, or 3rd-party API crashes).
- Ensures consistent logging:
- Always logs
error_code, message, description.
- Can log full tracebacks for handled errors or only for unhandled ones.
- Supports multiple response formats:
ResponseModel (your unified API response structure)
RFC7807 (Problem Details for HTTP APIs)
dict (plain JSON dicts)
- Can decide if
null fields are included in OpenAPI docs or hidden.
- Centralizes all logging options in one place.
Why it’s great:
- I don’t have to remember to register handlers in every new project.
- I can switch response formats for the whole API in one line.
- Logging behavior is configurable without touching the actual endpoints.
- It keeps my project clean and DRY.
Example – minimal setup:
from fastapi import FastAPI
from api_exception import register_exception_handlers
app = FastAPI()
register_exception_handlers(app)
Example:
from fastapi import FastAPI
from api_exception import (
register_exception_handlers,
ResponseFormat,
logger
)
app = FastAPI()
#You can set different log levels. `default="WARNING"`
logger.setLevel("DEBUG")
#Default settings are provided below.
register_exception_handlers(
app=app,
response_format=ResponseFormat.RESPONSE_MODEL,
use_fallback_middleware=True,
log_traceback=True,
log_traceback_unhandled_exception=True,
include_null_data_field_in_openapi=True,
# v0.2.0 new features
log_level="ERROR", # override verbosity for exception logs
log_request_context=True, # include request headers in logs
log_header_keys=("x-request-id", "user-agent"),
extra_log_fields={"service": "user-api"}, # inject structured metadata
response_headers=("x-user-id",), # echo back specific headers in response
)
“Copy-Paste" Example
Below is a minimal, end-to-end example showing:
- one unified
ResponseModel for success and errors
- global handler registration with
register_exception_handlers which let's you auto log, show tracebacks for both APIException and unhandled exceptions such as db or third party errors. Moreover, the ResponseFormat will be ResponseModel.
- a custom exception code set via
BaseExceptionCode
- how unhandled exceptions are caught and shaped the same way
from typing import List
from fastapi import FastAPI, Path
from pydantic import BaseModel, Field
from api_exception import (
APIException,
BaseExceptionCode,
ResponseModel,
register_exception_handlers,
APIResponse,
logger,
)
app = FastAPI()
#1) Register once → consistent responses + logging everywhere
register_exception_handlers(app=app)
# Optional: set a different log level for 'apiexception'
logger.setLevel("DEBUG")
#2) Define your custom exception codes (clean logs, reusable messages)
class CustomExceptionCode(BaseExceptionCode):
USER_NOT_FOUND = ("USR-404", "User not found.", "The user ID does not exist.")
INVALID_API_KEY = ("API-401", "Invalid API key.", "Provide a valid API key.")
PERMISSION_DENIED = ("PERM-403", "Permission denied.", "Access to this resource is forbidden.")
#3) Your domain models
class UserModel(BaseModel):
id: int = Field(...)
username: str = Field(...)
class UserResponse(BaseModel):
users: List[UserModel] = Field(..., description="List of user objects")
#4) An endpoint that returns unified responses in all cases
@app.get(
"/user/{user_id}",
response_model=ResponseModel[UserResponse],
responses=APIResponse.default(),
)
async def user(user_id: int = Path(..., description="The ID of the user")):
# Handled error example (uses your code+message+description and logs it)
if user_id == 1:
raise APIException(
error_code=CustomExceptionCode.USER_NOT_FOUND,
http_status_code=404, # or 401 if you prefer
)
# Unhandled error example (still unified + logged)
if user_id == 3:
a = 1
b = 0
c = a / b # ZeroDivisionError,caught by fallback, same JSON shape
return c
# Success case (same structure, frontend always reads status+data)
users = [
UserModel(id=1, username="John Doe"),
UserModel(id=2, username="Jane Smith"),
UserModel(id=3, username="Alice Johnson"),
]
data = UserResponse(users=users)
return ResponseModel[UserResponse](
data=data,
description="User found and returned.",
)
After running the above app, you will have the swagger documentation as in the video below.
Click to Watch The Video
When the app raise APIException, the exception will be logged as below by default:

When the app raise unexpected error such as a ZeroDivisionError, a database connection failure, or a third-party API timeout, the exception will be logged as below by default:

What’s New in v0.2.0
The v0.2.0 release focused on making exception handling even more flexible and logging more powerful:
Added
- Advanced logging customizations in
register_exception_handlers:
log_level: override verbosity for exception logging.
log_request_context: toggle to include/exclude request headers in logs.
log_header_keys: define which headers are logged (default: x-request-id, user-agent, etc.).
extra_log_fields: inject custom structured metadata into logs (e.g. user_id, masked API keys).
- Response headers echo in
register_exception_handlers:
response_headers=True → default headers echoed back.
response_headers=False → no headers echoed.
response_headers=("x-user-id",) → custom headers echoed.
APIException now accepts header parameters (responses can carry custom headers).
- Mypy support for static type checking.
- Extended docs with logging patterns and advanced usage examples.
Changed
- Logging format revamped → now more structured, readable, and consistent.
- Error logs enriched with request path, method, client IP, HTTP version, etc.
Fixed
- Swagger/OpenAPI now shows consistent error schemas even when
data was missing.
- File structure cleanup → simplified imports in
__init__.py.
- Type hint fixes for
error_code as BaseExceptionCode.
Upgrade now:
pip install apiexception --upgrade
Performance Impact
I wanted to make sure this solution would not slow down the API.
So, I ran a simple benchmark with 200 concurrent users and compared it to FastAPI’s built-in HTTPException:
- fastapi.HTTPException → 2.00 ms
- api_exception.APIException → 2.72 ms
That’s only +0.72 ms difference, when you think that HTTPException doesn't log the exceptions however APIException does log the exceptions that occur in the app so the difference is fair for me.
Community recognition
- The library was featured on Python Weekly #710, one of the largest Python newsletters worldwide.
- It also reached #3 in r/FastAPI’s pip-package flair on Reddit.
This kind of feedback and visibility motivates me to keep improving APIException and adding more features for the community.
Why I Think This Matters
- Frontend developers love it because they can handle every response the same way.
- Backend developers love it because debugging is easier with consistent logs.
- QA testers love it because error states are predictable.
- API consumers love it because the docs clearly show all response shapes.
If this resonates with you, give it a quick spin:
- Install:
pip install apiexception
- Register:
register_exception_handlers(app)
- Define codes: extend
BaseExceptionCode from api_exception
- Success: return
ResponseModel[...]
- Errors: raise
APIException(...)
- Docs: add
APIResponse.default() to your routes → makes Swagger docs much cleaner
I’m actively improving the library—feedback, issues, and PRs are very welcome. And if it saves you some time (it probably will), a GitHub star would mean a lot.
Links