Claude-skill-registry http-interceptors
Angular 21+ functional HTTP interceptors for auth, error handling, loading states, retry logic, caching, and security best practices
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/http-interceptors" ~/.claude/skills/majiayu000-claude-skill-registry-http-interceptors && rm -rf "$T"
skills/data/http-interceptors/SKILL.mdHTTP 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:
- Less Boilerplate: Pure functions are simpler than classes
- Better Tree-Shaking: Smaller bundle sizes
- Enhanced Developer Experience: More readable and maintainable
- Composition: Higher-order functions enable advanced patterns
- 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
: Complete frontend patterns.claude/agents/agent-frontend.md
Angular Functional Interceptors (2025)
- Intercepting requests and responses • Angular
- Functional Approach for HTTP Interceptors | JavaScript in Plain English
- Mastering Modern Angular: Functional Route Guards & Interceptors | Medium
- HTTP interceptors in Angular (2025 update) | Angular Training
Caching Strategies
- Client Side Caching With Interceptors | DEV Community
- Caching with HttpInterceptor in Angular | LogRocket
- Angular: Caching service using Http Interceptor | Medium
- Optimizing Angular Performance with HttpInterceptor Caching | OpenReplay