---
name: manejo-errores
description: >
  Patrones de manejo de errores en Python y TypeScript. Jerarquía de excepciones,
  excepciones personalizadas, códigos de error, logging estructurado, error boundaries,
  degradación elegante, patrones de reintento y circuit breakers.
version: "1.2.0"
evolved: true
evolved-from: "1.1.1"
evolved-at: "2026-05-04"
evolved-by: "aprender"
evolved-note: "+sección anti-patrón except Exception con isinstance interno — usar excepts planos por tipo (sync desde global tras sesión SIGM Fase 5b). Preserva gotcha try/catch require de v1.1.1."
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para monitoreo y alertas en producción (Prometheus, Grafana, PagerDuty) — para observabilidad cargar `monitoring-alertas` o `sre-patrones`."
  - "No cargar para manejo de errores específico de frameworks (FastAPI exception handlers, Angular Error Interceptor) — los ejemplos aquí son patrones base; para frameworks específicos cargar el skill correspondiente."
  - "No cargar para errores de compilación o build (TypeScript, Rust, Java) — para diagnóstico de errores de compilación cargar `typescript-diagnosticos` o el skill de build errors correspondiente."
  - "No cargar para logging de auditoría o compliance (GDPR, SOC2 log requirements) — este skill cubre logging operacional de errores, no logging de compliance."
evolvable: true  # default para skill estandar
---
# Manejo de Errores — Patrones de Producción

## Cuándo NO cargar

- La tarea es monitoreo y alertas en producción: Prometheus, Grafana, SLOs, PagerDuty — cargar `monitoring-alertas` o `sre-patrones`.
- El manejo de errores es específico de un framework: FastAPI exception handlers, Angular HTTP Interceptor — cargar el skill del framework; los patrones aquí son base.
- Los errores son de compilación o build: TypeScript errors, Rust borrow checker, Java compile errors — cargar `typescript-diagnosticos` o el skill de build errors del lenguaje.
- El logging es de auditoría o compliance (GDPR, SOC2) — este skill cubre logging operacional de errores en runtime, no logging de compliance.

## Principios Fundamentales

El manejo de errores no es una ocurrencia tardía: es parte del diseño de la interfaz.
Un módulo que no especifica su contrato de errores es un módulo incompleto.

**Reglas de oro:**
1. Nunca silencies una excepción sin loguear o re-lanzar.
2. Transforma excepciones de bajo nivel en errores de dominio en la frontera de tu módulo.
3. El mensaje de error debe responder: ¿qué pasó?, ¿dónde?, ¿qué hace el sistema ahora?
4. Los errores recuperables se manejan; los no recuperables se propagan.

---

## Python — Jerarquía de Excepciones

```python
# BIEN - jerarquía clara con códigos de error
class ErrorAplicacion(Exception):
    """Base de todos los errores de la aplicación."""
    codigo: str = "APP_ERROR"

    def __init__(self, mensaje: str, detalle: dict | None = None) -> None:
        super().__init__(mensaje)
        self.mensaje = mensaje
        self.detalle = detalle or {}

    def to_dict(self) -> dict:
        return {
            "codigo": self.codigo,
            "mensaje": self.mensaje,
            "detalle": self.detalle,
        }

class ErrorValidacion(ErrorAplicacion):
    codigo = "VALIDATION_ERROR"

class ErrorNoEncontrado(ErrorAplicacion):
    codigo = "NOT_FOUND"

class ErrorConflicto(ErrorAplicacion):
    codigo = "CONFLICT"

class ErrorExterno(ErrorAplicacion):
    """Para fallos en servicios de terceros."""
    codigo = "EXTERNAL_SERVICE_ERROR"
```

---

## Anti-patrón: `except Exception` con `isinstance` interno

**Problema**: capturar todo con `except Exception` y luego despachar por tipo con
`isinstance` enmascara excepciones futuras del módulo y dificulta seguir el flujo
de control.

