smithery/tidemann

http-interceptors

Angular 21+ functional HTTP interceptors for auth, error handling, loading states, retry logic, caching, and security best practices

Installation

$ npx skills add smithery/tidemann --skill http-interceptors

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/tidemann.

npx skills add smithery/tidemann

Browse all from smithery/tidemann

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Write, Edit, Glob, Grep

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 25,349 B
  • docs SUMMARY.md 157 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

HTTP Interceptors Skill

Expert in implementing Angular 21+ functional HTTP interceptors for cross-cutting concerns.

When to Use This Skill

Use this skill when:

  • Setting up authentication with automatic token injection
  • Implementing global error handling
  • Adding loading state management
  • Configuring retry logic for failed requests
  • Implementing request caching/deduplication
  • Converting API services from Promises to Observables
  • Implementing security best practices (JWT, CSRF protection)

Angular 21 Functional Interceptors (2025)

Why Functional Interceptors?

Introduced in Angular v15+, functional interceptors are now the recommended approach over class-based interceptors:

Advantages:

  1. Less Boilerplate: Pure functions are simpler than classes
  2. Better Tree-Shaking: Smaller bundle sizes
  3. Enhanced Developer Experience: More readable and maintainable
  4. Composition: Higher-order functions enable advanced patterns
  5. Predictable Behavior: Especially in complex setups

Note: Class-based guard interfaces were deprecated in v16. While they still work for backward compatibility, all new development should use functional interceptors.

Basic Structure

import { HttpInterceptorFn } from '@angular/common/http';

export const myInterceptor: HttpInterceptorFn = (req, next) => {
  // Receive outgoing HttpRequest
  // 'next' represents the next processing step in chain

  // Process or modify request
  const modifiedReq = req.clone({
    /* ... */
  });

  // Pass to next interceptor or make request
  return next(modifiedReq);
};

Configuration

Interceptors are chained together in the order listed via dependency injection:

// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor, // First
        retryInterceptor, // Second
        cacheInterceptor, // Third
        errorInterceptor, // Last (should always be last)
      ]),
    ),
  ],
};

Order Matters: Interceptors execute in the order provided. Error handling should typically be last.

Core Principle

NEVER manually handle these concerns in individual services:

  • ❌ Manual token injection in every request
  • ❌ Per-service error handling
  • ❌ Repetitive loading state management
  • ❌ Manual retry logic

ALWAYS use interceptors for cross-cutting HTTP concerns.

Required Interceptors

1. Auth Interceptor

Automatically adds authentication token to all requests.

// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { TokenService } from '../services/token.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const tokenService = inject(TokenService);
  const token = tokenService.getToken();

  // Skip auth for public endpoints
  if (req.url.includes('/auth/login') || req.url.includes('/auth/register')) {
    return next(req);
  }

  // Add token if available
  if (!token) {
    return next(req);
  }

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

  return next(authReq);
};

2. Error Interceptor

Handles HTTP errors globally.

// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { NotificationService } from '../services/notification.service';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const notification = inject(NotificationService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      let errorMessage = 'An error occurred';

      if (error.error instanceof ErrorEvent) {
        // Client-side error
        errorMessage = error.error.message;
      } else {
        // Server-side error
        switch (error.status) {
          case 401:
            errorMessage = 'Unauthorized. Please login again.';
            router.navigate(['/auth/login']);
            break;
          case 403:
            errorMessage = 'Access forbidden.';
            break;
          case 404:
            errorMessage = 'Resource not found.';
            break;
          case 500:
            errorMessage = 'Server error. Please try again later.';
            break;
          default:
            errorMessage = error.error?.message || error.message;
        }
      }

      // Show notification
      notification.error(errorMessage);

      // Re-throw for component-level handling if needed
      return throwError(() => new Error(errorMessage));
    }),
  );
};

3. Loading Interceptor

Manages global loading state.

// interceptors/loading.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs';
import { LoadingService } from '../services/loading.service';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loadingService = inject(LoadingService);

  // Skip loading for certain requests
  if (req.headers.has('X-Skip-Loading')) {
    const newReq = req.clone({
      headers: req.headers.delete('X-Skip-Loading'),
    });
    return next(newReq);
  }

  loadingService.show();

  return next(req).pipe(
    finalize(() => {
      loadingService.hide();
    }),
  );
};

4. Retry Interceptor

Automatically retries failed requests with exponential backoff.

// interceptors/retry.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { retry, timer } from 'rxjs';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // Only retry GET requests and specific errors
  const shouldRetry = (error: unknown) => {
    if (!(error instanceof HttpErrorResponse)) return false;
    if (req.method !== 'GET') return false;

    // Retry on network errors or 5xx server errors
    return error.status === 0 || error.status >= 500;
  };

  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, retryCount) => {
        if (!shouldRetry(error)) {
          throw error;
        }

        // Exponential backoff: 1s, 2s, 4s
        const delayMs = Math.pow(2, retryCount - 1) * 1000;
        return timer(delayMs);
      },
    }),
  );
};

