Angularのインターセプターとは、HttpClient が送るすべてのリクエストと、返ってくるすべてのレスポンスの通り道に、共通の処理を差し込む仕組みです。 いまの書き方は、関数ひとつで書く HttpInterceptorFn を provideHttpClient(withInterceptors([...])) に並べる形です。トークンの付与、エラーメッセージの共通化、ログ、リトライを各画面から切り離して1か所にまとめられます。

この記事では、そのまま貼って使える4本のインターセプター(トークン付与・共通エラー処理・ログ・リトライ)に加えて、401 のときに1回だけトークンを再取得して再送する実装、特定のリクエストだけ対象から外す HttpContextToken、複数並べたときの通過順、クラス型との対応、テストの書き方までを扱います。掲載したコードと出力は、すべて下の環境で動かしたものです。

確認環境:Ubuntu 24.04 / Node.js 24.19.0 / Angular 22.2.1(@angular/cli 22.2.1 の ng new で作成)/ RxJS 7.8.2 / TypeScript 6.0.3 / Vitest 5.0.3(ng test)/ Chromium 141(Playwright 1.56.0)

インターセプターの早見表

やりたいこと 書き方 置く位置の目安
インターセプターを登録する provideHttpClient(withInterceptors([a, b, c])) app.config.ts
関数の形 const x: HttpInterceptorFn = (req, next) => next(req) 1ファイル1本
ヘッダーを足す(トークン付与) next(req.clone({ setHeaders: { Authorization: ... } })) 再取得より外側
サービスを使う 関数の先頭で inject(AuthService) コールバックの中では呼ばない
エラーを共通で処理する next(req).pipe(catchError(...)) で通知し、throwError で渡し直す 再取得・リトライより外側
所要時間やステータスを記録する next(req).pipe(tap({ next, error })) いちばん外側
一時的なエラーを送り直す next(req).pipe(retry({ count, delay })) いちばん内側
401 で再取得して再送する catchError → auth.refresh() → switchMap(() => next(req.clone(...))) トークン付与の内側
特定のリクエストを対象から外す HttpContextToken を作り、req.context.get(TOKEN) で判定 各インターセプターの先頭
クラス型も一緒に動かす withInterceptorsFromDi() を追加 書いた位置で動く
テストする provideHttpClient(withInterceptors([...])) と provideHttpClientTesting() HttpTestingController で応答を返す

通過順のルールはひとつだけです。リクエストは配列に書いた順に通り、レスポンスは逆順に戻ります。この記事の後半で、console 出力で確かめた結果を載せています。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularインターセプターの使い方|トークン付与とエラー共通処理」

インターセプターの基本の形と登録のしかた

関数ひとつで書ける

HttpInterceptorFn は、リクエスト req と、次の処理へ渡す関数 next を受け取り、レスポンスの Observable を返す関数です。

引数・戻り値 中身 できること
req HttpRequest(書き換え不可) URL・メソッド・ヘッダー・context を読む。変えたいときは req.clone({...})
next 次のインターセプター(最後は実際の通信) next(req) で先へ進める。呼ばなければ通信は行われない
戻り値 Observable<HttpEvent> pipe() でレスポンスやエラーに処理を足す

「next(req) の前に書いた処理はリクエストに、pipe() の中に書いた処理はレスポンスに効く」 と覚えると、どのインターセプターも同じ読み方ができます。

app.config.ts に並べる

登録は provideHttpClient に withInterceptors を渡すだけです。この記事で作る5本を並べた完成形を先に示します。順番の理由はあとの章で説明します。

// src/app/app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './http/auth.interceptor';
import { errorInterceptor } from './http/error.interceptor';
import { loggingInterceptor } from './http/logging.interceptor';
import { refreshInterceptor } from './http/refresh.interceptor';
import { retryInterceptor } from './http/retry.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([
        loggingInterceptor, // 1. いちばん外側:最終結果と所要時間を記録
        errorInterceptor,   // 2. 再取得・リトライのあとに残ったエラーだけ通知
        authInterceptor,    // 3. トークン付与
        refreshInterceptor, // 4. 401 なら再取得して再送
        retryInterceptor,   // 5. いちばん内側:一時的なエラーを送り直す
      ]),
    ),
  ],
};

