EthicsComputerCourses
Join training +91 90532 10052

HTTP Interceptors: Auth, Errors & Retries

Angular · Lesson 23 of 41 Intermediate 8 min read Updated

The HttpClient lesson introduced interceptors with a simple header. Real company apps need more from that layer: every request to your API carries a login token, an expired token is refreshed without throwing the user out, a flaky mobile network gets a second chance, and every failure becomes a message a human can understand.

In this lesson you will build those four interceptors step by step for an HR leave app, wire them in the right order and test them without a server.

How a chain of interceptors runs

withInterceptors([a, b, c]) builds a chain. A request passes through a, then b, then c, then goes to the network. The response (or error) travels back the other way: c sees it first and a sees it last.

Text
request  ->  errorInterceptor  ->  authInterceptor  ->  retryInterceptor  ->  server
response <-  errorInterceptor  <-  authInterceptor  <-  retryInterceptor  <-  server

That gives a simple rule for ordering: the interceptor that should see the final outcome goes first. The error interceptor must report an error only after retries and token refresh have failed, so it is the outermost. The retry interceptor is closest to the server, so it repeats only the network call.

Step by step: an auth layer that survives expiry

Most APIs use two tokens. A short-lived access token (often 15 minutes) goes in the Authorization header of every request. A longer-lived refresh token can be swapped for a new pair when the access token expires, and the server answers an expired access token with status 401 Unauthorized.

  1. Create context tokens. An HttpContextToken is a typed flag you attach to a single request, so an interceptor can treat it differently. The default value comes from the function you pass in:
TypeScript
import { HttpContextToken } from '@angular/common/http';

export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
export const SILENT_ERRORS = new HttpContextToken<boolean>(() => false);
  1. Write an AuthStore that keeps the tokens in a signal and knows how to refresh them. Several requests can fail with 401 at the same moment; shareReplay(1) makes them all wait for one refresh call instead of sending five:
TypeScript
import { HttpClient, HttpContext } from '@angular/common/http';
import { Service, computed, inject, signal } from '@angular/core';
import { Observable, finalize, map, shareReplay, tap } from 'rxjs';

interface Tokens {
  access: string;
  refresh: string;
}

@Service()
export class AuthStore {
  private http = inject(HttpClient);
  private readonly tokens = signal<Tokens | null>(null);
  private refreshing: Observable<string> | null = null;

  readonly accessToken = computed(() => this.tokens()?.access ?? null);
  readonly isLoggedIn = computed(() => this.tokens() !== null);

  setTokens(tokens: Tokens) {
    this.tokens.set(tokens);
  }

  logout() {
    this.tokens.set(null);
  }

  refresh(): Observable<string> {
    this.refreshing ??= this.http
      .post<Tokens>(
        '/api/auth/refresh',
        { refreshToken: this.tokens()?.refresh },
        { context: new HttpContext().set(SKIP_AUTH, true).set(SILENT_ERRORS, true) },
      )
      .pipe(
        tap((t) => this.tokens.set(t)),
        map((t) => t.access),
        finalize(() => (this.refreshing = null)),
        shareReplay(1),
      );
    return this.refreshing;
  }
}

The refresh request carries both flags: it must not get the old access token, and if it fails, the user should see one message, not two.

  1. Write the auth interceptor. It adds the token, and when the server answers 401 it refreshes once and repeats the original request with the new token. If the refresh also fails, the session is really over, so it logs out and sends the user to the login page:
TypeScript
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SKIP_AUTH)) {
    return next(req);
  }
  const auth = inject(AuthStore);
  const router = inject(Router);
  const withToken = (token: string | null) =>
    token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;

  return next(withToken(auth.accessToken())).pipe(
    catchError((err: unknown) => {
      if (!(err instanceof HttpErrorResponse) || err.status !== 401 || !auth.isLoggedIn()) {
        return throwError(() => err);
      }
      return auth.refresh().pipe(
        switchMap((token) => next(withToken(token))),
        catchError((refreshError) => {
          auth.logout();
          router.navigate(['/login']);
          return throwError(() => refreshError);
        }),
      );
    }),
  );
};

Notice that inject() is called at the top of the interceptor function, which is an injection context. Do not call it inside the catchError callback.

  1. Write a retry interceptor. Retrying is safe only for requests that do not change data. A GET that fails twice is harmless to repeat; a POST /api/leaves that timed out may already have been saved, and repeating it could apply for leave twice. Retry only network failures (status 0) and server errors (5xx), with a growing delay:
TypeScript
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { retry, throwError, timer } from 'rxjs';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') {
    return next(req);
  }
  return next(req).pipe(
    retry({
      count: 2,
      delay: (err: HttpErrorResponse, attempt) =>
        err.status === 0 || err.status >= 500 ? timer(attempt * 1000) : throwError(() => err),
    }),
  );
};

The first retry waits 1 second and the second waits 2 seconds. A 404 or 422 is thrown straight away, because asking again will not change the answer.

  1. Write the error interceptor. It turns status codes into plain messages and hands them to a small Notifier service (a signal holding a list of messages that a toast component displays). It rethrows the error, so the component that made the request can still react, for example by stopping a spinner:
