Back to CourseIntermediate5 min read
Angular /

Functional HTTP Interceptors

Add headers, logging, retries and error handling to every request with functional interceptors.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: June 16, 2026
Last updated: September 7, 2026
Intermediate Topic

One place for cross cutting concerns

An interceptor sits between your code and the network. Every request passes through it, so authentication headers, logging and error handling live in one place.

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

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

  if (!token) return next(req);

  return next(req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  }));
};
provideHttpClient(
  withInterceptors([authInterceptor]),
)

Every request now carries the token, and no service has to think about it.

Requests are immutable

You cannot modify a request. You clone it with changes.

// does nothing
req.headers.set('Authorization', token);

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

return next(modified);

clone accepts several options:

req.clone({
  setHeaders: { 'X-Request-Id': id },       // add or replace headers
  setParams: { version: '2' },              // add or replace query params
  url: `${baseUrl}${req.url}`,              // rewrite the URL
  body: transformed,                        // replace the body
  withCredentials: true,
});

Order matters

Interceptors run in the order you list them, and responses come back in reverse.

withInterceptors([
  loggingInterceptor,   // sees the request first, the response last
  authInterceptor,
  retryInterceptor,     // sees the request last, the response first
])

Put logging first so it observes the fully prepared request. Put retries close to the network so they happen before anything else reacts.

Adding a base URL

export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.url.startsWith('http')) return next(req);    // already absolute

  const base = inject(API_URL);

  return next(req.clone({ url: `${base}${req.url}` }));
};

Now services write /users and the interceptor makes it https://api.example.com/users. Changing environments becomes one provider change.

Logging and timing

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const started = performance.now();

  return next(req).pipe(
    tap({
      next: event => {
        if (event.type === HttpEventType.Response) {
          const ms = Math.round(performance.now() - started);
          console.log(`${req.method} ${req.urlWithParams} ${event.status} in ${ms}ms`);
        }
      },
      error: error => {
        console.error(`${req.method} ${req.urlWithParams} failed`, error);
      },
    }),
  );
};

next(req) returns a stream of events, not just the response. Checking event.type is how you find the actual response.

Retrying

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') return next(req);          // only retry safe methods

  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, retryCount) => {
        if (error.status >= 400 && error.status < 500) throw error;   // do not retry
        return timer(Math.pow(2, retryCount) * 500);                  // back off
      },
    }),
  );
};

Two rules worth keeping. Only retry idempotent requests, since retrying a POST can create duplicates. And do not retry client errors, because a 404 will still be a 404.

The exponential delay avoids hammering a struggling server.

Global error handling

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

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      switch (error.status) {
        case 0:
          notifications.show('No connection');
          break;
        case 401:
          auth.signOut();
          router.navigate(['/login']);
          break;
        case 403:
          notifications.show('You do not have permission');
          break;
        case 500:
          notifications.show('Something went wrong on our side');
          break;
      }

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

Rethrowing matters. Swallowing the error means the calling code thinks the request succeeded and never sees it.

Status 0 means the request never reached the server: offline, DNS failure, or a CORS rejection.

Refreshing a token

The most involved interceptor most applications need.

let refreshing: Observable<string> | null = null;

export const refreshTokenInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);

  return next(withToken(req, auth.token())).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status !== 401 || req.url.includes('/refresh')) {
        return throwError(() => error);
      }

      refreshing ??= auth.refresh().pipe(
        shareReplay(1),
        finalize(() => { refreshing = null; }),
      );

      return refreshing.pipe(
        switchMap(token => next(withToken(req, token))),
      );
    }),
  );
};

function withToken(req: HttpRequest<unknown>, token: string | null) {
  return token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;
}

Two details make this work. The refreshing variable means several failing requests share one refresh call rather than each triggering its own. And skipping the refresh endpoint itself prevents an infinite loop when the refresh also returns 401.

Skipping an interceptor

HttpContext passes per request flags.

export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
this.http.get('/api/public', {
  context: new HttpContext().set(SKIP_AUTH, true),
});
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SKIP_AUTH)) return next(req);

  // ... add the token
};

