DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

Auth, retry, logging: three interceptors, zero classes

Interceptors used to be the most ceremony-per-feature API in Angular: a class, an interface, a multi-provider registration with HTTP_INTERCEPTORS and multi: true — the line everyone copy-pasted and nobody could write from memory. The functional version is just... a function, registered in order:

provideHttpClient(
  withInterceptors([authInterceptor, retryInterceptor, loggingInterceptor]),
)
Enter fullscreen mode Exit fullscreen mode

Here are the three I end up writing on basically every project, with the details that distinguish "works in the demo" from "works in production".

Auth: clone, don't mutate — and let some requests through

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SKIP_AUTH)) return next(req);

  const token = inject(TokenStore).token();
  if (!token) return next(req);

  return next(req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  }));
};
Enter fullscreen mode Exit fullscreen mode

Requests are immutable, so it's clone() or nothing. The part worth copying is the first line: SKIP_AUTH is an HttpContextToken, and it solves the "but the login call itself shouldn't carry a token" problem without the URL-matching if-chains that grow hair over time:

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

// at the call site that needs the exception:
this.http.post('/auth/login', creds, {
  context: new HttpContext().set(SKIP_AUTH, true),
});
Enter fullscreen mode Exit fullscreen mode

The exception is declared where the exception is, not in a growing denylist inside the interceptor. Six months later, that's the difference between reading one line and archaeology.

Retry: the interceptor that can double-charge someone

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

  return next(req).pipe(
    retry({
      count: 2,
      delay: (error, retryCount) => {
        const status = error instanceof HttpErrorResponse ? error.status : 0;
        if (status > 0 && status < 500) throw error; // 4xx: our fault, don't retry
        return timer(1000 * Math.pow(2, retryCount)); // 2s, 4s
      },
    }),
  );
};
Enter fullscreen mode Exit fullscreen mode

Two guards carry all the weight. Only GETs: a POST that times out may have succeeded server-side — retry it and you've created the duplicate order / double payment incident that ends up with your name on the postmortem. Only 5xx and network errors: retrying a 401 or a 404 is asking the same question louder. With those two rules, automatic retry goes from scary to boring — and a flaky corporate proxy mostly disappears from your error tracker.

Logging: only the requests that deserve it

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const started = performance.now();
  return next(req).pipe(
    finalize(() => {
      const ms = performance.now() - started;
      if (ms > 1000) {
        console.warn(`[slow] ${req.method} ${req.urlWithParams}${Math.round(ms)}ms`);
      }
    }),
  );
};
Enter fullscreen mode Exit fullscreen mode

Logging every request is noise nobody reads. Logging requests over a threshold gives you something I've found disproportionately useful: the slow-endpoint report assembles itself in the console while you develop, and the worst offenders become impossible to not know about.

Order is not a detail

The array order is execution order on the way out, reversed on the way back. With [auth, retry, logging]: auth runs first, so every retry attempt carries the token — flip them and a token refresh between attempts can send a stale header. And because logging sits innermost, it times each attempt rather than the sum. When an interceptor chain misbehaves, the order is the first thing I check, and it's the thing the type system can't check for you: the array compiles in any order. It just doesn't work in any order.

Top comments (0)