Angular 22.2.1 では、provideHttpClient() を書かなくても HttpClient 自体は注入できました。ただしインターセプターを使うには provideHttpClient(withInterceptors([...])) の記述が必要です。

登録順と通過順を console 出力で確かめる

名前を出力するだけのインターセプターを3本作り、A・B・C の順に登録して確かめます。

// src/app/http/order.interceptors.ts
import { HttpEventType, HttpHandler, HttpInterceptor, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { tap } from 'rxjs';

const trace = (name: string): HttpInterceptorFn => (req, next) => {
  console.log(`-> ${name} request`);
  return next(req).pipe(
    tap((event) => {
      if (event.type === HttpEventType.Response) {
        console.log(`<- ${name} response`);
      }
    }),
  );
};

export const aInterceptor = trace('A');
export const bInterceptor = trace('B');
export const cInterceptor = trace('C');

@Injectable()
export class LegacyInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    console.log('-> Legacy(class) request');
    return next.handle(req).pipe(
      tap((event) => {
        if (event.type === HttpEventType.Response) {
          console.log('<- Legacy(class) response');
        }
      }),
    );
  }
}

withInterceptors([aInterceptor, bInterceptor, cInterceptor]) で登録し、GET を1回送ったときの出力です。

-> A request
-> B request
-> C request
(backend) 200 を返す
<- C response
<- B response
<- A response
** subscribe next
方向 通る順番 意味
リクエスト A → B → C → 通信 配列の先頭が最初にリクエストを受け取る
レスポンス 通信 → C → B → A → 呼び出し元 配列の先頭が最後にレスポンスを受け取る

配列の先頭ほど「外側」、末尾ほど「通信に近い内側」 です。玉ねぎの皮のように包んでいると考えると、どの処理をどこに置くかを決めやすくなります。公式ドキュメントにも、設定したインターセプターは providers に並べた順でつながると書かれています。

コピペで使える4本のインターセプター

1. トークン付与(Authorization ヘッダー)

まず、トークンを持つサービスです。再取得の処理と、あとで使う除外用の SKIP_AUTH も同じファイルに置いています。

// src/app/http/auth.service.ts
import { HttpClient, HttpContext, HttpContextToken } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, finalize, map, shareReplay, tap } from 'rxjs';

/** true を渡したリクエストにはトークンを付けない(ログイン・再取得・外部APIなど) */
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly http = inject(HttpClient);
  readonly accessToken = signal<string | null>(null);
  private refreshing$: Observable<string> | null = null;

  /** 再取得は同時に1回だけ。進行中ならその結果を共有する */
  refresh(): Observable<string> {
    if (!this.refreshing$) {
      this.refreshing$ = this.http
        .post<{ accessToken: string }>('/api/auth/refresh', {}, {
          context: new HttpContext().set(SKIP_AUTH, true),
          withCredentials: true,
        })
        .pipe(
          map((res) => res.accessToken),
          tap((token) => this.accessToken.set(token)),
          finalize(() => (this.refreshing$ = null)),
          shareReplay(1),
        );
    }
    return this.refreshing$;
  }

  logout(): void {
    this.accessToken.set(null);
  }
}

インターセプター本体は数行です。

// src/app/http/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService, SKIP_AUTH } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).accessToken();
  if (!token || req.context.get(SKIP_AUTH)) {
    return next(req);
  }
  // リクエストは書き換えられないので clone で新しく作る
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

トークン abc123 を入れて GET を送り、実際に出ていくリクエストのヘッダーを出力した結果です。

Authorization = Bearer abc123

req は書き換えられないオブジェクトなので、ヘッダーを足すときは必ず req.clone() で新しいリクエストを作って next に渡します。

2. エラーの共通処理

ステータスごとのメッセージを1か所で決め、通知用のサービスへ渡します。画面側の subscribe の error にも同じエラーが届くよう、最後に throwError で渡し直すのがポイントです。

