Skip to content

Patrones Prácticos de Manejo de Errores ​

En las aplicaciones RxJS, un manejo de errores apropiado es la base para construir sistemas confiables. En este artículo presentamos 7 patrones de manejo de errores que pueden usarse inmediatamente en el trabajo práctico.

Por qué es importante el manejo de errores ​

En los streams Observable, cuando ocurre un error, el stream termina y los valores posteriores no fluyen. Si no se comprende esta característica y se descuida el manejo de errores, la aplicación puede detenerse inesperadamente o no proporcionar retroalimentación adecuada al usuario.

Los 3 objetivos del manejo de errores:

  1. Asegurar la continuidad - Hacer posible que el stream continúe después de que ocurra un error
  2. Mejorar la experiencia del usuario - Proporcionar mensajes de error apropiados y medios de recuperación
  3. Observabilidad del sistema - Detectar problemas tempranamente mediante registro y monitoreo de errores

En este artículo explicamos cómo aplicar el conocimiento fundamental aprendido en el Capítulo 6 "Manejo de Errores" a escenarios prácticos.

Estructura de este artículo ​

Manejo de Errores en Llamadas API ​

Las llamadas API son el área donde los errores ocurren con más frecuencia. Es necesario responder a varios escenarios de error como códigos de estado HTTP, errores de red y timeouts.

Procesamiento de Errores según el Estado HTTP ​

Se realiza un manejo de errores apropiado según el código de estado HTTP.

typescript
import { Observable, throwError, catchError, retry, tap } from 'rxjs';
import { ajax, AjaxError } from 'rxjs/ajax';

/**
 * Información detallada del error HTTP
 */
interface HttpErrorInfo {
  status: number;
  message: string;
  retryable: boolean;
  userMessage: string;
}

/**
 * Servicio de clasificación de errores HTTP
 */
class HttpErrorClassifier {
  /**
   * Genera información de error a partir del código de estado
   */
  classify(error: AjaxError): HttpErrorInfo {
    const status = error.status;

    // 4xx Errores de cliente
    if (status >= 400 && status < 500) {
      return this.handleClientError(status, error);
    }

    // 5xx Errores de servidor
    if (status >= 500) {
      return this.handleServerError(status, error);
    }

    // Error de red (status = 0)
    if (status === 0) {
      return {
        status: 0,
        message: 'Network error',
        retryable: true,
        userMessage: 'No se puede conectar a la red. Por favor, verifique su conexión.'
      };
    }

    // Otros errores
    return {
      status,
      message: 'Unknown error',
      retryable: false,
      userMessage: 'Ha ocurrido un error inesperado.'
    };
  }

  private handleClientError(status: number, error: AjaxError): HttpErrorInfo {
    switch (status) {
      case 400:
        return {
          status,
          message: 'Bad Request',
          retryable: false,
          userMessage: 'Hay un error en el contenido de entrada.'
        };

      case 401:
        return {
          status,
          message: 'Unauthorized',
          retryable: false,
          userMessage: 'Se requiere autenticación. Por favor, inicie sesión.'
        };

      case 403:
        return {
          status,
          message: 'Forbidden',
          retryable: false,
          userMessage: 'No tiene permisos para realizar esta operación.'
        };

      case 404:
        return {
          status,
          message: 'Not Found',
          retryable: false,
          userMessage: 'No se encontraron los datos.'
        };

      case 422:
        return {
          status,
          message: 'Unprocessable Entity',
          retryable: false,
          userMessage: 'No se pudieron procesar los datos de entrada.'
        };

      case 429:
        return {
          status,
          message: 'Too Many Requests',
          retryable: true, // Puede reintentarse (requiere tiempo de espera)
          userMessage: 'Demasiadas solicitudes. Por favor, espere un momento y vuelva a intentar.'
        };

      default:
        return {
          status,
          message: `Client Error ${status}`,
          retryable: false,
          userMessage: 'No se pudo procesar la solicitud.'
        };
    }
  }

  private handleServerError(status: number, error: AjaxError): HttpErrorInfo {
    switch (status) {
      case 500:
        return {
          status,
          message: 'Internal Server Error',
          retryable: true,
          userMessage: 'Ha ocurrido un error en el servidor. Por favor, espere un momento y vuelva a intentar.'
        };

      case 502:
      case 503:
      case 504:
        return {
          status,
          message: 'Service Unavailable',
          retryable: true,
          userMessage: 'El servidor no está disponible temporalmente. Por favor, espere un momento y vuelva a intentar.'
        };

      default:
        return {
          status,
          message: `Server Error ${status}`,
          retryable: true,
          userMessage: 'Ha ocurrido un error en el servidor.'
        };
    }
  }
}

/**
 * Servicio cliente HTTP
 */
class HttpClientService {
  private errorClassifier = new HttpErrorClassifier();

  /**
   * Solicitud GET (con manejo de errores)
   */
  get<T>(url: string): Observable<T> {
    return ajax.get<T>(url).pipe(
      tap(() => console.log(`GET ${url} - Success`)),
      catchError((error: unknown) => this.handleError(error, url))
    );
  }

