Module 5: Error Handling and CORS
Custom Exception Handlers, a Consistent Format, and Basic Logging
Capsule overview
In the previous capsule you learned HTTPException — the direct way to return errors from an endpoint. It works for individual cases, but as your API grows, problems show up: every endpoint defines its own error format, the client can't predict the structure, and unexpected errors return a generic 500 with no useful information.
Custom exception handlers solve this. You define exception classes for each type of business error (BookNotFoundError, DuplicateBookError) and register global handlers that intercept those exceptions and return consistently formatted responses. Every error in your API — 404, 422, 500 — has exactly the same structure.
By the end of this capsule you'll be able to create custom exceptions, register global exception handlers, define a consistent error format, customize Pydantic's 422 response, catch unexpected errors with a catch-all handler, and add basic logging.
The problem: inconsistent errors
Look at this code with HTTPException in every endpoint:
from fastapi import FastAPI, HTTPException
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
raise HTTPException(status_code=404, detail="Book not found")
@app.post("/books")
def create_book(title: str, author: str, year: int):
for book in books:
if book["title"].lower() == title.lower():
raise HTTPException(status_code=409, detail=f"'{title}' already exists")
new_book = {"id": len(books) + 1, "title": title, "author": author, "year": year}
books.append(new_book)
return new_book
GET returns {"detail": "Book not found"}. POST returns {"detail": "'Hopscotch' already exists"}. There are no error codes, no extra data, and nothing forcing new endpoints to follow the same pattern.
Custom exception classes
Define an exception class for each kind of error in your domain:
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
class DuplicateBookError(Exception):
def __init__(self, title: str):
self.title = title
self.message = f"Book with title '{title}' already exists"
Each class inherits from Exception and stores the relevant information. The advantage over HTTPException: raise BookNotFoundError(book_id=42) is semantic — you know exactly what happened without reading the message.
Registering exception handlers
The classes don't do anything on their own. You have to tell FastAPI how to turn them into HTTP responses:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
class DuplicateBookError(Exception):
def __init__(self, title: str):
self.title = title
self.message = f"Book with title '{title}' already exists"
@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(request: Request, exc: BookNotFoundError):
return JSONResponse(
status_code=404,
content={
"status": "error",
"error_code": "BOOK_NOT_FOUND",
"message": exc.message,
"detail": {"book_id": exc.book_id}
}
)
@app.exception_handler(DuplicateBookError)
async def duplicate_book_handler(request: Request, exc: DuplicateBookError):
return JSONResponse(
status_code=409,
content={
"status": "error",
"error_code": "DUPLICATE_BOOK",
"message": exc.message,
"detail": {"title": exc.title}
}
)
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
raise BookNotFoundError(book_id=book_id)
@app.post("/books")
def create_book(title: str, author: str, year: int):
for book in books:
if book["title"].lower() == title.lower():
raise DuplicateBookError(title=title)
new_book = {"id": len(books) + 1, "title": title, "author": author, "year": year}
books.append(new_book)
return new_book
Each handler receives request and exc (the exception instance), and returns a JSONResponse.
curl -s http://127.0.0.1:8000/books/99 | python -m json.tool
Expected output:
{
"status": "error",
"error_code": "BOOK_NOT_FOUND",
"message": "Book with id 99 not found",
"detail": {"book_id": 99}
}
curl -s -X POST "http://127.0.0.1:8000/books?title=Hopscotch&author=Cortázar&year=1963" | python -m json.tool
Expected output:
{
"status": "error",
"error_code": "DUPLICATE_BOOK",
"message": "Book with title 'Hopscotch' already exists",
"detail": {"title": "Hopscotch"}
}
The consistent error format
A standard structure for all of your error responses:
{
"status": "error",
"error_code": "BOOK_NOT_FOUND",
"message": "Human-readable message",
"detail": {}
}
| Field | Purpose |
|---|---|
| status | Always "error" — the client can tell errors from successes |
| error_code | A machine-readable code: BOOK_NOT_FOUND, VALIDATION_ERROR |
| message | A human-readable description |
| detail | Optional extra data: IDs, fields with errors |
The key piece is error_code. The HTTP status (404) gives the broad category. The error_code says exactly what happened — a 404 could be "book not found" or "author not found", and the client can tell them apart.
A helper centralizes the creation:
def create_error_response(status_code: int, error_code: str, message: str, detail=None):
content = {"status": "error", "error_code": error_code, "message": message}
if detail is not None:
content["detail"] = detail
return JSONResponse(status_code=status_code, content=content)
@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(request: Request, exc: BookNotFoundError):
return create_error_response(404, "BOOK_NOT_FOUND", exc.message, {"book_id": exc.book_id})
If tomorrow you add a timestamp field to every error, you change it in one place.
Overriding the 422 handler: Pydantic validation
When Pydantic rejects a request, FastAPI returns a 422 in a default format that doesn't follow your standard. Override the RequestValidationError handler:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field
app = FastAPI()
def create_error_response(status_code: int, error_code: str, message: str, detail=None):
content = {"status": "error", "error_code": error_code, "message": message}
if detail is not None:
content["detail"] = detail
return JSONResponse(status_code=status_code, content=content)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": " -> ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"]
})
return create_error_response(422, "VALIDATION_ERROR", "Request validation failed", errors)
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "year": 3000}' | python -m json.tool
Expected output:
{
"status": "error",
"error_code": "VALIDATION_ERROR",
"message": "Request validation failed",
"detail": [
{"field": "body -> title", "message": "String should have at least 1 character", "type": "string_too_short"},
{"field": "body -> author", "message": "Field required", "type": "missing"},
{"field": "body -> year", "message": "Input should be less than or equal to 2026", "type": "less_than_equal"}
]
}
Catch-all handler and basic logging
What about bugs you never caught? Without a catch-all, the client gets a generic 500. With logging, you know what failed:
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
app = FastAPI()
def create_error_response(status_code: int, error_code: str, message: str, detail=None):
content = {"status": "error", "error_code": error_code, "message": message}
if detail is not None:
content["detail"] = detail
return JSONResponse(status_code=status_code, content=content)
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(request: Request, exc: BookNotFoundError):
logger.warning(f"Book not found: id={exc.book_id} | path={request.url.path}")
return create_error_response(404, "BOOK_NOT_FOUND", exc.message, {"book_id": exc.book_id})
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(
f"Unexpected error on {request.method} {request.url.path}: {exc}",
exc_info=True
)
return create_error_response(500, "INTERNAL_ERROR", "An unexpected error occurred")
@app.get("/books/{book_id}")
def get_book(book_id: int):
if book_id == 99:
raise BookNotFoundError(book_id=book_id)
if book_id == 0:
result = 1 / 0 # Simulates a bug
return {"id": book_id, "title": "One Hundred Years of Solitude"}
Logging levels:
| Level | When to use it | Example |
|---|---|---|
logger.info() | Normal events | "Book created: id=3" |
logger.warning() | Expected errors | "Book not found: id=99" |
logger.error() | Unexpected errors | "ZeroDivisionError on GET /books/0" |
curl -s http://127.0.0.1:8000/books/0 | python -m json.tool
The client receives: {"status": "error", "error_code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}
In the uvicorn terminal:
2026-03-13 10:30:05 - __main__ - ERROR - Unexpected error on GET /books/0: division by zero
Traceback (most recent call last):
File "main.py", line 34, in get_book
result = 1 / 0
ZeroDivisionError: division by zero
exc_info=True includes the full traceback — without it, you'd only see the message.
Comparison: HTTPException vs custom exceptions
| Aspect | HTTPException | Custom exception + handler |
|---|---|---|
| Setup | Zero — it ships with FastAPI | You define a class + a handler |
| Format | Fixed {"detail": "..."} | You control the whole format |
| Consistency | Depends on each developer | The handler guarantees the structure |
| Logging | Manual in every endpoint | Centralized in the handler |
| Reuse | You repeat status_code + detail | raise BookNotFoundError(id) — clean |
| When to use | Prototypes, simple errors | Professional APIs, teams |
Rule of thumb: if your API has 3 endpoints, HTTPException is fine. If it has 10+, custom exceptions save you a headache. You can use both in the same app.
Organizing your error handling
Group things logically in your file: (1) Exceptions — the classes, (2) Helper — create_error_response, (3) Handlers — the @app.exception_handler() functions, (4) Endpoints — the business logic. As your API grows, group exceptions by resource: BookNotFoundError and DuplicateBookError together; AuthorNotFoundError in another group. Each exception has a single purpose and stores the data its handler needs.
Connection to the project
The exception handlers you just learned carry over to the Module 6 project (the CRUD API). Your To-Do List API will have TaskNotFoundError and DuplicateTaskError, each with its handler, all returning the same format. The 422 override makes sure validation errors follow your standard. And the catch-all with logging protects you against unexpected errors.
Troubleshooting
Problem 1: The exception handler never runs — FastAPI returns a generic 500
Cause: You registered the handler for a different exception than the one you raise.
# ❌ Handler for BookNotFoundError but you raise BookError — no match
@app.exception_handler(BookNotFoundError)
async def handler(request, exc):
return JSONResponse(status_code=404, content={"error": "not found"})
@app.get("/books/{book_id}")
def get_book(book_id: int):
raise BookError("not found")
# ✅ The exception you raise matches the one you registered
@app.get("/books/{book_id}")
def get_book(book_id: int):
raise BookNotFoundError(book_id=book_id)
Problem 2: The 422 override doesn't work
Cause: You imported from the wrong module.
# ❌ This does NOT catch Pydantic validation errors
from fastapi import HTTPException
@app.exception_handler(HTTPException)
async def handler(request, exc): ...
# ✅ The correct import
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc): ...
Problem 3: The logs don't show up in the terminal
Cause: You never called logging.basicConfig().
# ❌ Unconfigured — the logs never print
logger = logging.getLogger(__name__)
logger.warning("test")
# ✅ Configure it at the top of the file
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
logger.warning("test") # Shows up in the terminal
Exercises
Exercise 1: A basic exception handler (Easy)
Create BookNotFoundError with a handler that returns a 404 in the consistent format. Add a GET /books/{book_id} endpoint that searches a list and raises the exception when it finds nothing. Test it with an existing ID and a nonexistent one.
See solution
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(request: Request, exc: BookNotFoundError):
return JSONResponse(
status_code=404,
content={"status": "error", "error_code": "BOOK_NOT_FOUND",
"message": exc.message, "detail": {"book_id": exc.book_id}}
)
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez"},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar"},
]
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
raise BookNotFoundError(book_id=book_id)
curl -s http://127.0.0.1:8000/books/1
# → {"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez"}
curl -s http://127.0.0.1:8000/books/99
# → {"status": "error", "error_code": "BOOK_NOT_FOUND", ...}
Exercise 2: The 422 override (Easy)
Create a Book model with Pydantic (title min 1, author min 1, year ge 1450 le 2026). Override RequestValidationError to return your consistent format. Test it by sending a body with missing fields.
See solution
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [{"field": " -> ".join(str(loc) for loc in e["loc"]),
"message": e["msg"], "type": e["type"]} for e in exc.errors()]
return JSONResponse(
status_code=422,
content={"status": "error", "error_code": "VALIDATION_ERROR",
"message": "Request validation failed", "detail": errors}
)
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"year": 3000}'
# → {"status": "error", "error_code": "VALIDATION_ERROR", "detail": [
# {"field": "body -> title", "message": "Field required", ...},
# {"field": "body -> author", "message": "Field required", ...},
# {"field": "body -> year", "message": "Input should be less than or equal to 2026", ...}]}
Exercise 3: Multiple exceptions with a helper (Medium)
Create BookNotFoundError, DuplicateBookError, and InvalidYearError. Use a create_error_response helper. Endpoints: GET /books/{book_id} and POST /books (validating duplicate titles and future years). Test all three scenarios.
See solution
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
app = FastAPI()
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
class DuplicateBookError(Exception):
def __init__(self, title: str):
self.title = title
self.message = f"Book '{title}' already exists"
class InvalidYearError(Exception):
def __init__(self, year: int):
self.year = year
self.message = f"Year {year} is in the future"
def create_error_response(status_code: int, error_code: str, message: str, detail=None):
content = {"status": "error", "error_code": error_code, "message": message}
if detail:
content["detail"] = detail
return JSONResponse(status_code=status_code, content=content)
@app.exception_handler(BookNotFoundError)
async def handle_not_found(request: Request, exc: BookNotFoundError):
return create_error_response(404, "BOOK_NOT_FOUND", exc.message, {"book_id": exc.book_id})
@app.exception_handler(DuplicateBookError)
async def handle_duplicate(request: Request, exc: DuplicateBookError):
return create_error_response(409, "DUPLICATE_BOOK", exc.message, {"title": exc.title})
@app.exception_handler(InvalidYearError)
async def handle_invalid_year(request: Request, exc: InvalidYearError):
return create_error_response(400, "INVALID_YEAR", exc.message, {"year": exc.year})
class BookCreate(BaseModel):
title: str = Field(min_length=1)
author: str = Field(min_length=1)
year: int
books = [{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez", "year": 1967}]
next_id = 2
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
raise BookNotFoundError(book_id=book_id)
@app.post("/books", status_code=201)
def create_book(book: BookCreate):
global next_id
if book.year > datetime.now().year:
raise InvalidYearError(year=book.year)
for existing in books:
if existing["title"].lower() == book.title.lower():
raise DuplicateBookError(title=book.title)
new_book = {"id": next_id, **book.model_dump()}
books.append(new_book)
next_id += 1
return new_book
curl -s http://127.0.0.1:8000/books/99
# → 404, error_code: "BOOK_NOT_FOUND"
curl -s -X POST http://127.0.0.1:8000/books -H "Content-Type: application/json" \
-d '{"title": "One Hundred Years of Solitude", "author": "Other", "year": 2020}'
# → 409, error_code: "DUPLICATE_BOOK"
curl -s -X POST http://127.0.0.1:8000/books -H "Content-Type: application/json" \
-d '{"title": "Future", "author": "Author", "year": 2099}'
# → 400, error_code: "INVALID_YEAR"
Exercise 4: A catch-all with logging (Medium)
Add a catch-all handler for Exception using logger.error() and exc_info=True. Create a GET /books/{book_id}/summary endpoint that reads a key that doesn't exist in a dict (simulating a bug). Verify that the client gets the consistent format and that the terminal shows the traceback.
See solution
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI()
@app.exception_handler(Exception)
async def general_handler(request: Request, exc: Exception):
logger.error(f"Error on {request.method} {request.url.path}: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "error_code": "INTERNAL_ERROR",
"message": "An unexpected error occurred"}
)
books = [{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez"}]
@app.get("/books/{book_id}/summary")
def get_book_summary(book_id: int):
for book in books:
if book["id"] == book_id:
return {"summary": book["summary"]} # KeyError: "summary" doesn't exist
return {"error": "not found"}
curl -s http://127.0.0.1:8000/books/1/summary
# → {"status": "error", "error_code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}
# Terminal: ERROR - Error on GET /books/1/summary: 'summary' + the full traceback
Exercise 5: A complete error handling system (Hard)
Build an API with: a BookCreate model (Pydantic: title, author, year, isbn min 10 max 13), the exceptions BookNotFoundError and DuplicateISBNError, the 422 override, a catch-all with logging, and a helper. Endpoints: GET /books, GET /books/{book_id}, POST /books (validating duplicate ISBNs), DELETE /books/{book_id}. Test 4 error scenarios.
See solution
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI()
class BookNotFoundError(Exception):
def __init__(self, book_id: int):
self.book_id = book_id
self.message = f"Book with id {book_id} not found"
class DuplicateISBNError(Exception):
def __init__(self, isbn: str):
self.isbn = isbn
self.message = f"ISBN '{isbn}' already exists"
def create_error_response(status_code, error_code, message, detail=None):
content = {"status": "error", "error_code": error_code, "message": message}
if detail is not None:
content["detail"] = detail
return JSONResponse(status_code=status_code, content=content)
@app.exception_handler(BookNotFoundError)
async def handle_not_found(request: Request, exc: BookNotFoundError):
return create_error_response(404, "BOOK_NOT_FOUND", exc.message, {"book_id": exc.book_id})
@app.exception_handler(DuplicateISBNError)
async def handle_duplicate(request: Request, exc: DuplicateISBNError):
return create_error_response(409, "DUPLICATE_ISBN", exc.message, {"isbn": exc.isbn})
@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError):
errors = [{"field": " -> ".join(str(l) for l in e["loc"]),
"message": e["msg"], "type": e["type"]} for e in exc.errors()]
return create_error_response(422, "VALIDATION_ERROR", "Validation failed", errors)
@app.exception_handler(Exception)
async def handle_general(request: Request, exc: Exception):
logger.error(f"Unexpected: {exc}", exc_info=True)
return create_error_response(500, "INTERNAL_ERROR", "An unexpected error occurred")
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=1450, le=2026)
isbn: str = Field(min_length=10, max_length=13)
books = [{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "isbn": "9780060883287"}]
next_id = 2
@app.get("/books")
def list_books():
return books
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
raise BookNotFoundError(book_id=book_id)
@app.post("/books", status_code=201)
def create_book(book: BookCreate):
global next_id
for existing in books:
if existing["isbn"] == book.isbn:
raise DuplicateISBNError(isbn=book.isbn)
new_book = {"id": next_id, **book.model_dump()}
books.append(new_book)
next_id += 1
return new_book
@app.delete("/books/{book_id}")
def delete_book(book_id: int):
for i, book in enumerate(books):
if book["id"] == book_id:
deleted = books.pop(i)
return {"message": f"Book '{deleted['title']}' deleted"}
raise BookNotFoundError(book_id=book_id)
curl -s http://127.0.0.1:8000/books/99 # → 404 BOOK_NOT_FOUND
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title":"X","author":"Y","year":2020,"isbn":"9780060883287"}' # → 409 DUPLICATE_ISBN
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" -d '{"title":""}' # → 422 VALIDATION_ERROR
curl -s -X DELETE http://127.0.0.1:8000/books/99 # → 404 BOOK_NOT_FOUND
Summary
- Custom exceptions (
BookNotFoundError,DuplicateBookError) inherit fromExceptionand store data relevant to the error @app.exception_handler()registers a global handler that intercepts an exception and returns aJSONResponsein your format- A consistent format with
status,error_code,message, anddetail— every error follows the same structure - The
create_error_responsehelper centralizes the creation of error responses - The 422 override with
RequestValidationError(fromfastapi.exceptions) customizes Pydantic's validation errors - A catch-all handler for
Exceptioncatches unexpected errors and returns a 500 without exposing internal details - Logging —
logger.warning()for expected errors,logger.error()withexc_info=Truefor unexpected errors with a traceback - HTTPException vs custom exceptions: HTTPException for prototypes; custom exceptions for consistency and centralized logging
Next capsule: CORS and Middleware — you'll learn what the same-origin policy is, why CORS exists, how to configure CORSMiddleware in FastAPI, and the middleware concept.
Additional resources
- FastAPI - Handling Errors - Official documentation on error handling in FastAPI
- FastAPI - Custom Exception Handlers - Registering handlers for custom exceptions
- FastAPI - Override Request Validation Exceptions - Customizing the 422 response
- Python Logging HOWTO - The official guide to Python's logging module
- Starlette - Exception Handlers - Exception handlers in Starlette (FastAPI's foundation)
- FastAPI Best Practices - Error Handling - Recommended patterns for error handling