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
/docses 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 | Éxito | Error "no existe" | Error "inválido" | Error "conflicto" |
|---|---|---|---|---|
| GET uno | 200 | 404 | 400 | — |
| GET lista | 200 | — | 400 | — |
| POST crear | 201 | — | 400/422 | 409 |
| PUT actualizar | 200 | 404 | 400/422 | 409 |
| PATCH actualizar | 200 | 404 | 400/422 | 409 |
| DELETE | 200 o 204 | 404 | 400 | 409 |
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
| Header | Uso |
|---|---|
Content-Type | FastAPI lo fija a application/json por defecto |
X-Total-Count | Total de registros (paginación) |
X-Request-ID | ID de correlación para logging |
Cache-Control | Control de caché |
Location | URL 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
Locationheader 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
detailomessage. - Para 422, incluye los campos que fallaron.
Headers
X-Total-Countpara paginación.X-Request-IDpara trazabilidad.Locationen 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.oko 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
- FastAPI - Response - JSONResponse y respuestas custom
- REST API Best Practices - Convenciones REST
- HTTP Status Codes - MDN - Referencia
Módulo 5, Cápsula 05 — FastAPI Fundamentals Guide