```python
# MAL — except genérico con isinstance interno
try:
    registro = await ev_svc.subir_evidencia(...)
except (MIMENoPermitidoError, TamanoExcedidoError) as exc:
    raise HTTPException(422, detail=...) from exc
except Exception as exc:  # ← captura TODO lo que no caía arriba
    if isinstance(exc, ErrorEvidencia):
        raise HTTPException(502, detail="MinIO error") from exc
    raise  # cualquier otra excepción se re-lanza
```

Problemas:
1. Si en el futuro `ev_svc` lanza una nueva excepción `EvidenciaCorruptaError` que debería
   ser 422, queda capturada por `except Exception` y se re-lanza como 500 silenciosamente.
2. La intención del código no es clara: ¿qué tipos esperamos? ¿qué hace fallback?
3. Auditores de seguridad no pueden verificar fácilmente qué excepciones están manejadas.

```python
# BIEN — except plano por tipo, sin isinstance interno
try:
    registro = await ev_svc.subir_evidencia(...)
except (MIMENoPermitidoError, TamanoExcedidoError) as exc:
    raise HTTPException(422, detail=str(exc)) from exc
except ErrorEvidencia as exc:
    logger.exception("Error MinIO al subir evidencia")
    raise HTTPException(502, detail="Error genérico al cliente") from exc
# Cualquier otra excepción se propaga al handler global — explícito.
```

Reglas:
- Un `except` por tipo (o tupla de tipos relacionados) que requiera tratamiento distinto.
- NO usar `except Exception` salvo en handlers globales explícitos (FastAPI exception_handler).
- Si necesitas distinguir múltiples tipos en un mismo handler, usa `except (A, B, C)` no
  `except Exception` + `isinstance`.

---

## Python — Encadenamiento de Excepciones

SIEMPRE usar `raise ... from exc` para preservar la cadena de errores:

```python
def cargar_config(ruta: str) -> Config:
    try:
        with open(ruta) as f:
            datos = json.load(f)
    except FileNotFoundError as exc:
        raise ErrorNoEncontrado(
            f"Archivo de configuración no encontrado: {ruta}",
        ) from exc
    except json.JSONDecodeError as exc:
        raise ErrorValidacion(
            f"JSON malformado en {ruta}: línea {exc.lineno}",
        ) from exc
```

---

## Python — Logging Estructurado

NUNCA usar `print()` o `logging.error("Algo salió mal")` sin contexto.

```python
import structlog

logger = structlog.get_logger(__name__)

def procesar_pedido(pedido_id: str, usuario_id: str) -> Resultado:
    log = logger.bind(pedido_id=pedido_id, usuario_id=usuario_id)
    log.info("iniciando_procesamiento_pedido")

    try:
        pedido = repositorio.obtener(pedido_id)
        resultado = servicio_pago.cobrar(pedido)
        log.info("pedido_procesado_exitosamente", monto=pedido.total)
        return resultado
    except ErrorExterno as exc:
        log.error(
            "fallo_servicio_externo",
            servicio=exc.detalle.get("servicio"),
            exc_info=True,
        )
        raise
    except Exception as exc:
        log.critical(
            "error_inesperado_procesando_pedido",
            tipo_excepcion=type(exc).__name__,
            exc_info=True,
        )
        raise ErrorAplicacion("Error interno al procesar el pedido") from exc
```

---

## Python — Patrón Retry con Backoff Exponencial

NUNCA hacer retry infinito sin backoff ni logging.

```python
from tenacity import (
    retry, stop_after_attempt, wait_exponential,
    retry_if_exception_type, before_sleep_log,
)

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception_type(ErrorExterno),
    before_sleep=before_sleep_log(logger_tenacity, logging.WARNING),
)
def llamar_api_con_retry(endpoint: str, payload: dict) -> dict:
    respuesta = httpx.post(endpoint, json=payload, timeout=10.0)
    if respuesta.status_code == 429:
        raise ErrorExterno("Rate limit alcanzado", {"servicio": endpoint, "codigo_http": 429})
    if respuesta.status_code >= 500:
        raise ErrorExterno("Error del servidor externo", {"servicio": endpoint})
    respuesta.raise_for_status()
    return respuesta.json()
```

