HTTP Interceptors — Auth Headers, Error Handling and Retry

📋 Table of Contents
  1. Functional Interceptors
  2. Common Mistakes

HTTP interceptors are middleware for Angular’s HTTP pipeline. Every HTTP request and response passes through all registered interceptors in order. The auth interceptor adds the JWT Bearer token to every outgoing request — so individual services never need to set auth headers manually. The error interceptor handles API error responses globally — redirecting to login on 401, showing toast notifications on 500, and parsing ValidationProblemDetails from 400 responses. Angular 15+ uses functional interceptors (HttpInterceptorFn) instead of class-based ones.

Functional Interceptors

import { HttpInterceptorFn, HttpRequest, HttpHandlerFn,
         HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError, retry } from 'rxjs';
import { Router } from '@angular/router';

// ── Auth Interceptor — adds JWT to every request ───────────────────────────
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token       = authService.accessToken();   // read from signal

  if (!token) return next(req);   // no token → pass through unchanged

  const authReq = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` }
  });
  return next(authReq);
};

// ── Error Interceptor — handles API errors globally ────────────────────────
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router   = inject(Router);
  const toastSvc = inject(ToastService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      switch (error.status) {
        case 0:
          // Network error (offline, CORS, server down)
          toastSvc.error('Network error. Check your connection.');
          break;
        case 401:
          // Unauthorised — redirect to login
          router.navigate(['/auth/login'], {
            queryParams: { returnUrl: router.url }
          });
          break;
        case 403:
          toastSvc.error('You do not have permission for this action.');
          break;
        case 429:
          // Rate limited — parse Retry-After header
          const retryAfter = error.headers.get('Retry-After') ?? '60';
          toastSvc.warn(`Too many requests. Please wait ${retryAfter}s.`);
          break;
        case 500:
        case 502:
        case 503:
          toastSvc.error('Server error. Please try again later.');
          break;
        // 400, 404, 409 etc. — let individual services handle these
      }
      // Always rethrow so services can handle errors they own
      return throwError(() => error);
    }),
  );
};

// ── Retry Interceptor — retry transient failures ───────────────────────────
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // Only retry GET requests (idempotent) — never retry POST/PUT/DELETE
  if (req.method !== 'GET') return next(req);

  return next(req).pipe(
    retry({
      count: 2,
      delay: 1000,
      resetOnSuccess: true,
    }),
  );
};

// ── Logging Interceptor — development-only request logging ────────────────
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = Date.now();
  console.log(`[HTTP] → ${req.method} ${req.url}`);

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        const elapsed = Date.now() - start;
        console.log(`[HTTP] ← ${event.status} ${req.url} (${elapsed}ms)`);
      }
    }),
  );
};

// ── Register in app.config.ts ─────────────────────────────────────────────
provideHttpClient(
  withInterceptors([
    loggingInterceptor,   // first in, last out (outermost)
    authInterceptor,      // adds auth headers
    errorInterceptor,     // handles response errors
    retryInterceptor,     // retries failed GETs
  ])
)
Note: Interceptors form a chain — the first registered interceptor is the outermost (first to process the request, last to process the response). The request flows through interceptors in registration order; the response flows back in reverse order. For the auth interceptor to add headers before the request is logged, register the logging interceptor before the auth interceptor. The next(req) call passes the (possibly modified) request to the next interceptor in the chain.
Tip: Use req.clone() to create a modified copy of the request rather than mutating the original. HttpRequest objects are immutable — attempting to set properties directly throws an error. req.clone({ setHeaders: { Authorization: '...' } }) creates a new request with the additional header while preserving all other request properties (URL, params, method, body). Always clone when modifying a request in an interceptor.
Warning: The error interceptor should always rethrow the error with throwError(() => error) after handling it globally. If you swallow the error with return EMPTY or return of(null), the service’s error handler and the component’s error state never fire — the observable just completes silently. Services that rely on seeing specific errors (400 validation errors to display form field messages) will miss them if the interceptor swallows them.

Common Mistakes

Mistake 1 — Swallowing errors in the interceptor (services miss validation errors)

❌ Wrong — return EMPTY after showing a toast; 400 validation errors never reach the service’s error handler.

✅ Correct — always return throwError(() => error) after logging/toasting; services handle their own errors.

Mistake 2 — Retrying non-idempotent requests (POST/PUT/DELETE retried causes duplicates)

❌ Wrong — retry interceptor retries all failed requests; a failed POST is retried, creating duplicate resources.

✅ Correct — only retry GET requests; never automatically retry POST, PUT, PATCH, or DELETE.

🧠 Test Yourself

Three interceptors are registered: [logging, auth, error]. A request is made. In what order do interceptors process the outgoing request?