Retry y timeout patterns

Avanzado
Angular
Angular
Actualizado: 25/09/2025

Retry automático con exponential backoff

Los errores de red son inevitables en aplicaciones web modernas. La conectividad puede fallar momentáneamente, los servidores pueden estar sobrecargados temporalmente, o pueden ocurrir errores transitorios que se resuelven por sí mismos. El patrón de retry automático con exponential backoff nos permite manejar estos casos de forma elegante, reintentando las peticiones fallidas con intervalos progresivamente mayores.

Fundamentos del retry pattern

El retry automático consiste en volver a ejecutar una petición HTTP cuando falla, pero no de forma inmediata. El exponential backoff añade una estrategia inteligente: cada reintento espera el doble de tiempo que el anterior, evitando sobrecargar un servidor que ya puede estar en problemas.

RxJS proporciona varios operadores para implementar retry logic. El operador retry() es el más básico, pero retryWhen() nos da control total sobre cuándo y cómo reintentar:

import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { retry, retryWhen, delay, take, concat, throwError } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  private http = inject(HttpClient);

  // Retry básico: 3 intentos inmediatos
  getData() {
    return this.http.get('/api/data')
      .pipe(retry(3));
  }

  // Retry con delay fijo
  getDataWithDelay() {
    return this.http.get('/api/data')
      .pipe(
        retryWhen(errors => 
          errors.pipe(
            delay(1000),
            take(3)
          )
        )
      );
  }
}

Implementando exponential backoff

El exponential backoff incrementa progresivamente el tiempo entre reintentos. Una implementación típica duplica el delay en cada intento:

import { timer, switchMap, finalize } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ResilientDataService {
  private http = inject(HttpClient);

  getDataWithBackoff() {
    return this.http.get('/api/data').pipe(
      retryWhen(errors => 
        errors.pipe(
          // Enumerar los intentos para calcular el delay
          scan((retryCount, error) => {
            // Solo reintentar errores de servidor (5xx) o red
            if (retryCount >= 3 || !this.shouldRetry(error)) {
              throw error;
            }
            return retryCount + 1;
          }, 0),
          // Calcular delay exponencial: 1s, 2s, 4s
          switchMap(retryCount => 
            timer(Math.pow(2, retryCount) * 1000)
          )
        )
      )
    );
  }

  private shouldRetry(error: any): boolean {
    // Reintentar solo errores 5xx o errores de red
    return error.status >= 500 || error.status === 0;
  }
}

Interceptor para retry global

Para centralizar la lógica de retry en toda la aplicación, podemos crear un interceptor funcional que aplique exponential backoff automáticamente:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { retryWhen, scan, switchMap, timer, throwError } from 'rxjs';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  const maxRetries = 3;
  const baseDelay = 1000;
  
  return next(req).pipe(
    retryWhen(errors => 
      errors.pipe(
        scan((retryCount, error) => {
          console.log(`Intento ${retryCount + 1} fallido:`, error.message);
          
          if (retryCount >= maxRetries || !isRetriableError(error)) {
            throw error;
          }
          
          return retryCount + 1;
        }, 0),
        switchMap(retryCount => {
          const delay = baseDelay * Math.pow(2, retryCount);
          console.log(`Reintentando en ${delay}ms...`);
          return timer(delay);
        })
      )
    )
  );
};

function isRetriableError(error: any): boolean {
  // Reintentar errores de servidor o problemas de conectividad
  return error.status >= 500 || 
         error.status === 0 || 
         error.name === 'TimeoutError';
}

Configuración del interceptor

Para aplicar el retry interceptor globalmente, lo registramos en la configuración de provideHttpClient:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { retryInterceptor } from './app/interceptors/retry.interceptor';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([retryInterceptor])
    )
  ]
});

Retry selectivo por endpoint

En algunos casos necesitamos diferentes estrategias según el endpoint. Podemos hacer el retry más inteligente analizando la URL o agregando headers específicos:

export const smartRetryInterceptor: HttpInterceptorFn = (req, next) => {
  // Configuración específica por endpoint
  const retryConfig = getRetryConfig(req.url);
  
  return next(req).pipe(
    retryWhen(errors => 
      errors.pipe(
        scan((retryCount, error) => {
          if (retryCount >= retryConfig.maxRetries || 
              !isRetriableError(error)) {
            throw error;
          }
          return retryCount + 1;
        }, 0),
        switchMap(retryCount => {
          const delay = retryConfig.baseDelay * 
                       Math.pow(retryConfig.backoffFactor, retryCount);
          return timer(delay);
        })
      )
    )
  );
};

function getRetryConfig(url: string) {
  if (url.includes('/api/critical')) {
    return { maxRetries: 5, baseDelay: 500, backoffFactor: 1.5 };
  }
  
  if (url.includes('/api/analytics')) {
    return { maxRetries: 1, baseDelay: 2000, backoffFactor: 2 };
  }
  
  return { maxRetries: 3, baseDelay: 1000, backoffFactor: 2 };
}

