# PATRÓN: MANEJO DE EXCEPCIONES

**Versión:** 1.0.0
**Fecha:** 2025-12-08
**Aplica a:** Backend (NestJS/Express)
**Prioridad:** OBLIGATORIA

---

## PROPÓSITO

Definir patrones estándar de manejo de errores para garantizar respuestas consistentes y debugging efectivo.

---

## PRINCIPIO FUNDAMENTAL

```
╔══════════════════════════════════════════════════════════════════════╗
║  EXCEPCIONES CLARAS Y CONSISTENTES                                   ║
║                                                                       ║
║  1. Usar HttpException estándar de NestJS                            ║
║  2. Mensajes claros para el usuario                                  ║
║  3. Detalles técnicos en logs (no en response)                       ║
║  4. Códigos HTTP semánticos                                          ║
╚══════════════════════════════════════════════════════════════════════╝
```

---

## 1. EXCEPCIONES HTTP ESTÁNDAR

### Matriz de Decisión

| Situación | Exception | HTTP Code | Cuándo Usar |
|-----------|-----------|-----------|-------------|
| Recurso no existe | `NotFoundException` | 404 | `findOne` retorna null |
| Ya existe (duplicado) | `ConflictException` | 409 | Violación de unique |
| Datos inválidos | `BadRequestException` | 400 | Validación de negocio falla |
| Sin autenticación | `UnauthorizedException` | 401 | Token falta o inválido |
| Sin permiso | `ForbiddenException` | 403 | Autenticado pero sin permiso |
| Método no permitido | `MethodNotAllowedException` | 405 | HTTP method incorrecto |
| Payload muy grande | `PayloadTooLargeException` | 413 | Archivo/body excede límite |
| Rate limit | `TooManyRequestsException` | 429 | Muchas peticiones |
| Error interno | `InternalServerErrorException` | 500 | Error inesperado |
| Servicio no disponible | `ServiceUnavailableException` | 503 | DB/API externa caída |

---

## 2. PATRONES POR CASO

### Not Found (404)

```typescript
// PATRÓN: Recurso no encontrado
async findOne(id: string): Promise<UserEntity> {
    const user = await this.repository.findOne({ where: { id } });

    if (!user) {
        throw new NotFoundException(`Usuario con ID ${id} no encontrado`);
    }

    return user;
}

// PATRÓN: Recurso relacionado no encontrado
async assignRole(userId: string, roleId: string): Promise<void> {
    const user = await this.userRepository.findOne({ where: { id: userId } });
    if (!user) {
        throw new NotFoundException(`Usuario ${userId} no encontrado`);
    }

    const role = await this.roleRepository.findOne({ where: { id: roleId } });
    if (!role) {
        throw new NotFoundException(`Rol ${roleId} no encontrado`);
    }

    // Proceder...
}
```

### Conflict (409)

```typescript
// PATRÓN: Duplicado por campo único
async create(dto: CreateUserDto): Promise<UserEntity> {
    const existing = await this.repository.findOne({
        where: { email: dto.email },
    });

    if (existing) {
        throw new ConflictException('El email ya está registrado');
    }

    return this.repository.save(this.repository.create(dto));
}

// PATRÓN: Duplicado con múltiples campos
async createProduct(dto: CreateProductDto): Promise<ProductEntity> {
    const existing = await this.repository.findOne({
        where: {
            sku: dto.sku,
            tenantId: dto.tenantId,
        },
    });

    if (existing) {
        throw new ConflictException(
            `Ya existe un producto con SKU ${dto.sku} en este tenant`
        );
    }

    return this.repository.save(this.repository.create(dto));
}
```

### Bad Request (400)

```typescript
// PATRÓN: Validación de negocio
async transfer(dto: TransferDto): Promise<void> {
    if (dto.fromAccountId === dto.toAccountId) {
        throw new BadRequestException(
            'La cuenta origen y destino no pueden ser iguales'
        );
    }

    const fromAccount = await this.findAccount(dto.fromAccountId);

    if (fromAccount.balance < dto.amount) {
        throw new BadRequestException('Saldo insuficiente para la transferencia');
    }

    // Proceder...
}

// PATRÓN: Estado inválido para operación
async cancelOrder(orderId: string): Promise<void> {
    const order = await this.findOne(orderId);

    if (order.status === 'delivered') {
        throw new BadRequestException(
            'No se puede cancelar un pedido ya entregado'
        );
    }

    if (order.status === 'cancelled') {
        throw new BadRequestException('El pedido ya está cancelado');
    }

    // Proceder...
}
```

### Forbidden (403)