// src/app/http/notifier.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Notifier {
  readonly message = signal('');
  show(text: string): void {
    this.message.set(text);
  }
}
// src/app/http/error.interceptor.ts
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Notifier } from './notifier.service';

/** true を渡したリクエストは共通の通知を出さない(画面側で個別に表示したいとき) */
export const SILENT_ERROR = new HttpContextToken<boolean>(() => false);

const MESSAGES: Record<number, string> = {
  0: 'サーバーに接続できませんでした。通信状態を確認してください。',
  400: '入力内容を確認してください。',
  401: 'ログインの有効期限が切れました。もう一度ログインしてください。',
  403: 'この操作を行う権限がありません。',
  404: 'データが見つかりませんでした。',
  409: 'ほかの人が先に更新しました。画面を読み込み直してください。',
};

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const notifier = inject(Notifier);
  return next(req).pipe(
    catchError((err: unknown) => {
      if (err instanceof HttpErrorResponse && !req.context.get(SILENT_ERROR)) {
        const text =
          MESSAGES[err.status] ??
          (err.status >= 500 ? 'サーバーでエラーが起きました。時間をおいて試してください。' : '通信でエラーが起きました。');
        notifier.show(text);
      }
      // 握りつぶさず、呼び出し元にも同じエラーを渡す
      return throwError(() => err);
    }),
  );
};

404・通信不能(status 0)・503 を返したときの出力です。

notifier = データが見つかりませんでした。
caught   = 404 Http failure response for /api/tasks/999: 404 Not Found
status 0 = サーバーに接続できませんでした。通信状態を確認してください。
status 503 = サーバーでエラーが起きました。時間をおいて試してください。
ステータス 起きていること 共通処理での扱い方の例
0 サーバーまで届いていない(回線・CORS など) 接続の確認を促す
400 / 422 入力内容がサーバーの検証を通らなかった 共通の案内を出し、項目ごとの表示は画面側で行う
401 ログインの有効期限切れ 再取得を試し、それでも 401 ならログイン画面へ
403 権限がない 権限がないことを伝える
404 対象がない 見つからないことを伝える
409 更新の競合 読み込み直しを促す
500 番台 サーバー側のエラー 時間をおいて試すよう伝える

入力フォームのように、画面ごとに細かく表示したい場面では SILENT_ERROR を付けて共通の通知を止められます(使い方は「HttpContextToken で特定のリクエストを除外する」の章)。

3. ログ(メソッド・URL・ステータス・所要時間)

// src/app/http/logging.interceptor.ts
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const started = performance.now();
  const label = `${req.method} ${req.urlWithParams}`;
  return next(req).pipe(
    tap({
      next: (event) => {
        if (event.type === HttpEventType.Response) {
          const ms = Math.round(performance.now() - started);
          console.log(`[http] ${label} -> ${event.status} (${ms}ms)`);
        }
      },
      error: (err: unknown) => {
        const ms = Math.round(performance.now() - started);
        const status = err instanceof HttpErrorResponse ? err.status : 'error';
        console.log(`[http] ${label} -> ${status} (${ms}ms)`);
      },
    }),
  );
};

ビルドしたアプリを Chromium で開き、404 を返す URL を呼んだときのブラウザーの console 出力です。

[http] GET /api/missing -> 404 (14ms)

tap は値を変えずに「のぞくだけ」の演算子なので、ログのように結果へ影響させたくない処理に向いています。HttpEventType.Response で絞っているのは、進捗イベントなど、レスポンス以外のイベントも流れてくることがあるためです。

4. リトライ(一時的なエラーだけ送り直す)

// src/app/http/retry.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { retry, throwError, timer } from 'rxjs';

const RETRY_STATUS = [0, 502, 503, 504];
const MAX_RETRY = 2;

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // 同じ内容を送り直しても結果が変わらない GET だけを対象にする
  if (req.method !== 'GET') {
    return next(req);
  }
  return next(req).pipe(
    retry({
      count: MAX_RETRY,
      delay: (err: unknown, retryCount: number) => {
        if (err instanceof HttpErrorResponse && RETRY_STATUS.includes(err.status)) {
          return timer(300 * 2 ** (retryCount - 1)); // 300ms → 600ms
        }
        return throwError(() => err); // 対象外のエラーはすぐ返す
      },
    }),
  );
};