Integración con signals

Para aplicaciones que usan signals, podemos crear un servicio que exponga el estado de los reintentos de forma reactiva:

@Injectable({
  providedIn: 'root'
})
export class RetryStatusService {
  private retryCount = signal(0);
  private isRetrying = signal(false);
  private lastError = signal<string | null>(null);

  readonly retryInfo = computed(() => ({
    count: this.retryCount(),
    isRetrying: this.isRetrying(),
    lastError: this.lastError()
  }));

  updateRetryStatus(count: number, error?: any) {
    this.retryCount.set(count);
    this.isRetrying.set(count > 0);
    this.lastError.set(error?.message || null);
  }

  resetRetryStatus() {
    this.retryCount.set(0);
    this.isRetrying.set(false);
    this.lastError.set(null);
  }
}

El patrón de retry con exponential backoff es esencial para construir aplicaciones resilientes. Permite que nuestras aplicaciones se recuperen automáticamente de errores transitorios sin abrumar los servidores, mejorando significativamente la experiencia del usuario en entornos de red inestables.

Timeout y manejo de errores HTTP

Los timeouts protegen nuestras aplicaciones de peticiones que se cuelgan indefinidamente, mientras que el manejo adecuado de errores HTTP permite ofrecer una experiencia de usuario consistente y profesional. Angular proporciona múltiples niveles de configuración para timeout y herramientas para manejar diferentes tipos de errores de forma centralizada.

Configuración de timeout global

Para establecer un timeout global que aplique a todas las peticiones HTTP, podemos configurarlo directamente en provideHttpClient():

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withRequestsMadeViaParent } from '@angular/common/http';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    // Configuración global de timeout (no directamente disponible)
    // Se debe hacer mediante interceptor
  ]
});

Como Angular no tiene configuración global nativa de timeout, implementamos un interceptor de timeout que aplique a todas las peticiones:

import { HttpInterceptorFn } from '@angular/common/http';
import { timeout, catchError, throwError } from 'rxjs';

export const timeoutInterceptor: HttpInterceptorFn = (req, next) => {
  const timeoutValue = req.headers.get('X-Timeout') 
    ? parseInt(req.headers.get('X-Timeout')!) 
    : 30000; // 30 segundos por defecto

  return next(req).pipe(
    timeout(timeoutValue),
    catchError(error => {
      if (error.name === 'TimeoutError') {
        console.error(`Request timeout después de ${timeoutValue}ms:`, req.url);
        return throwError(() => ({
          ...error,
          message: 'La petición ha tardado demasiado en responder',
          isTimeout: true
        }));
      }
      return throwError(() => error);
    })
  );
};

Timeout por petición específica

Para casos específicos donde necesitamos timeouts diferentes, podemos configurarlos a nivel de servicio o petición individual:

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  private http = inject(HttpClient);

  // Timeout específico usando RxJS
  getDataWithTimeout() {
    return this.http.get('/api/data').pipe(
      timeout(10000), // 10 segundos
      catchError(error => {
        if (error.name === 'TimeoutError') {
          return throwError(() => new Error('Datos no disponibles temporalmente'));
        }
        return throwError(() => error);
      })
    );
  }

  // Timeout usando headers personalizados
  getCriticalData() {
    const headers = { 'X-Timeout': '5000' };
    return this.http.get('/api/critical', { headers });
  }

  // Timeout con fallback automático
  getDataWithFallback() {
    return this.http.get('/api/primary').pipe(
      timeout(3000),
      catchError(error => {
        if (error.name === 'TimeoutError') {
          console.warn('Timeout en endpoint principal, usando fallback');
          return this.http.get('/api/fallback');
        }
        return throwError(() => error);
      })
    );
  }
}

Interceptor para manejo centralizado de errores

Un interceptor de manejo de errores nos permite centralizar toda la lógica de error handling y logging:

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';

export const errorHandlerInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      // Logging centralizado
      logError(error, req.url);

      // Manejo por tipo de error
      const handledError = handleHttpError(error);
      
      return throwError(() => handledError);
    })
  );
};

function logError(error: HttpErrorResponse, url: string) {
  const errorInfo = {
    url,
    status: error.status,
    message: error.message,
    timestamp: new Date().toISOString()
  };

  if (error.status >= 500) {
    console.error('Error de servidor:', errorInfo);
  } else if (error.status >= 400) {
    console.warn('Error de cliente:', errorInfo);
  } else {
    console.info('Error de red:', errorInfo);
  }
}

