Module 5: Error Handling and CORS
HTTPException and Semantic Status Codes
Capsule overview
In the previous module your endpoints handle the "not found" case like this: return {"error": "Book not found"}. The server returns status 200 with an error body — as far as the client is concerned, the request "succeeded" even though the resource doesn't exist. A frontend checking response.ok never catches the problem. A monitoring system records 100% success. Your API is lying.
FastAPI's HTTPException fixes this at the root. Instead of returning a dict with a message, you raise an exception that stops execution, sets the right HTTP status code, and generates a structured error response. raise HTTPException(status_code=404, detail="Book not found") produces status 404, a consistent JSON body, and zero ambiguity for the client.
But raising exceptions isn't enough on its own — you need to know which status code to use in each situation. 404 isn't the same as 400, 409, or 422. Each code communicates something specific to the client. By the end of this capsule you'll know how to apply HTTPException with semantic status codes to every CRUD operation in your Books API.
The problem: errors with status 200
Your current code from Module 4:
from fastapi import FastAPI
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
return {"error": "Book not found"} # ← status 200
return book
curl -s -w "\nStatus: %{http_code}\n" http://127.0.0.1:8000/books/999
{"error":"Book not found"}
Status: 200
Status 200 — "OK". But the book doesn't exist. A frontend would evaluate if (response.ok) → true → and try to render {"error": "Book not found"} as if it were a book.
HTTPException: your first HTTP exception
from fastapi import FastAPI, HTTPException
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar",
"year": 1963, "genre": "Experimental Novel", "available": False},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return book
curl -s -w "\nStatus: %{http_code}\n" http://127.0.0.1:8000/books/999
{"detail":"Book not found"}
Status: 404
Status 404. A body with "detail" — FastAPI's standard error format.
raise, not return
raise stops execution immediately. None of the code after it runs:
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
# Only runs if the book exists — no else, no extra indentation
print(f"Returning book: {book['title']}")
return book
detail accepts str, dict, or list
# A simple string
raise HTTPException(status_code=400, detail="Bad request")
# A dict with extra context
raise HTTPException(
status_code=404,
detail={"message": "Book not found", "book_id": book_id,
"suggestion": "Use GET /books to see available books"}
)
# A list of errors
raise HTTPException(
status_code=400,
detail=[{"field": "year", "error": "Must be between 1000 and 2030"},
{"field": "genre", "error": "Cannot be empty"}]
)
Status codes in practice: the Books API
404 Not Found — the resource doesn't exist
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
return book
400 Bad Request — invalid business logic
A request that's technically correct but breaks a business rule:
@app.post("/books/{book_id}/borrow")
def borrow_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
if not book["available"]:
raise HTTPException(
status_code=400,
detail={"message": "Book is not available for borrowing", "book_id": book_id}
)
book["available"] = False
return {"message": f"Book '{book['title']}' borrowed successfully"}
First you check whether it exists (404), then whether it can be borrowed (400). The order matters.
409 Conflict — a duplicate of existing data
Using BookCreate from Module 4:
@app.post("/books", status_code=201)
def create_book(book: BookCreate):
existing = next(
(b for b in books if b["title"].lower() == book.title.lower()), None
)
if existing:
raise HTTPException(
status_code=409,
detail={"message": "A book with this title already exists",
"existing_id": existing["id"], "title": existing["title"]}
)
new_book = {"id": generate_id(), **book.model_dump()}
books.append(new_book)
return new_book
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967, "genre": "Novel"}'
{"detail":{"message":"A book with this title already exists","existing_id":1,"title":"One Hundred Years of Solitude"}}
Status: 409
422 Unprocessable Entity — Pydantic validation (automatic)
When Pydantic rejects data, FastAPI returns 422 automatically — you don't raise it yourself:
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "year": 5000}'
# Status: 422 — with details for every validation error
204 No Content — a successful DELETE with no body
from fastapi import Response
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
books.remove(book)
return Response(status_code=204)
Headers in HTTPException
HTTPException accepts a third parameter, headers:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"}
)
raise HTTPException(
status_code=429,
detail="Too many requests. Try again later.",
headers={"Retry-After": "60"}
)
curl -s -D - http://127.0.0.1:8000/admin/dashboard 2>&1 | head -5
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json
WWW-Authenticate: Bearer tells the client which authentication scheme to use. Retry-After: 60 tells it to wait 60 seconds.
Applying HTTPException across the whole CRUD
Using the models from Module 4 (BookCreate, BookUpdate, BookPatch, BookResponse), apply HTTPException to every operation. A find_book() helper keeps you from repeating the lookup:
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar",
"year": 1963, "genre": "Experimental Novel", "available": False},
]
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1000, le=2030)
genre: str
available: bool = True
class BookUpdate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1000, le=2030)
genre: str
available: bool
class BookPatch(BaseModel):
title: str | None = None
author: str | None = None
year: int | None = None
genre: str | None = None
available: bool | None = None
class BookResponse(BaseModel):
id: int
title: str
author: str
year: int
genre: str
available: bool
def generate_id():
if not books:
return 1
return max(b["id"] for b in books) + 1
def find_book(book_id: int):
return next((b for b in books if b["id"] == book_id), None)
@app.get("/books/{book_id}", response_model=BookResponse)
def get_book(book_id: int):
book = find_book(book_id)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
return book
@app.post("/books", response_model=BookResponse, status_code=201)
def create_book(book: BookCreate):
existing = next((b for b in books if b["title"].lower() == book.title.lower()), None)
if existing:
raise HTTPException(status_code=409, detail=f"Book '{existing['title']}' already exists")
new_book = {"id": generate_id(), **book.model_dump()}
books.append(new_book)
return new_book
@app.put("/books/{book_id}", response_model=BookResponse)
def update_book(book_id: int, book: BookUpdate):
existing = find_book(book_id)
if not existing:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
existing.update(book.model_dump())
return existing
@app.patch("/books/{book_id}", response_model=BookResponse)
def patch_book(book_id: int, book: BookPatch):
existing = find_book(book_id)
if not existing:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
existing.update(book.model_dump(exclude_unset=True))
return existing
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
book = find_book(book_id)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
books.remove(book)
return Response(status_code=204)
Every endpoint that takes a book_id checks existence with a 404 before doing anything else. POST checks for duplicates with a 409.
curl -s -w "\nStatus: %{http_code}\n" http://127.0.0.1:8000/books/999
# {"detail":"Book with id 999 not found"} Status: 404
The status module, for readability
status_code=404 works, but what is 409? FastAPI re-exports readable constants:
from starlette.status import HTTP_404_NOT_FOUND, HTTP_201_CREATED, HTTP_409_CONFLICT
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Book not found")
# Also straight from fastapi:
from fastapi import status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
HTTP_404_NOT_FOUND is clearer than 404 when you read the code months later. Both forms work — it's a team preference.
status_code in decorators: success isn't always 200
By default FastAPI returns 200. But not every successful operation is a 200:
| Operation | Decorator | Meaning |
|---|---|---|
GET | 200 (default) | Resource found |
POST | status_code=201 | Resource created |
PUT/PATCH | 200 (default) | Resource updated |
DELETE | status_code=204 | Resource deleted, no body |
The status_code in the decorator defines the success status. HTTPException defines the error ones.
Comparison: return dict vs JSONResponse vs HTTPException
| Aspect | return {"error": ...} | JSONResponse(status_code=404, ...) | raise HTTPException(404, ...) |
|---|---|---|---|
| Status code | Always 200 | You define it | You define it |
| Stops execution | No | No (return) | Yes (raise) |
| Standard format | No | You define it | {"detail": "..."} automatically |
| When to use it | Never for errors | Custom non-error responses | HTTP errors |
JSONResponse has its place for custom responses with special headers, but for errors always use HTTPException.
Quick reference: HTTP status codes
| Code | Name | When to use it |
|---|---|---|
| 200 | OK | General success (GET, PUT, PATCH) |
| 201 | Created | Resource created (POST) |
| 204 | No Content | Success with no body (DELETE) |
| 400 | Bad Request | Invalid business logic |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated but without permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Conflict with existing data |
| 422 | Unprocessable Entity | Pydantic validation failed (automatic) |
| 500 | Internal Server Error | A bug in your code (you don't raise it) |
Troubleshooting
Problem 1: raise HTTPException doesn't stop execution
Cause: You wrote return HTTPException(...) instead of raise.
# ❌ return creates the object but never raises it
@app.get("/books/{book_id}")
def get_book(book_id: int):
if not find_book(book_id):
return HTTPException(status_code=404, detail="Not found") # status 200
return find_book(book_id)
# ✅ raise throws the exception
@app.get("/books/{book_id}")
def get_book(book_id: int):
if not find_book(book_id):
raise HTTPException(status_code=404, detail="Not found") # status 404
return find_book(book_id)
Problem 2: DELETE returns a body when it should be a 204
Cause: You're returning a dict. A 204 must have no body.
# ❌ A body with status 204
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
books.remove(find_book(book_id))
return {"message": "deleted"}
# ✅ An empty Response
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
books.remove(find_book(book_id))
return Response(status_code=204)
Problem 3: It always returns 500 instead of the expected status
Cause: There's a Python error (TypeError, KeyError) before the raise HTTPException. FastAPI catches the Python exception and returns a 500. Check the uvicorn logs — the traceback shows the real error.
Problem 4: You're using 422 for business errors
Cause: Confusion between format validation (422, automatic) and business validation (400, manual). Rule: if Pydantic validates it → 422 automatically. If your code validates business logic (a book that isn't available) → 400 manually.
Exercises
Exercise 1: Migrate return to HTTPException (Easy)
You have these endpoints returning errors with return. Migrate both to HTTPException with status 404.
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
return {"error": "Book not found"}
return book
@app.put("/books/{book_id}")
def update_book(book_id: int, book: BookUpdate):
existing = next((b for b in books if b["id"] == book_id), None)
if not existing:
return {"error": "Book not found"}
existing.update(book.model_dump())
return existing
See solution
from fastapi import FastAPI, HTTPException
app = FastAPI()
books = [{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True}]
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
return book
@app.put("/books/{book_id}")
def update_book(book_id: int, book: BookUpdate):
existing = next((b for b in books if b["id"] == book_id), None)
if not existing:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
existing.update(book.model_dump())
return existing
curl -s -w "\nStatus: %{http_code}\n" http://127.0.0.1:8000/books/999
# {"detail":"Book with id 999 not found"} Status: 404
Exercise 2: POST with duplicate detection (Medium)
Create POST /authors that takes name and country. If an author with the same name already exists (case-insensitive), return 409 with existing_id. Otherwise, create it with status 201.
See solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
authors = [
{"id": 1, "name": "Gabriel García Márquez", "country": "Colombia"},
{"id": 2, "name": "Julio Cortázar", "country": "Argentina"},
]
class AuthorCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
country: str = Field(min_length=1, max_length=50)
class AuthorResponse(BaseModel):
id: int
name: str
country: str
def generate_id():
return max((a["id"] for a in authors), default=0) + 1
@app.post("/authors", response_model=AuthorResponse, status_code=201)
def create_author(author: AuthorCreate):
existing = next((a for a in authors if a["name"].lower() == author.name.lower()), None)
if existing:
raise HTTPException(
status_code=409,
detail={"message": f"Author '{existing['name']}' already exists",
"existing_id": existing["id"]}
)
new_author = {"id": generate_id(), **author.model_dump()}
authors.append(new_author)
return new_author
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/authors \
-H "Content-Type: application/json" \
-d '{"name": "gabriel garcía márquez", "country": "Colombia"}'
# {"detail":{"message":"Author 'Gabriel García Márquez' already exists","existing_id":1}} Status: 409
Exercise 3: Borrow/return with business rules (Medium)
Create POST /books/{book_id}/borrow and POST /books/{book_id}/return. Borrow fails with 400 if the book is already borrowed. Return fails with 400 if it's already available. Both fail with 404 if the book doesn't exist.
See solution
from fastapi import FastAPI, HTTPException
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "available": True},
{"id": 2, "title": "Hopscotch", "available": False},
]
def find_book(book_id: int):
return next((b for b in books if b["id"] == book_id), None)
@app.post("/books/{book_id}/borrow")
def borrow_book(book_id: int):
book = find_book(book_id)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
if not book["available"]:
raise HTTPException(status_code=400, detail=f"Book '{book['title']}' is already borrowed")
book["available"] = False
return {"message": f"Book '{book['title']}' borrowed successfully"}
@app.post("/books/{book_id}/return")
def return_book(book_id: int):
book = find_book(book_id)
if not book:
raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")
if book["available"]:
raise HTTPException(status_code=400, detail=f"Book '{book['title']}' is not currently borrowed")
book["available"] = True
return {"message": f"Book '{book['title']}' returned successfully"}
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/books/1/borrow
# {"message":"Book 'One Hundred Years of Solitude' borrowed successfully"} Status: 200
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/books/2/borrow
# {"detail":"Book 'Hopscotch' is already borrowed"} Status: 400
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/books/999/borrow
# {"detail":"Book with id 999 not found"} Status: 404
Exercise 4: Full CRUD for Movies (Hard)
Build a CRUD for Movie (title, director, year 1888-2030, genre, rating 0.0-10.0): GET all, GET by id (404), POST with a duplicate-title check (409), PATCH (404), DELETE (404 → 204). Use find_movie() as a helper.
See solution
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field
app = FastAPI()
movies = [
{"id": 1, "title": "Pan's Labyrinth", "director": "Del Toro",
"year": 2006, "genre": "Fantasy", "rating": 8.2},
{"id": 2, "title": "Roma", "director": "Cuarón",
"year": 2018, "genre": "Drama", "rating": 7.7},
]
class MovieCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
director: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1888, le=2030)
genre: str = Field(min_length=1)
rating: float = Field(ge=0.0, le=10.0)
class MoviePatch(BaseModel):
title: str | None = None
director: str | None = None
year: int | None = None
genre: str | None = None
rating: float | None = None
class MovieResponse(BaseModel):
id: int
title: str
director: str
year: int
genre: str
rating: float
def generate_id():
return max((m["id"] for m in movies), default=0) + 1
def find_movie(movie_id: int):
return next((m for m in movies if m["id"] == movie_id), None)
@app.get("/movies/{movie_id}", response_model=MovieResponse)
def get_movie(movie_id: int):
movie = find_movie(movie_id)
if not movie:
raise HTTPException(status_code=404, detail=f"Movie with id {movie_id} not found")
return movie
@app.post("/movies", response_model=MovieResponse, status_code=201)
def create_movie(movie: MovieCreate):
existing = next((m for m in movies if m["title"].lower() == movie.title.lower()), None)
if existing:
raise HTTPException(status_code=409, detail=f"Movie '{existing['title']}' already exists")
new_movie = {"id": generate_id(), **movie.model_dump()}
movies.append(new_movie)
return new_movie
@app.patch("/movies/{movie_id}", response_model=MovieResponse)
def patch_movie(movie_id: int, movie: MoviePatch):
existing = find_movie(movie_id)
if not existing:
raise HTTPException(status_code=404, detail=f"Movie with id {movie_id} not found")
existing.update(movie.model_dump(exclude_unset=True))
return existing
@app.delete("/movies/{movie_id}", status_code=204)
def delete_movie(movie_id: int):
movie = find_movie(movie_id)
if not movie:
raise HTTPException(status_code=404, detail=f"Movie with id {movie_id} not found")
movies.remove(movie)
return Response(status_code=204)
curl -s -w "\nStatus: %{http_code}\n" -X POST http://127.0.0.1:8000/movies \
-H "Content-Type: application/json" \
-d '{"title": "Roma", "director": "Cuarón", "year": 2018, "genre": "Drama", "rating": 7.7}'
# Status: 409
curl -s -w "\nStatus: %{http_code}\n" -X DELETE http://127.0.0.1:8000/movies/2
# Status: 204
Exercise 5: A structured detail with suggestions (Hard)
Modify GET /books/{book_id} so the 404 returns a detail with: message, book_id, suggestion, and available_ids (a list of the existing IDs).
See solution
from fastapi import FastAPI, HTTPException
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar",
"year": 1963, "genre": "Experimental Novel", "available": False},
{"id": 3, "title": "Pedro Páramo", "author": "Juan Rulfo",
"year": 1955, "genre": "Novel", "available": True},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if not book:
raise HTTPException(
status_code=404,
detail={
"message": f"Book with id {book_id} not found",
"book_id": book_id,
"suggestion": "Use GET /books to see all available books",
"available_ids": [b["id"] for b in books]
}
)
return book
curl -s http://127.0.0.1:8000/books/999 | python -m json.tool
{
"detail": {
"message": "Book with id 999 not found",
"book_id": 999,
"suggestion": "Use GET /books to see all available books",
"available_ids": [1, 2, 3]
}
}
Summary
HTTPExceptionreplacesreturn {"error": ...}— a real status code, not a 200 for errorsraise, notreturn— it stops the endpoint's execution immediatelydetailacceptsstr,dict, orlist— from simple messages to structured errors- 404: the resource doesn't exist — GET, PUT, PATCH, DELETE by ID
- 400: invalid business logic — borrowing a book that isn't available
- 409: a conflict with existing data — a duplicate title on POST
- 422: Pydantic generates it automatically — you don't raise it
- 204: a successful DELETE with no body — use
Response(status_code=204) - 201: a successful POST —
status_code=201in the decorator headersin HTTPException: for protocols like 401 and 429starlette.status: readable constants (HTTP_404_NOT_FOUND)- JSONResponse for custom responses, HTTPException for errors
Next capsule: Custom Exception Handlers — your own exceptions and global handlers for a consistent error format across the whole API.
Additional resources
- FastAPI - Handling Errors - Official documentation on HTTPException and error handling
- FastAPI - Response Status Code - How to use status_code in decorators
- MDN - HTTP Status Codes - Complete reference for every HTTP status code
- Starlette - Status Codes - The status code constants FastAPI re-exports
- FastAPI - Additional Responses - Documenting error responses in /docs with OpenAPI
- RFC 9110 - HTTP Semantics - The official specification for HTTP status codes