503 を2回返したあと 200 を返したときの出力です(ログ用インターセプターを外側に置いています)。

1回目 503 → 300ms 待つ
2回目 503 → 600ms 待つ
[http] GET /api/tasks -> 200 (900ms)
3回目 200 body = [{"id":1}]

3回とも 503 のとき、POST のとき、404 のときの結果は次のとおりです。

最終的な status = 503
POST 503 = 503 / GET 404 = 404 (どちらも1回だけ)
判断すること この実装での答え 理由
どのメソッドを送り直すか GET だけ POST を送り直すと、同じ登録が2回行われることがある
どのステータスを送り直すか 0・502・503・504 時間をおけば通る見込みがあるもの。404 や 400 は何度送っても同じ結果になる
何回まで 2回(合計3回) 待ち時間の合計を1秒以内に収める
間隔 300ms → 600ms と倍にする サーバーが立ち直る時間を少しずつ長く取る

ログが1行だけなのは、リトライをログより内側に置いたためです。外側のインターセプターからは、送り直しをまとめた最終結果だけが見えます。

5本を並べる順番とその理由

位置 インターセプター ここに置く理由
1(外側) ログ 再取得やリトライを含めた最終結果と、利用者が待った合計時間を記録できる
2 共通エラー処理 再取得やリトライで立ち直ったエラーは通知せず、最後まで残ったエラーだけを通知できる
3 トークン付与 最初のリクエストにトークンを付ける
4 401 の再取得 再送するリクエストには自分で新しいトークンを付けるので、トークン付与より内側でよい
5(内側) リトライ 通信にいちばん近い場所で、一時的なエラーだけを送り直す

インターセプターの中から next(...) を呼び直すと、その呼び直したリクエストは「自分より内側」だけをもう一度通ります。 再取得インターセプターが再送するリクエストは、外側にあるトークン付与をもう通りません。そのため、再送のときは再取得インターセプター自身が Authorization を付け直しています。

401 のときに1回だけ再取得して再送する

アクセストークンの有効期限が切れると、API は 401 を返します。ここで再取得用の API を呼び、新しいトークンで同じリクエストを送り直せば、利用者は期限切れに気づかずに操作を続けられます。

// src/app/http/refresh.interceptor.ts
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService, SKIP_AUTH } from './auth.service';

/** 再送したリクエストに付ける印。2回目の 401 では再取得しない */
const RETRIED = new HttpContextToken<boolean>(() => false);

export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  return next(req).pipe(
    catchError((err: unknown) => {
      const canRefresh =
        err instanceof HttpErrorResponse &&
        err.status === 401 &&
        !req.context.get(SKIP_AUTH) &&
        !req.context.get(RETRIED);
      if (!canRefresh) {
        return throwError(() => err);
      }
      return auth.refresh().pipe(
        catchError((refreshErr: unknown) => {
          auth.logout(); // 再取得もできなければログアウト扱いにする
          return throwError(() => refreshErr);
        }),
        switchMap((token) =>
          next(
            req.clone({
              setHeaders: { Authorization: `Bearer ${token}` },
              context: req.context.set(RETRIED, true),
            }),
          ),
        ),
      );
    }),
  );
};

この実装には、ぐるぐる回り続けないための工夫が3つ入っています。

工夫 コード上の場所 これがないと起きること
再送は1回だけ RETRIED を context に付け、付いていれば再取得しない 再送も 401 のとき、再取得と再送をくり返す
再取得の API は対象外 SKIP_AUTH が付いたリクエストは素通し 再取得の API が 401 を返すと、さらに再取得を呼ぶ
再取得は同時に1回 AuthService.refresh() が進行中の Observable を共有(shareReplay(1)) 一覧画面などで複数の API が同時に 401 になると、その本数分だけ再取得が走る

