Error Handling in FastAPI
At this point, you are probably familiar with the concept of error handling as we have used it in previous chapters. We have been using the built-in HTTPException
class to raise exceptions within our application. While HTTPException
is useful for general errors, there may be situations where we need to handle errors specific to the functionalities of our application. In such cases, creating custom error classes can provide more specific and easy-to-document error handling. In this chapter, we will explore how to create custom error-handling for our application to manage errors more effectively.
The FastAPI HTTPException
Class
The HTTPException
class in FastAPI is an excellent tool for raising exceptions by creating a JSON response with the appropriate status code and message. It also allows us to provide headers for the response. Here's a simple example of how it works:
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tag does not exist"
)
In this example, if the tag
variable is None
, an HTTPException
is raised with a 404 Not Found
status code and a custom error message.
While this method is sufficient for most cases, there are situations where we may want to provide more details about what caused the error and what users can do to resolve it. This is where custom error handling comes into play.
Creating Custom Exceptions
To implement custom error-handling, we will create exception classes and handlers that return JSON responses with detailed information about the exceptions. Start by creating a file named errors.py
in the src
directory. Inside errors.py
, add the following code:
class BooklyException(Exception):
"""This is the base class for all bookly errors"""
pass
class InvalidToken(BooklyException):
"""User has provided an invalid or expired token"""
pass
class RevokedToken(BooklyException):
"""User has provided a token that has been revoked"""
pass
class AccessTokenRequired(BooklyException):
"""User has provided a refresh token when an access token is needed"""
pass
class RefreshTokenRequired(BooklyException):
"""User has provided an access token when a refresh token is needed"""
pass
class UserAlreadyExists(BooklyException):
"""User has provided an email for a user who exists during sign up."""
pass
class InvalidCredentials(BooklyException):
"""User has provided wrong email or password during log in."""
pass
class InsufficientPermission(BooklyException):
"""User does not have the necessary permissions to perform an action."""
pass
class BookNotFound(BooklyException):
"""Book Not found"""
pass
class TagNotFound(BooklyException):
"""Tag Not found"""
pass
class TagAlreadyExists(BooklyException):
"""Tag already exists"""
pass
class UserNotFound(BooklyException):
"""User Not found"""
pass
Exception Classes and Their Descriptions
Each exception class in our application handles a specific type of error. Here’s a table summarizing the exceptions and their descriptions:
Exception Class | Description |
---|---|
BooklyException |
This is the base class for all Bookly errors |
InvalidToken |
User has provided an invalid or expired token |
RevokedToken |
User has provided a token that has been revoked |
AccessTokenRequired |
User has provided a refresh token when an access token is needed |
RefreshTokenRequired |
User has provided an access token when a refresh token is needed |
UserAlreadyExists |
User has provided an email for a user who exists during sign up |
InvalidCredentials |
User has provided wrong email or password during log in |
InsufficientPermission |
User does not have the necessary permissions to perform an action |
BookNotFound |
Book Not found |
TagNotFound |
Tag Not found |
TagAlreadyExists |
Tag already exists |
UserNotFound |
User Not found |
Creating Error Handlers
Each exception class, when handled, will be raised when a specific error has been encountered within the application. To manage these errors effectively, we need to create error-handler functions for each exception. Manually creating these functions can be repetitive and time-consuming. To streamline this process, we can use a function that dynamically creates error-handlers, making our code cleaner and easier to maintain.
def create_exception_handler(
status_code: int, initial_detail: Any
) -> Callable[[Request, Exception], JSONResponse]:
async def exception_handler(request: Request, exc: BooklyException):
return JSONResponse(content=initial_detail, status_code=status_code)
return exception_handler
The create_exception_handler
function helps us generate custom error handlers quickly. It takes two parameters: status_code
and initial_detail
. The status_code
parameter is the HTTP status code that will be returned when the error occurs. The initial_detail
parameter contains the message or content to include in the JSON response.
Within create_exception_handler
, we define an inner function called exception_handler
. This function handles our custom exceptions. It takes two parameters: request
, which is an instance of Request
from FastAPI, and exc
, which is the exception instance. The exception_handler
function returns a JSONResponse
with the specified status_code
and initial_detail
.
The outer function, create_exception_handler
, returns the inner exception_handler
function. This allows us to easily create specific handlers for different exceptions by calling create_exception_handler
with the appropriate arguments.
Registering Error Handlers
To use this function, we can define error handlers for each custom exception by passing the relevant status code and error details. Each handler will return a consistent and informative JSON response, ensuring that each error type is managed properly. We wrap all error handlers in the register_error_handlers
function which takes in the FastAPI app
instance we may want to register exception handlers.
from fastapi import FastAPI
... # rest of the file
def register_error_handlers(app: FastAPI):
app.add_exception_handler(
UserAlreadyExists,
create_exception_handler(
status_code=status.HTTP_403_FORBIDDEN,
initial_detail={
"message": "User with email already exists",
"error_code": "user_exists",
},
),
)
app.add_exception_handler(
UserNotFound,
create_exception_handler(
status_code=status.HTTP_404_NOT_FOUND,
initial_detail={
"message": "User not found",
"error_code": "user_not_found",
},
),
)
app.add_exception_handler(
BookNotFound,
create_exception_handler(
status_code=status.HTTP_404_NOT_FOUND,
initial_detail={
"message": "Book not found",
"error_code": "book_not_found",
},
),
)
app.add_exception_handler(
InvalidCredentials,
create_exception_handler(
status_code=status.HTTP_400_BAD_REQUEST,
initial_detail={
"message": "Invalid Email Or Password",
"error_code": "invalid_email_or_password",
},
),
)
app.add_exception_handler(
InvalidToken,
create_exception_handler(
status_code=status.HTTP_401_UNAUTHORIZED,
initial_detail={
"message": "Token is invalid Or expired",
"resolution": "Please get new token",
"error_code": "invalid_token",
},
),
)
app.add_exception_handler(
RevokedToken,
create_exception_handler(
status_code=status.HTTP_401_UNAUTHORIZED,
initial_detail={
"message": "Token is invalid or has been revoked",
"resolution": "Please get new token",
"error_code": "token_revoked",
},
),
)
app.add_exception_handler(
AccessTokenRequired,
create_exception_handler(
status_code=status.HTTP_401_UNAUTHORIZED,
initial_detail={
"message": "Please provide a valid access token",
"resolution": "Please get an access token",
"error_code": "access_token_required",
},
),
)
app.add_exception_handler(
RefreshTokenRequired,
create_exception_handler(
status_code=status.HTTP_403_FORBIDDEN,
initial_detail={
"message": "Please provide a valid refresh token",
"resolution": "Please get an refresh token",
"error_code": "refresh_token_required",
},
),
)
app.add_exception_handler(
InsufficientPermission,
create_exception_handler(
status_code=status.HTTP_401_UNAUTHORIZED,
initial_detail={
"message": "You do not have enough permissions to perform this action",
"error_code": "insufficient_permissions",
},
),
)
app.add_exception_handler(
TagNotFound,
create_exception_handler(
status_code=status.HTTP_404_NOT_FOUND,
initial_detail={"message": "Tag Not Found", "error_code": "tag_not_found"},
),
)
app.add_exception_handler(
TagAlreadyExists,
create_exception_handler(
status_code=status.HTTP_401_UNAUTHORIZED,
initial_detail={
"message": "Tag Already exists",
"error_code": "tag_exists",
},
),
)
app.add_exception_handler(
BookNotFound,
create_exception_handler(
status_code=status.HTTP_404_NOT_FOUND,
initial_detail={
"message": "Book Not Found",
"error_code": "book_not_found",
},
),
)
@app.exception_handler(500)
async def internal_server_error(request, exc):
return JSONResponse(
content={
"message": "Oops! Something went wrong",
"error_code": "server_error",
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
create_exception_handler
function, we can maintain a cleaner and more manageable codebase. This approach simplifies the process of handling various exceptions and ensures that our application provides clear and helpful error messages to the users.
To make this work, we shall call the register_error_handlers
function inside src/__init__.py
. Let us do that:
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
from src.auth.routes import auth_router
from src.books.routes import book_router
from src.reviews.routes import review_router
from src.tags.routes import tags_router
from .errors import register_error_handlers
version = "v1"
app = FastAPI(
title="Bookly",
description="A REST API for a book review web service",
version=version,
)
register_error_handlers(app) # add this line
app.include_router(book_router, prefix=f"/api/{version}/books", tags=["books"])
app.include_router(auth_router, prefix=f"/api/{version}/auth", tags=["auth"])
app.include_router(review_router, prefix=f"/api/{version}/reviews", tags=["reviews"])
app.include_router(tags_router, prefix=f"/api/{version}/tags", tags=["tags"])
Calling the register_error_handlers
function enables us to register the created error handlers so that our application makes use of them. We provide the main application instance app
to make sure the application uses them.
Modifying our code
After that, we shall then modify our code to make use of the created exceptions. We shall begin with the authentication dependencies. Let us go to src/auth/dependencies.py
and add the following code.
from typing import Any, List
from fastapi import Depends, Request
from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials
from sqlmodel.ext.asyncio.session import AsyncSession
from src.db.main import get_session
from src.db.models import User
from src.db.redis import token_in_blocklist
from .service import UserService
from .utils import decode_token
from src.errors import (
InvalidToken,
RefreshTokenRequired,
AccessTokenRequired,
InsufficientPermission
)
user_service = UserService()
class TokenBearer(HTTPBearer):
def __init__(self, auto_error=True):
super().__init__(auto_error=auto_error)
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
creds = await super().__call__(request)
token = creds.credentials
token_data = decode_token(token)
if not self.token_valid(token):
raise InvalidToken()
if await token_in_blocklist(token_data["jti"]):
raise InvalidToken()
self.verify_token_data(token_data)
return token_data
def token_valid(self, token: str) -> bool:
token_data = decode_token(token)
return token_data is not None
def verify_token_data(self, token_data):
raise NotImplementedError("Please Override this method in child classes")
class AccessTokenBearer(TokenBearer):
def verify_token_data(self, token_data: dict) -> None:
if token_data and token_data["refresh"]:
raise AccessTokenRequired()
class RefreshTokenBearer(TokenBearer):
def verify_token_data(self, token_data: dict) -> None:
if token_data and not token_data["refresh"]:
raise RefreshTokenRequired()
async def get_current_user(
token_details: dict = Depends(AccessTokenBearer()),
session: AsyncSession = Depends(get_session),
):
user_email = token_details["user"]["email"]
user = await user_service.get_user_by_email(user_email, session)
return user
class RoleChecker:
def __init__(self, allowed_roles: List[str]) -> None:
self.allowed_roles = allowed_roles
def __call__(self, current_user: User = Depends(get_current_user)) -> Any:
if current_user.role in self.allowed_roles:
return True
raise InsufficientPermission()
What we've accomplished is replacing every instance where we used HTTPException
with our own custom exceptions designed for each specific type of error.
To keep this chapter concise, I'll leave you with the task of updating the rest of our application's code to include custom error handlers similar to the ones we've created here.
Conclusion
Throughout this chapter, we've explored the creation of custom error handlers. These handlers empower us to provide tailored error messages based on the specific issues encountered within our application. We began by creating custom exception classes to encapsulate different types of errors. Following that, we implemented handlers to manage these exceptions and ensure appropriate responses are sent back to clients. Finally, we concluded by integrating these custom error handlers into our application, thereby enhancing its robustness and user experience.