Validation and error responses
- Lesson 8
- 45 min
- Module 3: Design a usable contract
Lesson 8 of 21 · Build and Deploy Your First APIValidation and error responses
1. Understand the web request
2. Build a small FastAPI service
3. Design a usable contract
4. Document the API with OpenAPI and Swagger UI
5. Explore and test with Postman
6. Test and harden the service
7. Deploy and verify
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 ready
What you need to know
- Exceptions and
except, from Exceptions and logging: Try and except. - FastAPI’s standard
422body and itslocfield, from Request and response models: Valid and invalid requests. - The contract table, from Resource and endpoint design: Rule 4: write the contract as a table.
What you need on your computer
- The API with
/v1/classifyand/v1/categories, from Resource and endpoint design: Guided practice: version the API. If/v1/categoriesis missing, do that guided practice again. - The files
billing.json,empty-subject.jsonandwrong-types.jsoninrequests, from Request and response models: Guided practice: build and call the endpoint. You add two more files in this lesson. ticket_api/classifier.py, from Separate concerns: Worked example: the classifier module. You put a bug in it on purpose, then remove it.- The development server runs, as in Environment and first endpoint: Step 3: start the server.
- If your project is missing or broken, copy the snapshot
end-of-m02, the project at the end of Module 2, as in Use a snapshot. Then do Resource and endpoint design: Guided practice: version the API again.
Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.
Three shapes are two too many
Section titled “Three shapes are two too many”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:
{"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. |
Exception handlers
Section titled “Exception handlers”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:
class ErrorDetail(BaseModel): code: str message: str fields: list[str] = []
class ErrorResponse(BaseModel): error: ErrorDetailThen a new module, errors.py, with the handlers:
import logging
from fastapi import FastAPI, Requestfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom 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:
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)Reject unknown fields
Section titled “Reject unknown fields”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:
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)Worked example: every error, one shape
Section titled “Worked example: every error, one shape”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.
Do not leak the inside of the server
Section titled “Do not leak the inside of the server”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).
HTTP/1.1 500 Internal Server Error{"error":{"code":"internal_error","message":"Something went wrong on the server.","fields":[]}}unexpected errorTraceback (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.
Guided practice: one shape
Section titled “Guided practice: one shape”- Add
ErrorDetailandErrorResponsetomodels.py, andextra="forbid"toTicketIn. - Make
ticket_api/errors.py, and register the handlers inmain.py. - Make the files for the table:
requests/extra-field.jsonwith{"subject": "Hi", "colour": "red"}, andrequests/broken.jsonwith{"subject": "x"(no closing brace). You have the others from Module 2. - Send each request from the table. Check the status code and the
fieldsof each one. - Put the
casefoldedbug in the classifier, sendrequests/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
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:
- Never return a
2xxcode for an operation that failed. Let the error reach a handler, or raise anHTTPExceptionwith a correct code. - For a part that is temporarily down, such as the classifier, return
503with its own code. You build this in Module 6. - Catch only the exceptions that you expect, as in the Python course, never every
Exceptionin 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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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).
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Use one error shape for every error: a stable
codefor programs, amessagefor people, and thefieldswith a problem. - Exception handlers turn each exception type into that shape. The handler for
Exceptioncatches 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