動かした結果(HttpTestingController)

トークン old で送り、401 を返し、再取得で new を返したときの出力です。

1回目 Authorization = Bearer old
refresh: method = POST / Authorization あり = false
[http] POST /api/auth/refresh -> 200 (0ms)
2回目 Authorization = Bearer new
[http] GET /api/tasks -> 200 (7ms)
body = [{"id":1}] / notifier = ""

呼び出し元には 200 の本体だけが届き、共通エラーの通知も出ていません。3本のリクエストが同時に 401 になった場合も、再取得は1回でした。

refresh の回数 = 1
[http] POST /api/auth/refresh -> 200 (0ms)
[http] GET /api/a -> 200 (1ms)
[http] GET /api/b -> 200 (3ms)
[http] GET /api/c -> 200 (4ms)
完了 = /api/a, /api/b, /api/c

再送も 401 だった場合と、再取得そのものが 401 だった場合です。どちらも2回目の再取得は行わず、エラーが外側の共通エラー処理まで届いています。

(再送も 401)
status = 401 / notifier = ログインの有効期限が切れました。もう一度ログインしてください。

(再取得が 401)
status = 401 / token = null / notifier = ログインの有効期限が切れました。もう一度ログインしてください。

動かした結果(Chromium と API サーバー)

ng build した成果物を、Node.js の小さな HTTP サーバーから配信し、Chromium で開いて確かめました。サーバーは Bearer new 以外には 401 を返し、/api/flaky は2回 503 を返したあと 200 を返すようにしてあります。サーバー側のログは次のとおりです。

[server] GET /api/tasks Authorization=Bearer old
[server] POST /api/auth/refresh Authorization=(none)
[server] GET /api/tasks Authorization=Bearer new
[server] GET /api/flaky Authorization=Bearer new
[server] GET /api/flaky Authorization=Bearer new
[server] GET /api/flaky Authorization=Bearer new

ブラウザー側の console 出力と画面の表示です。

[http] POST /api/auth/refresh -> 200 (13ms)
[http] GET /api/tasks -> 200 (106ms)
result = [{"id":1,"title":"write docs"}]
[http] GET /api/flaky -> 200 (926ms)
result = {"ok":true,"attempt":3}

401 → 再取得 → 再送の3回の通信が、アプリ側からは 200 の1回に見えていることがわかります。再取得に使うトークン(リフレッシュトークン)は、この例ではサーバーが Cookie で受け取る想定にして withCredentials: true を付けています。

HttpContextToken で特定のリクエストを除外する

「このリクエストだけはトークンを付けたくない」「この画面だけは共通の通知を出したくない」という場面では、HttpContextToken を使います。リクエストに付けられる目印で、ヘッダーと違ってサーバーには送られません。

// 目印を作る(既定値は false)
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
// 呼び出し側:目印を付けて送る
http.get('https://example.com/holidays.json', {
  context: new HttpContext().set(SKIP_AUTH, true),
}).subscribe();
// インターセプター側:目印を読む
if (req.context.get(SKIP_AUTH)) {
  return next(req);
}

トークンを入れた状態で、目印を付けて送った結果です。

has Authorization = false
除外の方法 書き方 向いている場面
HttpContextToken 呼び出し側で context を渡す 呼び出しごとに決めたいとき。意図がコードに残る
URL で判定 req.url.startsWith('/api/') など 自分の API だけにトークンを付けたいとき
メソッドで判定 req.method !== 'GET' など リトライのように、メソッドで安全性が変わる処理

外部のサービスへ送るリクエストに自分のトークンを付けないよう、トークン付与は「付ける相手」を URL か HttpContextToken で絞っておくと安心です。

なお、HttpContext はリクエストのほかの部分と違って書き換えられる、と公式ドキュメントに書かれています。送り直しのときも同じ context が引き継がれるので、この記事の RETRIED のように「1回やった」という印を残す用途に使えます。

クラス型インターセプターとの対応表

以前から使われているクラス型(HttpInterceptor を実装し、HTTP_INTERCEPTORS に multi: true で登録する形)と、関数型の対応です。

