# Async Python — Patrones y Ejemplos Completos

Referencia extendida de la skill `async-python`. Contiene ejemplos de código completos
y patrones avanzados que complementan las instrucciones principales en SKILL.md.

---

## asyncio.gather — Concurrencia de tareas independientes

```python
import asyncio
import aiohttp
from typing import NamedTuple

class ResultadoAPI(NamedTuple):
    url: str
    datos: dict | None
    error: str | None

async def fetch_json(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
        resp.raise_for_status()
        return await resp.json()

async def fetch_multiples(urls: list[str]) -> list[ResultadoAPI]:
    """Descarga múltiples URLs concurrentemente con manejo de errores individual."""
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_json(session, url) for url in urls]
        # return_exceptions=True evita que un error cancele todas las tareas
        resultados = await asyncio.gather(*tasks, return_exceptions=True)

    return [
        ResultadoAPI(
            url=url,
            datos=r if not isinstance(r, Exception) else None,
            error=str(r) if isinstance(r, Exception) else None,
        )
        for url, r in zip(urls, resultados)
    ]
```

---

## asyncio.TaskGroup — Python 3.11+

```python
import asyncio
from contextlib import asynccontextmanager

async def procesar_lote(ids: list[int]) -> list[dict]:
    """TaskGroup cancela TODAS las tareas si una falla — comportamiento más seguro."""
    resultados: list[dict] = []

    async with asyncio.TaskGroup() as tg:
        tasks = [
            tg.create_task(obtener_registro(id_), name=f"registro-{id_}")
            for id_ in ids
        ]
    return [t.result() for t in tasks]

async def obtener_registro(id_: int) -> dict:
    await asyncio.sleep(0.1)  # Simular I/O
    return {"id": id_, "nombre": f"Registro {id_}"}
```

---

## Context Managers Asíncronos

```python
from contextlib import asynccontextmanager
import asyncio

@asynccontextmanager
async def cronometro(nombre: str):
    """Context manager async para medir tiempos."""
    inicio = asyncio.get_event_loop().time()
    try:
        yield
    finally:
        elapsed = asyncio.get_event_loop().time() - inicio
        print(f"[{nombre}] tardó {elapsed:.3f}s")

# Uso
async def proceso_pesado():
    async with cronometro("importación de nómina"):
        await importar_nomina()

# Inicialización y cierre de recursos (FastAPI lifespan)
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    await engine.connect()
    await redis_client.ping()
    yield
    # Shutdown
    await engine.dispose()
    await redis_client.close()
```

---

## Queues Async — Productor/Consumidor

```python
import asyncio
from dataclasses import dataclass
from typing import Callable, Awaitable

@dataclass
class Tarea:
    id: int
    payload: dict

async def productor(queue: asyncio.Queue, tareas: list[Tarea]) -> None:
    for tarea in tareas:
        await queue.put(tarea)
    await queue.put(None)

async def consumidor(
    queue: asyncio.Queue,
    procesar: Callable[[Tarea], Awaitable[None]],
    worker_id: int,
) -> None:
    while True:
        tarea = await queue.get()
        if tarea is None:
            queue.task_done()
            break
        try:
            await procesar(tarea)
        except Exception as e:
            print(f"[Worker {worker_id}] Error en tarea {tarea.id}: {e}")
        finally:
            queue.task_done()

async def pipeline_tareas(
    tareas: list[Tarea],
    procesar: Callable[[Tarea], Awaitable[None]],
    num_workers: int = 5,
    max_queue: int = 100,
) -> None:
    queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue)

    async with asyncio.TaskGroup() as tg:
        tg.create_task(productor(queue, tareas))
        for i in range(num_workers):
            tg.create_task(consumidor(queue, procesar, worker_id=i))
```

---

## Error Handling — Reintento con backoff exponencial