---

## Gotcha: Backoff en Workers y Reconexión de Servicios

En workers, colas de mensajes y conexiones a bases de datos, el retry debe usar
backoff exponencial con techo (cap). Sin techo, los reintentos se espacian tanto
que el worker parece muerto. Sin backoff, los reintentos saturan el servicio caído.

```python
# Patrón de reconexión con backoff exponencial y cap
import time
import random

def conectar_con_backoff(crear_conexion, max_intentos=10, base=1.0, cap=30.0):
    """Reconecta con backoff exponencial: 1s → 2s → 4s → 8s → ... → 30s max."""
    for intento in range(max_intentos):
        try:
            return crear_conexion()
        except Exception as exc:
            if intento == max_intentos - 1:
                raise
            delay = min(base * (2 ** intento), cap)
            # Jitter para evitar thundering herd
            delay *= 0.5 + random.random() * 0.5
            logger.warning(
                "reconexion_fallida",
                intento=intento + 1,
                proximo_reintento_seg=round(delay, 1),
                error=str(exc),
            )
            time.sleep(delay)
```

### Reglas de backoff para workers

- **Base**: 1 segundo. Nunca 0 — un retry inmediato multiplica la carga.
- **Cap**: 30 segundos para servicios internos, 60 segundos para APIs externas.
- **Jitter obligatorio**: sin jitter, N workers reintentan al mismo instante (thundering herd).
- **Max intentos finito**: siempre. Un worker que reintenta infinitamente consume
  recursos sin producir valor. Tras agotar intentos: enviar a dead letter queue.
- **Loguear cada reintento**: intento N de M, próximo reintento en X segundos.
  Sin logs, un worker silenciosamente atascado es invisible.

---

## Patrones Avanzados

Para implementaciones completas de Circuit Breaker, TypeScript Result types,
Angular Error Boundaries, Angular HTTP Interceptor y Graceful Degradation,
ver [recursos/implementaciones-completas.md](recursos/implementaciones-completas.md).

---

## Gotchas / Errores comunes no obvios

**`raise ErrorDominio() from exc` con `exc` siendo `None` (Python) causa `TypeError` en el traceback**: si se hace `raise MiError("msg") from None` es válido (suprime el contexto), pero `raise MiError("msg") from variable_que_puede_ser_none` lanza `TypeError: exception causes must derive from BaseException` cuando la variable es `None`. Causa: Python valida que el cause sea una excepción o `None` literal, no una variable que puede ser `None`. Fix: usar `raise MiError("msg") from exc` solo cuando `exc` se capturó en un `except` — fuera de un bloque except usar simplemente `raise MiError("msg")`.

**`structlog` configurado con `JSONRenderer` en producción pero `ConsoleRenderer` en tests puede causar que los tests pasen con logs que explotan en producción**: la configuración de structlog se setea globalmente con `structlog.configure()` — si los tests usan una configuración diferente al código de producción, los procesadores que funcionan en tests pueden fallar en prod. Causa: structlog es un singleton de configuración global. Fix: no mezclar configuraciones — usar un mismo renderer en tests y en producción, o verificar que los procesadores configurados son compatibles con ambos entornos.

**`tenacity` con `retry_if_exception_type(ErrorExterno)` no reintenta si la excepción está envuelta en otra**: si el código lanza `ErrorServicio("msg") from exc` donde `ErrorServicio` hereda de `ErrorAplicacion` (no de `ErrorExterno`), el retry no se activa aunque el error subyacente sea externo. Causa: `retry_if_exception_type` verifica el tipo exacto de la excepción lanzada, no la causa. Fix: asegurarse de lanzar exactamente la excepción que coincide con `retry_if_exception_type`, o usar `retry_if_exception(lambda e: isinstance(e, (ErrorExterno, httpx.HTTPError)))` con una función predicado más amplia.