TypeScript
export function messageFor(err: HttpErrorResponse): string {
  if (err.status === 0) return 'No internet connection. Check your network and try again.';
  if (err.status === 401) return 'Your session has ended. Please log in again.';
  if (err.status === 403) return 'You do not have permission to do this.';
  if (err.status === 404) return 'We could not find what you asked for.';
  if (err.status >= 500) return 'The server had a problem. Please try again in a few minutes.';
  return 'Something went wrong. Please check your input.';
}

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const notifier = inject(Notifier);
  return next(req).pipe(
    catchError((err: unknown) => {
      if (err instanceof HttpErrorResponse && !req.context.get(SILENT_ERRORS)) {
        notifier.error(messageFor(err));
      }
      return throwError(() => err);
    }),
  );
};
  1. Register the chain in app.config.ts, outermost first:
TypeScript
provideHttpClient(withInterceptors([errorInterceptor, authInterceptor, retryInterceptor])),

With the whole chain in place, this is what happens in each situation (checked with the tests below):

Output
GET with a token          -> header "Authorization: Bearer abc123"
GET fails 503, then 200   -> retried after 1 s, data arrives, no message shown
GET fails 500 three times -> error 500, one message: "The server had a problem. Please try again in a few minutes."
POST fails 503            -> not retried, same message shown once
Two GETs get 401 together -> one refresh call, both repeated with "Bearer new"
Refresh fails             -> logged out, navigate(['/login']), one message: "Your session has ended. Please log in again."

Connecting the login page and the guard

The interceptors handle tokens during API calls; the router decides who may open which page. A guard like the authGuard from Route Parameters, Guards & Lazy Loading reads auth.isLoggedIn() and redirects to /login?returnUrl=.... The login page posts the user's email and password with SKIP_AUTH, calls auth.setTokens(...) with the answer, then navigates to the returnUrl. Together they form the complete flow: guard at the door, interceptor on every request, refresh in the background, logout when the session truly ends.

Where should tokens live? Keeping the access token only in memory (a signal) means a malicious script injected into the page cannot read it from localStorage later. Many teams go one step further and let the server keep the refresh token in an HttpOnly cookie that JavaScript cannot read at all. Discuss this with your backend team; the Security lesson explains the risks.

Testing an interceptor

HttpTestingController sits behind the interceptors, so your tests exercise the real chain. Vitest's fake timers skip the retry delay:

TypeScript
beforeEach(() => {
  vi.useFakeTimers();
  TestBed.configureTestingModule({
    providers: [
      provideRouter([]),
      provideHttpClient(withInterceptors([errorInterceptor, authInterceptor, retryInterceptor])),
      provideHttpClientTesting(),
    ],
  });
});
afterEach(() => vi.useRealTimers());

it('retries a GET after a 503', () => {
  const http = TestBed.inject(HttpClient);
  const ctrl = TestBed.inject(HttpTestingController);
  let data: string[] = [];
  http.get<string[]>('/api/leaves').subscribe((d) => (data = d));

  ctrl.expectOne('/api/leaves').flush('down', { status: 503, statusText: 'Service Unavailable' });
  vi.advanceTimersByTime(1000);
  ctrl.expectOne('/api/leaves').flush(['Diwali leave']);

  expect(data).toEqual(['Diwali leave']);
  expect(TestBed.inject(Notifier).messages()).toEqual([]);
});

Common mistakes

  • Retrying POST, PUT or PATCH. You can create duplicate orders or leave requests. Retry only safe, read-only requests unless the API supports idempotency keys.
  • Refreshing once per failed request. Without a shared refresh observable, five parallel 401s send five refresh calls and the later ones may invalidate the earlier tokens.
  • Swallowing errors. Returning EMPTY or of(null) from catchError in a global interceptor hides failures from the component, which then waits forever.
  • Sending the token everywhere. Add it only to your own API (check the URL or use a context flag), never to third-party domains.
  • Showing raw server text such as stack traces to users. Map errors to friendly messages and log details separately.

Key points

  • Interceptors run in array order for requests and in reverse order for responses; put the one that needs the final result first.
  • HttpContextToken flags let single requests opt out, for example SKIP_AUTH for login and refresh calls.
  • On a 401, refresh the token once (share it with shareReplay) and repeat the request; log out if the refresh fails.
  • Retry only GET requests that failed with status 0 or 5xx, with a delay, using retry({ count, delay }).
  • A global error interceptor converts errors into user messages and rethrows them so components can still react.

Exercise

Add a fourth interceptor, loadingInterceptor, that counts requests in flight in a LoadingStore service (a count signal and a busy computed signal that is true when the count is above zero). Requests with a new SILENT_LOADING context token must not change the count. Decide where it goes in the chain and explain why.

Show solution
TypeScript
export const SILENT_LOADING = new HttpContextToken<boolean>(() => false);

@Service()
export class LoadingStore {
  readonly count = signal(0);
  readonly busy = computed(() => this.count() > 0);
}

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SILENT_LOADING)) {
    return next(req);
  }
  const loading = inject(LoadingStore);
  loading.count.update((c) => c + 1);
  return next(req).pipe(finalize(() => loading.count.update((c) => c - 1)));
};

provideHttpClient(withInterceptors([loadingInterceptor, errorInterceptor, authInterceptor, retryInterceptor])),

Put it first, so the spinner stays visible during retries and token refresh and disappears only when the final result arrives. finalize runs on success, error and cancellation, so the count never gets stuck.

Quick Quiz

1. With withInterceptors([a, b, c]), which interceptor sees the server's response first?

2. Why should a retry interceptor skip POST requests?

3. What is shareReplay(1) used for in the refresh logic?

4. What should a global error interceptor do after showing a message?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call