```python
import asyncio
import logging
from typing import TypeVar, Callable, Awaitable

logger = logging.getLogger(__name__)
T = TypeVar("T")

async def reintentar(
    func: Callable[[], Awaitable[T]],
    max_intentos: int = 3,
    delay_base: float = 1.0,
    errores_reintentables: tuple[type[Exception], ...] = (Exception,),
) -> T:
    ultimo_error: Exception | None = None
    for intento in range(1, max_intentos + 1):
        try:
            return await func()
        except errores_reintentables as e:
            ultimo_error = e
            if intento < max_intentos:
                delay = delay_base * (2 ** (intento - 1))
                logger.warning(
                    "Intento %d/%d falló (%s). Reintentando en %.1fs",
                    intento, max_intentos, e, delay,
                )
                await asyncio.sleep(delay)
    raise ultimo_error  # type: ignore[misc]

# Uso
async def obtener_datos_con_reintento(id_: str) -> dict:
    return await reintentar(
        lambda: fetch_json(f"/api/datos/{id_}"),
        max_intentos=3,
        errores_reintentables=(aiohttp.ClientError, asyncio.TimeoutError),
    )
```

---

## Testing de Código Async

```python
import pytest
import asyncio
from unittest.mock import AsyncMock, patch

# pytest-asyncio — configuración en pyproject.toml
# [tool.pytest.ini_options]
# asyncio_mode = "auto"

@pytest.fixture
async def db_session():
    """Fixture async con rollback automático."""
    async with TestingSessionLocal() as session:
        async with session.begin():
            yield session
            await session.rollback()  # Siempre limpiar

@pytest.mark.asyncio
async def test_obtener_usuario_con_roles(db_session):
    # Arrange
    usuario = await crear_usuario_fixture(db_session, roles=["ADMIN"])

    # Act
    resultado = await obtener_usuario_con_roles(db_session, usuario.id)

    # Assert
    assert resultado.id == usuario.id
    assert len(resultado.roles) == 1
    assert resultado.roles[0].nombre == "ADMIN"

# Mock de llamadas HTTP
@pytest.mark.asyncio
async def test_fetch_url_reintenta_en_error():
    llamadas = 0

    async def mock_get(*args, **kwargs):
        nonlocal llamadas
        llamadas += 1
        if llamadas < 3:
            raise aiohttp.ClientError("Fallo temporal")
        return MockResponse(status=200, data={"ok": True})

    with patch("aiohttp.ClientSession.get", side_effect=mock_get):
        resultado = await obtener_datos_con_reintento("123")

    assert resultado == {"ok": True}
    assert llamadas == 3
```

---

## Receta: Service HTTP con Pool de Conexiones

```python
import asyncio
import aiohttp
from typing import Any

class ClienteHTTP:
    """Cliente HTTP reutilizable con pool de conexiones."""

    def __init__(self, base_url: str, max_conexiones: int = 20):
        self._base_url = base_url.rstrip("/")
        self._connector = aiohttp.TCPConnector(limit=max_conexiones)
        self._session: aiohttp.ClientSession | None = None

    async def __aenter__(self) -> "ClienteHTTP":
        self._session = aiohttp.ClientSession(
            connector=self._connector,
            timeout=aiohttp.ClientTimeout(total=30),
            headers={"Content-Type": "application/json"},
        )
        return self

    async def __aexit__(self, *args: Any) -> None:
        if self._session:
            await self._session.close()
        await self._connector.close()

    async def get(self, path: str, **kwargs: Any) -> dict:
        assert self._session, "Usar dentro de 'async with ClienteHTTP()'"
        url = f"{self._base_url}/{path.lstrip('/')}"
        async with self._session.get(url, **kwargs) as resp:
            resp.raise_for_status()
            return await resp.json()

# Uso en FastAPI startup
cliente_sap: ClienteHTTP | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global cliente_sap
    async with ClienteHTTP("https://sap.empresa.com.mx/api") as client:
        cliente_sap = client
        yield
```