```typescript
// PATRÓN: Sin permiso sobre recurso
async update(userId: string, dto: UpdateUserDto, currentUser: User): Promise<UserEntity> {
    const user = await this.findOne(userId);

    // Solo el usuario mismo o admin puede editar
    if (user.id !== currentUser.id && !currentUser.roles.includes('admin')) {
        throw new ForbiddenException('No tienes permiso para editar este usuario');
    }

    return this.repository.save({ ...user, ...dto });
}

// PATRÓN: Límite de plan/tenant
async createProject(dto: CreateProjectDto, tenant: Tenant): Promise<Project> {
    const projectCount = await this.repository.count({
        where: { tenantId: tenant.id },
    });

    if (projectCount >= tenant.plan.maxProjects) {
        throw new ForbiddenException(
            `Tu plan permite máximo ${tenant.plan.maxProjects} proyectos. ` +
            'Actualiza tu plan para crear más.'
        );
    }

    // Proceder...
}
```

### Unauthorized (401)

```typescript
// PATRÓN: Token inválido (en Guard)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    handleRequest(err: any, user: any, info: any) {
        if (err || !user) {
            if (info?.name === 'TokenExpiredError') {
                throw new UnauthorizedException('Tu sesión ha expirado');
            }
            if (info?.name === 'JsonWebTokenError') {
                throw new UnauthorizedException('Token inválido');
            }
            throw new UnauthorizedException('No autenticado');
        }
        return user;
    }
}

// PATRÓN: Credenciales incorrectas
async login(dto: LoginDto): Promise<TokenResponse> {
    const user = await this.userRepository.findOne({
        where: { email: dto.email },
    });

    if (!user || !(await bcrypt.compare(dto.password, user.password))) {
        throw new UnauthorizedException('Credenciales incorrectas');
    }

    // Generar token...
}
```

### Internal Server Error (500)

```typescript
// PATRÓN: Error inesperado con logging
async processPayment(dto: PaymentDto): Promise<PaymentResult> {
    try {
        const result = await this.paymentGateway.charge(dto);
        return result;
    } catch (error) {
        // Log detallado para debugging
        this.logger.error('Error procesando pago', {
            dto,
            error: error.message,
            stack: error.stack,
            gatewayResponse: error.response?.data,
        });

        // Respuesta genérica al usuario
        throw new InternalServerErrorException(
            'Error procesando el pago. Por favor intenta de nuevo.'
        );
    }
}
```

---

## 3. ESTRUCTURA DE RESPUESTA DE ERROR

### Formato Estándar

```typescript
// Respuesta de error estándar
interface ErrorResponse {
    statusCode: number;
    message: string | string[];
    error: string;
    timestamp: string;
    path: string;
}

// Ejemplo de respuesta
{
    "statusCode": 404,
    "message": "Usuario con ID abc-123 no encontrado",
    "error": "Not Found",
    "timestamp": "2024-01-15T10:30:00.000Z",
    "path": "/api/v1/users/abc-123"
}
```

### Exception Filter Global

```typescript
// filters/http-exception.filter.ts
import {
    ExceptionFilter,
    Catch,
    ArgumentsHost,
    HttpException,
    HttpStatus,
    Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(GlobalExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();

        let status = HttpStatus.INTERNAL_SERVER_ERROR;
        let message: string | string[] = 'Error interno del servidor';
        let error = 'Internal Server Error';

        if (exception instanceof HttpException) {
            status = exception.getStatus();
            const exceptionResponse = exception.getResponse();

            if (typeof exceptionResponse === 'object') {
                message = (exceptionResponse as any).message || exception.message;
                error = (exceptionResponse as any).error || exception.name;
            } else {
                message = exceptionResponse;
            }
        } else if (exception instanceof Error) {
            // Log error interno completo
            this.logger.error('Unhandled exception', {
                message: exception.message,
                stack: exception.stack,
                path: request.url,
                method: request.method,
                body: request.body,
                user: (request as any).user?.id,
            });
        }

        response.status(status).json({
            statusCode: status,
            message,
            error,
            timestamp: new Date().toISOString(),
            path: request.url,
        });
    }
}
```

---

## 4. EXCEPCIONES PERSONALIZADAS

### Cuándo Crear Excepción Custom

```typescript
// CREAR excepción custom cuando:
// 1. Necesitas información adicional estructurada
// 2. El error es específico del dominio
// 3. Quieres diferenciar en handling

// NO crear custom para errores HTTP estándar
// ❌ class UserNotFoundException extends HttpException {} // Usar NotFoundException
```

### Ejemplo Excepción Custom

```typescript
// exceptions/business.exception.ts
export class InsufficientBalanceException extends BadRequestException {
    constructor(
        public readonly currentBalance: number,
        public readonly requiredAmount: number,
    ) {
        super({
            message: `Saldo insuficiente. Tienes $${currentBalance}, necesitas $${requiredAmount}`,
            error: 'Insufficient Balance',
            currentBalance,
            requiredAmount,
            deficit: requiredAmount - currentBalance,
        });
    }
}

// Uso
if (account.balance < amount) {
    throw new InsufficientBalanceException(account.balance, amount);
}
```

