Module 5: Error Handling and CORS

Respuestas Consistentes

Descripción de la cápsula

Hasta ahora has retornado recursos directamente (return item) o lanzado HTTPException. Las respuestas de éxito suelen ser distintas entre endpoints, y los errores pueden tener formatos variables. Para que el cliente (frontend, mobile) trabaje de forma predecible, conviene definir patrones de respuesta consistentes: mismo esquema para éxitos y mismo esquema para errores.

Esta cápsula cubre cómo estructurar respuestas de éxito y error, buenas prácticas de status codes, uso de JSONResponse para control fino, headers comunes en respuestas, y un resumen de recomendaciones para APIs profesionales.


Por qué consistencia

Cuando todas las respuestas siguen el mismo formato:

  • El cliente puede escribir un único parser (if (response.success) { ... } else { ... })
  • La documentación en /docs es más clara
  • Menos bugs por asumir estructuras distintas
  • Integración con otros servicios más sencilla

Formato de éxito

Estructura básica

{
  "success": true,
  "data": { ... }
}

Para listas:

{
  "success": true,
  "data": [...],
  "count": 42
}

Para recursos individuales:

{
  "success": true,
  "data": {
    "id": 1,
    "name": "Laptop"
  }
}

Implementación con Pydantic

from typing import Generic, TypeVar
from pydantic import BaseModel

T = TypeVar("T")

from typing import TypeVar, List
T = TypeVar("T")

class SuccessResponse(BaseModel, Generic[T]):
    success: bool = True
    data: T

@app.get("/items/{item_id}")
def get_item(item_id: int):
    item = find_item(item_id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")
    return SuccessResponse(data=item)

Formato de error

Estructura unificada para todos los errores:

{
  "success": false,
  "error": {
    "code": "not_found",
    "message": "Item with id 999 not found",
    "details": {}
  }
}

Con errores de validación:

{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "Datos inválidos",
    "details": {
      "errors": [
        {"field": "email", "message": "Formato inválido"}
      ]
    }
  }
}

Esto lo implementas en tus custom exception handlers (Cápsula 03).


Tabla de status codes: best practices

OperaciónÉxitoError "no existe"Error "inválido"Error "conflicto"
GET uno200404400
GET lista200400
POST crear201400/422409
PUT actualizar200404400/422409
PATCH actualizar200404400/422409
DELETE200 o 204404400409

Convenciones

  • 200 OK: GET, PUT, PATCH exitosos. DELETE puede retornar 200 con body o 204 sin body.
  • 201 Created: POST que crea un recurso. Incluir el recurso creado en el body.
  • 204 No Content: DELETE exitoso sin body. Alternativa: 200 con {"message": "deleted"}.
  • 400 Bad Request: Datos inválidos por lógica de negocio.
  • 404 Not Found: Recurso no existe.
  • 409 Conflict: Conflicto (ej. email duplicado).
  • 422 Unprocessable Entity: Validación de schema fallida (Pydantic).

Response headers útiles

HeaderUso
Content-TypeFastAPI lo fija a application/json por defecto
X-Total-CountTotal de registros (paginación)
X-Request-IDID de correlación para logging
Cache-ControlControl de caché
LocationURL del recurso creado (POST con 201)

Ejemplo: Location en POST 201

from fastapi.responses import JSONResponse

@app.post("/items", status_code=201)
def create_item(item: Item):
    new_item = save_item(item)
    return JSONResponse(
        status_code=201,
        content={"success": True, "data": new_item},
        headers={"Location": f"/items/{new_item['id']}"}
    )

JSONResponse para control total

Cuando necesitas status code dinámico, headers custom o un body que no sea un dict simple:

from fastapi.responses import JSONResponse

@app.get("/items")
def list_items(skip: int = 0, limit: int = 10):
    total = len(items_db)
    paginated = items_db[skip : skip + limit]
    return JSONResponse(
        status_code=200,
        content={
            "success": True,
            "data": paginated,
            "count": len(paginated),
            "total": total
        },
        headers={"X-Total-Count": str(total)}
    )

Response + headers con parámetro inyectado

Alternativa usando el objeto Response:

from fastapi import Response

@app.get("/items")
def list_items(response: Response, skip: int = 0, limit: int = 10):
    total = len(items_db)
    paginated = items_db[skip : skip + limit]
    response.headers["X-Total-Count"] = str(total)
    return {"success": True, "data": paginated, "total": total}

API completa con respuestas consistentes

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse

app = FastAPI(title="Items API", version="1.0.0")

items_db = [
    {"id": 1, "name": "Laptop", "price": 999.99},
    {"id": 2, "name": "Mouse", "price": 29.99},
]

def next_id():
    return max((i["id"] for i in items_db), default=0) + 1