function handleHttpError(error: HttpErrorResponse) {
  switch (error.status) {
    case 0:
      return {
        ...error,
        userMessage: 'Sin conexión a internet',
        type: 'network'
      };
    
    case 401:
      return {
        ...error,
        userMessage: 'Sesión expirada',
        type: 'auth'
      };
    
    case 403:
      return {
        ...error,
        userMessage: 'Sin permisos para esta operación',
        type: 'permission'
      };
    
    case 404:
      return {
        ...error,
        userMessage: 'Recurso no encontrado',
        type: 'notfound'
      };
    
    case 429:
      return {
        ...error,
        userMessage: 'Demasiadas peticiones, intenta más tarde',
        type: 'ratelimit'
      };
    
    default:
      return {
        ...error,
        userMessage: error.status >= 500 
          ? 'Error del servidor, intenta más tarde'
          : 'Error inesperado',
        type: 'unknown'
      };
  }
}

Servicio de fallback responses

Para mejorar la experiencia de usuario, podemos implementar respuestas de fallback cuando las peticiones principales fallan:

@Injectable({
  providedIn: 'root'
})
export class FallbackService {
  private http = inject(HttpClient);
  private fallbackData = new Map<string, any>();

  getDataWithFallback<T>(url: string, fallbackValue?: T) {
    return this.http.get<T>(url).pipe(
      // Guardar respuesta exitosa como fallback
      tap(response => {
        this.fallbackData.set(url, response);
      }),
      catchError(error => {
        console.warn(`Error en ${url}, usando fallback:`, error.message);
        
        // Intentar usar datos en caché
        const cached = this.fallbackData.get(url);
        if (cached) {
          return of(cached);
        }
        
        // Usar valor de fallback proporcionado
        if (fallbackValue !== undefined) {
          return of(fallbackValue);
        }
        
        // Sin fallback disponible
        return throwError(() => error);
      })
    );
  }

  // Fallback con datos estáticos
  getMenuItems() {
    const staticMenu = [
      { id: 1, title: 'Inicio', path: '/' },
      { id: 2, title: 'Productos', path: '/products' }
    ];

    return this.getDataWithFallback('/api/menu', staticMenu);
  }
}

Circuit breaker pattern básico

El circuit breaker evita realizar peticiones a servicios que están fallando consistentemente:

@Injectable({
  providedIn: 'root'
})
export class CircuitBreakerService {
  private failures = new Map<string, number>();
  private lastFailure = new Map<string, number>();
  private readonly maxFailures = 5;
  private readonly timeoutWindow = 60000; // 1 minuto

  private http = inject(HttpClient);

  get<T>(url: string): Observable<T> {
    if (this.isCircuitOpen(url)) {
      const timeRemaining = this.getTimeUntilReset(url);
      return throwError(() => ({
        message: `Servicio temporalmente no disponible. Reintenta en ${Math.ceil(timeRemaining / 1000)}s`,
        isCircuitOpen: true,
        timeRemaining
      }));
    }

    return this.http.get<T>(url).pipe(
      tap(() => {
        // Reset en caso de éxito
        this.failures.delete(url);
        this.lastFailure.delete(url);
      }),
      catchError(error => {
        this.recordFailure(url);
        return throwError(() => error);
      })
    );
  }

  private isCircuitOpen(url: string): boolean {
    const failures = this.failures.get(url) || 0;
    const lastFailureTime = this.lastFailure.get(url) || 0;
    const timeSinceLastFailure = Date.now() - lastFailureTime;

    // Circuit cerrado si ha pasado el tiempo de timeout
    if (timeSinceLastFailure > this.timeoutWindow) {
      this.failures.delete(url);
      this.lastFailure.delete(url);
      return false;
    }

    return failures >= this.maxFailures;
  }

  private recordFailure(url: string): void {
    const current = this.failures.get(url) || 0;
    this.failures.set(url, current + 1);
    this.lastFailure.set(url, Date.now());
  }

  private getTimeUntilReset(url: string): number {
    const lastFailureTime = this.lastFailure.get(url) || 0;
    return Math.max(0, this.timeoutWindow - (Date.now() - lastFailureTime));
  }
}

Integración completa de interceptors

Para combinar todos los patterns, organizamos los interceptors en el orden correcto:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { timeoutInterceptor } from './app/interceptors/timeout.interceptor';
import { retryInterceptor } from './app/interceptors/retry.interceptor';
import { errorHandlerInterceptor } from './app/interceptors/error-handler.interceptor';

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([
        timeoutInterceptor,    // Primero: timeout
        retryInterceptor,      // Segundo: retry automático
        errorHandlerInterceptor // Último: manejo de errores final
      ])
    )
  ]
});

La combinación de timeout y manejo de errores crea aplicaciones verdaderamente resilientes. Los timeouts evitan que las peticiones se cuelguen indefinidamente, mientras que el manejo inteligente de errores proporciona feedback útil al usuario y permite implementar estrategias de recuperación automática.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Angular

Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Comprender el patrón de retry automático y su implementación con exponential backoff.
  • Aprender a crear interceptores para aplicar retry y timeout de forma global.
  • Configurar timeouts específicos por petición y manejar errores HTTP centralizadamente.
  • Implementar estrategias de fallback y circuit breaker para mejorar la robustez.
  • Integrar múltiples interceptores para una gestión completa de errores y reintentos.