5. Cache Interceptor

Caches GET requests to avoid duplicate calls.

// interceptors/cache.interceptor.ts
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { of, tap, share } from 'rxjs';
import { HttpCacheService } from '../services/http-cache.service';

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  const cache = inject(HttpCacheService);

  // Only cache GET requests
  if (req.method !== 'GET') {
    return next(req);
  }

  // Check if cached
  const cachedResponse = cache.get(req.url);
  if (cachedResponse) {
    return of(cachedResponse);
  }

  // Make request and cache
  return next(req).pipe(
    tap((event) => {
      if (event instanceof HttpResponse) {
        cache.set(req.url, event);
      }
    }),
    share(), // Share to prevent duplicate in-flight requests
  );
};

Supporting Services

TokenService

// services/token.service.ts
import { Injectable, inject } from '@angular/core';
import { StorageService, STORAGE_KEYS } from './storage.service';
import { z } from 'zod';

@Injectable({
  providedIn: 'root',
})
export class TokenService {
  private readonly storage = inject(StorageService);

  getToken(): string | null {
    return this.storage.get(STORAGE_KEYS.AUTH_TOKEN, z.string());
  }

  setToken(token: string): void {
    this.storage.set(STORAGE_KEYS.AUTH_TOKEN, token);
  }

  removeToken(): void {
    this.storage.remove(STORAGE_KEYS.AUTH_TOKEN);
  }

  hasToken(): boolean {
    return this.getToken() !== null;
  }
}

NotificationService

// services/notification.service.ts
import { Injectable, signal } from '@angular/core';

export interface Notification {
  id: string;
  type: 'success' | 'error' | 'info' | 'warning';
  message: string;
  duration?: number;
}

@Injectable({
  providedIn: 'root',
})
export class NotificationService {
  private readonly notificationsSignal = signal<Notification[]>([]);
  readonly notifications = this.notificationsSignal.asReadonly();

  private idCounter = 0;

  private show(type: Notification['type'], message: string, duration = 5000): void {
    const notification: Notification = {
      id: `notification-${this.idCounter++}`,
      type,
      message,
      duration,
    };

    this.notificationsSignal.update((notifications) => [...notifications, notification]);

    if (duration > 0) {
      setTimeout(() => {
        this.dismiss(notification.id);
      }, duration);
    }
  }

  success(message: string, duration?: number): void {
    this.show('success', message, duration);
  }

  error(message: string, duration?: number): void {
    this.show('error', message, duration);
  }

  info(message: string, duration?: number): void {
    this.show('info', message, duration);
  }

  warning(message: string, duration?: number): void {
    this.show('warning', message, duration);
  }

  dismiss(id: string): void {
    this.notificationsSignal.update((notifications) => notifications.filter((n) => n.id !== id));
  }

  clear(): void {
    this.notificationsSignal.set([]);
  }
}

LoadingService

// services/loading.service.ts
import { Injectable, signal, computed } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class LoadingService {
  private readonly countSignal = signal(0);
  readonly isLoading = computed(() => this.countSignal() > 0);

  show(): void {
    this.countSignal.update((count) => count + 1);
  }

  hide(): void {
    this.countSignal.update((count) => Math.max(0, count - 1));
  }

  reset(): void {
    this.countSignal.set(0);
  }
}

HttpCacheService

// services/http-cache.service.ts
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';

interface CacheEntry {
  response: HttpResponse<unknown>;
  timestamp: number;
}

@Injectable({
  providedIn: 'root',
})
export class HttpCacheService {
  private cache = new Map<string, CacheEntry>();
  private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes

  get(url: string): HttpResponse<unknown> | null {
    const entry = this.cache.get(url);
    if (!entry) return null;

    // Check if expired
    if (Date.now() - entry.timestamp > this.defaultTTL) {
      this.cache.delete(url);
      return null;
    }

    return entry.response;
  }

  set(url: string, response: HttpResponse<unknown>): void {
    this.cache.set(url, {
      response,
      timestamp: Date.now(),
    });
  }

  clear(url?: string): void {
    if (url) {
      this.cache.delete(url);
    } else {
      this.cache.clear();
    }
  }

  clearPattern(pattern: RegExp): void {
    for (const key of this.cache.keys()) {
      if (pattern.test(key)) {
        this.cache.delete(key);
      }
    }
  }
}

Configuration

