Skip to content

Validation and error responses

  • Lesson 8
  • 45 min
  • Module 3: Design a usable contract

What you will be able to do

Make every error look the same, so a client can handle all of them with one piece of code. And make sure that a bug in your API shows the client a short, safe message, while the details stay in your logs.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.

Today, the API answers errors in three different ways:

Error Body now
Validation (422) {"detail": [{"type": ..., "loc": [...], "msg": ..., "input": ...}]}
Unknown path (404) or method (405) {"detail": "Not Found"}
A bug in the code (500) Internal Server Error: plain text, not JSON

A client must handle each shape separately, and one of them is not even JSON. The validation error also sends back the client’s own input value, which can be long, or private.

The fix is one error shape for every error, with a code that a program can check and a message that a person can read:

The error shape
{"error": {"code": "invalid_request", "message": "The request is not valid.", "fields": ["subject"]}}
Field For Rule
code Programs A fixed word that never changes, such as invalid_request or not_found. Clients write if code == "not_found".
message People A short sentence. It can change, so programs must not compare it.
fields Both The request fields with a problem. Empty when no field is to blame.

When something goes wrong, FastAPI raises an exception: RequestValidationError for invalid input, HTTPException for 404 and 405, and any other exception for a bug. An exception handler is a function that turns one exception type into a response. You register one handler for each type, and they all use the same shape.

First, the shape as Pydantic models, at the end of models.py. The models document the shape too, which you use in Module 4:

ticket_api/models.py (add at the end)
class ErrorDetail(BaseModel):
code: str
message: str
fields: list[str] = []
class ErrorResponse(BaseModel):
error: ErrorDetail

Then a new module, errors.py, with the handlers:

ticket_api/errors.py
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from ticket_api.models import ErrorDetail, ErrorResponse
logger = logging.getLogger("ticket_api")
CODES = {404: "not_found", 405: "method_not_allowed"}
def field_name(error: dict) -> str:
"""Return the field that a validation error is about, such as "subject"."""
if error["type"] == "json_invalid":
return "body"
return ".".join(str(part) for part in error["loc"][1:]) or "body"
def error_response(status: int, code: str, message: str, fields=()) -> JSONResponse:
detail = ErrorDetail(code=code, message=message, fields=list(fields))
return JSONResponse(
status_code=status, content=ErrorResponse(error=detail).model_dump()
)
def add_error_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def invalid_request(request: Request, exc: RequestValidationError):
fields = [field_name(e) for e in exc.errors()]
return error_response(
422, "invalid_request", "The request is not valid.", fields
)
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
code = CODES.get(exc.status_code, "http_error")
return error_response(exc.status_code, code, str(exc.detail))
@app.exception_handler(Exception)
async def unexpected(request: Request, exc: Exception):
logger.exception("unexpected error")
return error_response(
500, "internal_error", "Something went wrong on the server."
)
Part Why
field_name A validation error’s loc is a path such as ["body", "subject"]. The client needs subject. For JSON that cannot be read at all (json_invalid), the problem is the whole body.
async def FastAPI requires handlers to be async functions. You do not need to know more about async here.
HTTPException from starlette FastAPI is built on a library called Starlette, which raises the 404 and 405 errors. This handler catches them too.
Exception The last line of defence: any error that no other handler caught, which means a bug. It logs the full traceback for you, and sends a short message to the client.

Register the handlers in main.py, after the application is made:

ticket_api/main.py (changes)
from ticket_api.errors import add_error_handlers
app = FastAPI(title="Ticket Classifier API")
app.state.classifier = KeywordClassifier()
add_error_handlers(app)
app.include_router(router)

In Request and response models, the API accepted {"subject": "Refund", "colour": "red"} and ignored colour. That hides typing mistakes. A client that sends "subjct" gets a confusing error about a missing subject. A client that sends "boddy" gets a result based on the subject only, with no error at all.

One line in TicketIn makes Pydantic reject every field that the model does not list:

ticket_api/models.py (changes)
from pydantic import BaseModel, ConfigDict, Field
class TicketIn(BaseModel):
model_config = ConfigDict(extra="forbid")
subject: str = Field(min_length=1, max_length=120)
body: str = Field(default="", max_length=5000)

Amira sends each failure row of her contract table:

