# Microservicios — Patrones y Ejemplos Completos

Referencia extendida de la skill `microservicios`. Contiene ejemplos de implementación
completos para API Gateway, Circuit Breaker, Saga Pattern, Distributed Tracing,
Health Checks y Service Discovery.

---

## API Gateway — Configuración NGINX

```python
# Kong, AWS API Gateway, o NGINX como gateway
# Responsabilidades del gateway:
# 1. Autenticación/autorización centralizada
# 2. Rate limiting
# 3. Request routing
# 4. SSL termination
# 5. Logging y métricas
# 6. Transformación de requests (versioning)

# Configuración NGINX como API Gateway básico
# nginx.conf
upstream servicio_empleados {
    server empleados-svc:8001;
    keepalive 32;
}

upstream servicio_nomina {
    server nomina-svc:8002;
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name api.empresa.com.mx;

    # Autenticación JWT en el gateway (todas las rutas protegidas)
    auth_jwt "API RRHH";
    auth_jwt_key_file /etc/nginx/jwt_public_key.pem;

    location /api/v1/empleados/ {
        proxy_pass http://servicio_empleados/;
        proxy_set_header X-User-ID $jwt_claim_sub;
        proxy_set_header X-User-Roles $jwt_claim_roles;
    }

    location /api/v1/nomina/ {
        proxy_pass http://servicio_nomina/;
        limit_req zone=nomina_zone burst=20 nodelay;   # Rate limiting
    }
}
```

---

## Circuit Breaker — Implementación completa

```python
# pip install circuitbreaker
from circuitbreaker import circuit, CircuitBreakerError
import httpx
import asyncio

class ClienteServicioEmpleados:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self._client = httpx.AsyncClient(timeout=5.0)

    @circuit(
        failure_threshold=5,      # Abrir después de 5 fallos consecutivos
        recovery_timeout=30,      # Intentar recuperar después de 30s
        expected_exception=httpx.RequestError,
    )
    async def obtener_empleado(self, empleado_id: str) -> dict:
        """
        Estados del circuit breaker:
        - CLOSED: tráfico normal
        - OPEN: rechaza requests (fallback inmediato)
        - HALF-OPEN: prueba un request para ver si el servicio se recuperó
        """
        response = await self._client.get(
            f"{self.base_url}/empleados/{empleado_id}"
        )
        response.raise_for_status()
        return response.json()

    async def obtener_empleado_con_fallback(self, empleado_id: str) -> dict:
        try:
            return await self.obtener_empleado(empleado_id)
        except CircuitBreakerError:
            return await self._obtener_de_cache(empleado_id)
        except httpx.RequestError as e:
            raise ServicioNoDisponible(f"Servicio de empleados inaccesible: {e}")

    async def _obtener_de_cache(self, empleado_id: str) -> dict:
        datos = await redis.get(f"empleado:{empleado_id}")
        if datos:
            return json.loads(datos)
        raise DatosNoEnCache(f"Empleado {empleado_id} no en caché")
```

---

## Saga Pattern — Transacciones distribuidas (Orquestación)

```python
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Awaitable

class EstadoSaga(str, Enum):
    INICIADA = "INICIADA"
    COMPLETADA = "COMPLETADA"
    FALLIDA = "FALLIDA"
    COMPENSANDO = "COMPENSANDO"

@dataclass
class PasoSaga:
    nombre: str
    ejecutar: Callable[..., Awaitable[dict]]
    compensar: Callable[..., Awaitable[None]]

class SagaAltaDeEmpleado:
    """
    Saga para el alta de un nuevo empleado.
    Pasos:
    1. Crear expediente en BD central
    2. Configurar nómina en SAP
    3. Crear cuenta en Active Directory
    4. Enviar correo de bienvenida
    """

    def __init__(
        self,
        svc_expedientes,
        svc_nomina,
        svc_directorio,
        svc_notificaciones,
    ):
        self._pasos = [
            PasoSaga(
                nombre="crear_expediente",
                ejecutar=svc_expedientes.crear,
                compensar=svc_expedientes.eliminar,
            ),
            PasoSaga(
                nombre="configurar_nomina",
                ejecutar=svc_nomina.configurar_empleado,
                compensar=svc_nomina.eliminar_empleado,
            ),
            PasoSaga(
                nombre="crear_cuenta_directorio",
                ejecutar=svc_directorio.crear_cuenta,
                compensar=svc_directorio.desactivar_cuenta,
            ),
            PasoSaga(
                nombre="enviar_bienvenida",
                ejecutar=svc_notificaciones.enviar_bienvenida,
                compensar=lambda **_: None,
            ),
        ]

    async def ejecutar(self, datos_empleado: dict) -> dict:
        resultados: list[dict] = []
        ejecutados: list[tuple[PasoSaga, dict]] = []

        for paso in self._pasos:
            try:
                resultado = await paso.ejecutar(**datos_empleado, **{
                    f"resultado_{p.nombre}": r for p, r in ejecutados
                })
                resultados.append(resultado)
                ejecutados.append((paso, resultado))
            except Exception as e:
                await self._compensar(ejecutados[::-1], datos_empleado)
                raise SagaFallida(
                    f"Saga falló en paso '{paso.nombre}': {e}"
                ) from e

        return {"resultados": resultados}

    async def _compensar(
        self,
        pasos_ejecutados: list[tuple[PasoSaga, dict]],
        datos: dict,
    ) -> None:
        for paso, resultado in pasos_ejecutados:
            try:
                await paso.compensar(**datos, resultado_previo=resultado)
            except Exception as e:
                logger.error("Error compensando %s: %s", paso.nombre, e)
```

