Module 5: Error Handling and CORS
Proyecto: Books API Robusta
Descripción del proyecto
Tu Books API tiene modelos Pydantic, validación automática, modelos separados por operación, response_model en cada endpoint, y documentación auto-generada en /docs. Pero los errores siguen siendo mentirosos: return ErrorResponse(message="...") con HTTP 200 cuando debería ser 404. No hay CORS configurado, así que ningún frontend puede consumirla. Y si un bug inesperado ocurre, el cliente ve un stack trace en vez de un error genérico.
En este proyecto transformas la Books API del Módulo 4 en una API robusta. Cada return ErrorResponse(...) se reemplaza con raise — ya sea HTTPException o una custom exception con handler. Cada error retorna el status code HTTP correcto. Las respuestas de error siguen un formato consistente. Un handler global captura errores inesperados. CORS está configurado para desarrollo. Y los errores se registran con logging para debugging.
El cambio no es masivo en cantidad de código. Es masivo en calidad de comunicación: tu API pasa de mentir sobre los errores a comunicarlos profesionalmente. Un frontend que consuma esta API puede confiar en los status codes, parsear errores con una estructura predecible, y funcionar desde cualquier origen autorizado.
Objetivos del proyecto
Al completar este proyecto:
- ✅ Reemplazas todos los
return ErrorResponse(...)conraisey el status code correcto - ✅ Defines custom exceptions:
BookNotFoundError,DuplicateBookError - ✅ Registras exception handlers con formato de error consistente
- ✅ Personalizas el handler de 422 (
RequestValidationError) - ✅ Implementas un handler global para
Exception(errores inesperados → 500) - ✅ Configuras
CORSMiddlewarepara desarrollo (localhost:3000, 5173, 5174) - ✅ Implementas logging básico con
loggingde Python - ✅
POST /booksretorna 409 si el libro ya existe (título + autor duplicado) - ✅
GET /books/{id},PUT,PATCH,DELETEretornan 404 si el libro no existe - ✅
PATCHcon body vacío retorna 400 - ✅ Todos los endpoints retornan status codes HTTP semánticos
¿Por qué este proyecto?
Observa la diferencia entre tu API del Módulo 4 y la del Módulo 5:
Módulo 4 (actual): Módulo 5 (este proyecto):
────────────────── ─────────────────────────
return ErrorResponse(...) → raise BookNotFoundError(id)
HTTP 200 para errores → HTTP 404, 400, 409 según el caso
Sin CORS → CORSMiddleware configurado
422 con formato default → 422 con formato custom consistente
Errores inesperados → stack trace → 500 genérico + logging interno
Sin logging → logger.warning() y logger.error()
ErrorResponse manual en endpoints → Handlers centralizados
Las custom exceptions
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, author: str):
self.title = title
self.author = author
self.message = f"A book titled '{title}' by {author} already exists"
BookNotFoundError reemplaza todos los if book is None: return ErrorResponse(...) del Módulo 4. DuplicateBookError agrega validación de duplicados que no existía antes.
El helper de respuestas de error
from fastapi.responses import JSONResponse
def error_response(status_code: int, code: str, message: str) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"code": code,
"message": message,
}
)
Todos los handlers usan esta función. El formato es consistente: status, code (para manejo programático) y message (para display).
El proyecto completo
app/main.py
import logging
from datetime import datetime
from fastapi import FastAPI, HTTPException, Query, Path, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator, computed_field
# --- Logging ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("books_api")
# --- App ---
app = FastAPI(
title="Books API",
description="API de gestión de libros con error handling profesional y CORS. "
"Módulo 5 — FastAPI Fundamentals.",
version="5.0.0",
)
# --- CORS Middleware ---
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Custom Exceptions ---
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, author: str):
self.title = title
self.author = author
self.message = f"A book titled '{title}' by {author} already exists"
# --- Error Response Helper ---
def make_error_response(status_code: int, code: str, message: str) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"code": code,
"message": message,
}
)
# --- Exception Handlers ---
@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 make_error_response(404, "BOOK_NOT_FOUND", exc.message)
@app.exception_handler(DuplicateBookError)
async def duplicate_book_handler(request: Request, exc: DuplicateBookError):
logger.warning(f"Duplicate book: '{exc.title}' by {exc.author}")
return make_error_response(409, "DUPLICATE_BOOK", exc.message)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return make_error_response(exc.status_code, f"HTTP_{exc.status_code}", exc.detail)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field = " → ".join(str(loc) for loc in error["loc"])
errors.append({
"field": field,
"message": error["msg"],
"type": error["type"],
})
logger.warning(
f"Validation error: {len(errors)} error(s) | "
f"Path: {request.method} {request.url.path}"
)
return JSONResponse(
status_code=422,
content={
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"errors": errors,
}
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(
f"Unhandled error: {type(exc).__name__}: {exc} | "
f"Method: {request.method} | Path: {request.url.path}",
exc_info=True
)
return make_error_response(
500, "INTERNAL_ERROR",
"An unexpected error occurred. Please try again later."
)
# --- Pydantic Models ---
class BookBase(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Título del libro")
author: str = Field(min_length=1, max_length=100, description="Nombre del autor")
year: int = Field(ge=1000, le=2030, description="Año de publicación")
genre: str = Field(min_length=1, description="Género literario")
available: bool = Field(default=True, description="Disponible para préstamo")
@field_validator("title", "author")
@classmethod
def must_not_be_empty_whitespace(cls, v: str) -> str:
if not v.strip():
raise ValueError("Cannot be empty or whitespace only")
return v.strip()
@field_validator("genre")
@classmethod
def validate_genre(cls, v: str) -> str:
allowed = [
"Ficción", "Novela", "Realismo mágico", "Poesía",
"Ensayo", "Ciencia ficción", "Terror", "Historia",
]
v_clean = v.strip()
if v_clean not in allowed:
raise ValueError(f"Genre must be one of: {', '.join(allowed)}")
return v_clean
class BookCreate(BookBase):
pass
class BookUpdate(BookBase):
pass
class BookPatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
author: str | None = Field(default=None, min_length=1, max_length=100)
year: int | None = Field(default=None, ge=1000, le=2030)
genre: str | None = None
available: bool | None = None
class BookResponse(BookBase):
id: int
@computed_field
@property
def age(self) -> int:
return datetime.now().year - self.year
class BookListResponse(BaseModel):
status: str = "success"
count: int
data: list[BookResponse]
class SuccessResponse(BaseModel):
status: str = "success"
message: str
data: BookResponse | None = None
# --- Data ---
books: list[dict] = [
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez",
"year": 1967, "genre": "Realismo mágico", "available": True},
{"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes",
"year": 1605, "genre": "Novela", "available": True},
{"id": 3, "title": "El Aleph", "author": "Jorge Luis Borges",
"year": 1949, "genre": "Ficción", "available": False},
{"id": 4, "title": "Rayuela", "author": "Julio Cortázar",
"year": 1963, "genre": "Ficción", "available": True},
{"id": 5, "title": "Pedro Páramo", "author": "Juan Rulfo",
"year": 1955, "genre": "Realismo mágico", "available": False},
{"id": 6, "title": "La Casa de los Espíritus", "author": "Isabel Allende",
"year": 1982, "genre": "Realismo mágico", "available": True},
{"id": 7, "title": "Ficciones", "author": "Jorge Luis Borges",
"year": 1944, "genre": "Ficción", "available": True},
{"id": 8, "title": "Conversación en La Catedral", "author": "Mario Vargas Llosa",
"year": 1969, "genre": "Novela", "available": True},
]
# --- Helpers ---
def find_book_or_fail(book_id: int) -> dict:
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
raise BookNotFoundError(book_id)
return book
def generate_id() -> int:
if not books:
return 1
return max(b["id"] for b in books) + 1
def check_duplicate(title: str, author: str, exclude_id: int | None = None) -> None:
for book in books:
if exclude_id and book["id"] == exclude_id:
continue
if book["title"].lower() == title.lower() and \
book["author"].lower() == author.lower():
raise DuplicateBookError(title, author)
# --- Root Endpoint ---
@app.get("/", tags=["General"])
def root():
return {
"service": "Books API",
"version": "5.0.0",
"docs": "/docs",
}
# --- Stats (before /books/{book_id} to avoid route conflict) ---
@app.get("/books/stats", tags=["Books"], summary="Book statistics")
def book_stats():
total = len(books)
available = sum(1 for b in books if b["available"])
unavailable = total - available
genre_counts: dict[str, int] = {}
for book in books:
genre = book["genre"]
genre_counts[genre] = genre_counts.get(genre, 0) + 1
years = [b["year"] for b in books]
return {
"status": "success",
"total": total,
"available": available,
"unavailable": unavailable,
"by_genre": genre_counts,
"oldest_year": min(years) if years else None,
"newest_year": max(years) if years else None,
}
# --- Read Endpoints ---
@app.get("/books", response_model=BookListResponse, tags=["Books"],
summary="List books with filters")
def list_books(
genre: str | None = Query(default=None, description="Filtrar por género exacto"),
author: str | None = Query(default=None,
description="Filtrar por autor (parcial, case-insensitive)"),
available: bool | None = Query(default=None, description="Filtrar por disponibilidad"),
search: str | None = Query(default=None, min_length=1, max_length=100,
description="Buscar en título y autor"),
min_year: int | None = Query(default=None, ge=1000,
description="Año mínimo de publicación"),
max_year: int | None = Query(default=None, le=2030,
description="Año máximo de publicación"),
skip: int = Query(default=0, ge=0, description="Resultados a saltar (paginación)"),
limit: int = Query(default=10, ge=1, le=100,
description="Máximo de resultados (1-100)"),
):
if min_year is not None and max_year is not None and min_year > max_year:
raise HTTPException(
status_code=400,
detail=f"min_year ({min_year}) cannot be greater than max_year ({max_year})"
)
results = books.copy()
if genre is not None:
results = [b for b in results if b["genre"].lower() == genre.lower()]
if author is not None:
results = [b for b in results if author.lower() in b["author"].lower()]
if available is not None:
results = [b for b in results if b["available"] == available]
if search is not None:
query = search.lower()
results = [
b for b in results
if query in b["title"].lower() or query in b["author"].lower()
]
if min_year is not None:
results = [b for b in results if b["year"] >= min_year]
if max_year is not None:
results = [b for b in results if b["year"] <= max_year]
total_filtered = len(results)
paginated = results[skip : skip + limit]
return BookListResponse(
count=total_filtered,
data=[BookResponse(**b) for b in paginated],
)
@app.get("/books/{book_id}", response_model=BookResponse, tags=["Books"],
summary="Get book by ID")
def get_book(
book_id: int = Path(..., ge=1, description="ID del libro (debe ser >= 1)"),
):
book = find_book_or_fail(book_id)
return book
# --- Create Endpoint ---
@app.post("/books", response_model=SuccessResponse, status_code=201, tags=["Books"],
summary="Create a new book")
def create_book(book: BookCreate):
check_duplicate(book.title, book.author)
new_book = {"id": generate_id(), **book.model_dump()}
books.append(new_book)
logger.info(f"Book created: id={new_book['id']} title='{new_book['title']}'")
return SuccessResponse(
message="Book created",
data=BookResponse(**new_book),
)
# --- Update Endpoints ---
@app.put("/books/{book_id}", response_model=SuccessResponse, tags=["Books"],
summary="Full update")
def update_book(
book: BookUpdate,
book_id: int = Path(..., ge=1, description="ID del libro a actualizar"),
):
existing = find_book_or_fail(book_id)
check_duplicate(book.title, book.author, exclude_id=book_id)
index = books.index(existing)
books[index] = {"id": book_id, **book.model_dump()}
logger.info(f"Book updated: id={book_id}")
return SuccessResponse(
message="Book updated",
data=BookResponse(**books[index]),
)
@app.patch("/books/{book_id}", response_model=SuccessResponse, tags=["Books"],
summary="Partial update")
def patch_book(
book: BookPatch,
book_id: int = Path(..., ge=1,
description="ID del libro a actualizar parcialmente"),
):
existing = find_book_or_fail(book_id)
update_data = book.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No fields provided for update")
new_title = update_data.get("title", existing["title"])
new_author = update_data.get("author", existing["author"])
if "title" in update_data or "author" in update_data:
check_duplicate(new_title, new_author, exclude_id=book_id)
existing.update(update_data)
logger.info(f"Book patched: id={book_id} fields={list(update_data.keys())}")
return SuccessResponse(
message="Book partially updated",
data=BookResponse(**existing),
)
# --- Delete Endpoint ---
@app.delete("/books/{book_id}", response_model=SuccessResponse, tags=["Books"],
summary="Delete book")
def delete_book(
book_id: int = Path(..., ge=1, description="ID del libro a eliminar"),
):
book = find_book_or_fail(book_id)
books.remove(book)
logger.info(f"Book deleted: id={book_id} title='{book['title']}'")
return SuccessResponse(message=f"Book {book_id} deleted")
Ejecutar
uvicorn app.main:app --reload
Abre http://127.0.0.1:8000/docs. Verás:
- Cada endpoint con su schema de entrada y salida
- Los modelos
BookCreate,BookUpdate,BookPatch,BookResponseen la sección "Schemas" - La versión
5.0.0en el título
Verificación paso a paso
Paso 1: Verificar que el servidor levanta
curl -s http://127.0.0.1:8000/ | python -m json.tool
{
"service": "Books API",
"version": "5.0.0",
"docs": "/docs"
}
Paso 2: Listar libros (éxito)
curl -s http://127.0.0.1:8000/books | python -m json.tool
Retorna "status": "success", "count": 8, y "data" con los 8 libros. Cada libro incluye age calculado por @computed_field.
Paso 3: Obtener libro existente (200)
curl -s -w "\nHTTP: %{http_code}\n" http://127.0.0.1:8000/books/1
{"id":1,"title":"Cien Años de Soledad","author":"Gabriel García Márquez","year":1967,"genre":"Realismo mágico","available":true,"age":59}
HTTP: 200
Paso 4: Obtener libro inexistente (404)
curl -s -w "\nHTTP: %{http_code}\n" http://127.0.0.1:8000/books/999
{"status":"error","code":"BOOK_NOT_FOUND","message":"Book with id 999 not found"}
HTTP: 404
Ya no es HTTP 200 con un body de error. Es HTTP 404 con un formato consistente. En el log del servidor verás: WARNING - Book not found: id=999 | Path: /books/999.
Paso 5: Crear libro válido (201)
curl -s -w "\nHTTP: %{http_code}\n" -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "El Túnel", "author": "Ernesto Sabato", "year": 1948, "genre": "Ficción"}'
{"status":"success","message":"Book created","data":{"id":9,"title":"El Túnel","author":"Ernesto Sabato","year":1948,"genre":"Ficción","available":true,"age":78}}
HTTP: 201
Paso 6: Crear libro duplicado (409)
curl -s -w "\nHTTP: %{http_code}\n" -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "El Túnel", "author": "Ernesto Sabato", "year": 1948, "genre": "Ficción"}'
{"status":"error","code":"DUPLICATE_BOOK","message":"A book titled 'El Túnel' by Ernesto Sabato already exists"}
HTTP: 409
Paso 7: Crear con datos inválidos (422 custom)
curl -s -w "\nHTTP: %{http_code}\n" -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "author": "", "year": 500, "genre": "Romance"}' \
| python -m json.tool
{
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"errors": [
{"field": "body → title", "message": "String should have at least 1 character", "type": "string_too_short"},
{"field": "body → author", "message": "String should have at least 1 character", "type": "string_too_short"},
{"field": "body → year", "message": "Input should be greater than or equal to 1000", "type": "greater_than_equal"},
{"field": "body → genre", "message": "Value error, Genre must be one of: Ficción, Novela, Realismo mágico, Poesía, Ensayo, Ciencia ficción, Terror, Historia", "type": "value_error"}
]
}
HTTP: 422
El 422 ahora sigue tu formato custom en vez del default de FastAPI.
Paso 8: PATCH con body vacío (400)
curl -s -w "\nHTTP: %{http_code}\n" -X PATCH http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{}'
{"status":"error","code":"HTTP_400","message":"No fields provided for update"}
HTTP: 400
Paso 9: PATCH válido (200)
curl -s -w "\nHTTP: %{http_code}\n" -X PATCH http://127.0.0.1:8000/books/3 \
-H "Content-Type: application/json" \
-d '{"available": true}'
{"status":"success","message":"Book partially updated","data":{"id":3,"title":"El Aleph","author":"Jorge Luis Borges","year":1949,"genre":"Ficción","available":true,"age":77}}
HTTP: 200
Solo se actualizó available — el resto permanece intacto.
Paso 10: PUT con libro inexistente (404)
curl -s -w "\nHTTP: %{http_code}\n" -X PUT http://127.0.0.1:8000/books/999 \
-H "Content-Type: application/json" \
-d '{"title": "X", "author": "Y", "year": 2000, "genre": "Ficción"}'
{"status":"error","code":"BOOK_NOT_FOUND","message":"Book with id 999 not found"}
HTTP: 404
Paso 11: DELETE exitoso (200)
curl -s -w "\nHTTP: %{http_code}\n" -X DELETE http://127.0.0.1:8000/books/5
{"status":"success","message":"Book 5 deleted","data":null}
HTTP: 200
Paso 12: DELETE de libro inexistente (404)
curl -s -w "\nHTTP: %{http_code}\n" -X DELETE http://127.0.0.1:8000/books/999
{"status":"error","code":"BOOK_NOT_FOUND","message":"Book with id 999 not found"}
HTTP: 404
Paso 13: Rango de años inválido (400)
curl -s -w "\nHTTP: %{http_code}\n" "http://127.0.0.1:8000/books?min_year=2000&max_year=1900"
{"status":"error","code":"HTTP_400","message":"min_year (2000) cannot be greater than max_year (1900)"}
HTTP: 400
Paso 14: Verificar CORS headers
curl -s -D - -o /dev/null \
-H "Origin: http://localhost:3000" \
http://127.0.0.1:8000/books
En los headers de respuesta verás:
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
Paso 15: Preflight OPTIONS
curl -s -D - -o /dev/null -X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
http://127.0.0.1:8000/books
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:3000
access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT
access-control-allow-headers: Content-Type
access-control-allow-credentials: true
Paso 16: Verificar CORS en respuestas de error
curl -s -D - \
-H "Origin: http://localhost:3000" \
http://127.0.0.1:8000/books/999
Los headers CORS aparecen incluso en la respuesta 404. El middleware se ejecuta para cada request, independientemente del resultado.
Paso 17: Verificar logging en la consola
Revisa la consola donde corre uvicorn. Deberías ver mensajes como:
2026-03-13 - books_api - INFO - Book created: id=9 title='El Túnel'
2026-03-13 - books_api - WARNING - Book not found: id=999 | Path: /books/999
2026-03-13 - books_api - WARNING - Duplicate book: 'El Túnel' by Ernesto Sabato
2026-03-13 - books_api - WARNING - Validation error: 4 error(s) | Path: POST /books
2026-03-13 - books_api - INFO - Book deleted: id=5 title='Pedro Páramo'
Paso 18: Revisar /docs
Abre http://127.0.0.1:8000/docs:
- El título dice "Books API" con versión 5.0.0
- Cada endpoint muestra sus schemas de request/response
- "Try it out" funciona para probar directamente
- La sección "Schemas" al final muestra todos los modelos Pydantic
Checklist de completitud
Error Handling:
- [ ] BookNotFoundError definida con book_id y message
- [ ] DuplicateBookError definida con title, author y message
- [ ] Handler para BookNotFoundError → 404
- [ ] Handler para DuplicateBookError → 409
- [ ] Handler para HTTPException con formato consistente
- [ ] Handler para RequestValidationError → 422 custom
- [ ] Handler para Exception → 500 genérico
- [ ] make_error_response() helper para formato consistente
- [ ] Formato: {"status": "error", "code": "...", "message": "..."}
- [ ] find_book_or_fail() usa raise BookNotFoundError
- [ ] check_duplicate() usa raise DuplicateBookError
CORS:
- [ ] CORSMiddleware registrado
- [ ] allow_origins incluye localhost:3000, 5173, 5174
- [ ] allow_credentials=True
- [ ] allow_methods=["*"]
- [ ] allow_headers=["*"]
- [ ] Headers CORS presentes en respuestas de éxito Y error
Logging:
- [ ] basicConfig con level=INFO y formato con timestamp
- [ ] logger.info() para operaciones exitosas (create, update, delete)
- [ ] logger.warning() para errores de negocio (not found, duplicate, validation)
- [ ] logger.error() con exc_info=True para errores inesperados
Endpoints con status codes correctos:
- [ ] GET /books → 200 (éxito), 400 (min_year > max_year)
- [ ] GET /books/{id} → 200 (éxito), 404 (no existe)
- [ ] POST /books → 201 (creado), 409 (duplicado), 422 (validación)
- [ ] PUT /books/{id} → 200 (éxito), 404 (no existe), 409 (duplicado)
- [ ] PATCH /books/{id} → 200 (éxito), 404 (no existe), 400 (body vacío)
- [ ] DELETE /books/{id} → 200 (éxito), 404 (no existe)
- [ ] GET /books/stats → 200 (siempre éxito)
- [ ] GET / → 200 con info del servicio
Modelos Pydantic (del Módulo 4, intactos):
- [ ] BookBase con Field() constraints y @field_validator
- [ ] BookCreate, BookUpdate heredan de BookBase
- [ ] BookPatch con campos opcionales
- [ ] BookResponse con id y @computed_field age
- [ ] BookListResponse con status, count, data
- [ ] SuccessResponse con status, message, data
- [ ] response_model en todos los endpoints
Funcionalidad:
- [ ] Filtros (genre, author, available, search, min_year, max_year)
- [ ] Paginación (skip, limit)
- [ ] PATCH con model_dump(exclude_unset=True)
- [ ] PUT con check_duplicate(exclude_id=book_id)
- [ ] 8 libros precargados
Rúbrica de evaluación (100 puntos)
Custom exceptions y handlers (20 puntos)
- (4 pts)
BookNotFoundErrorconbook_idymessage - (4 pts)
DuplicateBookErrorcontitle,authorymessage - (4 pts) Handler para
BookNotFoundError→ 404 con formato consistente - (4 pts) Handler para
DuplicateBookError→ 409 con formato consistente - (4 pts)
make_error_response()helper usado en todos los handlers
Override de handlers default (15 puntos)
- (5 pts) Handler para
HTTPExceptioncon formato custom - (5 pts) Handler para
RequestValidationErrorcon formato custom y lista de errores - (5 pts) Handler para
Exceptioncon mensaje genérico y logging
Status codes correctos en endpoints (20 puntos)
- (3 pts) GET
/books/{id}→ 404 conBookNotFoundError - (3 pts) POST
/books→ 409 conDuplicateBookError - (3 pts) POST
/books→ 201 en éxito - (3 pts) PUT
/books/{id}→ 404 y 409 según el caso - (3 pts) PATCH
/books/{id}→ 400 con body vacío - (3 pts) DELETE
/books/{id}→ 404 si no existe - (2 pts) GET
/books→ 400 conmin_year > max_year
CORS configurado (15 puntos)
- (5 pts)
CORSMiddlewareregistrado conapp.add_middleware() - (4 pts)
allow_originscon al menoslocalhost:3000 - (3 pts) Headers CORS presentes en respuestas exitosas
- (3 pts) Headers CORS presentes en respuestas de error
Logging (10 puntos)
- (3 pts)
basicConfigconfigurado con nivel y formato - (3 pts)
logger.info()para operaciones exitosas - (2 pts)
logger.warning()para errores de negocio - (2 pts)
logger.error()conexc_info=Trueen handler global
Helpers (10 puntos)
- (4 pts)
find_book_or_fail()lanzaBookNotFoundError - (3 pts)
check_duplicate()lanzaDuplicateBookErrorconexclude_id - (3 pts)
generate_id()funciona correctamente
Formato de error consistente (5 puntos)
- (3 pts) Todas las respuestas de error siguen
{"status": "error", "code": "...", "message": "..."} - (2 pts) El 422 custom incluye
"errors": [...]con detalle por campo
Calidad de código (5 puntos)
- (2 pts) Modelos Pydantic del M4 intactos y funcionando
- (1 pt) response_model en todos los endpoints
- (1 pt) 8 libros precargados
- (1 pt) Código limpio, sin
return ErrorResponse(...)del M4
Troubleshooting
Problema 1: Los tests con curl retornan {"detail":"..."} en vez de {"status":"error",...}
Causa: El handler de HTTPException no está registrado, o se importó la clase incorrecta.
# ✅ Verificar que ambos imports existen
from fastapi import HTTPException # para raise
from fastapi import Request # para handlers
Verifica que @app.exception_handler(HTTPException) está registrado. Si falta, FastAPI usa el handler default que retorna {"detail": "..."}.
Problema 2: El handler de RequestValidationError nunca se ejecuta
Causa: Import incorrecto.
# ❌ Esto es de Pydantic — no captura errores de validación de requests
from pydantic import ValidationError
# ✅ Esto es de FastAPI — captura errores de validación de requests
from fastapi.exceptions import RequestValidationError
Problema 3: CORS funciona para GET pero no para POST
Causa: El preflight (OPTIONS) falla. Verifica que allow_methods=["*"] está configurado y que el middleware está registrado antes que otros componentes.
curl -s -D - -o /dev/null -X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
http://127.0.0.1:8000/books
Si no ves access-control-allow-methods en la respuesta, el middleware no está activo.
Problema 4: DuplicateBookError no se lanza en PUT
Causa: check_duplicate() no excluye el libro actual — un PUT del libro 1 con el mismo título lanza 409 contra sí mismo.
# ❌ Sin exclude_id — el libro conflicta consigo mismo
check_duplicate(book.title, book.author)
# ✅ Con exclude_id — ignora el libro que estás actualizando
check_duplicate(book.title, book.author, exclude_id=book_id)
Problema 5: Logging no aparece en consola
Causa: basicConfig no fue llamado, o fue llamado después de que un handler ya configuró el logger.
# ✅ Llamar basicConfig lo más arriba posible en el módulo
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger("books_api")
basicConfig solo tiene efecto la primera vez que se llama. Si otra librería lo llamó antes (uvicorn, por ejemplo), puedes necesitar configurar el logger directamente:
logger = logging.getLogger("books_api")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
logger.addHandler(handler)
Reflexión: Antes y después
| Aspecto | Módulo 4 | Módulo 5 |
|---|---|---|
| Error "no encontrado" | return ErrorResponse(...) con HTTP 200 | raise BookNotFoundError(id) → HTTP 404 |
| Duplicados | No se detectan | check_duplicate() → HTTP 409 |
| Formato de errores | Inconsistente (ErrorResponse vs {"detail": ...}) | Consistente: {"status": "error", "code": "...", "message": "..."} |
| Validación 422 | Formato default de FastAPI | Formato custom con lista de errores detallados |
| Error inesperado | Stack trace visible, 500 genérico | Mensaje genérico al cliente, stack trace en log |
| CORS | No configurado | CORSMiddleware con orígenes de desarrollo |
| Logging | Ninguno | INFO para éxito, WARNING para errores de negocio, ERROR para bugs |
| Código de error en endpoint | 5-6 líneas con if/return ErrorResponse | 1 línea con raise o helper |
La cantidad de código total es similar. Pero la responsabilidad cambió: los endpoints solo se preocupan por la lógica de negocio. Los handlers se preocupan por el formato y los status codes. Los helpers se preocupan por la búsqueda y validación de duplicados. Cada capa hace exactamente una cosa.
Patrones que aplicaste
1. Separación de concerns: negocio vs errores
Los endpoints contienen lógica de negocio. Los handlers contienen lógica de formato de errores. Los helpers contienen lógica de búsqueda y validación. No se mezclan.
2. Custom exceptions como lenguaje de dominio
raise BookNotFoundError(book_id) es más expresivo que raise HTTPException(404, detail="..."). El código de tu endpoint lee como una historia: "si no existe, lanza error de libro no encontrado."
3. Handler global como red de seguridad
@app.exception_handler(Exception) captura todo lo que no anticipaste. Garantiza que el cliente nunca vea un stack trace y que tú siempre veas el error en los logs.
4. Formato consistente
make_error_response() garantiza que todas las respuestas de error siguen la misma estructura. Un frontend puede confiar en parsear error.code y error.message sin importar qué error ocurrió.
5. CORS como middleware
CORSMiddleware se ejecuta para cada request — éxito y error. Los headers CORS siempre están presentes. Un frontend nunca es bloqueado por CORS en una respuesta de error.
6. Logging por nivel
INFO para operaciones normales ("libro creado"). WARNING para errores esperados ("libro no encontrado"). ERROR para errores inesperados ("ZeroDivisionError"). Cada nivel tiene un propósito distinto.
Resumen
En este proyecto hiciste tu Books API robusta:
- Custom exceptions (
BookNotFoundError,DuplicateBookError) reemplazanreturn ErrorResponse(...)conraise - Exception handlers centralizan el formato de errores — los endpoints solo lanzan excepciones
make_error_response()garantiza formato consistente:{"status": "error", "code": "...", "message": "..."}- Override de 422 personaliza los errores de validación de Pydantic
- Handler global captura errores inesperados con logging y mensaje genérico
CORSMiddlewarepermite acceso cross-origin desde frontends de desarrollo- Logging registra operaciones y errores para debugging sin exponer al cliente
- Status codes correctos: 200, 201, 400, 404, 409, 422, 500 — cada uno en su contexto
- Los modelos Pydantic del M4 permanecen intactos — error handling se agrega como capa
check_duplicate()conexclude_idpreviene conflictos en PUT y PATCH
Tu API ahora comunica errores profesionalmente. Los status codes son correctos, las respuestas de error son predecibles, los frontends pueden consumirla sin CORS, y los errores se registran para debugging.
Recursos adicionales
- FastAPI - Handling Errors - Tutorial oficial completo de error handling
- FastAPI - CORS - Configuración de CORSMiddleware
- MDN - HTTP Status Codes - Referencia completa de status codes
- Python logging - HOWTO - Guía oficial de logging en Python
- FastAPI - Middleware - Concepto de middleware en FastAPI
- Starlette - Exceptions - Base de exception handling en Starlette
¿Qué sigue?
Tu Books API está completa: modelos Pydantic, error handling profesional, CORS configurado, logging activo. Todo lo que aprendiste en los Módulos 1-5 — setup, path operations, request/response, Pydantic, error handling, CORS — se integra en esta API funcional.
En el Módulo 6 (Proyecto Final: To-Do List API) construirás una API nueva desde cero aplicando todos estos conceptos:
Módulo 5 (ahora): Módulo 6 (siguiente):
────────────────── ─────────────────────
Books API como proyecto evolutivo → To-Do List API desde cero
Conceptos aprendidos incrementalmente → Todos los conceptos integrados
Una entidad (Book) → Múltiples entidades (Task, Category)
Error handling en API existente → Error handling diseñado desde el inicio
El Módulo 6 es la prueba de que dominas FastAPI fundamentals: diseñarás modelos, endpoints, error handling y CORS desde cero, sin código base previo. Si tu Books API del Módulo 5 funciona correctamente, estás listo.
Módulo 5 completado. Tu API es robusta. Los errores son honestos. Los frontends pueden conectarse. Los logs registran lo que importa. Siguiente parada: el proyecto final.