Picture this: you’re building an e-commerce application, and your payment processing API occasionally returns 503 errors during peak traffic. Your checkout flow breaks, customers abandon their carts, and support tickets pile up. Sound familiar?

This exact scenario happened to me last year. Our solution? A well-crafted Angular interceptor that automatically retries failed requests, times out stalled connections, and handles errors gracefully.

Today, I’ll walk you through building this interceptor step by step.

Why Interceptors Are Perfect for This Job

HTTP interceptors in Angular sit between your application and the server, intercepting every HTTP request and response.

They’re the ideal place to implement cross-cutting concerns like retry logic, timeouts, and error handling.

Without interceptors, you’d need to add retry logic to every service call:

// Without interceptor - repetitive and error-prone
getUserData() {
  return this.http.get('/api/users').pipe(
    timeout(10000),
    retryWhen(errors => errors.pipe(
      scan((retryCount, error) => {
        if (retryCount >= 3 || error.status < 500) {
          throw error;
        }
        return retryCount + 1;
      }, 0),
      delay(1000)
    )),
    catchError(this.handleError)
  );
}

With an interceptor, your services stay clean and focused:

// With interceptor - clean and simple
getUserData() {
  return this.http.get('/api/users');
}

Understanding the Flow

Before diving into code, let’s visualize how our interceptor will work:

Angular diagram illustrating how HTTP requests are cancelled on route changes using takeUntil

How Angular Cancels HTTP Requests on Route Change with takeUntil

The interceptor applies a timeout to every request, attempts retries for recoverable errors, and formats all errors consistently.

Implementing Retry and Timeout Logic in an Angular Interceptor

Let’s start with the basic structure of our interceptor:

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError, timer } from 'rxjs';
import { retryWhen, timeout, catchError, mergeMap, finalize } from 'rxjs/operators';

@Injectable()
export class RetryTimeoutInterceptor implements HttpInterceptor {
  private readonly maxRetries = 3;
  private readonly timeoutDuration = 10000; // 10 seconds
  private readonly retryDelay = 1000; // 1 second base delay

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      timeout(this.timeoutDuration),
      retryWhen(errors => this.retryStrategy(errors)),
      catchError(error => this.handleError(error))
    );
  }

  private retryStrategy(errors: Observable<any>): Observable<any> {
    return errors.pipe(
      mergeMap((error, index) => {
        const retryAttempt = index + 1;
        
        // Don't retry on client errors (4xx) or if max retries exceeded
        if (retryAttempt > this.maxRetries || !this.shouldRetry(error)) {
          return throwError(error);
        }

        console.log(`Retry attempt ${retryAttempt} for error:`, error.message);
        
        // Exponential backoff: 1s, 2s, 4s
        const delayTime = this.retryDelay * Math.pow(2, retryAttempt - 1);
        return timer(delayTime);
      })
    );
  }

  private shouldRetry(error: HttpErrorResponse): boolean {
    // Retry on network errors or server errors (5xx)
    return !error.status || error.status >= 500;
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    const formattedError = this.formatError(error);
    console.error('HTTP Error:', formattedError);
    return throwError(formattedError);
  }

  private formatError(error: HttpErrorResponse): any {
    return {
      message: this.getErrorMessage(error),
      status: error.status,
      statusText: error.statusText,
      timestamp: new Date().toISOString(),
      url: error.url
    };
  }

  private getErrorMessage(error: HttpErrorResponse): string {
    if (!error.status) {
      return 'Network connection failed. Please check your internet connection.';
    }
    
    if (error.status >= 500) {
      return 'Server error occurred. Please try again later.';
    }
    
    if (error.status === 404) {
      return 'The requested resource was not found.';
    }
    
    if (error.status === 401) {
      return 'You are not authorized to access this resource.';
    }
    
    return error.error?.message || 'An unexpected error occurred.';
  }
}

Advanced Retry Logic with Configuration

The basic interceptor works well, but real applications need more flexibility. Let’s enhance it with configuration options:

export interface RetryConfig {
  maxRetries: number;
  timeoutDuration: number;
  retryDelay: number;
  retryableStatusCodes: number[];
  exponentialBackoff: boolean;
}