項目 クラス型 関数型
定義 @Injectable() class X implements HttpInterceptor const x: HttpInterceptorFn = (req, next) => ...
メソッド intercept(req, next) 関数そのもの
次へ渡す next.handle(req) next(req)
サービスの受け取り コンストラクターの引数、または inject() 関数の先頭で inject()
登録 { provide: HTTP_INTERCEPTORS, useClass: X, multi: true } withInterceptors([x])
有効にする設定 provideHttpClient(withInterceptorsFromDi()) provideHttpClient(withInterceptors([...]))
順番 providers に登録された順 配列に書いた順

書き換えは機械的にできます。intercept の中身を関数に移し、next.handle(req) を next(req) に、コンストラクターで受け取っていたサービスを関数の先頭の inject() に置き換えます。

両方を一緒に動かすとき

クラス型を残したまま関数型を足す場合は、withInterceptorsFromDi() を追加します。前の章のコードにある LegacyInterceptor(クラス型)を次のように登録して確かめました。

providers: [
  provideHttpClient(
    withInterceptors([aInterceptor]),
    withInterceptorsFromDi(),
    withInterceptors([bInterceptor]),
  ),
  { provide: HTTP_INTERCEPTORS, useClass: LegacyInterceptor, multi: true },
]
-> A request
-> Legacy(class) request
-> B request
<- B response
<- Legacy(class) response
<- A response

クラス型は、withInterceptorsFromDi() を書いた位置で動きました。 公式ドキュメントは、クラス型は providers の登録順で動くため構成が大きくなると順番を追いにくくなること、動きが読みやすい関数型をすすめることを説明しています。

HttpTestingController でテストする

インターセプターのテストは、本番と同じ withInterceptors([...]) を登録し、provideHttpClientTesting() で通信だけを差し替える形にします。expectOne() で出ていったリクエストをつかまえ、flush() で好きな応答を返せます。

// src/app/http/article.spec.ts
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { authInterceptor } from './auth.interceptor';
import { AuthService, SKIP_AUTH } from './auth.service';
import { errorInterceptor } from './error.interceptor';
import { Notifier } from './notifier.service';
import { refreshInterceptor } from './refresh.interceptor';
import { retryInterceptor } from './retry.interceptor';

describe('インターセプター', () => {
  let http: HttpClient;
  let ctrl: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        // 本番と同じ順番で登録し、通信だけをテスト用に差し替える
        provideHttpClient(withInterceptors([errorInterceptor, authInterceptor, refreshInterceptor, retryInterceptor])),
        provideHttpClientTesting(),
      ],
    });
    http = TestBed.inject(HttpClient);
    ctrl = TestBed.inject(HttpTestingController);
    TestBed.inject(AuthService).accessToken.set('old');
  });

  afterEach(() => {
    ctrl.verify(); // 応答していないリクエストが残っていればここで検出される
    vi.useRealTimers();
  });

  it('Authorization ヘッダーを付ける', () => {
    http.get('/api/tasks').subscribe();
    const req = ctrl.expectOne('/api/tasks');
    expect(req.request.headers.get('Authorization')).toBe('Bearer old');
    req.flush([]);
  });

  it('SKIP_AUTH を付けたリクエストには付けない', () => {
    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({});
  });

  it('401 なら再取得して、新しいトークンで 1 回だけ再送する', () => {
    let body: unknown;
    http.get('/api/tasks').subscribe((b) => (body = b));

    ctrl.expectOne('/api/tasks').flush(null, { status: 401, statusText: 'Unauthorized' });
    ctrl.expectOne('/api/auth/refresh').flush({ accessToken: 'new' });
    const resent = ctrl.expectOne('/api/tasks');
    expect(resent.request.headers.get('Authorization')).toBe('Bearer new');
    resent.flush([{ id: 1 }]);

    expect(body).toEqual([{ id: 1 }]);
    expect(TestBed.inject(Notifier).message()).toBe(''); // 立ち直ったので通知は出ない
  });

  it('GET の 503 は待ってから送り直す', () => {
    vi.useFakeTimers();
    let body: unknown;
    http.get('/api/tasks').subscribe((b) => (body = b));

    ctrl.expectOne('/api/tasks').flush(null, { status: 503, statusText: 'Service Unavailable' });
    ctrl.expectNone('/api/tasks'); // 待ち時間のあいだは送らない
    vi.advanceTimersByTime(300);
    ctrl.expectOne('/api/tasks').flush([{ id: 1 }]);

    expect(body).toEqual([{ id: 1 }]);
  });

  it('404 は共通メッセージを出して、エラーも呼び出し元へ渡す', () => {
    let status = 0;
    http.get('/api/tasks/999').subscribe({ error: (e) => (status = e.status) });
    ctrl.expectOne('/api/tasks/999').flush(null, { status: 404, statusText: 'Not Found' });
    expect(status).toBe(404);
    expect(TestBed.inject(Notifier).message()).toBe('データが見つかりませんでした。');
  });
});

