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(...) con raise y 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 CORSMiddleware para desarrollo (localhost:3000, 5173, 5174)
  • ✅ Implementas logging básico con logging de Python
  • POST /books retorna 409 si el libro ya existe (título + autor duplicado)
  • GET /books/{id}, PUT, PATCH, DELETE retornan 404 si el libro no existe
  • PATCH con 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, BookResponse en la sección "Schemas"
  • La versión 5.0.0 en 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) BookNotFoundError con book_id y message
  • (4 pts) DuplicateBookError con title, author y message
  • (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 HTTPException con formato custom
  • (5 pts) Handler para RequestValidationError con formato custom y lista de errores
  • (5 pts) Handler para Exception con mensaje genérico y logging

Status codes correctos en endpoints (20 puntos)

  • (3 pts) GET /books/{id} → 404 con BookNotFoundError
  • (3 pts) POST /books → 409 con DuplicateBookError
  • (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 con min_year > max_year

CORS configurado (15 puntos)

  • (5 pts) CORSMiddleware registrado con app.add_middleware()
  • (4 pts) allow_origins con al menos localhost:3000
  • (3 pts) Headers CORS presentes en respuestas exitosas
  • (3 pts) Headers CORS presentes en respuestas de error

Logging (10 puntos)

  • (3 pts) basicConfig configurado con nivel y formato
  • (3 pts) logger.info() para operaciones exitosas
  • (2 pts) logger.warning() para errores de negocio
  • (2 pts) logger.error() con exc_info=True en handler global

Helpers (10 puntos)

  • (4 pts) find_book_or_fail() lanza BookNotFoundError
  • (3 pts) check_duplicate() lanza DuplicateBookError con exclude_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

AspectoMódulo 4Módulo 5
Error "no encontrado"return ErrorResponse(...) con HTTP 200raise BookNotFoundError(id) → HTTP 404
DuplicadosNo se detectancheck_duplicate() → HTTP 409
Formato de erroresInconsistente (ErrorResponse vs {"detail": ...})Consistente: {"status": "error", "code": "...", "message": "..."}
Validación 422Formato default de FastAPIFormato custom con lista de errores detallados
Error inesperadoStack trace visible, 500 genéricoMensaje genérico al cliente, stack trace en log
CORSNo configuradoCORSMiddleware con orígenes de desarrollo
LoggingNingunoINFO para éxito, WARNING para errores de negocio, ERROR para bugs
Código de error en endpoint5-6 líneas con if/return ErrorResponse1 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) reemplazan return ErrorResponse(...) con raise
  • 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
  • CORSMiddleware permite 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() con exclude_id previene 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

  1. FastAPI - Handling Errors - Tutorial oficial completo de error handling
  2. FastAPI - CORS - Configuración de CORSMiddleware
  3. MDN - HTTP Status Codes - Referencia completa de status codes
  4. Python logging - HOWTO - Guía oficial de logging en Python
  5. FastAPI - Middleware - Concepto de middleware en FastAPI
  6. 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.