### Excepción para Errores de Integración

```typescript
// exceptions/integration.exception.ts
export class PaymentGatewayException extends ServiceUnavailableException {
    constructor(
        public readonly gateway: string,
        public readonly originalError: string,
    ) {
        super({
            message: 'Error de conexión con el servicio de pagos',
            error: 'Payment Gateway Error',
            gateway,
        });
    }
}

// Uso
try {
    await stripe.charges.create(params);
} catch (error) {
    throw new PaymentGatewayException('Stripe', error.message);
}
```

---

## 5. LOGGING DE ERRORES

### Niveles de Log

```typescript
// logger.service.ts
@Injectable()
export class AppLogger {
    private readonly logger = new Logger();

    // ERROR: Errores que requieren atención
    error(message: string, context: object) {
        this.logger.error(message, { ...context, timestamp: new Date() });
    }

    // WARN: Situaciones anómalas pero manejadas
    warn(message: string, context: object) {
        this.logger.warn(message, { ...context, timestamp: new Date() });
    }

    // INFO: Eventos importantes del negocio
    info(message: string, context: object) {
        this.logger.log(message, { ...context, timestamp: new Date() });
    }

    // DEBUG: Información para desarrollo
    debug(message: string, context: object) {
        this.logger.debug(message, { ...context, timestamp: new Date() });
    }
}
```

### Qué Loggear

```typescript
// SIEMPRE loggear en ERROR:
{
    message: 'Descripción del error',
    stack: error.stack,           // Stack trace
    userId: currentUser?.id,       // Quién causó el error
    tenantId: currentUser?.tenant, // Contexto de tenant
    requestId: request.id,         // Para tracing
    path: request.url,             // Endpoint
    method: request.method,        // HTTP method
    body: sanitize(request.body),  // Body (sin passwords)
    query: request.query,          // Query params
    timestamp: new Date(),         // Cuándo
}

// NUNCA loggear:
// - Passwords
// - Tokens
// - Tarjetas de crédito
// - Datos sensibles (CURP, RFC, etc.)
```

---

## 6. DOCUMENTACIÓN SWAGGER

```typescript
// Documentar posibles errores en controller
@Post()
@ApiOperation({ summary: 'Crear usuario' })
@ApiResponse({ status: 201, description: 'Usuario creado', type: UserEntity })
@ApiResponse({ status: 400, description: 'Datos inválidos' })
@ApiResponse({ status: 409, description: 'Email ya registrado' })
@ApiResponse({ status: 401, description: 'No autenticado' })
@ApiResponse({ status: 403, description: 'Sin permisos' })
async create(@Body() dto: CreateUserDto): Promise<UserEntity> {
    return this.service.create(dto);
}
```

---

## 7. CHECKLIST DE MANEJO DE ERRORES

```
Service:
[ ] Cada método que busca por ID usa NotFoundException si no existe
[ ] Operaciones de creación verifican duplicados → ConflictException
[ ] Validaciones de negocio usan BadRequestException
[ ] Verificaciones de permisos usan ForbiddenException
[ ] Errores de integraciones externas tienen try/catch
[ ] Errores inesperados se loggean con contexto completo
[ ] Mensajes de error son claros para el usuario

Controller:
[ ] Swagger documenta posibles errores
[ ] @ApiResponse para cada código de error posible

Global:
[ ] GlobalExceptionFilter configurado
[ ] Logger configurado para errores
[ ] Errores no exponen detalles técnicos en producción
```

---

## ANTI-PATRONES

```typescript
// ❌ NUNCA: Mensaje genérico sin contexto
throw new BadRequestException('Error');

// ✅ SIEMPRE: Mensaje descriptivo
throw new BadRequestException('El email ya está registrado');

// ❌ NUNCA: Exponer stack trace en response
throw new Error(error.stack);

// ✅ SIEMPRE: Log interno, mensaje limpio al usuario
this.logger.error('Error detallado', { stack: error.stack });
throw new InternalServerErrorException('Error procesando solicitud');

// ❌ NUNCA: Catch vacío
try { ... } catch (e) { }

// ✅ SIEMPRE: Manejar o re-lanzar
try { ... } catch (e) {
    this.logger.error('Context', { error: e });
    throw new InternalServerErrorException('Mensaje usuario');
}

// ❌ NUNCA: 500 para errores de validación
throw new InternalServerErrorException('Email inválido');

// ✅ SIEMPRE: Código HTTP semántico
throw new BadRequestException('Email inválido');
```

---

**Versión:** 1.0.0 | **Sistema:** SIMCO | **Tipo:** Patrón de Excepciones