ng test --watch=false --reporters=verbose の結果です。

 ✓ |demo| src/app/http/article.spec.ts > インターセプター > Authorization ヘッダーを付ける 45ms
 ✓ |demo| src/app/http/article.spec.ts > インターセプター > SKIP_AUTH を付けたリクエストには付けない 14ms
 ✓ |demo| src/app/http/article.spec.ts > インターセプター > 401 なら再取得して、新しいトークンで 1 回だけ再送する 17ms
 ✓ |demo| src/app/http/article.spec.ts > インターセプター > GET の 503 は待ってから送り直す 5ms
 ✓ |demo| src/app/http/article.spec.ts > インターセプター > 404 は共通メッセージを出して、エラーも呼び出し元へ渡す 3ms
やりたいこと 書き方
1件だけ出ていることを確かめて取り出す ctrl.expectOne('/api/tasks')
出ていないことを確かめる ctrl.expectNone('/api/tasks')
複数件まとめて取り出す ctrl.match('/api/auth/refresh')(配列が返る)
正常な応答を返す req.flush(body)
エラーの応答を返す req.flush(null, { status: 401, statusText: 'Unauthorized' })
通信そのものが届かなかった状態にする req.error(new ProgressEvent('error'))
応答していないリクエストが残っていないか確かめる ctrl.verify()
待ち時間を進める vi.useFakeTimers() と vi.advanceTimersByTime(300)

テストがうまく通らないときは、次のメッセージが手がかりになります。どちらも実際に出力されたものです。

Expected no open requests, found 1: GET /api/left
Expected one matching request for criteria "Match URL: /api/other", found none. Requests received are: GET /api/left.

1行目は verify() で応答していないリクエストが残っていたとき、2行目は expectOne() に渡した URL が実際のリクエストと合っていないときに出ます。2行目には実際に出ていたリクエストの一覧が付くので、URL やクエリ文字列の違いをすぐに見つけられます。

つまずきやすい箇所と直し方

inject() をコールバックの中で呼ぶと NG0203 になる

エラーが起きたときだけサービスを使いたくて、catchError の中で inject() を呼んだ例です。

// 直す前:コールバックの中で inject している
const lateInject: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError((err) => {
      inject(Notifier).show('error'); // コールバックの中で inject
      return throwError(() => err);
    }),
  );

500 を返すと、次のエラーになりました。

NG0203: The `_Notifier` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203

inject() が使えるのは、インターセプターの関数が呼ばれた直後の同期的な部分だけです。関数の先頭で受け取って変数に入れておき、コールバックではその変数を使います。 この記事の errorInterceptor がその形で、先頭の const notifier = inject(Notifier); で受け取ってから catchError の中で使っています。

headers.set() の戻り値を使わないとヘッダーが付かない

// 直す前:戻り値を使っていない
const mutate: HttpInterceptorFn = (req, next) => {
  req.headers.set('X-App', 'demo'); // 戻り値を使っていない
  return next(req);
};