@Injectable()
export class ConfigurableRetryInterceptor implements HttpInterceptor {
  private config: RetryConfig = {
    maxRetries: 3,
    timeoutDuration: 10000,
    retryDelay: 1000,
    retryableStatusCodes: [0, 408, 429, 500, 502, 503, 504],
    exponentialBackoff: true
  };

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // Check if request should skip retry logic
    if (this.shouldSkipRetry(req)) {
      return next.handle(req).pipe(
        timeout(this.config.timeoutDuration),
        catchError(error => this.handleError(error))
      );
    }

    return next.handle(req).pipe(
      timeout(this.config.timeoutDuration),
      retryWhen(errors => this.createRetryStrategy(errors)),
      catchError(error => this.handleError(error))
    );
  }

  private shouldSkipRetry(req: HttpRequest<any>): boolean {
    // Skip retry for POST/PUT/PATCH requests by default
    // Add custom header to override: 'X-Retry-Request': 'true'
    const isModifyingRequest = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
    const hasRetryHeader = req.headers.get('X-Retry-Request') === 'true';
    
    return isModifyingRequest && !hasRetryHeader;
  }

  private createRetryStrategy(errors: Observable<any>): Observable<any> {
    return errors.pipe(
      mergeMap((error, index) => {
        const retryAttempt = index + 1;
        
        if (retryAttempt > this.config.maxRetries || !this.isRetryableError(error)) {
          return throwError(error);
        }

        const delay = this.calculateDelay(retryAttempt);
        console.log(`Retrying request in ${delay}ms (attempt ${retryAttempt})`);
        
        return timer(delay);
      })
    );
  }

  private calculateDelay(retryAttempt: number): number {
    if (this.config.exponentialBackoff) {
      // Exponential backoff with jitter
      const exponentialDelay = this.config.retryDelay * Math.pow(2, retryAttempt - 1);
      const jitter = Math.random() * 0.1 * exponentialDelay; // Add up to 10% jitter
      return exponentialDelay + jitter;
    }
    
    return this.config.retryDelay;
  }

  private isRetryableError(error: HttpErrorResponse): boolean {
    return this.config.retryableStatusCodes.includes(error.status || 0);
  }
}

Error Classification and Handling Strategy

Different errors require different handling approaches. Here’s how we can classify and handle them:

Error TypeStatus CodeActionUser Message
Network0Retry + Show offline indicator“Connection lost. Retrying…”
Timeout-Retry + Log“Request taking too long. Retrying…”
Rate Limit429Retry with longer delay“Too many requests. Please wait…”
Server Error5xxRetry + Log“Server issue. Trying again…”
Client Error4xxDon’t retry + Log“Invalid request. Please check your input.”
Auth Error401/403Don’t retry + Redirect“Please log in again.”

Let’s implement this classification:

export enum ErrorType {
  NETWORK = 'NETWORK',
  TIMEOUT = 'TIMEOUT',
  SERVER = 'SERVER',
  CLIENT = 'CLIENT',
  AUTH = 'AUTH',
  RATE_LIMIT = 'RATE_LIMIT'
}

export interface ClassifiedError {
  type: ErrorType;
  originalError: HttpErrorResponse;
  message: string;
  retryable: boolean;
  userFriendly: boolean;
}

@Injectable()
export class ErrorClassificationService {
  classifyError(error: HttpErrorResponse): ClassifiedError {
    if (!error.status) {
      return {
        type: ErrorType.NETWORK,
        originalError: error,
        message: 'Network connection failed',
        retryable: true,
        userFriendly: true
      };
    }

    if (error.status === 429) {
      return {
        type: ErrorType.RATE_LIMIT,
        originalError: error,
        message: 'Rate limit exceeded',
        retryable: true,
        userFriendly: true
      };
    }

    if (error.status === 401 || error.status === 403) {
      return {
        type: ErrorType.AUTH,
        originalError: error,
        message: 'Authentication required',
        retryable: false,
        userFriendly: true
      };
    }

    if (error.status >= 400 && error.status < 500) {
      return {
        type: ErrorType.CLIENT,
        originalError: error,
        message: 'Invalid request',
        retryable: false,
        userFriendly: false
      };
    }

    if (error.status >= 500) {
      return {
        type: ErrorType.SERVER,
        originalError: error,
        message: 'Server error occurred',
        retryable: true,
        userFriendly: true
      };
    }

    return {
      type: ErrorType.CLIENT,
      originalError: error,
      message: 'Unknown error occurred',
      retryable: false,
      userFriendly: false
    };
  }
}