Register Interceptors in App Config

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';
import { errorInterceptor } from './interceptors/error.interceptor';
import { loadingInterceptor } from './interceptors/loading.interceptor';
import { retryInterceptor } from './interceptors/retry.interceptor';
import { cacheInterceptor } from './interceptors/cache.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor,
        retryInterceptor,
        cacheInterceptor,
        loadingInterceptor,
        errorInterceptor, // Error should be last
      ]),
    ),
    // ... other providers
  ],
};

Order matters: Interceptors run in the order provided.

Advanced Caching Patterns (2025 Best Practices)

Common Caching Pitfalls to Avoid

1. Infinite Cache Growth

  • Problem: In-memory cache grows indefinitely
  • Solution: Implement size limits or LRU eviction

2. In-Flight Request Duplication

  • Problem: Multiple parallel requests to same URL before cache populates
  • Solution: Store in-flight observable in cache with shareReplay

3. Stale Data

  • Problem: Cached responses return outdated data
  • Solution: Implement TTL (Time-To-Live) and cache invalidation

LRU (Least Recently Used) Cache

// services/lru-cache.service.ts
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';

interface CacheEntry {
  response: HttpResponse<unknown>;
  timestamp: number;
}

@Injectable({
  providedIn: 'root',
})
export class LRUCacheService {
  private cache = new Map<string, CacheEntry>();
  private readonly maxSize = 100;
  private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes

  get(url: string): HttpResponse<unknown> | null {
    const entry = this.cache.get(url);
    if (!entry) return null;

    // Check TTL
    if (Date.now() - entry.timestamp > this.defaultTTL) {
      this.cache.delete(url);
      return null;
    }

    // Move to end (most recently used)
    this.cache.delete(url);
    this.cache.set(url, entry);

    return entry.response;
  }

  set(url: string, response: HttpResponse<unknown>): void {
    // Remove oldest if at capacity
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(url, {
      response,
      timestamp: Date.now(),
    });
  }
}

In-Flight Request Deduplication

Prevents duplicate parallel requests using shareReplay:

// services/request-deduplication.service.ts
import { Injectable } from '@angular/core';
import { Observable, share } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class RequestDeduplicationService {
  private inFlightRequests = new Map<string, Observable<unknown>>();

  deduplicate<T>(key: string, request: () => Observable<T>): Observable<T> {
    // Return existing request if in-flight
    if (this.inFlightRequests.has(key)) {
      return this.inFlightRequests.get(key) as Observable<T>;
    }

    // Create new request with share operator
    const sharedRequest = request().pipe(
      share({
        // Remove from map when complete
        resetOnComplete: () => {
          this.inFlightRequests.delete(key);
        },
      }),
    );

    this.inFlightRequests.set(key, sharedRequest);
    return sharedRequest;
  }
}

Usage in Interceptor:

export const deduplicationInterceptor: HttpInterceptorFn = (req, next) => {
  const dedup = inject(RequestDeduplicationService);

  // Only deduplicate GET requests
  if (req.method !== 'GET') {
    return next(req);
  }

  return dedup.deduplicate(req.urlWithParams, () => next(req));
};

Cache Invalidation on Mutations

export class TaskService {
  private http = inject(HttpClient);
  private cache = inject(HttpCacheService);

  createTask(task: CreateTaskRequest): Observable<Task> {
    return this.http.post<Task>('/api/tasks', task).pipe(
      tap(() => {
        // Invalidate all task-related caches
        this.cache.clearPattern(/\/api\/tasks/);
      }),
    );
  }

  updateTask(id: string, updates: Partial<Task>): Observable<Task> {
    return this.http.put<Task>(`/api/tasks/${id}`, updates).pipe(
      tap(() => {
        // Invalidate specific task and list caches
        this.cache.clear(`/api/tasks/${id}`);
        this.cache.clearPattern(/\/api\/tasks($|\?)/);
      }),
    );
  }
}

Conditional Caching with HttpContext

Control caching per-request using HttpContext:

// Define context token
export const CACHE_ENABLED = new HttpContextToken<boolean>(() => true);
export const CACHE_TTL = new HttpContextToken<number>(() => 5 * 60 * 1000);

// Use in interceptor
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  const cache = inject(HttpCacheService);

  // Check if caching is enabled for this request
  if (!req.context.get(CACHE_ENABLED) || req.method !== 'GET') {
    return next(req);
  }

  // Get custom TTL if provided
  const ttl = req.context.get(CACHE_TTL);

  // Check cache
  const cached = cache.getWithTTL(req.urlWithParams, ttl);
  if (cached) {
    return of(cached);
  }

  // Make request and cache
  return next(req).pipe(
    tap((event) => {
      if (event instanceof HttpResponse) {
        cache.setWithTTL(req.urlWithParams, event, ttl);
      }
    }),
  );
};

// Disable caching for specific request
this.http.get('/api/tasks', {
  context: new HttpContext().set(CACHE_ENABLED, false),
});