**El patrón de backoff con `random.random()` no es criptográficamente seguro pero eso no importa — lo que importa es usar `random.seed()` en tests**: si los tests verifican el comportamiento del backoff con un mock de `time.sleep`, los tests pueden ser no-deterministas si `random.random()` devuelve valores diferentes en cada ejecución. Causa: jitter aleatorio hace los tests flakys. Fix: en tests de backoff, mockear `random.random` para que retorne un valor fijo (`mocker.patch('random.random', return_value=0.5)`), o usar `secrets.randbelow` solo en contextos donde el timing de seguridad importa.

**Cachear "resultado nulo por fallo transitorio" con el mismo TTL que un resultado exitoso silencia alertas legítimas**: un hook/script verifica algo cada 24h (ej: consultar npm por una nueva versión); si la consulta falla (timeout, red caída, dependencia fuera de línea), cachear el fallo con TTL completo de 24h hace que el siguiente check no reintente hasta el día siguiente — el usuario queda sin aviso durante una ventana completa pese a que la infraestructura ya se recuperó minutos después. Causa: usar un solo TTL nominal sin distinguir éxito de fallo transitorio; el estado "no pude verificar" se trata como "verifiqué y todo OK". Fix: implementar **throttle adaptativo** con dos constantes distintas — `SUCCESS_TTL` largo (24h) y `FAILURE_TTL` corto (1h, 5min según el costo del check); al leer el cache, elegir el intervalo según si el resultado anterior fue exitoso o nulo. Patrón:

```js
function debeVerificar() {
  try {
    const data = JSON.parse(fs.readFileSync(flagPath(), 'utf8'));
    const intervalo = data.resultado ? SUCCESS_TTL : FAILURE_TTL;
    return (Date.now() - data.timestamp) > intervalo;
  } catch {
    return true;
  }
}
```

Aplica a: health checks periódicos, verificación de nuevas versiones, consultas de cuota/rate-limit de APIs externas, chequeos de certificados, polling de colas. La línea divisoria es "¿el resultado anterior fue información real o fue ausencia de información?".

**Patrón `try { require } catch` para módulos opcionales en Node con fallback noop**: un hook o lib en `hooks/lib/` requiere de otra lib del mismo directorio (ej. `atomic-write.js`) que puede o no existir según la versión instalada del sistema o si el archivo se ejecuta standalone (CLI ad-hoc, test aislado). Importar con `require('./atomic-write')` directo lanza `MODULE_NOT_FOUND` y el hook crashea, bloqueando cualquier operación que lo invoque. Causa: `require` es síncrono y propaga la excepción al caller. Fix: envolver el require en try/catch y proveer un fallback que cumpla el mismo contrato de la dependencia, sin garantías adicionales (atomicidad, persistencia, etc.):

```js
let atomicWriteSync, atomicWriteJSON;
try {
  ({ atomicWriteSync, atomicWriteJSON } = require('./atomic-write'));
} catch {
  // Fallback no-atómico — funciona pero pierde la garantía de write atómico
  atomicWriteSync = (p, c, e) => fs.writeFileSync(p, c, e);
  atomicWriteJSON = (p, o) => fs.writeFileSync(p, JSON.stringify(o, null, 2), 'utf8');
}
```

Aplica a: módulos en `hooks/lib/` que se cargan desde otros hooks pero también pueden ejecutarse vía `node -e` o tests aislados, libs que dependen de otras libs del mismo paquete pero queremos que el lib funcione si alguien la copia sola, scripts de mantenimiento que viven en `scripts/` y referencian utilidades de `hooks/lib/`. NO aplica a dependencias del package.json (esas SÍ deben fallar si no están — son contratos firmes); aplica solo a dependencias internas del propio repositorio cuya disponibilidad no es garantizada en todos los contextos de ejecución. Evidencia: 15 archivos críticos de `hooks/` y `scripts/` migraron a este patrón en sesión 2026-05-02.

