Angular HTTP Interceptors — Logging, Loading State and Retry

Angular HTTP interceptors form a pipeline through which every HTTP request and response passes. Each interceptor can modify the request before it is sent, transform the response, handle errors, add side effects (logging, loading state), or retry failed requests. The interceptor order in withInterceptors([...]) determines the wrapping — the first interceptor in the array is the outermost wrapper (runs first on request, last on response).

Complete Interceptor Stack

// ── 1. Logging interceptor ─────────────────────────────────────────────────
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const config = inject(APP_CONFIG);
  if (!config.enableDebugTools) return next(req);

  const start = Date.now();
  console.log(`→ ${req.method} ${req.url}`);

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        const ms = Date.now() - start;
        console.log(`← ${event.status} ${req.url} (${ms}ms)`);
      }
    }),
    catchError(err => {
      const ms = Date.now() - start;
      console.error(`✖ ${err.status} ${req.url} (${ms}ms)`, err.error);
      return throwError(() => err);
    }),
  );
};

// ── 2. Loading interceptor ────────────────────────────────────────────────
// Skip for background requests (e.g., view count increment)
const SKIP_LOADING_HEADER = 'X-Skip-Loading';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(LoadingService);
  if (req.headers.has(SKIP_LOADING_HEADER)) {
    return next(req.clone({ headers: req.headers.delete(SKIP_LOADING_HEADER) }));
  }

  loading.start();
  return next(req).pipe(
    finalize(() => loading.stop()),
  );
};

// ── 3. Retry interceptor ──────────────────────────────────────────────────
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // Only retry network errors (status 0), not HTTP errors (4xx/5xx)
  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, attempt) => {
        if (error.status !== 0) return throwError(() => error);  // don't retry HTTP errors
        return timer(Math.pow(2, attempt) * 1000);  // 2s, 4s, 8s
      },
    }),
  );
};

// ── 4. Cache interceptor ──────────────────────────────────────────────────
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL_MS = 30_000;  // 30 seconds

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  // Only cache GET requests without auth requirements
  if (req.method !== 'GET' || req.headers.has('Authorization')) return next(req);

  const cached = cache.get(req.urlWithParams);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS)
    return of(new HttpResponse({ body: cached.data, status: 200 }));

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse && event.status === 200)
        cache.set(req.urlWithParams, { data: event.body, timestamp: Date.now() });
    }),
  );
};

// ── Interceptor order in app.config.ts ────────────────────────────────────
// Outermost first (processes request first, response last)
provideHttpClient(withInterceptors([
  loggingInterceptor,    // 1. outer: logs first and last
  loadingInterceptor,    // 2. tracks all requests
  retryInterceptor,      // 3. retries before auth/error handling
  authInterceptor,       // 4. adds token, handles 401/refresh
  errorInterceptor,      // 5. inner: translates HTTP errors to ApiError
]))
Note: The X-Skip-Loading header pattern allows individual requests to opt out of the global loading state. Fire-and-forget calls (view count increment, analytics pings) should not show a loading spinner — they run silently in the background. Add the header when making the request: http.post(url, body, { headers: { 'X-Skip-Loading': 'true' } }). The loading interceptor strips the header before forwarding the request so it never reaches the server.
Tip: The retry interceptor specifically targets status === 0 errors (network errors — no response received) rather than HTTP errors (4xx/5xx — server responded). Retrying a 404 or 500 is counterproductive — the server is responding correctly, retrying will produce the same response. Retrying a status 0 makes sense — the request may not have reached the server at all due to a transient network blip. Always check the error status before retrying.
Warning: The in-memory cache interceptor shown here is a simple demonstration — it has several production caveats. It does not handle cache invalidation (a POST that modifies data should invalidate related GET caches), has no maximum size limit (unbounded growth), and is per-browser-session (shared across components). For production caching, consider using Angular’s HttpClient with browser’s native HTTP caching (Cache-Control headers from the API) rather than an in-memory interceptor. The native cache is more correct and respects the API’s stated cache policy.

Interceptor Execution Order

Interceptor Request Phase Response Phase
loggingInterceptor 1st — logs “→ GET /api/posts” 5th — logs “← 200 (45ms)”
loadingInterceptor 2nd — loading.start() 4th — loading.stop() (finalize)
retryInterceptor 3rd 3rd — retries on network error
authInterceptor 4th — adds Bearer token 2nd — handles 401/refresh
errorInterceptor 5th — inner, closest to HTTP 1st — first to see response

Common Mistakes

Mistake 1 — Retrying HTTP errors (4xx/5xx) instead of only network errors (429 hammering)

❌ Wrong — retry on all errors; a 429 Too Many Requests is retried 3 times immediately; rate limit hit gets worse.

✅ Correct — only retry status 0 (network errors); use specific delay for 429 if needed.

Mistake 2 — Wrong interceptor order (auth interceptor outside retry — retried with wrong/missing token)

❌ Wrong — authInterceptor before retryInterceptor; retry sends request without token if auth interceptor fails.

✅ Correct — retryInterceptor wraps authInterceptor; each retry attempt goes through auth to get fresh token.

🧠 Test Yourself

A GET request to /api/posts has a network failure (status 0). The retry interceptor has count: 3, delay: timer(2000). How many total HTTP requests are made?