// 直した後:clone で新しいリクエストを作る
const cloned: HttpInterceptorFn = (req, next) => next(req.clone({ setHeaders: { 'X-App': 'demo' } }));
headers.set だけ: X-App = null
clone: X-App = demo

HttpHeaders の set() は元のオブジェクトを変えず、新しいオブジェクトを返します。エラーにはならないので気づきにくい箇所です。公式ドキュメントは、リクエストが書き換え不可になっていることで、同じリクエストが送り直しなどで何度インターセプターを通っても同じ結果になる、と説明しています。

そのほかのチェックリスト

症状 確認すること 直し方
インターセプターが動かない provideHttpClient(withInterceptors([...])) に入っているか app.config.ts の配列に追加する
クラス型だけ動かない withInterceptorsFromDi() があるか provideHttpClient に追加する
画面側の error が呼ばれない catchError の中で別の値を返していないか throwError(() => err) で渡し直す
再取得が何度も走る 再取得 API に SKIP_AUTH が付いているか、再送に印を付けているか この記事の RETRIED と SKIP_AUTH の形にする
再送したリクエストにトークンがない 再送が外側のインターセプターを通ると考えていないか 再送するインターセプター自身がヘッダーを付ける
リトライのたびに通知が出る 共通エラー処理がリトライより内側にないか 共通エラー処理を外側(配列の前)へ移す
ログの時間が短すぎる ログが内側にないか ログを配列の先頭へ移す

よくある質問

Q. インターセプターは何本に分けるのがよいですか? A. 役割ごとに1本ずつに分けるのがおすすめです。トークン付与・エラー処理・ログ・リトライを1本にまとめると、順番を入れ替えたり、テストで1本だけ外したりすることが難しくなります。この記事のように1ファイル1本にしておくと、withInterceptors([...]) の配列を見るだけで全体の流れが読めます。

Q. 関数型のインターセプターでサービスは使えますか? A. 使えます。関数の先頭で inject(AuthService) のように受け取ります。公式ドキュメントにも、インターセプターは登録したインジェクターの注入コンテキストで実行され、inject で依存を取得できると書かれています。pipe() の中のコールバックでは呼べないので、先頭で変数に入れておきます。

Q. 401 の再取得は、インターセプター以外の方法でもできますか? A. トークンの有効期限をアプリ側で把握して、期限が近づいたら先に再取得しておく方法もあります。ただ、端末の時計のずれやスリープ明けなどで期限切れのリクエストが出ることはあるため、この記事のような「401 が返ったら1回だけ再取得して再送する」仕組みを併せて持っておくと、利用者の操作が途切れにくくなります。

Q. クラス型のインターセプターは書き直したほうがよいですか? A. クラス型も withInterceptorsFromDi() を付ければ Angular 22.2.1 でそのまま動きました。新しく書くものは関数型にして、既存のものは手を入れる機会に関数型へ移していく進め方が負担の少ない方法です。対応表のとおり、書き換えは next.handle(req) を next(req) に、コンストラクターの引数を inject() に置き換える作業が中心です。

まとめ

  • インターセプターは HttpInterceptorFn で書き、provideHttpClient(withInterceptors([...])) に並べて登録する。
  • リクエストは配列に書いた順、レスポンスは逆順に通る。 先頭が外側、末尾が通信に近い内側。
  • おすすめの並びは「ログ → 共通エラー処理 → トークン付与 → 401 の再取得 → リトライ」。 立ち直ったエラーは通知されず、最終結果だけが記録される。
  • リクエストを変えるときは req.clone()。 headers.set() の戻り値を使わないとヘッダーは付かない。
  • 401 の再取得は「再送は1回だけ」「再取得 API は対象外」「同時に1回」の3点を入れる。
  • 除外したいリクエストには HttpContextToken の目印を付ける。 サーバーには送られず、意図がコードに残る。
  • テストは本番と同じ並びを登録し、HttpTestingController の expectOne と flush で応答を返して確かめる。

参考・出典

  • Angular 公式ドキュメント「Interceptors」 https://angular.dev/guide/http/interceptors

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularインターセプターの使い方|トークン付与とエラー共通処理」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?