## Gotcha — Retry con cliente HTTP interno: propagar el timeout

### NUNCA: wrapper de retry que promete N segundos sobre cliente HTTP con timeout M << N

**Problema**: abstracciones de retry (cadenas de fallback, reintentos con backoff) suelen envolver el intento con `asyncio.wait_for(timeout=N)` pero llaman a un cliente HTTP que tiene su propio `read_timeout = M` fijo y más corto. Si el servidor tarda entre M y N segundos, el cliente HTTP aborta con `ReadTimeout` y el wrapper interpreta "fallo" → degrada a otro modelo o vía, aunque el wrapper prometía esperar N segundos.

Caso real observado: wrapper `llm_fallback` prometía 300s al primer modelo, pero `OllamaClient` tenía `httpx.Timeout(read=30.0)` fijo. Ollama necesita 60-120s para cargar un modelo grande en VRAM → aborta a los 30s, 3 reintentos fallan, wrapper degrada al siguiente modelo. Síntoma: log con mensajes de error vacíos (`fallo (intento 1/3): .`), porque `ReadTimeout` sin mensaje se convierte en `"."` vía `str(exc)`.

```python
# MAL — timeouts desalineados: el wrapper promete 300s, el cliente corta a 30s
class Client:
    timeout = 30.0
    def _crear(self): return httpx.AsyncClient(timeout=httpx.Timeout(read=self.timeout))
    async def generate(self, model, prompt):
        async with self._crear() as c: return await c.post("/generate", json=...)

async def fallback_chain(client, cadena):
    for modelo, timeout_s in cadena:
        try:
            # timeout_s=300, pero client.generate aborta internamente a los 30s
            return await asyncio.wait_for(
                client.generate(modelo, prompt), timeout=timeout_s,
            )
        except (asyncio.TimeoutError, ReadTimeout):
            continue  # degrada al siguiente modelo innecesariamente
```

```python
# BIEN — cliente acepta override de timeout; wrapper lo propaga
class Client:
    timeout = 30.0  # default para llamadas cortas
    def _crear(self, read_timeout_override=None):
        t = read_timeout_override if read_timeout_override is not None else self.timeout
        return httpx.AsyncClient(timeout=httpx.Timeout(read=t))
    async def generate(self, model, prompt, timeout=None):
        async with self._crear(read_timeout_override=timeout) as c:
            return await c.post("/generate", json=...)

async def fallback_chain(client, cadena):
    for modelo, timeout_s in cadena:
        try:
            # Ahora el cliente tiene el mismo timeout que el wrapper
            try:
                call = client.generate(modelo, prompt, timeout=timeout_s)
            except TypeError:
                # Cliente hermano sin soporte de `timeout` kwarg
                call = client.generate(modelo, prompt)
            return await asyncio.wait_for(call, timeout=timeout_s)
        except (asyncio.TimeoutError, ConnectionError):
            continue
```

**Regla general**: en cualquier pila `wrapper_con_retry → cliente_HTTP`, el timeout del cliente HTTP debe ser ≥ al timeout que el wrapper comunica al usuario. Si el cliente tiene default corto (típico: 30s), aceptar un kwarg `timeout` y que el wrapper lo propague en cada intento.

**Aplicabilidad**: Celery + requests, nginx + backend, httpx + retry libraries, cualquier abstracción que prometa tiempos más largos que el default del transporte subyacente. Añadir verificación en code review cada vez que se implemente una capa de retry sobre HTTP.

## Checklist de Revisión — Manejo de Errores

