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
535 lines
15 KiB
Markdown
535 lines
15 KiB
Markdown
# 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
|