Python para m-b-core/Prácticas/7 · Errores, logs y tareas
Práctica 7 de 8

Errores, logging y tareas idempotentes

Tres cosas que en producción se notan el mismo día: un error que llega al cliente como 200 con {"error"} dentro, un log que no se puede filtrar porque el dato está dentro de una frase, y un cron que se reintenta y manda el recordatorio dos veces. Aquí las arreglas de raíz: excepciones de dominio traducidas a HTTP una sola vez, logs en JSON con extra={} y PII redactada, y una tarea que puedes correr tres veces seguidas sin que nadie reciba dos mensajes.

concepto · el error es un dato del dominio, el status es una traducción2–3 sesionesexception_handler · logging · hmac · tenacity · guideline 3
Construyesshared/errors/, shared/logging_setup.py, shared/settings.py, services/worker/ + 17 tests
Aprendesraise ... from e, @app.exception_handler, extra={}, ContextVar, hmac.compare_digest, idempotencia
Igual que en m-b-coreerror_response(), codes.py, RequestContextFilter, X-Cron-Secret: mismas formas
Terminas conUn endpoint de cron que Cloud Scheduler puede reintentar sin miedo

Objetivos

  • Escribir excepciones de dominio (LocalNoExiste, StockInsuficiente, UpstreamNoDisponible) que no saben de HTTP, y traducirlas a 404 / 409 / 503 en un solo sitio.
  • Capturar la excepción concreta (httpx.HTTPError), no Exception, y encadenar con raise ... from e para no perder la causa.
  • Emitir logs JSON con el evento en snake_case y los datos en extra={}, con request_id automático y email / teléfono / bearer redactados.
  • Elegir el nivel correcto según la guideline 3 del CLAUDE.md: INFO negocio, WARNING recuperado, ERROR se rompió.
  • Validar la configuración al arrancar con pydantic-settings: si falta el secreto, el proceso no levanta.
  • Proteger un endpoint de cron con hmac.compare_digest y hacer la tarea idempotente por tabla, para que un reintento no duplique nada.
Por qué esta práctica existe

La guía de criterio tiene el vicio 3 ("el error como decoración"): responder 200 con un {"error": ...} adentro, tragar excepciones con except: pass y depurar con print. En el core nuevo el problema es otro y más sutil: hay 161 raise HTTPException repartidos por services/api, cada uno decidiendo su propio status y su propia forma de cuerpo, mientras shared/errors/responses.py define la forma. Y el guard de los endpoints admin compara el secreto con !=: el informe contó 23 sitios; en el checkout de staging de hoy son 20 archivos, y hmac.compare_digest aparece cero veces en todo el repo.

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__.py
from .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:

make run-api
curl -s -i http://127.0.0.1:8000/v1/locales/999
Deberías ver
HTTP/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 ver
services/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 vicioQué pasa de verdadEn su lugar
except Exception: passEl 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.py
import 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.py
async 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 ver
pydantic_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 ver
pydantic_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:

make run-worker
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 ver
test_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.

Entiende el código (y dónde está lo mismo en m-b-core)

Lo que escribisteEn m-b-coreNota
shared/errors/codes.py con constantesshared/errors/codes.pyIdéntico en espíritu y en nombres (VALIDATION_ERROR, *_NOT_FOUND). Ya está bien: úsalo, no inventes strings sueltos.
error_response(code, message, status, detail)shared/errors/responses.py:11-16Misma firma exacta, incluido que status_code no sale en el cuerpo. Es la forma de error del backend.
Devolver el error con la forma de la casaservices/api/app/cities/routes.py:43-49Aquí error_response se usa bien, pero desde la ruta: JSONResponse(status_code=400, content=error_response(...)). Funciona; lo que no escala es repetirlo 161 veces.
@app.exception_handler(DomainError) en main.pyservices/api/app/libro/errors.py + main.py:162-164El único registro de handlers del repo, y es para reescribir el 422 con forma de Laravel en las rutas de libro. No hay handler de excepciones de dominio: por eso los 161 raise HTTPException.
request_id_var + middlewareshared/log_context.py (RequestContextFilter, JsonFormatter, set_request_id)Misma idea, mejor hecha: allá es un logging.Filter, así que el request_id se inyecta aunque el log salga de shared/db.py, que no sabe nada de HTTP.
log.info("evento", extra={...}), niveles, sin PIICLAUDE.md §3 + docs/explain/logging-strategy.md"INFO business, WARNING recovered, ERROR failed"; "no PII/secrets; use type(exc).__name__, never str(exc)"; "request_id is auto-tagged by the filter — don't pass it manually".
hmac.compare_digest en un solo Dependsservices/api/app/admin/_deps.py:13-21, worker/.../legacy_replica/router.py:74!= en 20 archivos, hmac.compare_digest en 0, y el guard reimplementado en 18 funciones mientras _deps.require_admin_key existe justo para compartirlo.
Settings(BaseSettings) con Field(...)shared/settings.py (clase plana, os.getenv en import)pydantic-settings==2.7.1 está en requirements/base.txt:6 y no se usa. Migrarlo es un PR chico, mecánico y muy visible.
Tarea idempotente por tabla Avisoservices/worker/app/modules/legacy_replica/router.pySu docstring documenta el contrato de auth y qué hace cada verbo. Lo que tú agregas es la garantía escrita: "reintentar es seguro", con el test que lo prueba.

