---
name: backend-error-design
description: >
  Diseño de jerarquías de excepciones de dominio en backend Python: contrato
  consistente (code, message, field opcional), kwargs explícitos obligatorios
  para prevenir args invertidos silenciosamente, mapeo a códigos HTTP, testing
  con exc_info.value.code (no "X" in str). Cargar cuando se diseñe o refactore
  excepciones de dominio (BusinessLogicError, ValidationError, NotFoundError,
  AuthorizationError, DuplicateError), cuando un test pase pero el contrato del
  error esté invertido en producción, o cuando manejo-errores no cubra el caso
  específico de excepciones de dominio con contrato code+message.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para manejo de errores HTTP genérico (try/except en endpoints, middleware de error handling) — para eso cargar `manejo-errores` que cubre el flujo HTTP completo."
  - "No cargar para frameworks no-Python (Node.js error patterns, Java exception hierarchies, Go error wrapping) — este skill es específico de excepciones Python con dataclass-like signature."
  - "No cargar para errores de capa externa (boto3 ClientError, httpx RequestError, BD driver) — esos NO son de dominio; usar el patrón genérico de mapear a HTTP 502 sin exponer el str(exc) interno."
  - "No cargar para mensajes de validación de Pydantic — Pydantic genera sus propios ValidationError; cargar `fastapi-experto` para el patrón de respuesta."
evolvable: true
---

# Backend Error Design

Patrón para diseñar excepciones de dominio que NO se rompen silenciosamente al cliente.

## Cuándo NO cargar

- La tarea es manejo de errores genérico HTTP (try/except en endpoint, middleware global de error) — cargar `manejo-errores`.
- El framework es Node.js, Java, Go o Rust — los patrones de excepciones difieren; este skill es específico Python.
- El error es de capa externa (S3, HTTP client, BD driver) — esos NO son excepciones de dominio; el patrón genérico es mapear a HTTP 502 con detail genérico.
- La validación es de Pydantic — Pydantic ya genera `ValidationError`; cargar `fastapi-experto` para el patrón de respuesta.

## El bug recurrente: args posicionales se invierten silenciosamente

Caso real SIGAF 2026-05-15 Pasada 2 verificar (commit `4d2fb23`). Firma de excepción:

```python
class BusinessLogicError(Exception):
    def __init__(self, message: str, code: str, field: str | None = None):
        super().__init__(message)
        self.message = message
        self.code = code
        self.field = field
```

3 sitios en `ordenes/service.py` invocaban la excepción así:

```python
# MAL — args invertidos: el primer string parece código, el segundo descripción.
# Pero la firma es (message, code), no (code, message).
raise BusinessLogicError(
    "HALLAZGO_ACTO_MISMATCH",
    f"El hallazgo {data.hallazgo_id} no pertenece al acto {data.acto_id}.",
)
```

Resultado: la excepción tiene `message="HALLAZGO_ACTO_MISMATCH"` y `code="El hallazgo X no pertenece al acto Y"`. El contrato HTTP devuelve al cliente:

```json
{
  "error": {
    "code": "El hallazgo 1234 no pertenece al acto 5678.",
    "message": "HALLAZGO_ACTO_MISMATCH"
  }
}
```

Inversión total. El cliente trata el texto descriptivo como código de error y el código como mensaje user-facing. El frontend no puede ramificar lógica por `code` porque cada error es único (incluye UUIDs).

### Por qué los tests NO detectaron el bug

Los tests asociados usaban:

```python
# MAL — la aserción pasa aunque el contrato esté invertido
with pytest.raises(BusinessLogicError) as exc_info:
    await service.crear_hallazgo_acto(data)
assert "HALLAZGO_ACTO_MISMATCH" in str(exc_info.value)
```

`str(BusinessLogicError(...))` retorna `self.message` (porque `super().__init__(message)` configura `__str__`). El test encuentra "HALLAZGO_ACTO_MISMATCH" en el `message` aunque ese campo debería contener la descripción, no el código. La aserción es estructuralmente débil.

## Patrón obligatorio: kwargs explícitos

### Regla 1: el llamador SIEMPRE usa kwargs

```python
# BIEN — kwargs explícitos no se pueden invertir
raise BusinessLogicError(
    message=f"El hallazgo {data.hallazgo_id} no pertenece al acto {data.acto_id}.",
    code="HALLAZGO_ACTO_MISMATCH",
)
```

Si el llamador escribe args posicionales, el linter debe alertar. Configurar Ruff con regla `B026` (estrella en kwargs) o crear un pre-commit hook que detecte el patrón:

```bash
# Detectar excepciones de dominio invocadas con primer arg posicional string corto en SCREAMING_CASE
grep -rnE "raise (BusinessLogicError|AuthorizationError|NotFoundError|ValidationV3Error|BusinessRuleError|DuplicateError)\(\s*\"[A-Z_]+\"" backend/ --include="*.py"
```

Si hay match: 99% es un bug latente de args invertidos.

### Regla 2: tests verifican `exc_info.value.code`, NO `str()`

```python
# BIEN — verifica el campo correcto del contrato
with pytest.raises(BusinessLogicError) as exc_info:
    await service.crear_hallazgo_acto(data)
assert exc_info.value.code == "HALLAZGO_ACTO_MISMATCH"
assert "no pertenece al acto" in exc_info.value.message
```

Esto fuerza al llamador a poner `code` en el campo `code`. Si está invertido, el test falla con `assert "El hallazgo X no pertenece..." == "HALLAZGO_ACTO_MISMATCH"`.