  /**
   * Solicitud POST (con manejo de errores)
   */
  post<T>(url: string, body: any): Observable<T> {
    return ajax.post<T>(url, body).pipe(
      tap(() => console.log(`POST ${url} - Success`)),
      catchError((error: unknown) => this.handleError(error, url))
    );
  }

  /**
   * Manejador de errores unificado
   */
  private handleError(error: any, url: string): Observable<never> {
    console.error(`HTTP Error at ${url}:`, error);

    if (error instanceof AjaxError) {
      const errorInfo = this.errorClassifier.classify(error);

      // Registrar información de error en el log
      this.logError(errorInfo, url);

      // Lanzar error comprensible para el usuario
      return throwError(() => errorInfo);
    }

    // Errores que no son AjaxError (errores de programación, etc.)
    return throwError(() => ({
      status: -1,
      message: error.message || 'Unknown error',
      retryable: false,
      userMessage: 'Ha ocurrido un error inesperado.'
    }));
  }

  private logError(errorInfo: HttpErrorInfo, url: string): void {
    // Enviar al servicio de registro de errores (ver más adelante)
    console.error('HTTP Error:', {
      url,
      status: errorInfo.status,
      message: errorInfo.message,
      timestamp: new Date().toISOString()
    });
  }
}

Criterios de clasificación de errores HTTP

  • 4xx Errores de cliente: Normalmente no se puede reintentar (se requiere corrección de entrada)
  • 5xx Errores de servidor: Puede reintentarse (problema temporal del lado del servidor)
  • 429 Too Many Requests: Puede reintentarse pero requiere tiempo de espera
  • 0 (Error de red): Puede reintentarse (problema de conexión)

Ejemplo de uso: Procesamiento de errores por estado ​

typescript
import { Component, OnInit } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

interface User {
  id: number;
  name: string;
  email: string;
}

class UserDetailManager {
  private destroy$ = new Subject<void>();
  private httpClient = new HttpClientService();

  user: User | null = null;
  loading = false;
  errorMessage = '';
  canRetry = false;

  // Callback para actualización de UI (opcional)
  onStateChange?: (state: { user: User | null; loading: boolean; errorMessage: string; canRetry: boolean }) => void;

  init(): void {
    this.loadUser();
  }

  loadUser(): void {
    this.loading = true;
    this.errorMessage = '';
    this.canRetry = false;
    this.updateUI();

    this.httpClient.get<User>('/api/users/1')
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: user => {
          this.user = user;
          this.loading = false;
          this.updateUI();
        },
        error: (errorInfo: HttpErrorInfo) => {
          this.loading = false;
          this.errorMessage = errorInfo.userMessage;
          this.canRetry = errorInfo.retryable;
          this.updateUI();

          // En caso de error de autenticación, redirigir a pantalla de inicio de sesión
          if (errorInfo.status === 401) {
            setTimeout(() => {
              window.location.href = '/login';
            }, 2000);
          }
        }
      });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        user: this.user,
        loading: this.loading,
        errorMessage: this.errorMessage,
        canRetry: this.canRetry
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Ejemplo de uso
const userDetail = new UserDetailManager();
userDetail.onStateChange = (state) => {
  console.log('State updated:', state);
  // Escribir aquí el procesamiento de actualización de UI
};
userDetail.init();

Consideración de la experiencia del usuario

  • Los mensajes de error deben indicar qué debe hacer el usuario a continuación, no detalles técnicos
  • Para errores que se pueden reintentar, mostrar botón de reintento
  • Para errores de autenticación, guiar automáticamente a la pantalla de inicio de sesión

Procesamiento de Errores de Red ​

Los errores de red a menudo son problemas temporales, por lo que se implementa una estrategia de reintento apropiada.

typescript
import { Observable, timer, throwError, retryWhen, mergeMap, tap } from 'rxjs';
/**
 * Configuración de reintento específica para errores de red
 */
interface NetworkRetryConfig {
  maxRetries: number;
  initialDelay: number;
  maxDelay: number;
  backoffMultiplier: number;
}

/**
 * Operador de reintento de red
 */
function retryWithBackoff(config: NetworkRetryConfig) {
  const {
    maxRetries = 3,
    initialDelay = 1000,
    maxDelay = 16000,
    backoffMultiplier = 2
  } = config;

  return <T>(source: Observable<T>) => source.pipe(
    retryWhen(errors => errors.pipe(
      mergeMap((error, index) => {
        const retryAttempt = index + 1;

        // Si se supera el número máximo de reintentos, lanzar error
        if (retryAttempt > maxRetries) {
          return throwError(() => ({
            ...error,
            message: `Error de red: Falló después de ${maxRetries} reintentos`,
            userMessage: 'No se puede conectar a la red. Por favor, espere un momento y vuelva a intentar.'
          }));
        }

        // Calcular tiempo de espera con backoff exponencial
        const delay = Math.min(
          initialDelay * Math.pow(backoffMultiplier, index),
          maxDelay
        );

        console.log(
          `Reintento ${retryAttempt}/${maxRetries} - reintentando después de ${delay}ms...`
        );

        // Reintentar después del delay
        return timer(delay).pipe(
          tap(() => console.log(`Ejecutando reintento ${retryAttempt}...`))
        );
      })
    ))
  );
}