Laravel y FastAPI, lado a lado

Laravel (lo que ya haces)
// dominio, sin saber de HTTP
throw new LocalNoExisteException("Local $id no existe");

// una sola traducción, en Handler.php
public function render($request, Throwable $e) {
    if ($e instanceof DomainException) {
        return response()->json([
            'success' => false, 'error' => $e->code,
        ], $e->status);
    }
}
Log::info('reserva.creada', ['reserva_id' => $id]);
// NUNCA: return response()->json(['error'=>...], 200)
m-b-core
# dominio, sin saber de HTTP
raise LocalNoExiste(f"Local {local_id} no existe")

# una sola traducción, en main.py
@app.exception_handler(DomainError)
async def handler(request, exc):
    return JSONResponse(
        status_code=exc.status_code,
        content=error_response(exc.code, exc.message),
    )

log.info("reserva.creada", extra={"reserva_id": rid})
# NUNCA: return {"error": ...}  con status 200

La diferencia práctica: en Laravel el Handler es uno y viene dado por el framework, así que es difícil no usarlo. En FastAPI tienes que decidir registrarlo, y como raise HTTPException(404, ...) funciona sin registrar nada, la deriva es el camino cómodo — 161 veces cómodo. Una igualdad: en los dos, Log::info("Reserva de {$user->email}") y log.info(f"...") son el mismo error, y ninguno de los dos frameworks te lo va a impedir. Eso lo hace el linter (G004) y la revisión.

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.

Si algo falla

El handler no corre: la excepción hereda de HTTPException

Si "para reusar el status_code" haces class LocalNoExiste(HTTPException, DomainError), Starlette busca el handler recorriendo el MRO y encuentra primero el de HTTPException, que es el que FastAPI registra por defecto. Tu handler no se ejecuta, no hay log, y el cuerpo sale con la forma del framework:

MRO: ['LocalNoExisteMal', 'HTTPException', 'HTTPException', 'DomainError', 'Exception']
/mal   -> 404 {'detail': 'Local 999 no existe'}

Ni success, ni error, ni la línea de log. El mismo síntoma exacto lo produce el atajo de escribir raise HTTPException(status_code=404, detail="...") dentro de una ruta: /atajo -> 404 {'detail': 'Local 999 no existe'}. Regla: tus excepciones de dominio heredan solo de Exception, y ningún módulo de negocio importa HTTPException.

Registré el handler dos veces y el segundo gana en silencio

app.exception_handlers es un diccionario por clase: registrar dos veces para DomainError no da error ni warning, simplemente el último sobreescribe al primero. Es fácil que pase cuando alguien agrega un handler "por si acaso" en el módulo además del de main.py:

handlers registrados para DomainError: h2
200 {'quien': 'routes.py', 'error': 'Local 999 no existe'}

Un 200 con {"error": ...} adentro, que es justo el vicio que la práctica venía a matar, y ninguna traza de por qué. Comprobación: python -c "from services.api.app.main import app; print({k.__name__: v.__name__ for k, v in app.exception_handlers.items()})". Los handlers se registran en main.py y en ningún otro sitio.

KeyError: "Attempt to overwrite 'message' in LogRecord"

Pasaste una clave reservada en extra. logging mete cada clave de extra como atributo del LogRecord y se niega a pisar los suyos: message, args, exc_info, msg, name, levelname, module, filename… No es el formatter el que revienta, es makeRecord, y revienta siempre, no solo cuando el log se emite:

  File ".../logging/__init__.py", line 1656, in makeRecord
    raise KeyError("Attempt to overwrite %r in LogRecord" % key)
KeyError: "Attempt to overwrite 'message' in LogRecord"

