workspace-v1/orchestration/patrones/PATRON-EXCEPTION-HANDLING.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

15 KiB

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)

// 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)

// 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)

// 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)

// 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)

// 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)

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// ❌ 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