This blog post is a translation of a Japanese article posted on May 8th, 2025.
Hi! I’m Atsushi Yasumoto (atusy) from the Sreake team.
When designing an API server, it’s important to handle program errors and return client or server errors. It’s also crucial to output error logs for future debugging.
FastAPI, the very popular Python web framework, provides a framework for error handling. The steps outlined in the official documentation (adding an exception handler) can cover most use cases. However, this is not well-suited for logging uncaught generic exceptions.
To tackle this, we’ll be covering the following:
- Basic error handling in FastAPI
- Why exception handlers are not great when dealing with generic exceptions
- How to intercept generic exceptions using middleware
You’ll find the source code for this article below! (Explanation in Japanese)
https://github.com/atusy/fastapi-error-handling-demo
Typical Error Handling with Exception Handlers
We’ll briefly explain how to add an exception handler to your application. You can find more details in the official documentation.
FastAPI > Learn > Tutorial – User Guide > Handling Errors
The documentation covers two ways of handling exceptions:
- Return an HTTP response with errors to the client by raising an HTTPException
- Add a custom exception handler with
@app.exception_handler(...)
to convert specified exceptions to HTTP responses- The
HTTPException
is itself converted by FastAPI’s default exception handlers, so you could argue exception handlers are the only included way of catching errors!
- The
Custom exception handlers are useful when you want to override the default exception handlers, or you need to handle exceptions provided by a third-party dependency. Here’s an example of how to do that with the Google Cloud SDK, where we output error logs and return a custom response when services are unavailable:
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from google.api_core.exceptions import GoogleAPIError, ServiceUnavailable
@app.exception_handler(GoogleAPIError)
async def handle_http_exception(__: Request, exc: GoogleAPIError) -> Response:
if isinstance(exc, ServiceUnavailable):
logger.exception("Google Cloud is unavailable")
return JSONResponse(
content={"message": "Service Unavailable"},
status_code=503,
)
raise exc # Unhandled errors are processed by FastAPI with a 500 status code
Exceptions handled by a custom exception handler are suppressed. However, this is not the case for the base Exception
class.
Even if you convert generic exceptions to “Internal Server Error” responses, the exception is still raised. This means that while the server itself continues to run, you get the full unprocessed traceback logged out. If you’re handling server error logging yourself, you’ll run into duplicate logs, which is especially frustrating if you use structured logging (unprocessed tracebacks are not structured).
This behavior (raising generic exceptions) is by design and comes from Starlette, the ASGI framework powering FastAPI. You can see the intent behind this when looking at Starlette’s source code:
(in
starlette.middleware.errors.ServerErrorMiddleware
)
# We always continue to raise the exception.# This allows servers to log the error, or allows test clients
# to optionally raise the error within the test case.
Let’s see how logging goes when using an exception handler to deal with generic exceptions. You’ll find the source code for this below.
https://github.com/atusy/fastapi-error-handling-demo
(We used Starlette v0.45.3 for this test. You may find newer versions have different behavior.)
In the following code, we raise an HTTPException
on the /404
path, and a generic Exception
on the /500
path. We add custom exception handlers for both exceptions, so the basic assumption would be that they both get handled the same: suppress the exception and return a custom response.
from http import HTTPStatus
from logging import getLogger
import uvicorn
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
logger = getLogger("uvicorn")
app = FastAPI()
@app.get("/404")
async def raise404(__: Request):
raise HTTPException(status_code=404, detail="Not Found")
@app.get("/500")
async def raise500(__: Request):
raise Exception("Unexpected Error!!")
@app.exception_handler(HTTPException)
async def handle_http_exception(__: Request, exc: HTTPException):
return JSONResponse(
content={"message": HTTPStatus(exc.status_code).phrase},
status_code=exc.status_code,
)
@app.exception_handler(Exception)
async def handle_exception(__: Request, exc: Exception) -> Response:
logger.exception(
str(exc),
exc_info=False, # This should be True to track errors, but False is fine for demo purposes
)
return JSONResponse(
content={"message": "Internal Server Error"},
status_code=500,
)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
But in practice, the base Exception
is actually raised, and an unprocessed traceback is output, as we can see in the below logs:
# Visiting "/404"
INFO: 127.0.0.1:47962 - "GET /404 HTTP/1.1" 404 Not Found
# No traceback, HTTPException was suppressed
# Visiting "/500"
ERROR: Unexpected Error!!
INFO: 127.0.0.1:47974 - "GET /500 HTTP/1.1" 500 Internal Server Error
# Exception was raised, and we get a traceback
ERROR: Exception in ASGI application
Traceback (most recent call last):
File ".../demo/.venv/
lib/python3.12/site-packages/uvicorn/protocols/http/h11_impl.py", line 403, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# A long traceback later...
File ".../demo/main.py", line 19, in raise500
raise Exception("Unexpected Error!!")
Exception: Unexpected Error!!
Handling Base Exceptions with Middleware
We’ve established that handling base exceptions with a custom exception handler is not a good idea, especially if you care about your logs. As this is a design decision in Starlette and FastAPI, it would be unproductive to try to hack around this behavior.
Instead of using a custom exception handler, let’s use a middleware to catch generic exceptions. If we encounter an untreated exception (not handled by any existing exception handler), we catch it in the middleware, output error logs and return a custom response.
To do this, we first have to remove the previous generic exception handler:
-@app.exception_handler(Exception)
-async def handle_exception(__: Request, exc: Exception) -> Response:
- logger.exception(
- str(exc),
- exc_info=False, # This should be True to track errors, but False is fine for demo purposes
- )
- return JSONResponse(
- content={"message": "Internal Server Error"},
- status_code=500,
- )
Then we add our middleware to catch generic exceptions:
from starlette import base
@app.middleware("http")
async def server_error_middleware(
request: Request, call_next: base.RequestResponseEndpoint
) -> Response:
try:
return await call_next(request)
except Exception:
logger.exception(
"Unexpected Error!!!",
exc_info=False, # This should be True to track errors, but False is fine for demo purposes
)
return JSONResponse(
status_code=500, content={"message": "Internal Server Error"}
)
Running the same test as before, we can see error handling on the /500
path now works just like on the /404
path: the exception is suppressed, we get nice error logs for developers, and there is no more huge traceback to look at.
# Visiting "/404"
INFO: 127.0.0.1:36242 - "GET /404 HTTP/1.1" 404 Not Found
# Visiting "/500"
ERROR: Unexpected Error!!!
INFO: 127.0.0.1:36258 - "GET /500 HTTP/1.1" 500 Internal Server Error
Conclusion
FastAPI’s custom exception handlers are useful when dealing with known exceptions from third-party dependencies. However, when handling generic exceptions and implementing server logs, it is far better to put that logic in a middleware.
This also illustrates the importance of design details in underlying libraries, in this case Starlette. When debugging cryptic implementation bugs, it may be a good idea to look under the hood and browse the Starlette source code for clues.
Happy Coding!