Cleaner than checking URLs with string matching, which breaks the moment a path changes.

Caching

export const CACHEABLE = new HttpContextToken<boolean>(() => false);

const cache = new Map<string, HttpResponse<unknown>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET' || !req.context.get(CACHEABLE)) return next(req);

  const cached = cache.get(req.urlWithParams);
  if (cached) return of(cached.clone());

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) cache.set(req.urlWithParams, event);
    }),
  );
};

Opt in rather than caching everything, and give it a way to expire. A cache that never invalidates causes stranger bugs than no cache at all.

Testing

TestBed.configureTestingModule({
  providers: [
    provideHttpClient(withInterceptors([authInterceptor])),
    provideHttpClientTesting(),
    { provide: AuthService, useValue: { token: () => 'test-token' } },
  ],
});

const http = TestBed.inject(HttpClient);
const controller = TestBed.inject(HttpTestingController);

http.get('/api/users').subscribe();

const request = controller.expectOne('/api/users');
expect(request.request.headers.get('Authorization')).toBe('Bearer test-token');

Quick recap

  • Interceptors are functions using inject(), registered with withInterceptors.
  • Requests are immutable, so use req.clone() with setHeaders or setParams.
  • They run in listed order for requests and reverse order for responses.
  • Only retry idempotent requests, and never retry 4xx errors.
  • Rethrow errors after handling them, or callers never learn the request failed.
  • Share one refresh call between concurrent 401s, and skip the refresh endpoint itself.
  • Use HttpContext tokens to opt out per request rather than matching URLs.
app.ts

import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse, HttpEventType, HttpResponse,
         HttpContextToken, HttpContext, HttpRequest,
         provideHttpClient, withInterceptors } from '@angular/common/http';
import { catchError, throwError, tap, retry, timer, switchMap, shareReplay,
         finalize, of, Observable } from 'rxjs';
// Requests are IMMUTABLE. Clone with changes, never mutate.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SKIP_AUTH)) return next(req);     // per request opt out
  const token = inject(AuthService).token();
  return token
    ? next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))
    : next(req);
};
// A context token is cleaner than matching URLs with strings
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
// this.http.get('/api/public', { context: new HttpContext().set(SKIP_AUTH, true) })
// next(req) returns a stream of EVENTS, not just the response
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const started = performance.now();
  return next(req).pipe(tap({
    next: event => {
      if (event.type === HttpEventType.Response)
        console.log(`${req.method} ${req.urlWithParams} ${event.status}`,
                    Math.round(performance.now() - started) + 'ms');
    },
  }));
};
// Only retry IDEMPOTENT requests, and never retry 4xx: a 404 stays a 404
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') return next(req);
  return next(req).pipe(retry({
    count: 3,
    delay: (error, attempt) => {
      if (error.status >= 400 && error.status < 500) throw error;
      return timer(Math.pow(2, attempt) * 500);        // exponential back off
    },
  }));
};
// Handle globally, then RETHROW. Swallowing means callers think it succeeded.
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(catchError((error: HttpErrorResponse) => {
    if (error.status === 0) inject(NotificationService).show('No connection');
    if (error.status === 401) inject(Router).navigate(['/login']);
    return throwError(() => error);
  }));
// Token refresh: ONE shared refresh for concurrent 401s, and skip the
// refresh endpoint itself or you get an infinite loop.
let refreshing: Observable<string> | null = null;
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  return next(req).pipe(catchError((error: HttpErrorResponse) => {
    if (error.status !== 401 || req.url.includes('/refresh'))
      return throwError(() => error);
    refreshing ??= auth.refresh().pipe(
      shareReplay(1), finalize(() => { refreshing = null; }));
    return refreshing.pipe(switchMap(token =>
      next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))));
  }));
};
// Order: requests run top to bottom, responses come back bottom to top.
provideHttpClient(withInterceptors([
  loggingInterceptor,   // sees the request first, the response last
  authInterceptor,
  retryInterceptor,     // closest to the network
]));

Was this explanation helpful?

Your feedback helps us improve the content for everyone.