Building a production-ready API is not only about handling successful requests. A good API should also handle failures consistently, safely, and predictably.
The key principle is simple:
Services should raise meaningful exceptions. Global handlers should convert those exceptions into consistent HTTP responses.
AppExceptionStart with a base exception for application-specific errors.
from typing import Anyclass AppException(Exception):def __init__(self,error_code: int,error_message: str,error: Any = None,status_code: int = 500,):self.status_code = status_codeself.error_code = error_codeself.error_message = error_messageself.error = errorsuper().__init__(error_message)
Each exception carries four pieces of information:
status_code: HTTP status code returned to the client.error_code: Application-specific error identifier.error_message: Human-readable description.error: Optional additional details, such as validation issues.This separates application error information from the HTTP response format.
A consistent response structure makes errors easier for frontend applications to handle.
from typing import Anyfrom fastapi.responses import JSONResponsedef generate_error_response(status_code: int,error_code: int,message: str,error: Any = None,) -> JSONResponse:return JSONResponse(status_code=status_code,content={"status_code": status_code,"error_code": error_code,"message": message,"error": error,},)
Example response:
{"status_code": 404,"error_code": 1001,"message": "Client not found","error": null}
The HTTP status communicates the type of failure, while error_code identifies the specific application error.
Instead of repeating error-handling logic in every route, register handlers centrally.
Handle AppException
import loggingfrom fastapi import Requestfrom fastapi.responses import JSONResponselogger = logging.getLogger(__name__)async def app_exception_handler(request: Request,exc: AppException,) -> JSONResponse:if exc.status_code >= 500:logger.error("Application error: %s",exc.error_message,)else:logger.warning("Application error: %s",exc.error_message,)return generate_error_response(status_code=exc.status_code,error_code=exc.error_code,message=exc.error_message,error=exc.error,)
This handler converts application exceptions into the standard response format.
Handle HTTPException
FastAPI’s HTTPException represents standard HTTP errors, such as 404 Not Found or 403 Forbidden.
from fastapi import HTTPExceptionasync def http_exception_handler(request: Request,exc: HTTPException,) -> JSONResponse:return generate_error_response(status_code=exc.status_code,error_code=exc.status_code,message="HTTP Exception",error=exc.detail,)
For production APIs, consider preserving a safe, human-readable exc.detail as the message instead of always returning "HTTP Exception".
Handle Unexpected Exceptions
Unexpected errors should be logged internally without exposing stack traces or sensitive details to clients.
async def generic_exception_handler(request: Request,exc: Exception,) -> JSONResponse:logger.exception("Unhandled exception")return generate_error_response(status_code=500,error_code=5000,message="Internal server error",)
The server logs retain diagnostic information, while the client receives a generic error message.
Handle Request Validation Errors
FastAPI raises RequestValidationError when incoming request data does not satisfy the declared schema.
from fastapi.exceptions import RequestValidationErrorasync def validation_exception_handler(request: Request,exc: RequestValidationError,) -> JSONResponse:return generate_error_response(status_code=422,error_code=4220,message="Request validation failed",error=exc.errors(),)
Before returning validation details, ensure they are JSON-serializable and do not expose sensitive input values.
Register the exception handlers when initializing the FastAPI application.
from fastapi import FastAPIapp = FastAPI()app.add_exception_handler(AppException,app_exception_handler,)app.add_exception_handler(HTTPException,http_exception_handler,)app.add_exception_handler(RequestValidationError,validation_exception_handler,)app.add_exception_handler(Exception,generic_exception_handler,)
FastAPI will dispatch matching exceptions to their registered handlers. More specific handlers should be registered for exceptions that require specialized behavior.
As the application grows, create specialized exceptions for different modules.
AppException├── DBException├── AuthException├── PaymentException└── ExternalAPIException
For example:
class DBException(AppException):passclass AuthException(AppException):pass
These subclasses inherit the common response metadata while making errors easier to identify and handle.
The service layer should focus on business logic rather than constructing HTTP responses.
from sqlalchemy.exc import SQLAlchemyErrorasync def get_client(session, client_id: int):try:result = await session.execute(select(Client).where(Client.id == client_id))client = result.scalar_one_or_none()except SQLAlchemyError as exc:await session.rollback()raise DBException(error_code=1001,error_message="Failed to retrieve client.",status_code=500,) from excif client is None:raise AppException(error_code=1002,error_message="Client not found.",status_code=404,)return client
Here, database failures and missing clients are handled differently:
The from exc clause preserves the original exception as the cause for internal debugging. Avoid returning raw database exception messages to clients.
A clean exception-handling architecture keeps business logic independent of HTTP concerns and makes API failures consistent, secure, and easier to debug.