Request Status Body
{"subject": ""} 422 {"error":{"code":"invalid_request","message":"The request is not valid.","fields":["subject"]}}
{"subject": 42, "body": ["a"]} 422 ... "fields":["subject","body"]
{"subject": "Hi", "colour": "red"} 422 ... "fields":["colour"]
{"subject": "x" (no closing brace) 422 ... "fields":["body"]
GET /v1/classify 405 {"error":{"code":"method_not_allowed","message":"Method Not Allowed","fields":[]}}
GET /v1/nothing 404 {"error":{"code":"not_found","message":"Not Found","fields":[]}}

The client’s input is no longer in the response. The client already knows what it sent.

A stack trace, a file path, a library version or a database message tells an attacker how your server works. It tells a normal user nothing useful. So the 500 handler sends only a fixed message. To see it, Amira puts a bug in the classifier on purpose: in predict, after lowered = text.lower(), she adds lowered = lowered.casefolded() (the real method is casefold).

The client sees
HTTP/1.1 500 Internal Server Error
{"error":{"code":"internal_error","message":"Something went wrong on the server.","fields":[]}}
The server's terminal shows (end)
unexpected error
Traceback (most recent call last):
...
lowered = lowered.casefolded()
AttributeError: 'str' object has no attribute 'casefolded'. Did you mean: 'casefold'?

The client learns that the fault is on the server, and nothing more. You get the whole traceback. (Uvicorn logs the error a second time, as Exception in ASGI application. That is normal.) She removes the bug again.

  1. Add ErrorDetail and ErrorResponse to models.py, and extra="forbid" to TicketIn.
  2. Make ticket_api/errors.py, and register the handlers in main.py.
  3. Make the files for the table: requests/extra-field.json with {"subject": "Hi", "colour": "red"}, and requests/broken.json with {"subject": "x" (no closing brace). You have the others from Module 2.
  4. Send each request from the table. Check the status code and the fields of each one.
  5. Put the casefolded bug in the classifier, send requests/billing.json, and compare what the client sees with what the server’s terminal shows. Then remove the bug.

Your turn: an error that your route raises

Section titled “Your turn: an error that your route raises”

Your routes can raise an error themselves with HTTPException. Add an endpoint GET /v1/categories/{name}, where {name} is a path parameter: FastAPI passes that part of the path to the function as an argument. It returns {"name": ..., "keywords": [...]} for a known category, from KEYWORDS in the classifier. For an unknown one, it raises HTTPException(status_code=404, detail="No such category.").

Test /v1/categories/billing, /v1/categories/refund and /v1/categories/other. Predict each response first. Which row of your contract table does other need?

Show one answer
ticket_api/routes.py (add)
from fastapi import HTTPException
from ticket_api.classifier import KEYWORDS
@router.get("/categories/{name}")
def get_category(name: str) -> dict:
if name not in KEYWORDS:
raise HTTPException(status_code=404, detail="No such category.")
return {"name": name, "keywords": list(KEYWORDS[name])}

billing gives 200 with its four keywords. refund gives 404, in the standard shape, because your handler turns the HTTPException into {"error":{"code":"not_found","message":"No such category.","fields":[]}}. other also gives 404, although /v1/categories lists it: other has no keywords. That is a contract question. Either other returns 200 with an empty list, or the list leaves it out. Write the decision in the contract table, then make the code match. Remove the endpoint before the next lesson. The rest of the course does not use it.

Common mistake

Success that hides a failure

Symptom: a client reports wrong results, but the logs show only 200 responses. In the code, a route catches an error and returns something like this:

try:
prediction = classifier.predict(text)
except Exception:
return {"category": "other", "priority": 3, "confidence": 0.0, "model_version": "error"}

Cause: the route turned a failure into a “success”. The client receives 200 and a category, and has no way to know that it is not real. Its program files the ticket as other, and nobody looks at it. Monitoring that counts 5xx responses sees nothing.

Fix:

  1. Never return a 2xx code for an operation that failed. Let the error reach a handler, or raise an HTTPException with a correct code.
  2. For a part that is temporarily down, such as the classifier, return 503 with its own code. You build this in Module 6.
  3. Catch only the exceptions that you expect, as in the Python course, never every Exception in a route.

Check your understanding

A client's code checks if response['error']['message'] == 'The request is not valid.'. What is wrong, and what should it check?

Show the answer

The message is for people, and it can change, for example after a spelling fix or a translation. Then the client’s check fails silently. It should check the status code and error.code (invalid_request), which are part of the contract and do not change within a version.

Check your understanding

Why does the 500 handler log the exception but send only a fixed message to the client?

Show the answer

The traceback contains file paths, code and library details that help an attacker. They do not help the client, who cannot fix a bug on the server. The log keeps every detail for the people who can fix it. The client only needs to know that it is a server error (500, internal_error).

Recap

  • Use one error shape for every error: a stable code for programs, a message for people, and the fields with a problem.
  • Exception handlers turn each exception type into that shape. The handler for Exception catches bugs.
  • extra="forbid" rejects unknown fields, so typing mistakes in field names are not ignored.
  • Never send a stack trace or internal details to a client. Log them.
  • Never return success for an operation that failed.

Next, you let a web page in a browser call your API, and only the pages that you choose.

Next: Browser access and CORS