Handling HTTP errors with FastAPI#
FastAPI automatically generates HTTP errors for for some types of erroneous client requests, such as 422 errors when the input to a handler doesn’t match its requirements.
Other errors are normally returned by raising fastapi.HTTPException
with an appropriate status_code
parameter.
Errors raised by FastAPI will have a JSON body that follows a standard syntax. To provide standard error reporting for all application APIs, you may wish to return other errors using the same structure.
Defining structured client errors#
The easiest way to return structured errors for invalid client requests is to define exceptions inheriting from ClientRequestError
and install the global client_request_error_handler
exception handler.
The combination of those two components will transform the exception into an HTTP reply with the correct error status and a properly-formatted JSON error body.
A typical application exception would look like this:
from fastapi import status
from safir.fastapi import ClientRequestError
class UnknownUserError(ClientRequestError):
"""Specified user is unknown."""
error = "unknown_user"
status_code = status.HTTP_404_NOT_FOUND
The exception should inherit from ClientRequestError
(possibly indirectly) and set the class variables error
and status_code
.
error
should be set to some short identifier for this error.
This will be reported in the type
field of the error message (see Syntax of FastAPI structured errors).
Then, in the application setup code of your FastAPI application, add a global error handler for all exceptions derived from ClientRequestError
:
from safir.fastapi import ClientRequestError, client_request_error_handler
app.exception_handler(ClientRequestError)(client_request_error_handler)
Any uncaught exception derived from ClientRequestError
will be caught by this exception handler and transformed into a properly-formatted HTTP error response.
If your application reports uncaught exceptions to Slack (see Reporting uncaught exceptions to a Slack webhook), these exceptions will be marked so that they won’t be reported.
(ClientRequestError
inherits from SlackIgnoredException
.)
Raising structured client errors#
Exceptions derived from ClientRequestError
can be raised like any other exception.
If the location of the data causing the error is known when the exception is raised, it can be specified in the optional location
and field_path
parameters to the exception constructor.
This data will populate the loc
field of the client error.
(See Syntax of FastAPI structured errors.)
For example, if your code has defined an UnknownUserError
and you know at the point where the exception is raised that the unknown user was specified in the user
path parameter to the route, you would raise the exception like this:
from safir.models import ErrorLocation
raise UnknownUserError("Unknown user", ErrorLocation.path, ["user"])
More commonly, the location information is only known to the handler, but the exception will be raised by internal service code.
In this case, do not specify location
or field_path
when raising the exception, and catch and re-raise the exception in the handler after adding additional location information.
For example, suppose the user specifies an invalid address in the address
field of a user
data structure in a JSON POST body, and you have defined an InvalidAddressError
raised by your internal service code (which may have no idea where the address comes from).
Your code may look something like this:
from safir.models import ErrorLocation
@router.post("/info/{username}")
async def get_info(username: str, data: UserData) -> None:
try:
return set_user_data(username, data)
except InvalidAddressError as e:
e.location = ErrorLocation.post
e.field_path = ["user", "address"]
raise
Syntax of FastAPI structured errors#
safir.models.ErrorModel
defines a Pydantic model compatible with the format used by FastAPI.
This consists of a detail
key that takes a list of safir.models.ErrorDetail
objects.
Each one has a type
attribute, which contains a short unique identifier for the error, and a msg
attribute, which contains the human-readable error message.
It optionally can also contain a loc
attribute.
If present, the loc
attribute is an ordered list of location keys identifing the specific input data that caused the error.
The first element of loc
should be chosen from the values of safir.models.ErrorLocation
.
For example, for an error in the job_id
path variable, the value of loc
would be [ErrorLocation.path, "job_id"]
.
For an error in a nested account
element in a JSON object submitted in the body of a POST request, the value of loc
might be [ErrorLocation.body, "config", "account"]
.
If the error is in the X-CSRF-Token
header, the value of loc
would be [ErrorLocation.header, "X-CSRF-Token"]
.
loc
may be omitted for errors not caused by a specific element of input data.
When using exceptions derived from ClientRequestError
, the first element of loc
is specified by location
and the remaining elements are specified by field_path
.
Raising structured errors without custom exceptions#
Sometimes it’s easier to raise a structured error directly without defining a custom exception.
fastapi.HTTPException
supports a detail
parameter that should include information about the cause of the error.
FastAPI accepts an arbitrary JSON-serializable data in that parameter, but for compatibility with the errors generated internally by FastAPI, the value should be an array of dict representations of safir.models.ErrorDetail
.
The code to raise fastapi.HTTPException
should therefore look something like this:
from safir.models import ErrorDetail, ErrorLocation
error = ErrorDetail(
loc=[ErrorLocation.path, "foo"],
msg="There is no foo",
type="unknown_foo",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=[error.model_dump(exclude_none=True)],
)
Declaring the error model#
To declare that a handler returns safir.models.ErrorModel
for a given error code, use the responses
attribute to either the route decorator or to include_router
if it applies to all routes provided by a router.
For example, for a single route:
from safir.models import ErrorModel
@router.get(
"/route/{foo}",
...,
responses={404: {"description": "Not found", "model": ErrorModel}},
)
async def route(foo: str) -> None:
...
If all routes provided by a router have the same error handling behavior for a given response code, it saves some effort to instead do this when including the router, normally in main.py
:
app.include_router(
api.router,
prefix="/auth/api/v1",
responses={
403: {"description": "Permission denied", "model": ErrorModel},
},
)