~6 min de lecture
HttpContext Angular : 5 patterns pour configurer tes interceptors sans header magique
Tu as un interceptor d'auth qui colle un Authorization: Bearer ... sur toutes les requêtes. Un jour, tu dois appeler un endpoint public qui n'aime pas que tu envoies un token. Tu fais quoi ? Probablement la même chose que les trois équipes que j'ai auditées ce trimestre : tu ajoutes un header X-Skip-Auth: true, ton interceptor le lit, le supprime, et n'ajoute pas le Bearer. Ça marche. C'est aussi sale qu'efficace.
Six mois plus tard, tu as X-Skip-Auth, X-Silent-Request, X-Retry-Count, X-Cache-Bypass, et un wiki interne qui documente lesquels ton backend tolère. Tu les supprimes tous dans les interceptors. Un jour un dev oublie de supprimer le sien, le backend le voit, ouvre un ticket. Tu refactores. Tu réinventes le bus de métadonnées de requête. Sauf que ce bus existe depuis Angular 12 et s'appelle HttpContext.
Voici 5 patterns concrets pour le mettre en place dès maintenant, avec à chaque fois le before/after et la version qui tient en prod.
Valide Angular 12+ pour
HttpContext/HttpContextToken. LesHttpInterceptorFn(interceptors fonctionnels) arrivent en v15 avecwithInterceptors(). Tout le code de cet article suppose Angular 17+ et les bons réflexes :inject(), signals,OnPush, control flow.
TL;DR
| Pattern | Use case | Token |
|---|---|---|
SKIP_AUTH |
endpoint public, login form | HttpContextToken<boolean> |
DISABLE_LOADING |
polling, autosave, suggest | HttpContextToken<boolean> |
RETRY_POLICY |
endpoint flaky, idempotent | HttpContextToken<RetryPolicy> |
CACHE_TTL |
cache mémoire opt-in | HttpContextToken<number> |
CORRELATION_ID |
tracing par feature | HttpContextToken<string | null> |
Règle d'or : un header est un contrat avec ton backend. HttpContext est un contrat avec tes propres interceptors. Ne mélange pas les deux.
Comment ça marche, en 30 secondes
HttpContextToken<T> est une clé typée avec une valeur par défaut. Tu la crées une fois, tu la lis dans n'importe quel interceptor avec req.context.get(TOKEN), et tu la passes à un appel HTTP via l'option context.
import { HttpContext, HttpContextToken } from '@angular/common/http';
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
this.http.get('/api/public/health', {
context: new HttpContext().set(SKIP_AUTH, true),
});
Le () => false est la valeur par défaut, évaluée paresseusement à chaque lecture si la clé n'a pas été set sur la requête. Tu n'as donc jamais besoin de gérer le cas "absent" dans tes interceptors : la valeur par défaut couvre 100% des requêtes existantes qui n'ont jamais entendu parler de ton token.
Important : HttpContext ne quitte jamais le client. Il n'est ni sérialisé, ni envoyé sur le réseau. Pas de fuite vers le backend, pas de header à supprimer dans l'interceptor. C'est exactement la propriété qu'on cherche.
Pattern 1 : SKIP_AUTH pour les endpoints publics
Le before honteux
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from './auth-store';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthStore);
if (req.headers.has('X-Skip-Auth')) {
const cleaned = req.clone({
headers: req.headers.delete('X-Skip-Auth'),
});
return next(cleaned);
}
const token = auth.token();
if (!token) {
return next(req);
}
return next(
req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}),
);
};
Et côté appelant :
this.http.get('/api/public/teasers', {
headers: { 'X-Skip-Auth': 'true' },
});
Tu envoies un header qui n'a aucune raison d'exister, tu paies le clonage pour l'enlever, et un grep X-Skip-Auth te ramène autant de résultats que de réponses à la même question.
Le after
import { HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from './auth-store';
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH)) {
return next(req);
}
const token = inject(AuthStore).token();
if (!token) {
return next(req);
}
return next(
req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}),
);
};
L'appel :
import { HttpContext } from '@angular/common/http';
this.http.get('/api/public/teasers', {
context: new HttpContext().set(SKIP_AUTH, true),
});
Type-safe, isolé du wire, et un "find usages" sur SKIP_AUTH dans l'IDE te dit exactement quels endpoints opt-out. Plus de chasse à la chaîne de caractères.
Pattern 2 : DISABLE_LOADING pour les requêtes silencieuses
Le polling de notifications, l'autosave d'un formulaire, le suggest d'une combo. Tu ne veux pas que ton spinner global se déclenche à chaque appel, et tu ne veux surtout pas que chaque feature aille toucher au store du loader directement.
Avant
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { finalize, Observable } from 'rxjs';
import { LoaderStore } from './loader-store';
import { Notification } from './notification';
@Injectable({ providedIn: 'root' })
export class NotificationPoller {
private readonly http = inject(HttpClient);
private readonly loader = inject(LoaderStore);
poll(): Observable<Notification[]> {
this.loader.suppress();
return this.http.get<Notification[]>('/api/notifications').pipe(
finalize(() => this.loader.restore()),
);
}
}
L'appelant doit connaître le store de loader. Pire, tu fuites une transaction suppress / restore qui peut se casser dès qu'une autre requête bouge dans le même intervalle.
Après
import { HttpContextToken } from '@angular/common/http';
export const DISABLE_LOADING = new HttpContextToken<boolean>(() => false);
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs';
import { LoaderStore } from './loader-store';
import { DISABLE_LOADING } from './http-context-tokens';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(DISABLE_LOADING)) {
return next(req);
}
const loader = inject(LoaderStore);
loader.increment();
return next(req).pipe(finalize(() => loader.decrement()));
};
import { HttpClient, HttpContext } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { DISABLE_LOADING } from './http-context-tokens';
import { Notification } from './notification';
@Injectable({ providedIn: 'root' })
export class NotificationPoller {
private readonly http = inject(HttpClient);
poll(): Observable<Notification[]> {
return this.http.get<Notification[]>('/api/notifications', {
context: new HttpContext().set(DISABLE_LOADING, true),
});
}
}
Le poller n'a plus à connaître le store du loader. Plus de transaction qui fuit. Le loader compte les requêtes opt-in et c'est tout.
Pattern 3 : RETRY_POLICY typé par endpoint
Les retries automatiques sur toutes les requêtes sont une mauvaise idée : tu retries des POST non idempotents, tu masques de vraies erreurs, tu fais 4x le travail sur une 401. Mais sur un GET de référentiel un peu flaky, tu en veux. La règle est par endpoint, pas globale.
La valeur typée
import { HttpContextToken } from '@angular/common/http';
export type RetryPolicy = {
count: number;
delayMs: number;
retryOn: ReadonlyArray<number>;
};
export const NO_RETRY: RetryPolicy = { count: 0, delayMs: 0, retryOn: [] };
export const RETRY_POLICY = new HttpContextToken<RetryPolicy>(() => NO_RETRY);
L'interceptor
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { retry, throwError, timer } from 'rxjs';
import { RETRY_POLICY } from './http-context-tokens';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
const policy = req.context.get(RETRY_POLICY);
if (policy.count === 0) {
return next(req);
}
return next(req).pipe(
retry({
count: policy.count,
delay: (error: HttpErrorResponse, attempt) => {
if (!policy.retryOn.includes(error.status)) {
return throwError(() => error);
}
return timer(policy.delayMs * 2 ** (attempt - 1));
},
}),
);
};
L'expression 2 ** (attempt - 1) te donne un backoff exponentiel sans dépendance externe. Le retryOn te garantit que tu ne retries pas une 401 (qui ne marchera jamais sans nouvelle session) ni une 422 (qui ne marchera jamais sans nouveau payload).
L'appel
this.http.get<Reference[]>('/api/reference/codes', {
context: new HttpContext().set(RETRY_POLICY, {
count: 3,
delayMs: 500,
retryOn: [502, 503, 504],
}),
});
L'intention est lisible à l'appel, et il n'existe aucune façon d'activer ce retry par défaut sur les POST par accident. La valeur par défaut NO_RETRY ferme la porte.
Pattern 4 : CACHE_TTL pour un cache mémoire opt-in
Tu veux mettre en cache 30 secondes la liste des pays, ne jamais cacher les notifications, et garder 5 minutes la photo de profil du user courant. La règle est métier, par appel, et elle n'a rien à faire ni dans une variable globale ni dans une convention d'URL.
import { HttpContextToken } from '@angular/common/http';
export const CACHE_TTL = new HttpContextToken<number>(() => 0);
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { of, tap } from 'rxjs';
import { CACHE_TTL } from './http-context-tokens';
import { HttpMemoryCache } from './http-memory-cache';
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const ttl = req.context.get(CACHE_TTL);
if (ttl <= 0 || req.method !== 'GET') {
return next(req);
}
const cache = inject(HttpMemoryCache);
const key = req.urlWithParams;
const cached = cache.read(key);
if (cached) {
return of(cached.clone());
}
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.write(key, event, ttl);
}
}),
);
};
this.http.get<Country[]>('/api/countries', {
context: new HttpContext().set(CACHE_TTL, 30_000),
});
Tu gardes le contrat REST propre. Pas de header Cache-Control côté client qu'un serveur strict pourrait rejeter. Pas de switch global "cache on / cache off". La règle est lisible à l'appel et ne s'applique qu'aux GET, garde-fou explicite dans l'interceptor.
Pattern 5 : CORRELATION_ID pour le tracing par feature
L'observabilité moderne te demande de relier une action utilisateur ("Save invoice") à toutes les requêtes HTTP qu'elle déclenche. Un correlation ID dans le header X-Correlation-Id fait le boulot. Mais tu ne veux pas l'injecter dans chaque service, et tu ne veux surtout pas le stocker dans un singleton mutant qui te jouera des tours dès qu'une deuxième action sera lancée en parallèle.
import { HttpContextToken } from '@angular/common/http';
export const CORRELATION_ID = new HttpContextToken<string | null>(() => null);
import { HttpInterceptorFn } from '@angular/common/http';
import { CORRELATION_ID } from './http-context-tokens';
export const correlationInterceptor: HttpInterceptorFn = (req, next) => {
const id = req.context.get(CORRELATION_ID);
if (!id) {
return next(req);
}
return next(
req.clone({
setHeaders: { 'X-Correlation-Id': id },
}),
);
};
Et tu encapsules dans une feature :
import { HttpClient, HttpContext } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CORRELATION_ID } from './http-context-tokens';
import { Invoice, SavedInvoice } from './invoice';
@Injectable({ providedIn: 'root' })
export class InvoiceApi {
private readonly http = inject(HttpClient);
save(invoice: Invoice, correlationId: string): Observable<SavedInvoice> {
const context = new HttpContext().set(CORRELATION_ID, correlationId);
return this.http.post<SavedInvoice>('/api/invoices', invoice, { context });
}
}
Le correlation ID vit dans la feature, est passé explicitement, et est propagé sans que les helpers HTTP en aient à le savoir. Aucune mutation globale, aucune injection cachée, et un test de l'API peut vérifier le header en sortie sans monter le moindre store.
Combiner plusieurs tokens sur une requête
HttpContext.set() retourne le contexte lui-même, donc tu chaînes :
const context = new HttpContext()
.set(SKIP_AUTH, true)
.set(DISABLE_LOADING, true)
.set(CACHE_TTL, 60_000);
this.http.get('/api/public/featured', { context });
Pour les combinaisons répétées, factorise dans un helper :
import { HttpContext } from '@angular/common/http';
import { DISABLE_LOADING, SKIP_AUTH } from './http-context-tokens';
export const publicReadContext = (): HttpContext =>
new HttpContext().set(SKIP_AUTH, true).set(DISABLE_LOADING, true);
Une fonction, pas une constante. Sinon le même HttpContext est partagé entre toutes les requêtes, et un set sur l'une mute la valeur lue par les autres. C'est silencieux et difficile à débugger.
Tester un interceptor qui lit HttpContext
HttpTestingController te laisse vérifier le contenu de la requête sortante sans monter de TestBed alambiqué :
import {
HttpClient,
HttpContext,
provideHttpClient,
withInterceptors,
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { authInterceptor, SKIP_AUTH } from './auth-interceptor';
describe('authInterceptor', () => {
it('skips the Authorization header when SKIP_AUTH is true', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
const http = TestBed.inject(HttpClient);
const ctrl = TestBed.inject(HttpTestingController);
http
.get('/api/public', {
context: new HttpContext().set(SKIP_AUTH, true),
})
.subscribe();
const req = ctrl.expectOne('/api/public');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
});
Pas de mock du store d'auth, pas de stub. La requête sortante est l'observable de vérité.
Récap actionnable
- Crée tes
HttpContextToken<T>dans un fichier dédié (http-context-tokens.ts). Garde la valeur par défaut sûre :false,0,null,NO_RETRY. Le défaut ne doit jamais surprendre une requête legacy. - Préfère les types riches (
RetryPolicy) plutôt qu'unbooleanqui se transformera en quatre booleans dans six mois. - Lis le token au début de l'interceptor avec
req.context.get(TOKEN). Pas besoin de tester l'absence : la valeur par défaut s'en occupe. - Pour les combos fréquentes, expose une factory
publicReadContext(). Jamais une constante mutable partagée. - Au moment des tests,
HttpTestingControllerte donne accès à la requête sortante : assert sur les headers et le contexte directement. - Pour migrer un header magique existant, fais le double pendant un sprint : nouveau token + ancien header lus simultanément. Quand la métrique de l'ancien header tombe à zéro, tu supprimes.
HttpContext n'est pas une feature exotique. C'est l'extension typée et locale de ta config par requête. Le jour où tu l'as adoptée, tu te demandes comment tu vivais avec quatre headers X- à supprimer dans chaque interceptor.