/**
 * Cliente HTTP con conciencia de red
 */
class NetworkAwareHttpClient {
  private httpClient = new HttpClientService();
  private errorClassifier = new HttpErrorClassifier();

  /**
   * Solicitud GET con soporte para errores de red
   */
  get<T>(url: string, retryConfig?: Partial<NetworkRetryConfig>): Observable<T> {
    const defaultConfig: NetworkRetryConfig = {
      maxRetries: 3,
      initialDelay: 1000,
      maxDelay: 16000,
      backoffMultiplier: 2
    };

    const config = { ...defaultConfig, ...retryConfig };

    return this.httpClient.get<T>(url).pipe(
      catchError((error: unknown) => {
        // Solo reintentar en caso de error de red (status = 0)
        if (error.status === 0) {
          return throwError(() => error);
        }
        // Para otros errores, fallar inmediatamente sin reintentar
        return throwError(() => error);
      }),
      retryWithBackoff(config)
    );
  }
}

Ejemplo de uso: Soporte offline ​

typescript
import { fromEvent, merge, map, startWith, switchMap, of } from 'rxjs';

/**
 * Gestión del estado online/offline
 */
class OnlineStatusService {
  /**
   * Observable del estado online
   */
  online$ = merge(
    fromEvent(window, 'online').pipe(map(() => true)),
    fromEvent(window, 'offline').pipe(map(() => false))
  ).pipe(
    startWith(navigator.onLine)
  );
}

/**
 * Visor de datos con soporte offline
 */
class DataViewerManager {
  private destroy$ = new Subject<void>();
  private networkClient = new NetworkAwareHttpClient();
  private onlineStatus = new OnlineStatusService();

  isOnline$ = this.onlineStatus.online$;
  data: any = null;
  loading = false;
  errorMessage = '';

  // Callback para actualización de UI (opcional)
  onStateChange?: (state: { isOnline: boolean; data: any; loading: boolean; errorMessage: string }) => void;

  init(): void {
    // Cargar datos automáticamente cuando se está online
    this.isOnline$.pipe(
      switchMap(isOnline => {
        this.updateUI(isOnline);
        if (isOnline) {
          return this.loadData();
        }
        return of(null);
      }),
      takeUntil(this.destroy$)
    ).subscribe({
      next: data => {
        if (data) {
          this.data = data;
          this.loading = false;
          this.errorMessage = '';
        }
      },
      error: error => {
        this.loading = false;
        this.errorMessage = error.userMessage;
      }
    });
  }

  private loadData(): Observable<any> {
    this.loading = true;

    return this.networkClient.get('/api/data', {
      maxRetries: 5,
      initialDelay: 2000
    });
  }

