Auth, retry, logging: three interceptors, zero classes
Functional interceptors turned HTTP middleware into composable functions. The three I install on every project, the ordering rule that isn't optional, and how to skip an interceptor for one request.
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.