### Regla 3: la firma de la excepción enfuerza kwargs-only (Python 3.8+)

Para excepciones nuevas, usar el separador `*` para forzar kwargs:

```python
class BusinessLogicError(Exception):
    def __init__(
        self,
        *,                              # ← obliga kwargs en TODOS los args posteriores
        message: str,
        code: str,
        field: str | None = None,
    ):
        super().__init__(message)
        self.message = message
        self.code = code
        self.field = field
```

Con esta firma, `BusinessLogicError("CODE", "msg")` falla en compile-time con `TypeError: __init__() takes 1 positional argument but 3 were given`. El linter detecta el bug ANTES de que llegue a runtime.

**Cuándo NO retrofitear `*` a una excepción existente**: si la excepción ya tiene 100+ sitios de invocación legacy con args posicionales, el retrofit es un breaking change. Estrategia: documentar la regla en CLAUDE.md, agregar el linter rule a CI, migrar incrementalmente, y aplicar `*` solo cuando todos los sitios estén migrados.

## Jerarquía de excepciones de dominio recomendada

```python
# core/exceptions.py
class DomainError(Exception):
    """Base de toda excepción de dominio. NO usar directamente, usar subclases."""

    def __init__(
        self,
        *,
        message: str,
        code: str,
        field: str | None = None,
        http_status: int = 400,
    ):
        super().__init__(message)
        self.message = message
        self.code = code
        self.field = field
        self.http_status = http_status

class ValidationV3Error(DomainError):
    """Input inválido — HTTP 422."""

    def __init__(self, *, message: str, code: str, field: str | None = None):
        super().__init__(message=message, code=code, field=field, http_status=422)

class NotFoundError(DomainError):
    """Recurso no encontrado — HTTP 404."""

    def __init__(self, *, message: str, code: str):
        super().__init__(message=message, code=code, http_status=404)

class AuthorizationError(DomainError):
    """Sin permiso para la acción — HTTP 403."""

    def __init__(self, *, message: str, code: str):
        super().__init__(message=message, code=code, http_status=403)

class BusinessLogicError(DomainError):
    """Reglas de negocio violadas — HTTP 422."""

    def __init__(self, *, message: str, code: str, field: str | None = None):
        super().__init__(message=message, code=code, field=field, http_status=422)

class DuplicateError(DomainError):
    """Recurso ya existe — HTTP 409."""

    def __init__(self, *, message: str, code: str, field: str | None = None):
        super().__init__(message=message, code=code, field=field, http_status=409)
```

El handler global de FastAPI mapea cada excepción al código HTTP:

```python
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    return JSONResponse(
        status_code=exc.http_status,
        content={
            "error": {
                "code": exc.code,
                "message": exc.message,
                "field": exc.field,
            }
        },
    )
```

## Anti-patrones

- **`raise DomainError("CODE", "msg")` con args posicionales** — invierte el contrato si firma es `(message, code)`. Fix: kwargs explícitos.
- **`assert "CODE" in str(exc_info.value)` en tests** — pasa aunque el código esté en el campo equivocado. Fix: `exc_info.value.code == "CODE"`.
- **Heredar de `Exception` sin definir `code`** — el handler global no puede mapear a HTTP estructurado. Fix: heredar de `DomainError` con código obligatorio.
- **`detail=str(exc)` en HTTPException raised a partir de capa externa** — fuga arquitectura (paths, hosts, credenciales parciales). Fix: log internamente con detalle, devolver mensaje genérico al cliente.
- **Inventar códigos sin convención** — `code="error_1"`, `code="oops"`. Fix: `code` en SCREAMING_SNAKE_CASE con prefijo de dominio (`HALLAZGO_ACTO_MISMATCH`, `CEDULA_PRELIMINAR_INMUTABLE`).

## Gotchas / Errores comunes no obvios

- **`str(excepción)` retorna `args[0]` aunque tengas `self.message`**: `super().__init__(message)` pasa `message` como `args[0]`, y `Exception.__str__` retorna `args[0]`. Si cambias la firma sin actualizar `super().__init__(...)`, `str(exc)` muestra el valor equivocado pero `exc.message` muestra el correcto. Las dos representaciones divergen y bugs distintos se manifiestan según qué tests usen `str()` vs `.message`. Fix: documentar que `str()` SIEMPRE refleja `message` y nunca cambiar `super().__init__(...)` sin actualizar la convención.
- **Excepciones de dominio que envuelven excepciones de capa externa pierden el traceback original**: `raise BusinessLogicError(...)` dentro de un `except BotoCoreError as exc` sin `raise BusinessLogicError(...) from exc` pierde la cadena `__cause__`. Fix: SIEMPRE `raise DomainError(...) from exc` cuando se envuelve excepción externa.
- **`field` se usa para validación pero no se valida en runtime**: nada impide pasar `field="invalid_field_name"` con un nombre que NO existe en el schema. Si el frontend usa `field` para highlightear el campo del formulario, recibe nombres muertos. Fix: validar en tests que cada `field=` referencia un campo real del schema asociado. Considera generar el conjunto de fields válidos desde el schema Pydantic con `Schema.model_fields.keys()`.
- **Tests que importan la excepción del módulo equivocado**: dos módulos pueden definir `BusinessLogicError` distintos. El test importa de `auth.exceptions`, el service raise desde `ordenes.exceptions`. `pytest.raises(BusinessLogicError)` NO matchea. Fix: una sola jerarquía en `core/exceptions.py`, prohibir definir excepciones duplicadas en módulos hijos.