- [ ] Toda excepción capturada tiene log o re-lanzamiento con contexto.
- [ ] Las excepciones de infraestructura se transforman en errores de dominio en la frontera del módulo.
- [ ] Los mensajes de error son accionables para quien los recibe.
- [ ] Los servicios externos tienen timeout + retry + circuit breaker.
- [ ] El logging incluye contexto suficiente para reproducir el error.
- [ ] Los errores HTTP tienen código de estado correcto (4xx vs 5xx).
- [ ] El tipo de error en TypeScript es explícito, no `any` ni `unknown` sin narrowing.
- [ ] Los errores de validación devuelven todos los campos inválidos de una vez (no uno por uno).

---

## `except Exception: pass` en cleanup es válido PERO con `logger.debug`

### Regla

El patrón `try: ... except Exception: pass` en código de **cleanup** (liberación
de recursos, eliminación de archivos temporales, cierre de conexiones al salir)
suele marcarse como anti-patrón automáticamente por linters y revisores. En
ese contexto específico es **válido**, pero debe acompañarse de `logger.debug`
para preservar trazabilidad.

### Por qué

El cleanup no debe bloquear el flujo principal ni escalar errores secundarios
sobre el error principal. Ejemplos:

- Eliminar un archivo `.tmp` cuya ruta ya no existe porque otro proceso lo borró.
- Cerrar un socket que el otro extremo cerró antes.
- Desregistrar un callback cuyo registro nunca se completó por error temprano.

En estos casos el error del cleanup es **irrelevante** para la lógica del
usuario — reintento no ayuda, propagación oculta el error real. Pero "no
hacer nada" a secas deja al operador ciego cuando un patrón de cleanup
sistemáticamente falla por bug real (ej: ruta mal construida, permisos).

### Patrón canónico

```python
import logging
logger = logging.getLogger(__name__)


def cerrar_recursos(recurso) -> None:
    """Cleanup: no retry, no raise, pero sí debug log."""
    try:
        recurso.close()
    except Exception as exc:
        # Nivel DEBUG: visible solo al investigar, silencioso en prod
        logger.debug("fallo_silenciado_en_cleanup", extra={
            "recurso": repr(recurso),
            "tipo_excepcion": type(exc).__name__,
            "mensaje": str(exc),
        })


def limpiar_temporal(ruta: Path) -> None:
    try:
        ruta.unlink(missing_ok=True)
    except Exception as exc:
        logger.debug(f"no se pudo eliminar {ruta}: {exc}")
```

### Reglas

- **Solo en cleanup / teardown / finally**: no en flujo principal, no en
  manejo de input de usuario, no en llamadas a APIs externas. Para esos
  casos el manejo es específico de la excepción esperada.
- **Nivel `debug`, NO `warning`**: los logs de warning de cleanup saturan
  la observabilidad y pierden señal. Debug queda invisible en producción
  normal pero accesible cuando se investiga.
- **Incluir contexto mínimo**: tipo de excepción y mensaje, más el recurso
  que se intentaba liberar. Sin eso el debug es tan inútil como el `pass`.
- **`except Exception`, no `except:` desnudo**: `except:` captura también
  `KeyboardInterrupt` y `SystemExit`, convirtiendo Ctrl-C en un "cleanup
  silencioso" que confunde al operador.

### Anti-patrones relacionados

```python
# MAL — pass desnudo sin trazabilidad
try:
    socket.close()
except:
    pass


# MAL — warning satura logs cuando el cleanup falla legítimamente
try:
    archivo_tmp.unlink()
except OSError as exc:
    logger.warning(f"fallo cleanup: {exc}")   # satura observabilidad


# MAL — cleanup que lanza oculta el error principal
try:
    operacion_principal()
finally:
    recurso.close()   # si esto lanza, el error original se pierde
```

### Aplicabilidad

Típico en:

- Métodos `__exit__` de context managers custom
- Bloques `finally` tras operaciones de red
- Lifecycle hooks de shutdown (atexit, signal handlers)
- Cleanup de recursos heredados (archivos, file handles, DB cursors, sockets)
- Garbage collection de objetos con `__del__`