// Custom TTL for request
this.http.get('/api/stats', {
  context: new HttpContext().set(CACHE_TTL, 60000), // 1 minute
});

Security Best Practices

JWT Token Handling

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const tokenService = inject(TokenService);
  const router = inject(Router);

  // Get token
  const token = tokenService.getToken();
  if (!token) {
    return next(req);
  }

  // Check token expiration
  if (tokenService.isTokenExpired(token)) {
    tokenService.removeToken();
    router.navigate(['/auth/login']);
    return throwError(() => new Error('Token expired'));
  }

  // Add token to request
  const authReq = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });

  return next(authReq);
};

CSRF Protection

export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
  // Skip for GET, HEAD, OPTIONS
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next(req);
  }

  // Get CSRF token from cookie or meta tag
  const csrfToken = getCsrfToken();

  if (csrfToken) {
    const secureReq = req.clone({
      setHeaders: { 'X-CSRF-TOKEN': csrfToken },
    });
    return next(secureReq);
  }

  return next(req);
};

401 Error Handling and Redirect

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const tokenService = inject(TokenService);
  const notification = inject(NotificationService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        // Clear token and redirect to login
        tokenService.removeToken();
        router.navigate(['/auth/login']);
        notification.error('Session expired. Please login again.');
      }

      return throwError(() => error);
    }),
  );
};

Service Migration: Promises to Observables

Before (Promises)

// api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);

  async get<T>(url: string): Promise<T> {
    return firstValueFrom(this.http.get<T>(url));
  }

  async post<T>(url: string, body: unknown): Promise<T> {
    const token = localStorage.getItem('token'); // Manual token
    return firstValueFrom(
      this.http.post<T>(url, body, {
        headers: { Authorization: `Bearer ${token}` },
      }),
    );
  }
}

After (Observables)

// api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);

  get<T>(url: string): Observable<T> {
    return this.http.get<T>(url);
    // Auth token added by interceptor
    // Errors handled by interceptor
    // Loading state managed by interceptor
  }

  post<T>(url: string, body: unknown): Observable<T> {
    return this.http.post<T>(url, body);
    // All cross-cutting concerns handled by interceptors
  }
}

Component Usage

export class MyComponent {
  private api = inject(ApiService);
  protected dataState = new AsyncState<Data[]>();

  async loadData(): Promise<void> {
    await this.dataState.execute(async () => {
      // Convert Observable to Promise
      return firstValueFrom(this.api.get<Data[]>('/api/data'));
    });
  }

  // Or use Observable directly
  protected data$ = this.api.get<Data[]>('/api/data');
}

Advanced Patterns

Request Deduplication

// services/request-deduplication.service.ts
import { Injectable } from '@angular/core';
import { Observable, share } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class RequestDeduplicationService {
  private inFlightRequests = new Map<string, Observable<unknown>>();

  deduplicate<T>(key: string, request: () => Observable<T>): Observable<T> {
    if (this.inFlightRequests.has(key)) {
      return this.inFlightRequests.get(key) as Observable<T>;
    }

    const sharedRequest = request().pipe(
      share({
        resetOnComplete: () => {
          this.inFlightRequests.delete(key);
        },
      }),
    );

    this.inFlightRequests.set(key, sharedRequest);
    return sharedRequest;
  }
}

Conditional Loading Indicator

// Skip loading for background requests
this.http.get('/api/data', {
  headers: new HttpHeaders({ 'X-Skip-Loading': 'true' }),
});

Cache Invalidation

// Clear cache after mutation
export class TaskService {
  private http = inject(HttpClient);
  private cache = inject(HttpCacheService);

  createTask(task: CreateTaskRequest): Observable<Task> {
    return this.http.post<Task>('/api/tasks', task).pipe(
      tap(() => {
        // Invalidate tasks list cache
        this.cache.clearPattern(/\/api\/tasks/);
      }),
    );
  }
}

Testing

Testing with Interceptors

import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';

describe('AuthInterceptor', () => {
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([authInterceptor])),
        provideHttpClientTesting(),
      ],
    });

    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should add auth token', () => {
    // Test implementation
  });
});

Success Criteria

Before marking HTTP layer implementation complete:

  • Auth interceptor configured (no manual token injection)
  • Error interceptor handles all HTTP errors
  • Loading interceptor manages global state
  • Retry logic configured for transient failures
  • Cache interceptor prevents duplicate requests
  • All services return Observables (not Promises)
  • TokenService abstracts token management
  • NotificationService replaces console.log
  • LoadingService provides global loading state
  • Tests cover interceptor logic

References

Project-Specific

  • GitHub Issue #257: HTTP Layer Improvements
  • .claude/agents/agent-frontend.md: Complete frontend patterns

Angular Functional Interceptors (2025)

Caching Strategies

Security Best Practices