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:

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 Type | Status Code | Action | User Message |
---|---|---|---|
Network | 0 | Retry + Show offline indicator | “Connection lost. Retrying…” |
Timeout | - | Retry + Log | “Request taking too long. Retrying…” |
Rate Limit | 429 | Retry with longer delay | “Too many requests. Please wait…” |
Server Error | 5xx | Retry + Log | “Server issue. Trying again…” |
Client Error | 4xx | Don’t retry + Log | “Invalid request. Please check your input.” |
Auth Error | 401/403 | Don’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?
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?
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?
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?
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?
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?
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?
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?
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?
Why is centralized error handling important in Angular applications?
retries
, timeouts
, and errors
in one place, you can quickly adapt to backend changes and improve overall reliability.