Integration with Global Error Handler

For production applications, you’ll want to integrate with a global error handling system:

@Injectable()
export class UnifiedErrorHandler extends ErrorHandler {
  constructor(
    private notificationService: NotificationService,
    private loggingService: LoggingService
  ) {
    super();
  }

  handleError(error: any): void {
    if (error instanceof ClassifiedError) {
      this.handleClassifiedError(error);
    } else {
      this.handleUnknownError(error);
    }
  }

  private handleClassifiedError(error: ClassifiedError): void {
    // Log the error
    this.loggingService.logError({
      type: error.type,
      message: error.message,
      status: error.originalError.status,
      url: error.originalError.url,
      timestamp: new Date().toISOString()
    });

    // Show user notification if appropriate
    if (error.userFriendly) {
      this.notificationService.showError(error.message);
    }

    // Handle specific error types
    switch (error.type) {
      case ErrorType.AUTH:
        this.handleAuthError();
        break;
      case ErrorType.NETWORK:
        this.handleNetworkError();
        break;
    }
  }

  private handleAuthError(): void {
    // Redirect to login or refresh token
    this.router.navigate(['/login']);
  }

  private handleNetworkError(): void {
    // Show offline indicator
    this.notificationService.showOfflineIndicator();
  }
}

Testing Your Interceptor

Testing interceptors requires careful setup. Here’s how to test the retry and timeout behavior:

describe('RetryTimeoutInterceptor', () => {
  let interceptor: RetryTimeoutInterceptor;
  let httpMock: HttpTestingController;
  let httpClient: HttpClient;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        RetryTimeoutInterceptor,
        {
          provide: HTTP_INTERCEPTORS,
          useClass: RetryTimeoutInterceptor,
          multi: true
        }
      ]
    });

    interceptor = TestBed.inject(RetryTimeoutInterceptor);
    httpMock = TestBed.inject(HttpTestingController);
    httpClient = TestBed.inject(HttpClient);
  });

  it('should retry on server error and succeed', fakeAsync(() => {
    let result: any;
    
    httpClient.get('/api/test').subscribe(
      response => result = response,
      error => result = error
    );

    // First request fails with 503
    const req1 = httpMock.expectOne('/api/test');
    req1.flush('Service unavailable', { status: 503, statusText: 'Service Unavailable' });

    // Fast-forward through retry delay
    tick(1000);

    // Second request succeeds
    const req2 = httpMock.expectOne('/api/test');
    req2.flush({ data: 'success' });

    tick();

    expect(result).toEqual({ data: 'success' });
    httpMock.verify();
  }));

  it('should not retry on client error', () => {
    let result: any;
    
    httpClient.get('/api/test').subscribe(
      response => result = response,
      error => result = error
    );

    const req = httpMock.expectOne('/api/test');
    req.flush('Bad request', { status: 400, statusText: 'Bad Request' });

    expect(result.status).toBe(400);
    httpMock.verify();
  });

  it('should timeout long requests', fakeAsync(() => {
    let result: any;
    
    httpClient.get('/api/slow').subscribe(
      response => result = response,
      error => result = error
    );

    const req = httpMock.expectOne('/api/slow');
    
    // Don't respond to simulate slow request
    tick(10000); // Fast-forward past timeout
    
    expect(result.name).toBe('TimeoutError');
    httpMock.verify();
  }));
});

Real-World Implementation Tips

When implementing this interceptor in production, consider these best practices:

Selective Retry Logic: Not all requests should be retried. Payment transactions, user registrations, and other critical operations might need custom handling. Use request headers or URL patterns to identify these cases.

Monitoring and Observability: Add metrics to track retry rates, timeout frequencies, and error patterns. This data helps you tune your configuration and identify backend issues early.

Progressive Delays: The exponential backoff with jitter prevents the “thundering herd” problem where multiple clients retry simultaneously, potentially making server issues worse.

Memory Management: Long retry chains can create memory leaks. Always ensure your observables complete properly, especially in error scenarios.

