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:

OperationDecoratorMeaning
GET200 (default)Resource found
POSTstatus_code=201Resource created
PUT/PATCH200 (default)Resource updated
DELETEstatus_code=204Resource 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

Aspectreturn {"error": ...}JSONResponse(status_code=404, ...)raise HTTPException(404, ...)
Status codeAlways 200You define itYou define it
Stops executionNoNo (return)Yes (raise)
Standard formatNoYou define it{"detail": "..."} automatically
When to use itNever for errorsCustom non-error responsesHTTP errors

JSONResponse has its place for custom responses with special headers, but for errors always use HTTPException.


Quick reference: HTTP status codes

CodeNameWhen to use it
200OKGeneral success (GET, PUT, PATCH)
201CreatedResource created (POST)
204No ContentSuccess with no body (DELETE)
400Bad RequestInvalid business logic
401UnauthorizedNot authenticated
403ForbiddenAuthenticated but without permission
404Not FoundResource doesn't exist
409ConflictConflict with existing data
422Unprocessable EntityPydantic validation failed (automatic)
500Internal Server ErrorA 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

  • HTTPException replaces return {"error": ...} — a real status code, not a 200 for errors
  • raise, not return — it stops the endpoint's execution immediately
  • detail accepts str, dict, or list — 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=201 in the decorator
  • headers in HTTPException: for protocols like 401 and 429
  • starlette.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

  1. FastAPI - Handling Errors - Official documentation on HTTPException and error handling
  2. FastAPI - Response Status Code - How to use status_code in decorators
  3. MDN - HTTP Status Codes - Complete reference for every HTTP status code
  4. Starlette - Status Codes - The status code constants FastAPI re-exports
  5. FastAPI - Additional Responses - Documenting error responses in /docs with OpenAPI
  6. RFC 9110 - HTTP Semantics - The official specification for HTTP status codes