Chapter 12, Basic
Global exception handling
Instead of spreading `try` and `HTTPException` across all routes, you register a handler in one place, and **every** application error comes out in the same format.
An exception from your domain
A custom exception says what went wrong in terms of your business, without worrying about HTTP. The route just raises it. The handler translates it into a response:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
class UsuarioNaoEncontrado(Exception):
def __init__(self, nome: str):
self.nome = nome
@app.exception_handler(UsuarioNaoEncontrado)
async def tratar_usuario_nao_encontrado(request: Request, exc: UsuarioNaoEncontrado):
return JSONResponse(
status_code=404,
content={"erro": "usuario_nao_encontrado", "mensagem": f"Usuário {exc.nome} não existe"},
)
@app.get("/usuarios/{nome}")
def obter(nome: str):
if nome != "Mohit":
raise UsuarioNaoEncontrado(nome)
return {"nome": nome}
cliente = TestClient(app)
print(cliente.get("/usuarios/Mohit").json())
resposta = cliente.get("/usuarios/Rohit")
print(resposta.status_code, resposta.json())
{'nome': 'Mohit'}
404 {'erro': 'usuario_nao_encontrado', 'mensagem': 'Usuário Rohit não existe'}
Without the handler, the exception would reach the client as a 500 generic. With it, any route in the application that raises UsuarioNaoEncontrado produces the same response, without repeating code.
Reformat validation errors
The errors 422 from FastAPI are rich, but they dump internal details and repeat the value sent in input, which may contain sensitive data. You can change the format by registering a handler for RequestValidationError:
from fastapi.exceptions import RequestValidationError
app_validacao = FastAPI()
@app_validacao.exception_handler(RequestValidationError)
async def tratar_validacao(request: Request, exc: RequestValidationError):
erros = [
{"campo": ".".join(str(p) for p in e["loc"][1:]), "mensagem": e["msg"]}
for e in exc.errors()
]
return JSONResponse(status_code=422, content={"erro": "dados_invalidos", "detalhes": erros})
@app_validacao.get("/produtos/{produto_id}")
def produto(produto_id: int, quantidade: int):
return {"produto_id": produto_id, "quantidade": quantidade}
print(TestClient(app_validacao).get("/produtos/abc?quantidade=x").json())
{'erro': 'dados_invalidos', 'detalhes': [{'campo': 'produto_id', 'mensagem': 'Input should be a valid integer, unable to parse string as an integer'}, {'campo': 'quantidade', 'mensagem': 'Input should be a valid integer, unable to parse string as an integer'}]}
The sent value (abc, x) no longer appears in the response: each error states which field failed and why.
Register the handlers before the first request
Starlette builds the stack of handlers and middlewares on the first request. A handler registered after that is silently ignored (a late middleware, on the other hand, raises an error, as you will see in chapter 14). In a real program, this does not happen, because everything is registered when the module is imported, before the server accepts connections. But in a notebook or in a test that reuses the
appafter having already called it, you might spend an afternoon looking for the reason. Therefore, each demonstration in this chapter uses its ownapp.
HTTP errors in the same format
Handling the HTTPException (from Starlette, the base of FastAPI) makes all your common errors, including the 404 of non-existent routes, come out in the same format. Here I use the standardized format RFC 9457 (application/problem+json), which many APIs adopt:
from starlette.exceptions import HTTPException as StarletteHTTPException
def problema(status: int, titulo: str, detalhe: str | None = None) -> JSONResponse:
corpo = {"type": "about:blank", "title": titulo, "status": status}
if detalhe:
corpo["detail"] = detalhe
return JSONResponse(corpo, status_code=status, media_type="application/problem+json")
app_http = FastAPI()
@app_http.exception_handler(StarletteHTTPException)
async def tratar_http(request: Request, exc: StarletteHTTPException):
return problema(exc.status_code, str(exc.detail))
resposta = TestClient(app_http).get("/rota-que-nao-existe")
print(resposta.status_code, resposta.headers["content-type"], resposta.json())
404 application/problem+json {'type': 'about:blank', 'title': 'Not Found', 'status': 404}
The last resort: unexpected errors
An exception that no one anticipated becomes a 500. The client should not see the exception text (it may leak paths, SQL snippets, library names). Register a handler for Exception that returns a neutral message, and send the detail to the log:
import logging
logger = logging.getLogger("app")
app_erro = FastAPI()
@app_erro.exception_handler(Exception)
async def tratar_inesperado(request: Request, exc: Exception):
logger.exception("Erro não tratado em %s", request.url.path)
return problema(500, "Erro interno do servidor")
@app_erro.get("/bug")
def bug():
return 1 / 0
sem_relancar = TestClient(app_erro, raise_server_exceptions=False)
resposta = sem_relancar.get("/bug")
print(resposta.status_code, resposta.json())
500 {'type': 'about:blank', 'title': 'Erro interno do servidor', 'status': 500}
The division by zero did not appear in the response. A client only sees "Internal Server Error", and the real reason was logged.
| Handler | Capture | For what |
|---|---|---|
| Domain exception | Your own class | Translate business rules into HTTP in one place |
RequestValidationError | Validation failures (422) | Standardize the format and do not leak the received value |
StarletteHTTPException | All HTTPException and the 404 for non-existent route | Unique error format |
Exception | The unexpected (500) | Hide the detail and log it |
Exercise 1
Translate a ValueError into 400
Register a handler for ValueError that returns 400 with {"erro": "valor_invalido", "mensagem": <texto do erro>}, and a route GET /raiz/{numero} that raises ValueError("não existe raiz de número negativo") for negative numbers.