def find_item(item_id: int):
    return next((i for i in items_db if i["id"] == item_id), None)


def success_response(data, status_code=200):
    return JSONResponse(
        status_code=status_code,
        content={"success": True, "data": data}
    )


@app.get("/")
def root():
    return success_response({"service": "Items API", "version": "1.0.0"})


@app.get("/items")
def list_items():
    return success_response(items_db)


@app.get("/items/{item_id}")
def get_item(item_id: int):
    item = find_item(item_id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")
    return success_response(item)


@app.post("/items", status_code=201)
def create_item(item: dict):
    name = item.get("name", "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="name is required")
    new_item = {"id": next_id(), "name": name, "price": item.get("price", 0)}
    items_db.append(new_item)
    return success_response(new_item, status_code=201)


@app.put("/items/{item_id}")
def update_item(item_id: int, item: dict):
    existing = find_item(item_id)
    if not existing:
        raise HTTPException(status_code=404, detail="Item not found")
    existing["name"] = item.get("name", existing["name"]).strip()
    if "price" in item:
        existing["price"] = item["price"]
    return success_response(existing)


@app.delete("/items/{item_id}")
def delete_item(item_id: int):
    item = find_item(item_id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")
    items_db.remove(item)
    return success_response({"message": "Item deleted", "id": item_id})

Best practices recap

Éxito

  • Usa 200 para GET, PUT, PATCH.
  • Usa 201 para POST que crea recurso.
  • Incluye el recurso creado en el body del 201.
  • Considera Location header en 201.
  • Mantén un campo común (success, data) en todas las respuestas de éxito.

Error

  • Usa HTTPException con status code correcto.
  • Unifica el formato con custom handlers.
  • Incluye mensaje claro en detail o message.
  • Para 422, incluye los campos que fallaron.

Headers

  • X-Total-Count para paginación.
  • X-Request-ID para trazabilidad.
  • Location en 201 para recursos creados.

Evitar

  • Retornar 200 con {"error": "..."}.
  • Mezclar formatos (a veces data, a veces el recurso directo).
  • Exponer stack traces o mensajes internos en producción.

Paginación con estructura consistente

Para listas paginadas, mantén el mismo wrapper:

{
  "success": true,
  "data": {
    "items": [...],
    "page": 1,
    "page_size": 10,
    "total": 42
  }
}

O alternativa:

{
  "success": true,
  "data": [...],
  "meta": {
    "page": 1,
    "page_size": 10,
    "total": 42
  }
}

Elige una estructura y úsala en todos los endpoints paginados.


Respuesta vacía y 204

Algunas APIs retornan 204 No Content con body vacío para DELETE. FastAPI lo soporta:

from fastapi.responses import Response

@app.delete("/items/{item_id}")
def delete_item(item_id: int):
    item = find_item(item_id)
    if not item:
        raise HTTPException(status_code=404, detail="Item not found")
    items_db.remove(item)
    return Response(status_code=204)

El cliente recibirá 204 sin body. Si prefieres confirmar con un mensaje, usa 200 con body como en los ejemplos anteriores.


Ejercicios

Ejercicio 1: Wrapper de éxito (Fácil)

Crea una función success(data, status=200) que retorne JSONResponse con {"success": true, "data": data}. Úsala en tres endpoints GET.

Ver solución
from fastapi.responses import JSONResponse

def success(data, status=200):
    return JSONResponse(status_code=status, content={"success": True, "data": data})

@app.get("/")
def root():
    return success({"info": "API"})

@app.get("/items")
def list_items():
    return success(items_db)

@app.get("/items/{id}")
def get_item(id: int):
    item = find_item(id)
    if not item:
        raise HTTPException(404, "Not found")
    return success(item)

Ejercicio 2: Status 201 con Location (Medio)

En POST /products, tras crear el producto, retorna 201 con body {"success": true, "data": product} y header Location: /products/{id}.

Ver solución
@app.post("/products", status_code=201)
def create_product(product: dict):
    new_id = max((p["id"] for p in products), default=0) + 1
    new_product = {"id": new_id, **product}
    products.append(new_product)
    return JSONResponse(
        status_code=201,
        content={"success": True, "data": new_product},
        headers={"Location": f"/products/{new_id}"}
    )

Ejercicio 3: X-Total-Count (Medio)

En GET /items?skip=0&limit=10, devuelve la lista paginada y el header X-Total-Count con el total de ítems. El body debe incluir total también.

Ver solución
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10):
    total = len(items_db)
    paginated = items_db[skip : skip + limit]
    return JSONResponse(
        content={"success": True, "data": paginated, "total": total},
        headers={"X-Total-Count": str(total)}
    )

Ejercicio 4: Elegir status code (Fácil)

Indica el status code adecuado: (a) GET recurso que existe. (b) POST que crea recurso. (c) DELETE recurso que no existe. (d) POST con email duplicado.

Ver solución
  • (a) 200 OK
  • (b) 201 Created
  • (c) 404 Not Found
  • (d) 409 Conflict

Ejercicio 5: Formato error consistente (Medio)

Tu custom handler de HTTPException debe retornar siempre {"ok": false, "status": <code>, "message": "..."}. Implementa el handler.

Ver solución
from fastapi import Request

@app.exception_handler(HTTPException)
async def http_handler(request: Request, exc: HTTPException):
    msg = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
    return JSONResponse(
        status_code=exc.status_code,
        content={"ok": False, "status": exc.status_code, "message": msg}
    )

Ejercicio 6: DELETE 204 vs 200 (Fácil)

¿Cuándo usar 204 No Content y cuándo 200 con body en DELETE? Da un ejemplo de cada caso.

Ver solución
  • 204 No Content: Cuando no devuelves body. El cliente solo necesita saber que se eliminó. return Response(status_code=204).
  • 200 con body: Cuando quieres confirmar con datos (ej. {"message": "deleted", "id": 5}). Útil si el cliente quiere el id o un mensaje.

Ambos son válidos. 204 es más estricto según HTTP; 200 con body es más informativo.


response_model con formato consistente

Si quieres que FastAPI valide y documente la respuesta, define un modelo para el wrapper:

from typing import TypeVar, List
T = TypeVar("T")

class SuccessResponse(BaseModel, Generic[T]):
    success: bool = True
    data: T

@app.get("/items", response_model=SuccessResponse[List[Item]])
def list_items():
    return SuccessResponse(data=items_db)

Así la documentación en /docs mostrará la estructura exacta.


Envelope pattern en APIs REST

El patrón de "envolver" datos en un objeto ({"success": true, "data": ...}) se llama envelope o wrapper. Algunas APIs prefieren retornar el recurso directo (return item) y usar solo el status code para indicar éxito/error. Ambas aproximaciones son válidas:

  • Con envelope: El cliente siempre parsea la misma estructura. Útil para frontends que quieren una capa de abstracción.
  • Sin envelope: Respuestas más pequeñas, más cercanas a REST puro. El cliente verifica response.ok o el status code.

Elige uno y mantén consistencia en toda la API.


HATEOAS y respuestas enriquecidas (opcional)

Algunas APIs incluyen links en las respuestas para navegación:

{
  "success": true,
  "data": {"id": 1, "name": "Laptop"},
  "links": {
    "self": "/items/1",
    "collection": "/items"
  }
}

No es obligatorio para una API REST básica, pero útil si quieres que el cliente descubra recursos por links en lugar de construir URLs manualmente.


Checklist de respuesta consistente

  • Todas las respuestas de éxito usan el mismo wrapper (success, data)
  • Todas las respuestas de error usan el mismo wrapper (success, error)
  • Status codes apropiados (200, 201, 404, 400, 409, 422)
  • Headers relevantes (X-Total-Count, Location) cuando aplica
  • Documentación en /docs refleja la estructura

Si cumples estos puntos, tu API tendrá un contrato de respuesta predecible para quien la consuma. Los clientes podrán manejar éxitos y errores de forma uniforme.


Troubleshooting

Content-Type incorrecto

Si retornas un str en lugar de dict, FastAPI puede devolver text/html. Asegúrate de retornar dict, lista o JSONResponse para que sea application/json.

Header no aparece en el cliente

Algunos headers están restringidos por CORS. Si el cliente no puede leer X-Total-Count, añádelo a expose_headers en CORSMiddleware.

Status code no cambia

Si usas return {"data": x} y esperas 201, el decorador debe tener status_code=201. O usa JSONResponse(status_code=201, content=...).

Respuestas inconsistentes entre endpoints

Define funciones helper (success_response, error_response) y úsalas en todos los endpoints. Los custom handlers unifican los errores.


Resumen

  • ✅ Formato de éxito: {"success": true, "data": ...} o similar
  • ✅ Formato de error: unificado vía custom handlers
  • ✅ Status codes: 200 (GET/PUT/PATCH), 201 (POST), 404, 400, 409, 422
  • ✅ Headers útiles: X-Total-Count, Location, X-Request-ID
  • ✅ JSONResponse cuando necesitas control total de status y headers
  • ✅ Consistencia reduce bugs y facilita la integración

Próxima cápsula: Proyecto — API robusta con error handling y CORS.


Recursos Adicionales

  1. FastAPI - Response - JSONResponse y respuestas custom
  2. REST API Best Practices - Convenciones REST
  3. HTTP Status Codes - MDN - Referencia

Módulo 5, Cápsula 05 — FastAPI Fundamentals Guide