Es decir: un log.debug("x", extra={"message": ...}) que en dev no se ve tumba el request en prod. Renombra: msg_text, detalle, error_msg. Y como los nombres cortos se agotan rápido, la convención de la casa ayuda: prefija los tuyos con el dominio (reserva_id, local_id), nunca uses palabras genéricas.

El log no aparece por ningún lado (y caplog sale vacío)

Alguien puso propagate = False en un logger padre para "silenciar el ruido de una librería". Como configure_logging monta el handler en el logger raíz, cortar la propagación en cualquier punto del camino deja el mensaje sin salida:

{"ts": "...", "level": "INFO", "logger": "services.worker.app.modules.recordatorios.service", "event": "recordatorios.done", "request_id": "-", "enviadas": 2}
--- fin (la segunda línea no salió)

Diagnóstico: logging.getLogger("services.worker").propagate y logging.getLogger().handlers. Para bajarle el volumen a una librería usa setLevel(logging.WARNING) en su logger, nunca propagate = False en uno de los tuyos. Y si es caplog el que sale vacío, revisa además que hiciste caplog.set_level(logging.INFO): por defecto captura desde WARNING.

test_200_con_secreto_e_idempotente pasa una vez y falla la siguiente

Este test usa la base real (practicas.db) porque prueba el endpoint entero con TestClient. La primera corrida deja los dos Aviso guardados, así que la segunda no envía nada:

services/worker/app/modules/recordatorios/tests/test_router.py:30: in test_200_con_secreto_e_idempotente
    assert r1.json()["enviadas"] + r2.json()["enviadas"] == 2
E   assert (0 + 0) == 2

Es la idempotencia funcionando: el test es el que está mal, no el código. Parche inmediato: rm -f practicas.db antes de make tests (o después de jugar con curl). Arreglo de verdad, que es el que harías en un PR: que el test apunte a su propia base con monkeypatch.setenv("DB_URL", f"sqlite+aiosqlite:///{tmp_path}/t.db") antes de importar la app, como hacen los de test_service.py con :memory:. Un test que depende de un archivo del repo es un test que va a fallar en CI el martes.

La tarea mandó dos veces el mismo aviso (con el Aviso ya implementado)

Casi siempre es un solo commit al final del bucle en vez de uno por reserva. Si el envío de la segunda revienta, el rollback se lleva también el aviso de la primera, que ya salió; cuando Cloud Tasks reintenta, la primera se manda otra vez. Reproducido:

corrida 1: reventó -> proveedor caído
corrida 1: enviados = ['ana@example.com'] | avisos en BD = 0
corrida 2: enviados = ['ana@example.com', 'ana@example.com', 'luis@example.com'] | avisos en BD = 2

Ana recibió dos. La regla: una unidad de trabajo = una transacción, y el commit del registro va inmediatamente después del efecto externo que registra. La otra variante del mismo bug es poner el session.add(Aviso(...)) antes del envío: entonces registras avisos que nunca salieron y nadie recibe nada nunca.

Listo cuando

  • Los 17 tests pasan con la base limpia; ruff check (con BLE y G), ruff format --check y mypy en verde.
  • Ningún módulo de negocio (queries.py, service.py) importa HTTPException ni JSONResponse: grep -rn "HTTPException" services/api/app/locales no devuelve nada.
  • Los cuatro status salen del handler y no de las rutas: 404, 409, 422, 503, más el 500 genérico sin traza para el cliente.
  • Puedes explicar por qué el 404 se loguea como WARNING y el 503 como ERROR, y qué pasa si los inviertes.
  • Ningún log.* del repo usa f-strings, y el curl con el correo de un comensal sale como <redacted> en el log.
  • Corres la tarea tres veces seguidas y select count(*) from aviso sigue diciendo 2; el README.md del módulo dice "reintentar es seguro" y hay un test que lo prueba.

Siguiente

En la Práctica 8 · Docker, pipeline y tu primer PR en m-b-core todo esto se empaqueta: la imagen multi-stage con su etapa de test, el ci.yml que corre ruff, mypy y pytest --cov como gate, y el checklist para abrir tu primer PR de verdad. El CRON_SECRET que aquí vive en un .env pasa a --set-secrets de Cloud Run, y las reglas de linter que agregaste en las prácticas 6 y 7 dejan de ser un acuerdo entre tú y tú.

Si te quedaste con ganas: el PR más limpio que sale de esta práctica es el Depends compartido con hmac.compare_digest en shared/, reemplazando las 18 copias del guard. Es mecánico, no cambia ningún contrato, y cierra de una vez la discusión de la comparación en tiempo constante.