# 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); const request = ctx.getRequest(); 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 { 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