Quick Demo Video
Watch how a messy error handling system transforms into a clean, unified API in under a minute.
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 thedata
field. -
If
status
is"FAIL"
→ check theerror_code
.- From there, the frontend can either use its own custom error message or use the
message
ordescription
fields returned by the API. - The
description
andmessage
fields can also serve different purposes in success cases (for example, human-readable info or debugging context).
- From there, the frontend can either use its own custom error message or use the
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
, anddescription
- 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.
- Always logs
- 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)
“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, theResponseFormat
will beResponseModel
. - 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, but you can set different log levels 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=401, # adjust to 404 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.
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:
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 Avg: 2.00 ms api_exception.APIException Avg: 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.
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:
pip install apiexception
, call register_exception_handlers(app)
, define your own custom exception class extending BaseExceptionCode
from api_exception
, return ResponseModel[...]
on success, and raise APIException(...)
for errors.
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
Top comments (0)