---

## Distributed Tracing — OpenTelemetry

```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

def configurar_tracing(servicio: str, jaeger_host: str = "jaeger") -> None:
    provider = TracerProvider()
    jaeger_exporter = JaegerExporter(
        agent_host_name=jaeger_host,
        agent_port=6831,
    )
    provider.add_span_processor(BatchSpanProcessor(jaeger_exporter))
    trace.set_tracer_provider(provider)

    FastAPIInstrumentor.instrument()
    HTTPXClientInstrumentor.instrument()
    SQLAlchemyInstrumentor.instrument()

tracer = trace.get_tracer(__name__)

async def calcular_nomina_quincenal(periodo_id: str) -> dict:
    with tracer.start_as_current_span("calcular_nomina_quincenal") as span:
        span.set_attribute("periodo.id", periodo_id)

        with tracer.start_as_current_span("obtener_empleados_activos"):
            empleados = await repo.obtener_empleados_activos()
            span.set_attribute("empleados.count", len(empleados))

        with tracer.start_as_current_span("calcular_percepciones"):
            percepciones = await calcular_percepciones(empleados)

        span.set_attribute("nomina.total", sum(p["total"] for p in percepciones))
        return {"periodo_id": periodo_id, "percepciones": percepciones}
```

---

## Health Checks — 3 endpoints estándar

```python
from fastapi import APIRouter
from sqlalchemy.ext.asyncio import AsyncSession

router = APIRouter()

@router.get("/health/live")
async def liveness():
    """
    Kubernetes liveness probe.
    Responde 200 si el proceso está vivo.
    NUNCA verificar dependencias externas aquí.
    """
    return {"status": "alive"}

@router.get("/health/ready")
async def readiness(db: AsyncSession = Depends(get_db)):
    """
    Kubernetes readiness probe.
    Verifica dependencias críticas.
    """
    checks = {}
    try:
        await db.execute(text("SELECT 1"))
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {e}"

    try:
        await redis.ping()
        checks["cache"] = "ok"
    except Exception as e:
        checks["cache"] = f"error: {e}"

    todo_ok = all(v == "ok" for v in checks.values())
    return JSONResponse(
        content={"status": "ready" if todo_ok else "not_ready", "checks": checks},
        status_code=200 if todo_ok else 503,
    )

@router.get("/health/startup")
async def startup_probe():
    """
    Kubernetes startup probe.
    Permite más tiempo para servicios con arranque lento.
    """
    head_revision = await obtener_revision_actual_db()
    expected_revision = get_alembic_head()
    if head_revision != expected_revision:
        raise HTTPException(503, "Migraciones pendientes")
    return {"status": "started", "revision": head_revision}
```

---

## Service Discovery — Consul

```python
import consul

class RegistroServicios:
    def __init__(self):
        self._consul = consul.aio.Consul(host="consul", port=8500)

    async def registrar(self, nombre: str, puerto: int, tags: list[str]) -> None:
        await self._consul.agent.service.register(
            name=nombre,
            service_id=f"{nombre}-{os.environ['HOSTNAME']}",
            address=os.environ["POD_IP"],
            port=puerto,
            tags=tags,
            check=consul.Check.http(
                f"http://{os.environ['POD_IP']}:{puerto}/health/ready",
                interval="10s",
                deregister="30s",
            ),
        )

    async def descubrir(self, nombre: str) -> str:
        _, servicios = await self._consul.health.service(nombre, passing=True)
        if not servicios:
            raise ServicioNoDisponible(f"No hay instancias de '{nombre}'")
        servicio = servicios[0]["Service"]
        return f"http://{servicio['Address']}:{servicio['Port']}"
```
