Angularの関数型インターセプターの順序と二重実行をレビューする
Angularの関数型インターセプターの順序と二重実行をレビューする
認証ヘッダーを足すインターセプター、401で更新をかけるインターセプター、所要時間を測るインターセプター。それぞれは短く、単体では読んで分かる。差分でも1ファイルずつ現れるため、レビューも1本ずつ読んで終わりやすい。
ところが1本のリクエストが実際に通る順序は、関数の中身ではなく provideHttpClient() の引数の並びで決まる。しかも鎖が組まれるのはアプリで最初に走ったリクエストの時点で、以後は同じ鎖が使われる。順序と、何が何回走るかは、インターセプターの外側にある仕掛けで決まっている。
登録順と入れ子の向き
@angular/common 22.2.1 の HttpInterceptorHandler.handle が鎖を組む部分はこうなっている。
const dedupedInterceptorFns = Array.from(
new Set([...this.injector.get(HTTP_INTERCEPTOR_FNS), ...rootInterceptorFns]),
);
this.chain = dedupedInterceptorFns.reduceRight(
(nextSequencedFn, interceptorFn) => chainedInterceptorFn(nextSequencedFn, interceptorFn, this.injector),
interceptorChainEndFn,
);reduceRight なので、配列の先頭にあるものがもっとも外側に来る。chainedInterceptorFn の中身は、次の1つを next として渡すだけの入れ子である。
function chainedInterceptorFn(chainTailFn, interceptorFn, injector) {
return (initialRequest, finalHandlerFn) =>
runInInjectionContext(injector, () =>
interceptorFn(initialRequest, downstreamRequest => chainTailFn(downstreamRequest, finalHandlerFn)),
);
}配列の先頭がリクエストを最初に触り、通信から戻った応答は最後に通る。withInterceptors([a, b, c]) と書けば、リクエストは a → b → c、応答は c → b → a の順になる。所要時間を測るインターセプターは、計りたい範囲を内側に抱える位置に置くことになる。認証ヘッダーの付与より前に置けば更新の待ち時間も含まれ、後に置けば含まれない。
配列の先頭が自分の書いたものだとは限らない。provideHttpClient() は、引数の feature を展開する前に XSRF のインターセプターを積む。
const providers = [HttpClient, FetchBackend, HttpInterceptorHandler,
{ provide: HttpHandler, useExisting: HttpInterceptorHandler },
{ provide: HttpBackend, useFactory: () => inject(FetchBackend) },
{ provide: HTTP_INTERCEPTOR_FNS, useValue: xsrfInterceptorFn, multi: true },
];
for (const feature of features) {
providers.push(...feature.ɵproviders);
}したがって xsrfInterceptorFn は常にいちばん外側である。withNoXsrfProtection() を書いても鎖から抜けるわけではなく、XSRF_ENABLED が偽になって何もせず next(req) を呼ぶだけになる。クラス形式のインターセプターを withInterceptorsFromDi() で持ち込んだ場合は、HTTP_INTERCEPTORS の全体が1つの枠として、withInterceptorsFromDi() を書いた位置に入る。関数型とクラス形式が混在した差分では、ファイルの数ではなく provideHttpClient() の引数の順序を読む。
鎖が組まれるのは最初の1本目
this.chain === null の判定があるため、鎖の組み立ては1度だけである。HTTP_INTERCEPTOR_FNS を読むのもその1回で、2本目以降のリクエストは組み終わった関数をそのまま使う。実行時にインターセプターを足したり外したりする実装は、最初のリクエストより後には効果を持たない。有効と無効を切り替えたいなら、鎖の中で条件を見る形にする。
組み立ての前に new Set が挟まっている。同じ関数が2回入っていれば1つに畳まれるが、畳まれる条件は参照の同一性である。
provideHttpClient(withInterceptors([makeAuthInterceptor()])),
// 別の場所で
provideHttpClient(withInterceptors([makeAuthInterceptor()])),ファクトリを呼ぶたびに別の関数ができるため、Set は別物として扱い、ヘッダーの付与が2回走る。インターセプターをモジュール直下の const で公開しているコードベースでは起きないが、設定を引数に取るファクトリ形式にした時点で、同じ設定なら同じ参照を返す配慮が要る。
子インジェクターでの二度目のprovideHttpClient
遅延ルートの providers に provideHttpClient() をもう一度書いた差分は、意図を確かめる対象である。HTTP_INTERCEPTOR_FNS は multi のトークンで、子の環境インジェクターで宣言し直すと親の寄与は引き継がれない。そのルート配下の HttpClient は、子に書いたインターセプターと XSRF だけの鎖を持つ。認証ヘッダーが root 側にあれば、そのルートの通信だけ無認証で出る。
親の鎖も通したいときに使うのが withRequestsMadeViaParent() である。これは HttpBackend を親の HttpHandler に差し替えるので、子の鎖を抜けた要求がそのまま親の鎖へ入る。handle の冒頭はこの構成を見分けている。
const parentHandler = this.injector.get(HttpHandler, null, { skipSelf: true });
const isDelegating = parentHandler !== null && this.backend === parentHandler;
const rootInterceptorFns = this.injector.get(HTTP_ROOT_INTERCEPTOR_FNS, [],
isDelegating ? { self: true } : undefined);委譲している子は HTTP_ROOT_INTERCEPTOR_FNS を自分の階層だけから読む。このトークンに入るのは SSR の転送キャッシュのインターセプターで、親子の両方で鎖に入れば1本のリクエストが2回キャッシュを見ることになる。親へ委譲する構成では、それが起きないように読み方が切り替わっている。裏を返せば、withRequestsMadeViaParent() を書かずに子で provideHttpClient() を呼ぶ構成は、親の鎖を通らない独立した経路である。レビューでは、子に置いた意図が「親の共通処理を外したい」なのかを差分の外から確かめる。
retryで再実行される範囲
再試行は、呼び出し側に書く場合とインターセプターに書く場合がある。どちらに書いたかで、再実行される範囲が変わる。
HttpClient.request は購読のたびに handle を呼ぶ形になっている。
const events$ = of(req).pipe(concatMap(req => this.handler.handle(req)));handle の中で chain(initialRequest, ...) が同期的に呼ばれ、各インターセプターの本体が順に走る。つまりインターセプターの本体は、呼び出し側の購読1回につき1回走る。呼び出し側に retry(1) を置くと購読がもう1回起き、組み終わった鎖を本体が上から順にもう一度通る。ヘッダーの付与もログの記録も、その分だけ行われる。
インターセプターの中に置いた場合は違う。
export const retryInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(retry({ count: 2, delay: 1000 }));next(req) が返す Observable を購読し直すため、再実行されるのは購読の側だけである。下流のインターセプターの本体はすでに1回走り終わっていて、そこで組み立てた要求が固定されている。たとえば下流で Authorization を付けていれば、再試行は同じトークンのまま飛ぶ。トークンが切れたことによる失敗に、この形の再試行は意味を持たない。更新してから投げ直す処理は、要求を組み立て直せる位置、つまりトークンを読むインターセプター自身の中に置くことになる。
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthStore);
return next(withToken(req, auth.token())).pipe(
catchError(err => err.status === 401
? from(auth.refresh()).pipe(switchMap(token => next(withToken(req, token))))
: throwError(() => err)),
);
};next を2回呼んでいるが、通信が2回走るのは1回目が401で終わったときだけである。無条件に2回呼ぶ形、たとえば計測のために next(req).subscribe() を別に足してから return next(req) と書く形は、そのまま通信を2本出す。最終のハンドラは購読ごとに新しい fetch を始める冷たい Observable なので、next の戻りを2回購読すれば2回通信する。
そして呼び出し側とインターセプターの両方に再試行があると、試行回数は掛け算になる。呼び出し側の retry(2) とインターセプターの retry(2) で、最悪9回の通信が出る。どちらに置くかは設計の判断だが、両方に置くことを選んだ差分はまずない。片方は書き忘れではなく、前の変更の残りである場合が多い。
同時実行とトークン更新
上の例には、まだ穴がある。401が同時に3本返ったとき、auth.refresh() が3回走る。インターセプターの本体はリクエストごとに走るため、更新の重複を止める仕組みはインターセプターの外に要る。更新中のPromiseを保持して使い回す形にするのが素直である。
refresh(): Promise<string> {
this.pending ??= this.requestNewToken().finally(() => (this.pending = undefined));
return this.pending;
}レビューでは、更新をかける経路が1本に束ねられているかを refresh の実装の側で確かめる。インターセプターのコードだけを読んでいると、ここは見えない。
injectが使える範囲
chainedInterceptorFn が runInInjectionContext で包むため、インターセプターの中で inject() が使える。使えるのは provideHttpClient() を呼んだ環境インジェクターから見えるものに限られ、呼べるのは同期の範囲だけである。switchMap のコールバックや await の後で inject() を呼ぶ形は、注入コンテキストを抜けているため例外になる。必要な依存は本体の先頭で受け取る。注入コンテキストと寿命の関係はAngularの購読と副作用の寿命を注入コンテキストから読むに整理した。
もう1つ、22.1 から鎖の呼び出しが untracked で包まれている。
return untracked(() => chain(initialRequest, downstreamRequest => this.backend.handle(downstreamRequest)));包まれるのは鎖の同期的な実行であり、そこで読んだ signal は外側の computed や effect の依存にならない。httpResource の読み込みの中からインターセプターがストアの signal を読んでも、その signal の変化で再取得が起きることはない。頼れる挙動として使うより、インターセプターで状態を読む設計そのものを見直す材料にする。インターセプターから signal を書き換える実装は、変更検知の外から状態が動くため、どこで値が変わったのかを追う手がかりが残らない。
レビュー観点チェックリスト
provideHttpClient()の引数の順序が、認証・再試行・計測の依存の順になっているか- 計測のインターセプターが、測りたい範囲を内側に抱える位置にあるか
- クラス形式のインターセプターが新規コードに増えていないか(
withInterceptorsFromDiは1つの枠として並ぶ) - ファクトリ形式のインターセプターが、同じ設定で同じ参照を返すか(
Setの畳み込みは参照で決まる) - 子インジェクターの
provideHttpClient()が、親の鎖を外す意図で書かれているか - 親の共通処理を通したい構成に
withRequestsMadeViaParent()があるか - 再試行が呼び出し側とインターセプターの両方に無いか
- 再試行がインターセプターにあるとき、要求を組み立て直す必要の無い失敗に限られているか
- トークン更新が、同時実行で重複しない形に束ねられているか
next(req)の戻りを2回購読していないかinject()が本体の同期の範囲だけで呼ばれているか- インターセプターから signal やストアを書き換えていないか
おわりに
インターセプターの差分は1本ずつ現れるが、挙動を決めるのは並びである。読む順序は、インターセプターのファイルより先に provideHttpClient() の呼び出しで、引数の列を上から下に追う。そこに現れない XSRF がいちばん外側にいることと、クラス形式の一群が withInterceptorsFromDi() の位置に入ることを織り込む。
二重実行の多くは、再試行の位置とトークン更新の束ね方から出てくる。インターセプターの中の retry は下流の本体を再実行しないため、要求を作り直す必要がある失敗には届かない。逆に呼び出し側の retry は鎖の全体を再実行するので、ログも計測も重複する。どちらの性質も、鎖が関数の入れ子でできていて、本体が購読のたびに1回走ることから説明できる。
通信とインターセプターの観点の全体はAngular 22のRxJS運用と購読の寿命をレビューする100観点の「インターセプター」の群に並べてある。Fetch が既定になった後の差分の読み方はAngular 22でHttpClientがFetch既定になった後の通信コードのレビュー観点にある。