Antes de empezar
Necesitas el mini-repo mesa-core-practicas con lo de las prácticas 3, 4 y 5: services/api/app/ con el módulo locales/ (rutas, schemas, queries), shared/db.py con el engine async, shared/models.py con los modelos SQLModel, pytest.ini con asyncio_mode = auto y el Makefile con PYTHONPATH := .. Verifica que sigue en verde:
cd mesa-core-practicas
source .venv/bin/activate
make tests && make lint
Agrega tres dependencias. pydantic-settings y tenacity ya están en el requirements/base.txt real de m-b-core (la primera está instalada y no se usa: paso 5); respx lo tienes desde la práctica 2.
requirements/base.txt (agregar)pydantic-settings==2.7.1
tenacity==9.0.0
pip install -r requirements/local.txt
Y crea el archivo de configuración local. .env va al .gitignore; .env.example va a git con las claves y sin los valores. Esta separación no es cosmética: en m-b-core, env/local.env y env/staging.env están versionados con valores reales y el .gitignore los desexcluye a propósito.
.env.example (a git)ENV=dev
LOG_LEVEL=INFO
DB_URL=sqlite+aiosqlite:///./practicas.db
UPSTREAM_URL=http://127.0.0.1:9999
CRON_SECRET=
cp .env.example .env
python -c "import secrets; print('CRON_SECRET=' + secrets.token_urlsafe(32))" >> .env
echo ".env" >> .gitignore
Un apunte antes de empezar. UPSTREAM_URL apunta a 127.0.0.1:9999, donde no hay nada escuchando. Es a propósito: en el paso 3 necesitas un servicio caído de verdad para ver un 503 real, no simulado.
Construcción paso a paso
Paso 1 Vocabulario de errores: códigos y excepciones de dominio
Dos archivos y una idea. Primero los códigos, como constantes con nombre: si mañana LOCAL_NOT_FOUND se llama distinto, se cambia en un sitio y no en las 40 rutas que lo escriben a mano. Es exactamente shared/errors/codes.py de m-b-core, que ya existe y ya está bien.
shared/errors/codes.py"""Códigos de error como constantes con nombre (igual que shared/errors/codes.py de m-b-core)."""
# 400 — validation
VALIDATION_ERROR = "VALIDATION_ERROR"
# 401
INVALID_CRON_SECRET = "INVALID_CRON_SECRET"
# 404
LOCAL_NOT_FOUND = "LOCAL_NOT_FOUND"
# 409 — business rule
STOCK_INSUFICIENTE = "STOCK_INSUFICIENTE"
# 503
UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE"
# 500
INTERNAL_ERROR = "INTERNAL_ERROR"
Ahora las excepciones. Lo importante está en la primera frase del docstring: no saben de HTTP. Una query que no encuentra un local no tiene por qué saber que eso "es un 404": eso lo decide el borde. Lo que sí lleva cada excepción es su código y el status que le corresponde, para que el handler no tenga que hacer un if gigante.
shared/errors/exceptions.py"""Excepciones de dominio.
Se lanzan desde services/queries; NUNCA saben de HTTP. La traducción a status +
error_response ocurre una sola vez, en los exception handlers de main.py.
"""
from __future__ import annotations
from . import codes
class DomainError(Exception):
"""Base: toda excepción de negocio tiene código y status por defecto."""
code: str = codes.INTERNAL_ERROR
status_code: int = 500
def __init__(self, message: str, *, detail: str | None = None) -> None:
super().__init__(message)
self.message = message
self.detail = detail
class LocalNoExiste(DomainError):
code = codes.LOCAL_NOT_FOUND
status_code = 404
class StockInsuficiente(DomainError):
code = codes.STOCK_INSUFICIENTE
status_code = 409
class UpstreamNoDisponible(DomainError):
code = codes.UPSTREAM_UNAVAILABLE
status_code = 503
Y la forma del cuerpo, copiada tal cual de m-b-core. Fíjate en que status_code es un parámetro que no aparece en el diccionario: está ahí para que quien lea la llamada sepa con qué status va, pero el status lo pone el JSONResponse. Es una rareza de la función real; la conservamos para que el código que escribas encaje sin adaptaciones.
shared/errors/responses.py"""error_response(): misma forma que m-b-core (shared/errors/responses.py)."""
from __future__ import annotations
from enum import Enum
from typing import Any
def error_response(
error_code: str | Enum,
message: str,
status_code: int = 400,
detail: str | None = None,
) -> dict[str, Any]:
error_value = error_code.value if isinstance(error_code, Enum) else str(error_code)
response: dict[str, Any] = {"success": False, "error": error_value, "message": message}
if detail:
response["detail"] = detail
return response
shared/errors/__init__.pyfrom .exceptions import DomainError, LocalNoExiste, StockInsuficiente, UpstreamNoDisponible
from .responses import error_response
__all__ = [
"DomainError",
"LocalNoExiste",
"StockInsuficiente",
"UpstreamNoDisponible",
"error_response",
]
Por qué una jerarquía y no un ValueError con un mensaje. Porque el
except es una decisión de tipos:
except DomainError atrapa las tres a la vez cuando quieres tratarlas igual, y
except LocalNoExiste atrapa solo una cuando no. Con mensajes de texto terminas comparando
strings en un
if. Si quieres el detalle del lenguaje:
definir tus propias excepciones y
la jerarquía estándar.
Paso 2 Una sola traducción de error a HTTP
Aquí está el corazón de la práctica. En main.py registras tres exception handlers y ese es el único lugar del servicio donde una excepción se convierte en status + cuerpo. A partir de ahí, ninguna ruta vuelve a escribir raise HTTPException(404, ...): las rutas dejan subir la excepción de dominio y ya.
services/api/app/main.py"""API: app + lifespan + exception handlers (una sola traducción error → HTTP)."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from shared.errors import DomainError, codes, error_response
from shared.logging_setup import configure_logging
from shared.settings import settings
from .middleware.request_id import request_id_middleware
from .router import build_api_router
configure_logging(settings.LOG_LEVEL)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(settings.UPSTREAM_TIMEOUT_S))
log.info("api.start", extra={"version": settings.APP_VERSION, "env": settings.ENV})
yield
await app.state.http.aclose()
app = FastAPI(
title="mesa-core-practicas",
lifespan=lifespan,
docs_url=None if settings.ENV == "prod" else "/docs",
)
app.middleware("http")(request_id_middleware)
app.include_router(build_api_router(), prefix="/v1")
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
# 4xx de negocio: WARNING (esperable). 5xx de dominio (upstream caído): ERROR con la causa
# resumida (el `from e`); el traceback completo se reserva para lo NO manejado (bugs).
level = logging.ERROR if exc.status_code >= 500 else logging.WARNING
log.log(
level,
"domain.error",
extra={"code": exc.code, "path": request.url.path, "cause": repr(exc.__cause__)},
)
return JSONResponse(
status_code=exc.status_code,
content=error_response(exc.code, exc.message, exc.status_code, exc.detail),
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content=error_response(
codes.VALIDATION_ERROR, "Entrada inválida", 422, str(exc.errors()[0]["msg"])
),
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
# Lo que no es de dominio es un bug: ERROR con traza al log, mensaje genérico al cliente.
log.error("unhandled", extra={"path": request.url.path}, exc_info=exc)
return JSONResponse(
status_code=500, content=error_response(codes.INTERNAL_ERROR, "Error interno", 500)
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "version": settings.APP_VERSION}
Y el middleware del request_id: lo genera (o toma el que puso el balanceador), lo guarda en un ContextVar para que cualquier log del request lo lleve sin pasarlo por parámetro, y lo devuelve en la respuesta para que soporte y Logs Explorer hablen del mismo id.
services/api/app/middleware/request_id.py"""Un id por request: llega en X-Request-ID (el LB lo pone) o se genera; se propaga a los logs."""
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from shared.logging_setup import request_id_var
async def request_id_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = request_id_var.set(rid)
try:
response = await call_next(request)
finally:
request_id_var.reset(token)
response.headers["X-Request-ID"] = rid
return response
Las rutas quedan flacas. get_local no menciona el 404 en ninguna parte:
services/api/app/locales/routes.py@router.get("/locales/{local_id}", response_model=LocalDTO)
async def get_local(local_id: int) -> LocalDTO:
return LocalDTO(**await queries.fetch_local(local_id)) # LocalNoExiste → 404 en el handler
@router.post("/locales/{local_id}/asignar", response_model=ReservaOut, status_code=201)
async def post_asignar(
local_id: int, request: Request, pax: Annotated[int, Query(ge=1, le=20)]
) -> ReservaOut:
result = await asignar_mesa(local_id, pax, _client(request))
return ReservaOut(reserva_id=1, **result)
Levanta la API en una terminal y prueba en otra:
curl -s -i http://127.0.0.1:8000/v1/locales/999
Deberías verHTTP/1.1 404 Not Found
server: uvicorn
content-type: application/json
x-request-id: 4a0c1e385c17
{"success":false,"error":"LOCAL_NOT_FOUND","message":"Local 999 no existe"}
curl -s -X POST "http://127.0.0.1:8000/v1/locales/11/asignar?pax=99"
curl -s -X POST -H "X-Request-ID: demo-503" "http://127.0.0.1:8000/v1/locales/11/asignar?pax=2"
Deberías ver{"success":false,"error":"VALIDATION_ERROR","message":"Entrada inválida","detail":"Input should be less than or equal to 20"}
{"success":false,"error":"UPSTREAM_UNAVAILABLE","message":"servicio de capacidad no disponible"}
Y en la terminal de la API, los mismos tres hechos en JSON. Fíjate en el request_id: el primero se generó solo y coincide con el header x-request-id que te devolvió el curl; el segundo es el que tú mandaste.
Deberías ver (recortado){"ts": "2026-08-17T18:08:49.524+00:00", "level": "INFO", "logger": "services.api.app.main", "event": "api.start", "request_id": "-", "version": "dev", "env": "dev"}
{"ts": "2026-08-17T18:08:49.628+00:00", "level": "WARNING", "logger": "services.api.app.main", "event": "domain.error", "request_id": "4a0c1e385c17", "code": "LOCAL_NOT_FOUND", "path": "/v1/locales/999", "cause": "None"}
{"ts": "2026-08-17T18:08:49.647+00:00", "level": "ERROR", "logger": "services.api.app.main", "event": "domain.error", "request_id": "demo-503", "code": "UPSTREAM_UNAVAILABLE", "path": "/v1/locales/11/asignar", "cause": "ConnectError('All connection attempts failed')"}
Léelo con calma. El 404 es
WARNING y el 503 es
ERROR, y esa diferencia la decide una línea (
level = ERROR if exc.status_code >= 500). Un local que no existe es un cliente preguntando por algo que no está: esperable. Un upstream caído es un incidente. Si los dos fueran ERROR, la alarma de "errores por minuto" se dispararía cada vez que un bot pide
/v1/locales/999. Y fíjate en
"cause": en el 503 dice
ConnectError(...) porque la excepción se encadenó con
from e. Eso es el paso 3. La referencia del mecanismo:
manejo de errores en FastAPI.
Paso 3 El catch correcto (y los tres vicios, escritos a propósito)
Este es el patrón que vas a repetir toda tu vida en m-b-core: en el borde con algo externo, capturas la excepción concreta, registras el hecho y la traduces a una excepción de tu dominio encadenando con from e.
services/api/app/locales/queries.py (agregar)async def fetch_local(local_id: int) -> dict[str, Any]:
"""Devuelve el local o lanza LocalNoExiste. Nunca devuelve None 'para que el caller vea'."""
row = _LOCALES.get(local_id)
if row is None:
raise LocalNoExiste(f"Local {local_id} no existe")
return row
async def fetch_capacidad_upstream(local_id: int, client: httpx.AsyncClient) -> int:
"""Consulta un servicio externo. Un fallo de red se TRADUCE a excepción de dominio."""
try:
r = await client.get(f"{settings.UPSTREAM_URL}/capacidad/{local_id}")
r.raise_for_status()
except httpx.HTTPError as e: # concreta: timeout, conexión, 5xx del upstream
raise UpstreamNoDisponible("servicio de capacidad no disponible") from e
return int(r.json()["capacidad"])
Tres detalles que valen los tres minutos que toma leerlos. httpx.HTTPError es la base de todos los errores de httpx (timeouts, conexión, raise_for_status): es concreta sin ser miope. El from e guarda la excepción original en __cause__, que es de donde salió el "cause": "ConnectError(...)" del paso 2. Y solo dos líneas van dentro del try: el r.json() queda fuera, porque si el upstream responde 200 con basura eso no es "upstream no disponible", es un bug que merece su propio 500.
Ahora escribe a propósito lo que no se debe, para verlo marcado. En el pyproject.toml agregas dos familias de reglas de ruff: BLE (blind except) y G (logging format).
pyproject.toml (modificar)[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "BLE", "G", "DTZ", "S608"] # ← BLE y G nuevos
services/api/app/locales/_como_no.py (temporal)"""Ejemplo a propósito de lo que ruff debe atrapar. Se borra después."""
import logging
import httpx
log = logging.getLogger(__name__)
async def capacidad(local_id: int, client: httpx.AsyncClient) -> int:
try:
r = await client.get(f"http://upstream/capacidad/{local_id}")
except Exception as e: # vicio 1: traga todo y sigue
print(f"error: {e}") # vicio 3: print
return 0
log.info(f"capacidad ok local={local_id}") # vicio: f-string en el log
log.warning("upstream lento: %s" % r.status_code) # vicio: % en el log
return int(r.json()["capacidad"])
ruff check --output-format=concise services/api/app/locales/_como_no.py
Deberías verservices/api/app/locales/_como_no.py:13:12: BLE001 Do not catch blind exception: `Exception`
services/api/app/locales/_como_no.py:16:14: G004 Logging statement uses f-string
services/api/app/locales/_como_no.py:17:17: UP031 Use format specifiers instead of percent format
services/api/app/locales/_como_no.py:17:17: G002 Logging statement uses `%`
Found 4 errors.
rm services/api/app/locales/_como_no.py
Los tres vicios de la guía, uno por uno, y qué hace cada uno realmente:
| El vicio | Qué pasa de verdad | En su lugar |
except Exception: pass | El error desaparece: nadie lo ve, y el programa sigue con datos inventados (return 0). El bug reaparece tres capas más arriba, sin traza. Ruff: BLE001. | except httpx.HTTPError as e: ... raise ... from e |
Responder 200 con {"error": "..."} | El cliente móvil hace if (response.ok) y entra al camino feliz con un cuerpo que no tiene los campos. Los reintentos y las alarmas por status quedan ciegos. | Status correcto + error_response(), siempre desde el handler |
print(f"error: {e}") | En Cloud Run va a stdout como textPayload: no se puede filtrar por campo, no lleva request_id, y si e trae un email lo publicaste. En services/ de m-b-core hay 94 print. | log.warning("evento", extra={"error": type(e).__name__}) |
Corre los tests de las rutas: los cuatro caminos de error y el feliz.
PYTHONPATH=. pytest services/api/app/locales -q
Deberías ver...... [100%]
6 passed in 0.14s
services/api/app/locales/tests/test_routes.py (extracto)def test_503_upstream_caido_no_es_500_ni_200(client):
from shared.errors import UpstreamNoDisponible
with patch(_CAP, new=AsyncMock(side_effect=UpstreamNoDisponible("caído"))):
r = client.post("/v1/locales/11/asignar?pax=2")
assert r.status_code == 503
assert r.json()["error"] == "UPSTREAM_UNAVAILABLE"
def test_bug_no_manejado_da_500_generico_sin_traza(caplog):
# TestClient relanza las excepciones del servidor por defecto (para que el test las vea);
# aquí queremos probar el handler de 500, así que se lo pedimos explícito.
with (
TestClient(app, raise_server_exceptions=False) as client,
patch(_CAP, new=AsyncMock(side_effect=ZeroDivisionError("boom"))),
):
r = client.post("/v1/locales/11/asignar?pax=2")
assert r.status_code == 500
assert r.json() == {"success": False, "error": "INTERNAL_ERROR", "message": "Error interno"}
assert "boom" not in r.text # el detalle va al log, no al cliente
assert any(x.levelno == logging.ERROR and x.exc_info for x in caplog.records)
El nombre del test es la especificación. test_503_upstream_caido_no_es_500_ni_200 dice las tres cosas que importan en una línea, incluidas las dos equivocadas. Y el último test fija la frontera que más se rompe en un incidente: al cliente, mensaje genérico; al log, traza completa. El assert "boom" not in r.text es lo que evita que un str(exc) con el correo de un comensal termine en la app.
Paso 4 Logs con extra={}, JSON y PII redactada
Un log es una fila de una tabla que todavía no existe. Si escribes log.info(f"reserva {rid} creada para {email}") tienes una frase; si escribes log.info("reserva.creada", extra={"reserva_id": rid}) tienes una fila con columnas que Logs Explorer puede filtrar (jsonPayload.reserva_id=501) y contar. Esa es toda la diferencia, y es literal la guideline 3 del CLAUDE.md: "snake-case event name as the message; all context in extra={...}".
shared/logging_setup.py"""Logging estructurado: JSON por línea, campos en `extra`, PII redactada.
Guideline 3 de m-b-core/CLAUDE.md: INFO eventos de negocio, DEBUG detalle,
WARNING dato raro, ERROR se rompió; SIEMPRE `extra={}`; nunca interpolar.
"""
from __future__ import annotations
import json
import logging
import re
import sys
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
_STD_ATTRS = set(logging.LogRecord("x", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
# Un email de verdad termina en un TLD con letras (así "python@3.12_4/…" en una ruta no matchea).
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}")
# Teléfono: solo con prefijo internacional (+51 987 654 321); los timestamps no lo tienen.
_PHONE = re.compile(r"\+\d{1,3}(?:[\s-]?\d{2,4}){2,4}")
_BEARER = re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]+")
# Campos que SIEMPRE son PII, se llamen como se llamen sus valores.
_PII_KEYS = {"email", "correo", "telefono", "phone", "celular", "destinatario", "to"}
_SKIP_KEYS = {"ts", "level", "logger", "request_id"}
def redact(value: Any, key: str | None = None) -> Any:
"""Redacta PII: por nombre de campo y por patrón; recursivo en dict/list."""
if key in _PII_KEYS and value is not None:
return "<redacted>"
if isinstance(value, str):
value = _EMAIL.sub("<email>", value)
value = _BEARER.sub("Bearer <redacted>", value)
return _PHONE.sub("<phone>", value)
if isinstance(value, dict):
return {k: (v if k in _SKIP_KEYS else redact(v, k)) for k, v in value.items()}
if isinstance(value, list):
return [redact(v) for v in value]
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"event": record.getMessage(),
"request_id": request_id_var.get(),
}
for key, val in record.__dict__.items(): # los campos de extra={}
if key not in _STD_ATTRS and not key.startswith("_"):
payload[key] = val
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(redact(payload), ensure_ascii=False, default=str)
def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers[:] = [handler]
root.setLevel(level)
La redacción tiene dos capas a propósito. Por nombre de campo (_PII_KEYS), que es la que de verdad funciona porque no depende de que el valor tenga forma de email; y por patrón, como red de seguridad para lo que se cuela dentro de un mensaje. Los tests fijan las dos, y también los falsos positivos que costaron una tarde: un timestamp con +00:00 no es un teléfono, y una ruta de Homebrew con python@3.12 no es un correo.
shared/tests/test_logging.pyimport json
import logging
from shared.logging_setup import JsonFormatter, redact, request_id_var
def test_redact_email_telefono_bearer():
assert redact("ana.perez@gmail.com llamó desde +51 987 654 321 con Bearer eyJabc.def") == (
"<email> llamó desde <phone> con Bearer <redacted>"
)
def test_no_redacta_timestamps_ni_rutas_con_arroba():
assert redact("2026-08-17T12:07:49.277+00:00") == "2026-08-17T12:07:49.277+00:00"
assert redact("/opt/homebrew/Cellar/python@3.12/bin") == "/opt/homebrew/Cellar/python@3.12/bin"
def test_redacta_por_nombre_de_campo():
assert redact({"destinatario": "cualquier cosa", "pax": 2}) == {
"destinatario": "<redacted>",
"pax": 2,
}
def test_formatter_emite_json_con_extra_y_request_id():
request_id_var.set("req-1")
rec = logging.LogRecord("t", logging.INFO, "", 0, "reserva.creada", (), None)
rec.local_id = 11
rec.email = "ana@example.com"
out = json.loads(JsonFormatter().format(rec))
assert out["event"] == "reserva.creada" and out["request_id"] == "req-1"
assert out["local_id"] == 11
assert out["email"] == "<redacted>" # por nombre de campo, antes que por patrón
PYTHONPATH=. pytest shared/tests -q
Deberías ver.... [100%]
4 passed in 0.00s
Y el otro test que importa: verificar desde el test de una ruta que el log llevó los campos, no un texto. caplog te da los LogRecord crudos, así que los campos de extra son atributos del record:
services/api/app/locales/tests/test_routes.py (agregar)def test_201_feliz_y_log_con_campos(client, caplog):
caplog.set_level(logging.INFO)
with patch(_CAP, new=AsyncMock(return_value=8)):
r = client.post("/v1/locales/11/asignar?pax=2", headers={"X-Request-ID": "req-abc"})
assert r.status_code == 201
rec = next(x for x in caplog.records if x.getMessage() == "asignacion.ok")
assert rec.local_id == 11 and rec.pax == 2 and rec.cap == 8 # campos de extra, no texto
Y así queda el servicio, con los tres niveles en su sitio:
services/api/app/locales/service.pyasync def asignar_mesa(local_id: int, pax: int, client: httpx.AsyncClient) -> dict[str, int]:
local = await queries.fetch_local(local_id) # puede lanzar LocalNoExiste
capacidad = await queries.fetch_capacidad_upstream(local_id, client) # UpstreamNoDisponible
if pax > capacidad:
# WARNING: dato inesperado del negocio, no un bug. Campos en extra, no en el mensaje.
log.warning(
"asignacion.sin_capacidad", extra={"local_id": local_id, "pax": pax, "cap": capacidad}
)
raise StockInsuficiente(f"No hay mesa para {pax} personas", detail=f"capacidad={capacidad}")
log.info("asignacion.ok", extra={"local_id": local["id"], "pax": pax, "cap": capacidad})
return {"local_id": local_id, "pax": pax, "mesa_asignada": 1}
La tabla de niveles, en cuatro líneas. INFO: pasó algo del negocio que querrás contar mañana (
asignacion.ok,
api.start).
DEBUG: detalle interno que en prod no quieres pagar.
WARNING: algo raro que el sistema
resolvió (query lenta, reintento, sin capacidad).
ERROR: se rompió y alguien tiene que mirarlo. Es la guideline 3 literal. Y el corolario que se olvida:
type(exc).__name__, nunca
str(exc), porque el mensaje de una excepción puede traer datos del usuario. Si quieres el fondo del módulo:
el HOWTO oficial y
niveles de verbosidad.
Paso 5 La configuración, validada al arrancar
Un módulo y solo uno lee variables de entorno. El resto del código importa settings. Con pydantic-settings además obtienes tipos reales (un float es un float, no la cadena "5.0") y, sobre todo, fallo al arrancar si falta algo obligatorio.
shared/settings.py"""Configuración centralizada: el ÚNICO módulo que lee variables de entorno.
m-b-core hoy usa una clase plana que hace `os.getenv` en import (shared/settings.py).
Aquí usamos pydantic-settings, que ya está en sus requirements: valida tipos,
falla al arrancar si falta algo obligatorio y documenta los defaults.
"""
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
ENV: str = "dev"
SERVICE_NAME: str = "api"
APP_VERSION: str = "dev"
LOG_LEVEL: str = "INFO"
DB_URL: str = "sqlite+aiosqlite:///./practicas.db"
UPSTREAM_URL: str = "http://127.0.0.1:9999"
UPSTREAM_TIMEOUT_S: float = 5.0
# Secreto compartido para tareas (Cloud Scheduler lo manda como X-Cron-Secret).
# Sin default: si falta, el worker no arranca. Nunca `= ""` (postmortem parallevar).
CRON_SECRET: str = Field(..., min_length=16)
settings = Settings() # type: ignore[call-arg]
Compruébalo: esconde el .env un momento y trata de importar.
mv .env .env.off
python -c "from shared.settings import settings"
Deberías verpydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
CRON_SECRET
Field required [type=missing, input_value={}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.10/v/missing
CRON_SECRET=corto python -c "from shared.settings import settings"
mv .env.off .env
Deberías verpydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
CRON_SECRET
String should have at least 16 characters [type=string_too_short, input_value='corto', input_type=str]
Por qué esto importa más de lo que parece. m-b-core tiene
pydantic-settings==2.7.1 en
requirements/base.txt desde hace meses y su
shared/settings.py sigue siendo una
class Settings plana con
os.getenv en el cuerpo de la clase. La consecuencia concreta:
SEND_API_KEY: str | None = os.getenv("SEND_API_KEY"). Si esa variable no está, el servicio
arranca igual con
None, y los guards lo tapan con un
if not settings.SEND_API_KEY: raise 401. Es decir: una mala configuración se manifiesta como "todos los endpoints admin devuelven 401 y nadie sabe por qué" en vez de "el proceso no levantó y el deploy falló". El default vacío es el bug; el
Field(...) es la vacuna. Referencia:
pydantic-settings.
Paso 6 La tarea del worker y su secreto
Segundo servicio en el mismo repo, igual que en m-b-core: services/worker/. Expone HTTP porque en Cloud Run el "cron" es Cloud Scheduler llamando a una URL. Y como es una URL pública, hay que autenticarla.
services/worker/app/modules/recordatorios/router.py"""POST /tasks/recordatorios — lo invoca Cloud Scheduler con X-Cron-Secret.
Comparación en tiempo constante (hmac.compare_digest). m-b-core hoy compara con `!=`
(services/api/app/admin/_deps.py:19, worker/app/modules/legacy_replica/router.py:74).
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from shared.db import get_session
from shared.errors import codes, error_response
from shared.settings import settings
from .notificador import NotificadorConsola
from .service import Resultado, enviar_recordatorios
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
# En la vida real vienen de la BD legacy (legacy_db_read); aquí una lista.
RESERVAS_MANANA: list[dict[str, Any]] = [
{"id": 501, "email": "ana@example.com", "hora": "20:00", "pax": 2},
{"id": 502, "email": "luis@example.com", "hora": "21:00", "pax": 4},
]
def require_cron_secret(x_cron_secret: Annotated[str | None, Header()] = None) -> None:
presented = x_cron_secret or ""
if not hmac.compare_digest(presented.encode(), settings.CRON_SECRET.encode()):
log.warning("tasks.auth_fail", extra={"header_present": bool(x_cron_secret)})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_response(codes.INVALID_CRON_SECRET, "Cron secret inválido", 401),
)
@router.post("/recordatorios", dependencies=[Depends(require_cron_secret)])
async def run_recordatorios(request: Request) -> dict[str, int]:
notificador = getattr(request.app.state, "notificador", None) or NotificadorConsola()
async with get_session() as session:
res: Resultado = await enviar_recordatorios(session, RESERVAS_MANANA, notificador)
return {"vistas": res.vistas, "enviadas": res.enviadas, "omitidas": res.omitidas}
Compara con el guard real de m-b-core, que es el que vas a encontrar cuando abras el repo:
m-b-core · services/api/app/admin/_deps.py:13-21 (así NO)def require_admin_key(x_admin_key: Annotated[str, Header()]) -> None:
"""Reject requests without a valid `X-Admin-Key` matching SEND_API_KEY."""
if not settings.SEND_API_KEY:
# Misconfiguration — admin surface effectively disabled. Rare, log loud.
logger.error("admin.auth.no_key_configured")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid admin key.")
if x_admin_key != settings.SEND_API_KEY: # ← comparación NO constante
logger.warning("admin.auth.invalid_key")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid admin key.")
Levanta el worker y prueba los tres casos:
curl -s -X POST http://127.0.0.1:8001/tasks/recordatorios
curl -s -X POST -H "X-Cron-Secret: no-es" http://127.0.0.1:8001/tasks/recordatorios
curl -s -X POST -H "X-Cron-Secret: $(grep CRON_SECRET .env | cut -d= -f2)" \
http://127.0.0.1:8001/tasks/recordatorios
Deberías ver{"detail":{"success":false,"error":"INVALID_CRON_SECRET","message":"Cron secret inválido"}}
{"detail":{"success":false,"error":"INVALID_CRON_SECRET","message":"Cron secret inválido"}}
{"vistas":2,"enviadas":2,"omitidas":0}
Y en la terminal del worker, los dos rechazos y el envío. Fíjate en "destinatario": "<redacted>": el NotificadorConsola pasó el correo entero al extra, y el formatter lo tapó por nombre de campo.
Deberías ver (recortado){"ts": "2026-08-17T18:09:04.908+00:00", "level": "WARNING", "logger": "services.worker.app.modules.recordatorios.router", "event": "tasks.auth_fail", "request_id": "-", "header_present": false}
{"ts": "2026-08-17T18:09:04.921+00:00", "level": "WARNING", "logger": "services.worker.app.modules.recordatorios.router", "event": "tasks.auth_fail", "request_id": "-", "header_present": true}
{"ts": "2026-08-17T18:09:04.929+00:00", "level": "INFO", "logger": "services.worker.app.modules.recordatorios.notificador", "event": "notificacion.enviada", "request_id": "-", "destinatario": "<redacted>", "chars": 39}
{"ts": "2026-08-17T18:09:04.933+00:00", "level": "INFO", "logger": "services.worker.app.modules.recordatorios.service", "event": "recordatorios.done", "request_id": "-", "vistas": 2, "enviadas": 2, "omitidas": 0}
¿De verdad importa el !=? Comparar dos cadenas con
!= corta en el primer byte distinto: el tiempo de respuesta filtra cuántos caracteres acertaste. Con red de por medio el ruido es enorme y hace falta un montón de muestras, así que no es la vulnerabilidad más urgente del repo. Pero
hmac.compare_digest cuesta
una línea, no tiene contras, y elimina la discusión. Lo que sí es grave es lo de al lado:
una sola clave (
SEND_API_KEY) para 65 endpoints admin, incluido un ejecutor de SQL, con el guard copiado en 18 funciones distintas. Cuando arregles la comparación vas a tener que tocar 20 archivos. Por eso tu versión vive en
un Depends. Detalle del módulo:
hmac.compare_digest.
Paso 7 Correrla tres veces, un solo aviso
Cloud Scheduler y Cloud Tasks reintentan. Un timeout del lado del que llama no significa que tu tarea no corrió: significa que no sabe. Así que tu tarea tiene que poder correr dos, tres o diez veces con el mismo efecto que una. Eso es idempotencia, y no se consigue con cuidado: se consigue con una restricción única en una tabla.
shared/models.py (agregar)class Aviso(SQLModel, table=True):
"""Un aviso enviado. La restricción única (reserva, tipo) ES la idempotencia."""
__table_args__ = (UniqueConstraint("reserva_id", "tipo", name="uq_aviso_reserva_tipo"),)
id: int | None = Field(default=None, primary_key=True)
reserva_id: int = Field(index=True)
tipo: str
enviado_en: datetime = Field(default_factory=_utcnow)
class Latido(SQLModel, table=True):
"""Última corrida de cada tarea: quien vigila mira este reloj."""
tarea: str = Field(primary_key=True)
ultima_corrida: datetime = Field(default_factory=_utcnow)
avisos_enviados: int = 0
Y el servicio. El orden de las tres operaciones es lo único que importa: consultar, enviar, registrar. Nunca registrar antes de enviar (si el envío falla nadie recibe nada y el sistema cree que sí), y nunca acumular todos los commit para el final (lo ves en la última trampa).
services/worker/app/modules/recordatorios/service.py@retry(
retry=retry_if_exception_type(EnvioFallido),
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=0.1, max=2),
reraise=True,
)
async def _enviar_con_reintentos(notificador: Notificador, destinatario: str, texto: str) -> None:
try:
await notificador.enviar(destinatario, texto)
except Exception as e: # noqa: BLE001 — el proveedor puede fallar de mil formas; las envolvemos
log.warning("notificacion.reintento", extra={"error": type(e).__name__})
raise EnvioFallido(str(e)) from e
async def enviar_recordatorios(
session: AsyncSession, reservas: list[dict[str, Any]], notificador: Notificador
) -> Resultado:
"""1) ¿ya avisé? → omitir. 2) enviar (con reintentos). 3) registrar. Nunca al revés."""
enviadas = omitidas = 0
for r in reservas:
ya = await session.execute(
select(Aviso).where(Aviso.reserva_id == r["id"], Aviso.tipo == TIPO_RECORDATORIO)
)
if ya.scalar_one_or_none() is not None:
omitidas += 1
continue
await _enviar_con_reintentos(
notificador, r["email"], f"Te esperamos mañana a las {r['hora']} para {r['pax']}."
)
session.add(Aviso(reserva_id=r["id"], tipo=TIPO_RECORDATORIO))
try:
await session.commit() # la UNIQUE cierra la carrera entre dos corridas simultáneas
except IntegrityError:
await session.rollback()
omitidas += 1
log.warning("aviso.duplicado_concurrente", extra={"reserva_id": r["id"]})
continue
enviadas += 1
latido = await session.get(Latido, "recordatorios") or Latido(tarea="recordatorios")
latido.ultima_corrida = datetime.now(UTC)
latido.avisos_enviados = enviadas
session.add(latido)
await session.commit()
log.info(
"recordatorios.done",
extra={"vistas": len(reservas), "enviadas": enviadas, "omitidas": omitidas},
)
return Resultado(vistas=len(reservas), enviadas=enviadas, omitidas=omitidas)
Con el worker levantado, dispara la tarea tres veces seguidas:
for i in 1 2 3; do
curl -s -X POST -H "X-Cron-Secret: $(grep CRON_SECRET .env | cut -d= -f2)" \
http://127.0.0.1:8001/tasks/recordatorios; echo
done
Deberías ver{"vistas":2,"enviadas":2,"omitidas":0}
{"vistas":2,"enviadas":0,"omitidas":2}
{"vistas":2,"enviadas":0,"omitidas":2}
python -c "
import sqlite3
c = sqlite3.connect('practicas.db')
for row in c.execute('select id, reserva_id, tipo, enviado_en from aviso'): print(row)
for row in c.execute('select * from latido'): print(row)
"
Deberías ver(1, 501, 'recordatorio_24h', '2026-08-17 18:09:04.929414')
(2, 502, 'recordatorio_24h', '2026-08-17 18:09:04.931369')
('recordatorios', '2026-08-17 18:09:04.949821', 0)
Tres corridas, dos filas. Y el Latido con avisos_enviados = 0 en la última: la tarea corrió y no había nada que hacer, que es distinto de "la tarea no corrió". Esa diferencia es la que quieres poder ver en un dashboard a las 3 de la mañana.
Ahora los tests, que dicen lo mismo pero sin servidor. Cada uno usa su propia base en memoria, así que no dependen del orden ni de lo que quedó de la corrida anterior:
services/worker/app/modules/recordatorios/tests/test_service.py@pytest.fixture()
async def session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with maker() as s:
yield s
await engine.dispose()
async def test_tres_corridas_un_solo_envio(session):
n = NotificadorConsola()
r1 = await enviar_recordatorios(session, RESERVAS, n)
r2 = await enviar_recordatorios(session, RESERVAS, n)
r3 = await enviar_recordatorios(session, RESERVAS, n)
assert (r1.enviadas, r2.enviadas, r3.enviadas) == (2, 0, 0)
assert (r2.omitidas, r3.omitidas) == (2, 2)
assert len(n.enviados) == 2
avisos = (await session.execute(select(Aviso))).scalars().all()
assert len(avisos) == 2
async def test_reintenta_y_luego_envia(session):
class Flaky:
def __init__(self, fallos: int) -> None:
self.fallos, self.calls = fallos, 0
self.enviados: list[str] = []
async def enviar(self, destinatario: str, texto: str) -> None:
self.calls += 1
if self.calls <= self.fallos:
raise ConnectionError("proveedor caído")
self.enviados.append(destinatario)
n = Flaky(fallos=2)
r = await enviar_recordatorios(session, RESERVAS[:1], n)
assert r.enviadas == 1 and n.calls == 3
async def test_reintentos_agotados_no_registra_aviso(session):
class Siempre:
async def enviar(self, destinatario: str, texto: str) -> None:
raise ConnectionError("no")
with pytest.raises(EnvioFallido):
await enviar_recordatorios(session, RESERVAS[:1], Siempre())
assert (await session.execute(select(Aviso))).scalars().all() == []
PYTHONPATH=. pytest services/worker/app/modules/recordatorios/tests/test_service.py -o addopts="" -v
Deberías vertest_service.py::test_tres_corridas_un_solo_envio PASSED [ 25%]
test_service.py::test_reintenta_y_luego_envia PASSED [ 50%]
test_service.py::test_reintentos_agotados_no_registra_aviso PASSED [ 75%]
test_service.py::test_latido_se_actualiza PASSED [100%]
4 passed in 1.18s
Dos capas de reintento y por qué no chocan. Dentro, tenacity reintenta solo el envío, con espera exponencial y cuatro intentos: eso resuelve el hipo de red de dos segundos sin volver a consultar la base ni repetir el resto. Fuera, Cloud Tasks reintenta la tarea entera: eso resuelve que el contenedor se muriera a la mitad. La primera capa nunca duplica porque el aviso se registra después; la segunda nunca duplica porque el select del principio ya encuentra el Aviso. Y test_reintentos_agotados_no_registra_aviso fija la esquina fea: si el envío falla de verdad, no queda un aviso mintiendo en la tabla, así que el reintento de mañana sí lo mandará.
Paso 8 Scheduler, Tasks y el commit
Lo que falta ya no es Python. Estos comandos son los que crearían el cron en GCP; no los corras desde esta práctica (no hay proyecto ni servicio desplegado, y crear un job de Scheduler en el proyecto real es un cambio de infraestructura que va por su propio PR). Léelos, entiende qué implica cada bandera, y déjalos escritos en el README de tu módulo.
services/worker/app/modules/recordatorios/README.md (documentar, no ejecutar)# Recordatorios 24 h
Cron: todos los días a las 10:00 hora de Lima.
Endpoint: POST /tasks/recordatorios · Auth: X-Cron-Secret
Idempotente: sí (tabla Aviso, UNIQUE reserva_id+tipo). Reintentar es seguro.
## Job de Cloud Scheduler (transitorio: secreto compartido)
gcloud scheduler jobs create http recordatorios-24h \
--location=us-central1 \
--schedule="0 10 * * *" --time-zone="America/Lima" \
--uri="https://WORKER_URL/tasks/recordatorios" \
--http-method=POST \
--headers="X-Cron-Secret=$(gcloud secrets versions access latest --secret=CRON_SECRET)" \
--attempt-deadline=300s \
--max-retry-attempts=3 --min-backoff=30s --max-backoff=300s
## Lo que deberíamos usar (identidad, no secreto compartido)
gcloud scheduler jobs create http recordatorios-24h \
--location=us-central1 \
--schedule="0 10 * * *" --time-zone="America/Lima" \
--uri="https://WORKER_URL/tasks/recordatorios" --http-method=POST \
--oidc-service-account-email=cron-recordatorios@PROJECT.iam.gserviceaccount.com \
--oidc-token-audience="https://WORKER_URL"
El worker valida el token OIDC y la audiencia; no hay secreto que rotar ni
que aparezca en `gcloud scheduler jobs describe`. Con --headers, el secreto
es legible por cualquiera con roles/cloudscheduler.viewer.
## Cola de Cloud Tasks (si el fan-out crece)
gcloud tasks queues create recordatorios --location=us-central1 \
--max-attempts=5 --min-backoff=10s --max-backoff=300s \
--max-dispatches-per-second=10
--time-zone="America/Lima", no UTC: si pones 0 10 * * * sin zona, en Cloud Run son las 5 de la mañana en Lima. Es la práctica 6 otra vez, ahora en YAML. Y --max-retry-attempts=3 solo es aceptable porque el paso 7 hizo la tarea idempotente: sin eso, esa bandera dice "manda el recordatorio hasta tres veces".
Cierra con la suite completa, el linter y un commit:
rm -f practicas.db # la BD local guarda los avisos de tus curl (ver trampas)
make tests && make lint
Deberías ver................. [100%]
17 passed in 1.33s
All checks passed!
37 files already formatted
Success: no issues found in 37 source files
git add -A && git commit -m "feat(errores): handlers únicos, logging estructurado y tarea idempotente"
El README es parte del entregable. Un endpoint de cron sin una nota que diga "es idempotente, reintentar es seguro" es una bomba de tiempo: el día que alguien tenga que decidir si vuelve a lanzar la tarea a las 3 a. m., va a decidir mal. En m-b-core el equivalente es services/worker/app/modules/legacy_replica/router.py, cuyo docstring explica en once líneas por qué acepta dos headers distintos con el mismo valor. Ese docstring vale más que el código.
Con Claude
El manejo de errores es donde un modelo, si lo dejas suelto, "arregla" el síntoma: envuelve todo en try/except Exception y devuelve 200 para que los tests dejen de fallar. Dale el criterio explícito y el resultado cambia.
Así noAgrega manejo de errores a este módulo.
Vas a recibir un try/except Exception por función y un montón de HTTPException con status inventados.
Así síEn este módulo, cada fallo debe subir como una excepción de shared/errors/exceptions.py; el módulo no puede importar HTTPException ni JSONResponse. En los bordes con red o BD, captura la excepción concreta de esa librería y encadena con raise ... from e. Dime primero qué excepciones de dominio faltan y qué status les corresponde, sin escribir código.
La restricción "no puede importar HTTPException" es la que hace todo el trabajo.
Así noPonle logs a esta función.
Salen cuatro log.info(f"...") con el email del comensal dentro de la frase.
Así síAgrega logging siguiendo la guideline 3 del CLAUDE.md: nombre de evento en snake_case como mensaje, todo el contexto en extra={}, nada de f-strings, sin PII (usa ids y type(exc).__name__). Dime para cada línea qué nivel elegiste y por qué.
Pedir el "por qué" del nivel es lo que evita que todo sea INFO.
Así noEl cron mandó recordatorios duplicados, arréglalo.
Te va a proponer un flag en memoria o un if ya_enviado que no sobrevive a dos instancias de Cloud Run.
Así síEsta tarea la reintenta Cloud Scheduler y corre en varias instancias a la vez. Quiero idempotencia garantizada por la base de datos, no por el código. Propón la restricción única y el orden de operaciones, y escribe primero el test que corre la tarea tres veces y verifica un solo envío.
"Garantizada por la base de datos" y "el test primero" son las dos palancas.
Ejercicio con Claude, ya con esta práctica hecha: pásale services/api/app/admin/_deps.py y services/worker/app/modules/legacy_replica/router.py de m-b-core y pídele "escribe el Depends compartido en shared/ que reemplaza a las 18 copias, con compare_digest, sin cambiar el contrato de headers; lista los archivos que habría que tocar". Es el borrador de un PR real.