  private updateUI(isOnline: boolean): void {
    if (this.onStateChange) {
      this.onStateChange({
        isOnline,
        data: this.data,
        loading: this.loading,
        errorMessage: this.errorMessage
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Ejemplo de uso
const dataViewer = new DataViewerManager();
dataViewer.onStateChange = (state) => {
  if (!state.isOnline) {
    console.log('Está offline. Por favor, verifique su conexión a Internet.');
  }
  console.log('State updated:', state);
};
dataViewer.init();

Mejores prácticas para soporte offline

  • Monitorear el estado online y reintentar automáticamente cuando se recupere la conexión
  • Indicar claramente el estado offline al usuario para reducir la frustración
  • Combinar con caché local para proporcionar funcionalidad parcial incluso offline

Procesamiento de Errores de Timeout ​

Cuando no hay respuesta durante mucho tiempo, se genera un error de timeout y se maneja apropiadamente.

typescript
import { timeout, catchError, throwError, Observable } from 'rxjs';

/**
 * Configuración de timeout
 */
interface TimeoutConfig {
  duration: number;
  message: string;
}

/**
 * Cliente HTTP con soporte de timeout
 */
class TimeoutAwareHttpClient {
  private networkClient = new NetworkAwareHttpClient();

  /**
   * Solicitud GET con timeout
   */
  get<T>(
    url: string,
    timeoutConfig: TimeoutConfig = {
      duration: 30000,
      message: 'La solicitud ha expirado'
    }
  ): Observable<T> {
    return this.networkClient.get<T>(url).pipe(
      timeout({
        each: timeoutConfig.duration,
        with: () => throwError(() => ({
          status: -2, // Estado personalizado para indicar timeout
          message: 'Timeout',
          retryable: true,
          userMessage: timeoutConfig.message
        }))
      }),
      catchError((error: unknown) => {
        if (error.status === -2) {
          console.error(`Timeout: ${url} (${timeoutConfig.duration}ms)`);
        }
        return throwError(() => error);
      })
    );
  }

  /**
   * Configuración de timeout apropiada según el tipo de operación
   */
  getWithOperationType<T>(
    url: string,
    operationType: 'fast' | 'normal' | 'slow'
  ): Observable<T> {
    const timeoutConfigs = {
      fast: {
        duration: 5000,
        message: 'La solicitud ha expirado (5 segundos)'
      },
      normal: {
        duration: 30000,
        message: 'La solicitud ha expirado (30 segundos)'
      },
      slow: {
        duration: 60000,
        message: 'La solicitud ha expirado (60 segundos)'
      }
    };

    return this.get<T>(url, timeoutConfigs[operationType]);
  }
}

Ejemplo de uso: Timeout con retroalimentación al usuario ​

typescript
import { Subject, interval, takeUntil, map, startWith } from 'rxjs';

/**
 * Gestor que maneja operaciones que toman tiempo
 */
class SlowOperationManager {
  private destroy$ = new Subject<void>();
  private timeoutClient = new TimeoutAwareHttpClient();

  loading = false;
  elapsedTime = 0;
  errorMessage = '';
  result: any = null;

  // Callback para actualización de UI (opcional)
  onStateChange?: (state: {
    loading: boolean;
    elapsedTime: number;
    errorMessage: string;
    result: any;
  }) => void;

  startOperation(): void {
    this.loading = true;
    this.errorMessage = '';
    this.result = null;
    this.updateUI();

    // Contador de tiempo transcurrido
    const timer$ = interval(1000).pipe(
      map(count => count + 1),
      startWith(0),
      takeUntil(this.destroy$)
    );

    timer$.subscribe(elapsed => {
      this.elapsedTime = elapsed;
      this.updateUI();
    });

    // Operación que toma tiempo (timeout de 60 segundos)
    this.timeoutClient.getWithOperationType('/api/slow-operation', 'slow')
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: result => {
          this.result = result;
          this.loading = false;
          this.destroy$.next(); // Detener temporizador
          this.updateUI();
        },
        error: error => {
          this.errorMessage = error.userMessage;
          this.loading = false;
          this.destroy$.next(); // Detener temporizador

          // Si es timeout, mostrar sugerencia adicional
          if (error.status === -2) {
            this.errorMessage += ' Es posible que la carga del servidor sea alta.';
          }
          this.updateUI();
        }
      });
  }

  retryOperation(): void {
    this.startOperation();
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        loading: this.loading,
        elapsedTime: this.elapsedTime,
        errorMessage: this.errorMessage,
        result: this.result
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Ejemplo de uso
const slowOp = new SlowOperationManager();
slowOp.onStateChange = (state) => {
  if (state.loading) {
    console.log(`Procesando... (${state.elapsedTime} segundos transcurridos)`);
    if (state.elapsedTime > 10) {
      console.log('El procesamiento está tomando tiempo. Por favor, espere.');
    }
  }
  if (state.errorMessage) {
    console.error(state.errorMessage);
  }
  if (state.result) {
    console.log('Procesamiento completado:', state.result);
  }
};
slowOp.startOperation();

Configuración de valores de timeout

  • Timeout demasiado corto: Hace fallar incluso procesamiento normal
  • Timeout demasiado largo: Hace esperar al usuario durante mucho tiempo
  • Establecer valores apropiados según la naturaleza de la operación y mostrar el tiempo transcurrido al usuario

Manejo Global de Errores ​

Gestionar centralizadamente los errores que ocurren en toda la aplicación, y realizar registro y monitoreo.

typescript
import { Subject, Observable, share } from 'rxjs';
/**
 * Gravedad del error
 */
enum ErrorSeverity {
  Info = 'info',
  Warning = 'warning',
  Error = 'error',
  Critical = 'critical'
}

/**
 * Información de error de aplicación
 */
interface AppError {
  id: string;
  timestamp: Date;
  severity: ErrorSeverity;
  message: string;
  userMessage: string;
  context?: any;
  stack?: string;
}

/**
 * Servicio manejador global de errores
 */
class GlobalErrorHandler {
  private errorSubject = new Subject<AppError>();

  /**
   * Stream de todos los errores
   */
  errors$: Observable<AppError> = this.errorSubject.asObservable().pipe(
    share()
  );

  /**
   * Registrar error
   */
  handleError(
    error: any,
    severity: ErrorSeverity = ErrorSeverity.Error,
    context?: any
  ): void {
    const appError: AppError = {
      id: this.generateErrorId(),
      timestamp: new Date(),
      severity,
      message: error.message || 'Unknown error',
      userMessage: error.userMessage || 'Ha ocurrido un error',
      context,
      stack: error.stack
    };

    // Fluir el error al stream
    this.errorSubject.next(appError);

    // Salida de log a consola
    this.logToConsole(appError);

    // Si es un error grave, enviar al servicio de registro externo
    if (severity === ErrorSeverity.Critical || severity === ErrorSeverity.Error) {
      this.sendToLogService(appError);
    }
  }

  private generateErrorId(): string {
    return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  }

  private logToConsole(error: AppError): void {
    const style = this.getConsoleStyle(error.severity);
    console.error(
      `%c[${error.severity.toUpperCase()}] ${error.message}`,
      style,
      {
        id: error.id,
        timestamp: error.timestamp.toISOString(),
        context: error.context,
        stack: error.stack
      }
    );
  }

  private getConsoleStyle(severity: ErrorSeverity): string {
    const styles = {
      [ErrorSeverity.Info]: 'color: #3498db',
      [ErrorSeverity.Warning]: 'color: #f39c12',
      [ErrorSeverity.Error]: 'color: #e74c3c',
      [ErrorSeverity.Critical]: 'color: #fff; background: #c0392b; font-weight: bold'
    };
    return styles[severity];
  }

  private sendToLogService(error: AppError): void {
    // Enviar a servicio de registro externo (Sentry, LogRocket, etc.)
    // Ejemplo de implementación:
    // Sentry.captureException(error);

    console.log('Enviando error al servicio de registro externo:', error.id);
  }
}

/**
 * Servicio de notificación de errores
 */
class ErrorNotificationService {
  private notificationSubject = new Subject<{
    message: string;
    type: 'info' | 'warning' | 'error'
  }>();

  notifications$ = this.notificationSubject.asObservable();

  constructor(private globalErrorHandler: GlobalErrorHandler) {
    // Suscribirse a errores globales y mostrar notificaciones
    this.globalErrorHandler.errors$.subscribe(error => {
      this.showNotification(error.userMessage, this.mapSeverityToType(error.severity));
    });
  }

  showNotification(
    message: string,
    type: 'info' | 'warning' | 'error' = 'info'
  ): void {
    this.notificationSubject.next({ message, type });
  }

  private mapSeverityToType(severity: ErrorSeverity): 'info' | 'warning' | 'error' {
    if (severity === ErrorSeverity.Info) return 'info';
    if (severity === ErrorSeverity.Warning) return 'warning';
    return 'error';
  }
}

Ejemplo de uso: Gestión de errores en toda la aplicación ​

typescript
import { Subject, Observable, of, switchMap, delay, startWith, catchError, throwError } from 'rxjs';

/**
 * Gestor que realiza la gestión de errores de toda la aplicación
 */
class AppErrorManager {
  private destroy$ = new Subject<void>();
  currentNotification$: Observable<any>;

  constructor(
    private errorNotificationService: ErrorNotificationService,
    private globalErrorHandler: GlobalErrorHandler
  ) {
    this.currentNotification$ = this.errorNotificationService.notifications$.pipe(
      switchMap(notification =>
        // Desaparece automáticamente después de 5 segundos
        of(notification).pipe(
          delay(5000),
          startWith(notification)
        )
      )
    );
  }

  init(): void {
    // Capturar Promise rejections no manejados
    window.addEventListener('unhandledrejection', event => {
      this.globalErrorHandler.handleError(
        { message: event.reason, userMessage: 'Ha ocurrido un error inesperado' },
        ErrorSeverity.Error,
        { type: 'unhandledRejection' }
      );
    });

    // Capturar errores de JavaScript
    window.addEventListener('error', event => {
      this.globalErrorHandler.handleError(
        { message: event.message, stack: event.error?.stack },
        ErrorSeverity.Critical,
        { filename: event.filename, lineno: event.lineno }
      );
    });

    // Suscribirse a notificaciones y actualizar UI
    this.currentNotification$.subscribe(notification => {
      if (notification) {
        console.log(`[${notification.type}] ${notification.message}`);
      }
    });
  }

  dismissNotification(): void {
    // Cerrar notificación (implementación simplificada)
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

/**
 * Servicio que gestiona datos de usuario
 */
class UserService {
  constructor(
    private httpClient: HttpClientService,
    private globalErrorHandler: GlobalErrorHandler
  ) {}

  loadUser(userId: number): Observable<User> {
    return this.httpClient.get<User>(`/api/users/${userId}`).pipe(
      catchError((error: unknown) => {
        // Registrar en manejador global de errores
        this.globalErrorHandler.handleError(
          error,
          ErrorSeverity.Error,
          { userId, operation: 'loadUser' }
        );

        // Relanzar error (para que el llamador también pueda procesarlo)
        return throwError(() => error);
      })
    );
  }
}

// Ejemplo de uso
const globalErrorHandler = new GlobalErrorHandler();
const errorNotificationService = new ErrorNotificationService(globalErrorHandler);
const appErrorManager = new AppErrorManager(errorNotificationService, globalErrorHandler);
appErrorManager.init();

const userService = new UserService(new HttpClientService(), globalErrorHandler);

Ventajas del manejo global de errores

  • Gestión centralizada: Registrar y monitorear todos los errores en un solo lugar
  • Mejora de la observabilidad: Suscribirse al stream de errores para crear estadísticas y dashboards
  • Unificación de la experiencia del usuario: UI de notificación de errores consistente
  • Facilita la depuración: Registrar ID de error, contexto y stack trace

Implementación de Estrategias de Reintento ​

Implementar estrategias de reintento flexibles según el tipo y situación del error.

typescript
import { Observable, throwError, timer, range, retryWhen, mergeMap, tap, finalize } from 'rxjs';
/**
 * Tipos de estrategias de reintento
 */
enum RetryStrategy {
  Immediate = 'immediate',      // Reintentar inmediatamente
  FixedDelay = 'fixed',        // Intervalo fijo
  ExponentialBackoff = 'exponential', // Backoff exponencial
  LinearBackoff = 'linear'     // Backoff lineal
}

/**
 * Configuración de reintento
 */
interface RetryConfig {
  strategy: RetryStrategy;
  maxRetries: number;
  initialDelay?: number;
  maxDelay?: number;
  shouldRetry?: (error: any) => boolean;
}

/**
 * Operador de reintento avanzado
 */
function advancedRetry(config: RetryConfig) {
  const {
    strategy,
    maxRetries,
    initialDelay = 1000,
    maxDelay = 32000,
    shouldRetry = () => true
  } = config;

  return <T>(source: Observable<T>) => source.pipe(
    retryWhen(errors => errors.pipe(
      mergeMap((error, index) => {
        const retryAttempt = index + 1;

        // Verificar si se puede reintentar
        if (!shouldRetry(error)) {
          console.log('Error no reintentar:', error.message);
          return throwError(() => error);
        }

        // Verificar número máximo de reintentos
        if (retryAttempt > maxRetries) {
          console.error(`Fallo de reintento: Falló después de ${maxRetries} intentos`);
          return throwError(() => ({
            ...error,
            message: `${error.message} (Falló después de ${maxRetries} reintentos)`,
            retriesExhausted: true
          }));
        }

        // Calcular tiempo de espera según estrategia de reintento
        const delay = calculateDelay(strategy, index, initialDelay, maxDelay);

        console.log(
          `Estrategia de reintento: ${strategy} | ` +
          `Intento ${retryAttempt}/${maxRetries} | ` +
          `reintentando después de ${delay}ms...`
        );

        return timer(delay);
      })
    ))
  );
}

/**
 * Cálculo de tiempo de espera por estrategia
 */
function calculateDelay(
  strategy: RetryStrategy,
  attemptIndex: number,
  initialDelay: number,
  maxDelay: number
): number {
  switch (strategy) {
    case RetryStrategy.Immediate:
      return 0;

    case RetryStrategy.FixedDelay:
      return initialDelay;

    case RetryStrategy.ExponentialBackoff:
      return Math.min(
        initialDelay * Math.pow(2, attemptIndex),
        maxDelay
      );

    case RetryStrategy.LinearBackoff:
      return Math.min(
        initialDelay * (attemptIndex + 1),
        maxDelay
      );

    default:
      return initialDelay;
  }
}

/**
 * Ejemplo de reintento condicional
 */
class SmartRetryHttpClient {
  private httpClient = new HttpClientService();

  /**
   * Reintento según el tipo de error
   */
  getWithSmartRetry<T>(url: string): Observable<T> {
    return this.httpClient.get<T>(url).pipe(
      advancedRetry({
        strategy: RetryStrategy.ExponentialBackoff,
        maxRetries: 3,
        initialDelay: 1000,
        maxDelay: 16000,
        shouldRetry: (error) => {
          // Determinar si se debe reintentar el error

          // Error de red -> Reintentar
          if (error.status === 0) return true;

          // 5xx Error de servidor -> Reintentar
          if (error.status >= 500) return true;

          // 429 Too Many Requests -> Reintentar
          if (error.status === 429) return true;

          // 4xx Error de cliente -> No reintentar
          if (error.status >= 400 && error.status < 500) return false;

          // Otros -> No reintentar
          return false;
        }
      }),
      tap({
        next: () => console.log('Solicitud exitosa'),
        error: error => {
          if (error.retriesExhausted) {
            console.error('Todos los reintentos fallaron');
          }
        }
      })
    );
  }
}

Ejemplo de uso: Comparación de reintentos por estrategia ​

typescript
import { Observable, finalize } from 'rxjs';

/**
 * Gestor para probar estrategias de reintento
 */
class RetryTestManager {
  strategies = [
    { type: RetryStrategy.Immediate, name: 'Reintentar inmediatamente' },
    { type: RetryStrategy.FixedDelay, name: 'Reintento a intervalo fijo' },
    { type: RetryStrategy.ExponentialBackoff, name: 'Backoff exponencial' },
    { type: RetryStrategy.LinearBackoff, name: 'Backoff lineal' }
  ];

  log = '';

  // Callback para actualización de log (opcional)
  onLogUpdate?: (log: string) => void;

  testStrategy(strategy: RetryStrategy): void {
    this.log = `Iniciando prueba de estrategia ${strategy}...\n`;
    this.updateLog();

    // Observable que siempre falla (para prueba)
    const failingRequest$ = new Observable(subscriber => {
      this.log += `Ejecutando solicitud...\n`;
      this.updateLog();
      setTimeout(() => {
        subscriber.error({ status: 500, message: 'Test error' });
      }, 100);
    });

    const startTime = Date.now();

    failingRequest$.pipe(
      advancedRetry({
        strategy,
        maxRetries: 3,
        initialDelay: 1000,
        maxDelay: 8000
      }),
      finalize(() => {
        const elapsed = Date.now() - startTime;
        this.log += `Completado (tiempo transcurrido: ${elapsed}ms)\n`;
        this.updateLog();
      })
    ).subscribe({
      error: () => {
        this.log += 'Todos los reintentos fallaron\n';
        this.updateLog();
      }
    });
  }

  private updateLog(): void {
    if (this.onLogUpdate) {
      this.onLogUpdate(this.log);
    }
  }
}

// Ejemplo de uso
const retryTest = new RetryTestManager();
retryTest.onLogUpdate = (log) => {
  console.log('Log:');
  console.log(log);
};
retryTest.testStrategy(RetryStrategy.ExponentialBackoff);

Guía de selección de estrategia de reintento

  • Immediate: Entorno de prueba o contención de bloqueo temporal
  • FixedDelay: Limitación de tasa (429) o carga del servidor
  • ExponentialBackoff: Error de red o error temporal del servidor (recomendado)
  • LinearBackoff: Cuando se espera recuperación gradual

Diseño de Límites de Error ​

Aislar errores a nivel de componente o módulo para que las fallas parciales no se propaguen a toda la aplicación.

typescript
import { Subject } from 'rxjs';

/**
 * Gestor de límites de error
 *
 * Captura errores a nivel de sección y
 * realiza procesamiento de fallback
 */
class ErrorBoundaryManager {
  fallbackTitle = 'Ha ocurrido un error';
  fallbackMessage = 'No se pudo cargar esta sección. Por favor, vuelva a intentar.';

  hasError = false;
  private destroy$ = new Subject<void>();
  private globalErrorHandler: GlobalErrorHandler;

  // Callback para actualización de UI (opcional)
  onStateChange?: (state: { hasError: boolean; fallbackTitle: string; fallbackMessage: string }) => void;
  onErrorOccurred?: (error: any) => void;

  constructor(globalErrorHandler: GlobalErrorHandler, options?: { fallbackTitle?: string; fallbackMessage?: string }) {
    this.globalErrorHandler = globalErrorHandler;
    if (options?.fallbackTitle) this.fallbackTitle = options.fallbackTitle;
    if (options?.fallbackMessage) this.fallbackMessage = options.fallbackMessage;
  }

  /**
   * Capturar error
   */
  catchError(error: any, context?: any): void {
    this.hasError = true;

    // Registrar en manejador global de errores
    this.globalErrorHandler.handleError(
      error,
      ErrorSeverity.Warning,
      { ...context, boundaryLocation: 'ErrorBoundaryManager' }
    );

    // Notificar al padre
    if (this.onErrorOccurred) {
      this.onErrorOccurred(error);
    }

    this.updateUI();
  }

  /**
   * Restablecer estado de error
   */
  retry(): void {
    this.hasError = false;
    this.updateUI();
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        hasError: this.hasError,
        fallbackTitle: this.fallbackTitle,
        fallbackMessage: this.fallbackMessage
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

/**
 * Gestor que maneja secciones del dashboard
 */
class DashboardManager {
  private userBoundary: ErrorBoundaryManager;
  private statisticsBoundary: ErrorBoundaryManager;
  private notificationsBoundary: ErrorBoundaryManager;

  constructor(globalErrorHandler: GlobalErrorHandler) {
    this.userBoundary = new ErrorBoundaryManager(
      globalErrorHandler,
      { fallbackTitle: 'No se puede cargar información de usuario' }
    );
    this.statisticsBoundary = new ErrorBoundaryManager(
      globalErrorHandler,
      { fallbackTitle: 'No se puede cargar información estadística' }
    );
    this.notificationsBoundary = new ErrorBoundaryManager(
      globalErrorHandler,
      { fallbackTitle: 'No se pueden cargar notificaciones' }
    );

    // Manejo de errores de sección de usuario
    this.userBoundary.onErrorOccurred = (error) => {
      console.log('Ocurrió un error en la sección de usuario:', error);
      // Procesar según sea necesario, como cargar datos alternativos
    };
  }

  getUserBoundary(): ErrorBoundaryManager {
    return this.userBoundary;
  }

  getStatisticsBoundary(): ErrorBoundaryManager {
    return this.statisticsBoundary;
  }

  getNotificationsBoundary(): ErrorBoundaryManager {
    return this.notificationsBoundary;
  }
}

/**
 * Gestor que maneja información de usuario
 */
class UserInfoManager {
  private httpClient = new HttpClientService();
  private errorBoundary?: ErrorBoundaryManager;

  user: User | null = null;
  loading = false;

  // Callback para actualización de UI (opcional)
  onStateChange?: (state: { user: User | null; loading: boolean }) => void;

  constructor(errorBoundary?: ErrorBoundaryManager) {
    this.errorBoundary = errorBoundary;
  }

  init(): void {
    this.loadUser();
  }

  loadUser(): void {
    this.loading = true;
    this.updateUI();

    this.httpClient.get<User>('/api/user/me').subscribe({
      next: user => {
        this.user = user;
        this.loading = false;
        this.updateUI();
      },
      error: error => {
        this.loading = false;
        this.updateUI();

        // Propagar error al límite de error
        if (this.errorBoundary) {
          this.errorBoundary.catchError(error, {
            component: 'UserInfoManager',
            operation: 'loadUser'
          });
        }
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        user: this.user,
        loading: this.loading
      });
    }
  }
}

// Ejemplo de uso
const globalErrorHandler = new GlobalErrorHandler();
const dashboard = new DashboardManager(globalErrorHandler);

const userInfo = new UserInfoManager(dashboard.getUserBoundary());
userInfo.onStateChange = (state) => {
  if (state.loading) {
    console.log('Cargando...');
  } else if (state.user) {
    console.log(`Usuario: ${state.user.name} (${state.user.email})`);
  }
};
userInfo.init();

Beneficios de los límites de error

  • Localización de fallas: El error en una sección no afecta otras secciones
  • Mejora de la experiencia del usuario: Proporcionar UI parcialmente funcional
  • Facilita la depuración: La ubicación de ocurrencia del error es clara
  • Recuperación gradual: Posibilidad de reintentar por sección

Resumen ​

En este artículo hemos explicado 7 patrones prácticos de manejo de errores en aplicaciones RxJS.

Puntos importantes ​

Principios del manejo de errores

  1. Predecir errores: Identificar de antemano escenarios de error posibles
  2. Clasificar apropiadamente: Implementar procesamiento según el tipo de error
  3. Considerar al usuario: Indicar la siguiente acción, no detalles técnicos
  4. Asegurar observabilidad: Registrar todos los errores en el log
  5. Establecer límites: Limitar el alcance del impacto del error

Lista de verificación de aplicación de patrones ​

  • [ ] Clasificar errores HTTP por código de estado
  • [ ] Implementar estrategia de reintento apropiada para errores de red
  • [ ] Establecer valor de timeout según la naturaleza de la operación
  • [ ] Gestión centralizada con manejador global de errores
  • [ ] Distinguir entre errores que se pueden reintentar y los que no
  • [ ] Aislar fallas a nivel de componente con límites de error
  • [ ] Mostrar mensajes de error comprensibles para el usuario

Secciones relacionadas ​

Código de Prueba ​

Ejemplos de código de prueba para verificar el comportamiento del manejo de errores.

typescript
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';

describe('HttpErrorClassifier', () => {
  let classifier: HttpErrorClassifier;

  beforeEach(() => {
    classifier = new HttpErrorClassifier();
  });

  it('should classify 404 as client error', () => {
    const error = { status: 404 } as AjaxError;
    const result = classifier.classify(error);

    expect(result.status).toBe(404);
    expect(result.retryable).toBe(false);
    expect(result.userMessage).toContain('encontraron');
  });

  it('should classify 500 as retryable server error', () => {
    const error = { status: 500 } as AjaxError;
    const result = classifier.classify(error);

    expect(result.status).toBe(500);
    expect(result.retryable).toBe(true);
    expect(result.userMessage).toContain('servidor');
  });

  it('should classify network error (status 0) as retryable', () => {
    const error = { status: 0 } as AjaxError;
    const result = classifier.classify(error);

    expect(result.status).toBe(0);
    expect(result.retryable).toBe(true);
    expect(result.userMessage).toContain('red');
  });
});

describe('SmartRetryHttpClient', () => {
  let client: SmartRetryHttpClient;
  let httpClientSpy: jasmine.SpyObj<HttpClientService>;

  beforeEach(() => {
    httpClientSpy = jasmine.createSpyObj('HttpClientService', ['get']);
    client = new SmartRetryHttpClient();
    (client as any).httpClient = httpClientSpy;
  });

  it('should retry on 500 error', (done) => {
    let attempts = 0;
    httpClientSpy.get.and.callFake(() => {
      attempts++;
      if (attempts < 3) {
        return throwError(() => ({ status: 500 }));
      }
      return of({ data: 'success' });
    });

    client.getWithSmartRetry('/api/test').subscribe({
      next: result => {
        expect(attempts).toBe(3);
        expect(result).toEqual({ data: 'success' });
        done();
      }
    });
  });

  it('should not retry on 404 error', (done) => {
    let attempts = 0;
    httpClientSpy.get.and.callFake(() => {
      attempts++;
      return throwError(() => ({ status: 404 }));
    });

    client.getWithSmartRetry('/api/test').subscribe({
      error: () => {
        expect(attempts).toBe(1);
        done();
      }
    });
  });
});

describe('GlobalErrorHandler', () => {
  let handler: GlobalErrorHandler;
  let receivedErrors: AppError[];

  beforeEach(() => {
    handler = new GlobalErrorHandler();
    receivedErrors = [];

    handler.errors$.subscribe(error => {
      receivedErrors.push(error);
    });
  });

  it('should emit error to stream', () => {
    handler.handleError(
      { message: 'Test error' },
      ErrorSeverity.Error
    );

    expect(receivedErrors.length).toBe(1);
    expect(receivedErrors[0].message).toBe('Test error');
    expect(receivedErrors[0].severity).toBe(ErrorSeverity.Error);
  });

  it('should generate unique error IDs', () => {
    handler.handleError({ message: 'Error 1' });
    handler.handleError({ message: 'Error 2' });

    expect(receivedErrors[0].id).not.toBe(receivedErrors[1].id);
  });
});

Recursos de Referencia ​

Recursos para aprender más profundamente sobre el manejo de errores.

Publicado bajo licencia CC-BY-4.0.