Chapter 11, Basic
Status codes and errors with HTTPException
The status code is the first thing the client reads. Getting the code right is what makes a client (and a human) know, without reading the body, if it worked, if the error was theirs or the server's.
The codes that matter
| Code | Name | When to use |
|---|---|---|
| 200 | OK | The read or modification was successful |
| 201 | Created | A new resource has been created |
| 204 | No Content | It worked, and there is no body (a DELETE, for example) |
| 400 | Bad Request | The request is malformed or violates a business rule |
| 401 | Unauthorized | The client is not authenticated (who are you?) |
| 403 | Forbidden | The client is authenticated, but does not have permission |
| 404 | Not Found | The resource does not exist |
| 409 | Conflict | Conflict with the current state (an email is already registered) |
| 422 | Unprocessable Entity | The submitted data does not pass validation (FastAPI uses it alone) |
| 500 | Internal Server Error | A bug on the server |
The first ten digits tell the story: 2xx is success, 4xx is client error (the caller can fix it) and 5xx is server error (the client cannot do anything).
Declare the success status
By default, every route returns 200. For a POST that creates, the correct response is 201, and the module status provides named constants that read themselves:
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class UsuarioEntrada(BaseModel):
nome: str
email: str
usuarios: dict[str, UsuarioEntrada] = {}
@app.post("/usuarios", status_code=status.HTTP_201_CREATED)
def criar(usuario: UsuarioEntrada):
if usuario.email in usuarios:
raise HTTPException(status.HTTP_409_CONFLICT, detail="E-mail já cadastrado")
usuarios[usuario.email] = usuario
return {"mensagem": "Usuário criado", "email": usuario.email}
cliente = TestClient(app)
corpo = {"nome": "Mohit", "email": "mohit@exemplo.com"}
print(cliente.post("/usuarios", json=corpo).status_code)
repetido = cliente.post("/usuarios", json=corpo)
print(repetido.status_code, repetido.json())
201
409 {'detail': 'E-mail já cadastrado'}
HTTPException: interrupt and respond with the error
The raise HTTPException(...) interrupts the function immediately and returns the error response, with the detail that you chose. A 404 classic:
@app.get("/usuarios/{email}")
def obter(email: str):
if email not in usuarios:
raise HTTPException(status_code=404, detail="Usuário não encontrado")
return usuarios[email]
print(cliente.get("/usuarios/mohit@exemplo.com").json())
resposta = cliente.get("/usuarios/nao@existe.com")
print(resposta.status_code, resposta.json())
{'nome': 'Mohit', 'email': 'mohit@exemplo.com'}
404 {'detail': 'Usuário não encontrado'}
The HTTPException also accepts headers, which the 401 uses to tell the client how to authenticate (chapter 20).
204: success without body
A DELETE successful has nothing to say. The status 204 means that, and the response comes without a body:
@app.delete("/usuarios/{email}", status_code=status.HTTP_204_NO_CONTENT)
def remover(email: str):
if email not in usuarios:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Usuário não encontrado")
del usuarios[email]
resposta = cliente.delete("/usuarios/mohit@exemplo.com")
print(resposta.status_code, resposta.content)
204 b''
Document the errors that the route can return
By default, the documentation only knows success and the 422. The parameter responses adds the other codes to the OpenAPI document, so that those consuming the API know what to expect:
@app.get("/contas/{conta_id}", responses={404: {"description": "Conta não encontrada"}})
def conta(conta_id: int):
raise HTTPException(status_code=404, detail="Conta não encontrada")
respostas = app.openapi()["paths"]["/contas/{conta_id}"]["get"]["responses"]
print(sorted(respostas))
['200', '404', '422']
Never return an error with status 200
Much material teaches how to write return {"error": "não encontrado"}. The result is a response of success (200) with an error message inside, and the client, who looks at the status first, thinks everything went well:
@app.get("/errado/{item_id}")
def errado(item_id: int):
return {"error": "Item não encontrado"}
resposta = cliente.get("/errado/1")
print(resposta.status_code, resposta.json())
200 {'error': 'Item não encontrado'}
And that "envelope" with status and message?
Some guides recommend always returning
{"status": "success", "message": ..., "data": ...}. I don't do that: the HTTP protocol already has the status, and an envelope duplicates the information (and invites the error from the previous paragraph). What matters is a consistent error format throughout the API, and the next chapter shows how to ensure this in one place.
Exercise 1
A withdrawal with insufficient balance
Create POST /saques that receives {"valor": ...} and has a balance of 100. If the amount exceeds the balance, return 400 with the message "Saldo insuficiente". If valid, deduct and return 201 with the new balance.