Configuration for Different Environments

Your retry strategy should adapt to different environments:

export const RETRY_CONFIG: RetryConfig = {
  development: {
    maxRetries: 1,
    timeoutDuration: 30000, // Longer timeout for debugging
    retryDelay: 500,
    retryableStatusCodes: [0, 500, 502, 503, 504],
    exponentialBackoff: false
  },
  production: {
    maxRetries: 3,
    timeoutDuration: 10000,
    retryDelay: 1000,
    retryableStatusCodes: [0, 408, 429, 500, 502, 503, 504],
    exponentialBackoff: true
  }
};

Final Thoughts and Best Practices for Angular Interceptors

Building a robust HTTP interceptor transforms how your Angular application handles network issues. Instead of scattered error handling throughout your codebase, you get centralized, consistent behavior that improves user experience and reduces support burden.

The interceptor we built handles the most common scenarios: network failures, server errors, and slow responses. It retries intelligently, fails gracefully, and provides meaningful feedback to users.

Remember to test thoroughly, monitor in production, and adjust your configuration based on real-world usage patterns. Your users will appreciate the smoother experience, and your support team will thank you for fewer “the app is broken” tickets.

Start with the basic implementation and gradually add features like custom error classification and global error handling as your application grows. The modular approach makes it easy to extend and maintain over time.

Frequently Asked Questions

What is the purpose of an Angular HTTP interceptor with retry and timeout?

An Angular HTTP interceptor with retry and timeout centralizes error handling, automatically retries failed requests, and enforces timeouts for slow responses. This improves API resilience, reduces code duplication, and ensures a consistent user experience. By handling these concerns in one place, your services remain clean and maintainable.

How does retry logic work in an Angular interceptor?

Retry logic in an interceptor uses RxJS operators like retryWhen to automatically re-attempt failed requests based on configurable rules. It can implement exponential backoff and limit retries to specific error types, such as network or server errors. This helps recover from transient issues without overwhelming the backend or the user.

When should I avoid retrying HTTP requests in Angular?

Avoid retrying requests for client errors (4xx), authentication failures (401/403), or operations that are not idempotent, such as payments or user registrations. Retrying these can cause duplicate actions or security issues. Use request headers or URL patterns to selectively skip retry logic for sensitive endpoints.

How can I handle timeouts for HTTP requests in Angular?

Use the RxJS timeout operator in your interceptor to automatically fail requests that take too long. This prevents your app from hanging on slow or unresponsive APIs and allows you to provide immediate feedback to users. Timeout durations should be configurable for different environments.

What is error classification and why is it important?

Error classification groups errors by type, such as network, timeout, server, client, or authentication errors, so you can handle each scenario appropriately. This enables you to show user-friendly messages, trigger retries only when safe, and log or escalate critical issues. Proper classification improves both user experience and debugging.

How do I test Angular HTTP interceptors with retry and timeout?

Use Angular’s HttpClientTestingModule and HttpTestingController to simulate server responses and verify retry, timeout, and error handling behavior. Write tests for different error scenarios, including network failures, server errors, and timeouts, to ensure your interceptor behaves as expected in all cases.

Can I customize retry and timeout behavior for different requests?

Yes, you can add custom headers or use request properties to control retry and timeout logic per request. For example, you might skip retries for POST requests or set longer timeouts for file uploads. This flexibility allows you to fine-tune resilience strategies for each API call.

What are best practices for implementing retry and timeout in Angular?

Start with conservative retry limits and reasonable timeouts, use exponential backoff with jitter to avoid overwhelming servers, and always classify errors before retrying. Monitor retry and timeout metrics in production to adjust your strategy as needed, and document your approach for your team.

How does a global error handler work with HTTP interceptors?

A global error handler can catch and process errors thrown by HTTP interceptors, allowing you to log issues, show notifications, or redirect users as needed. Integrating interceptors with a global handler ensures consistent error reporting and user feedback across your entire application.

Why is centralized error handling important in Angular applications?

Centralized error handling reduces code duplication, ensures consistent user messaging, and makes your application easier to maintain and debug. By handling retries, timeouts, and errors in one place, you can quickly adapt to backend changes and improve overall reliability.
See other angular posts