VOOZH about

URL: https://dev.to/aelmufti/auth-retry-logging-three-interceptors-zero-classes-3jh2

⇱ Auth, retry, logging: three interceptors, zero classes - DEV Community


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]),
)

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}` },
 }));
};

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),
});

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
 },
 }),
 );
};

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`);
 }
 }),
 );
};

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.