Angular のエラーハンドリングとは、アプリのどこかで起きた例外や HTTP エラーを「決まった場所」に集めて、記録・通知・画面遷移を共通化する仕組みです。 集める場所は3つあります。HTTP のエラーをステータス別にさばく関数型インターセプター(HttpInterceptorFn)、Angular が捕まえた未処理エラーの最終的な受け皿である ErrorHandler、そしてブラウザの window に届いたエラーを ErrorHandler へ回す provideBrowserGlobalErrorListeners です。

この記事では、Angular 22.2.1 で作ったアプリに「テンプレート内の例外」「subscribe 内の例外」「Promise の未処理 reject」「HTTP 401/403/404/500」「通信断(status 0)」を1つずつ起こし、どの層で捕まったかをコンソール出力で確かめて一覧表にしました。 ErrorHandler の自作クラス、ステータス別の共通対応(再ログイン・通知・リトライ)を入れたインターセプター、テストコードは、そのまま貼って使える形で載せています。

確認環境:Linux(x64)、Node.js 24.21.0、Angular CLI / Angular 22.2.1、RxJS 7.8.2、TypeScript 6.0.3、Vitest 5.0.3、Playwright 1.56.0 の Chromium。ng new の既定(zone.js を使わない zoneless 構成)で作成し、ng build の本番ビルドをブラウザで開いて確かめています。

Angular エラーハンドリングの早見表

層 何を捕まえるか 書く場所 主な役割
1. 関数型インターセプター HttpClient のすべての通信エラー(401・403・404・500・status 0 など) provideHttpClient(withInterceptors([...])) ステータス別の共通対応(再ログイン・通知・リトライ)
2. ErrorHandler(自作クラス) Angular が捕まえた未処理エラー(テンプレート、クリック処理など)と、3 から回ってきたエラー { provide: ErrorHandler, useClass: GlobalErrorHandler } 記録、最終的な案内表示
3. provideBrowserGlobalErrorListeners window の error と unhandledrejection(setTimeout 内の例外、Promise の未処理 reject、subscribe 内の例外) app.config.ts の providers(ng new で最初から入っている) 2 の ErrorHandler へ転送
やりたいこと 使うもの
401 が返ったら再ログイン画面へ移動したい インターセプターの catchError で router.navigate(['/login'])
403・500 で共通のメッセージを出したい インターセプターの catchError で通知サービスを呼ぶ
通信断や 5xx を自動でやり直したい インターセプターで retry({ count, delay })(GET に限定)
404 を画面ごとの文言で出したい 画面側の subscribe の error コールバック(または catchError)
想定していない例外を記録したい 自作の ErrorHandler
Promise や setTimeout の中の例外も記録したい provideBrowserGlobalErrorListeners を providers に置く

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

(記載例)出典:株式会社RJC「Angularエラーハンドリング|ErrorHandlerとHTTP共通処理」

エラーがどの層で捕まるかの一覧表

10種類のエラーを実際に起こし、コンソールに出た内容から「どこで捕まったか」をまとめたのが次の表です。ng new で作った直後の構成(provideBrowserGlobalErrorListeners あり)なら、どのエラーも最後は ErrorHandler に届きます。

エラーの種類 インターセプター ErrorHandler(リスナーあり) ErrorHandler(リスナーなし)
テンプレート内の例外 通らない 届く 届く
クリック処理(イベントハンドラー)の例外 通らない 届く 届く
subscribe の next の中の例外 通らない 届く 届かない(ブラウザの未捕捉エラーになる)
Promise の未処理 reject 通らない 届く 届かない(同上)
setTimeout の中の例外 通らない 届く 届かない(同上)
HTTP 401 / 403 / 404 / 500(error コールバックなし) 捕まる 届く(HttpErrorResponse として) 届かない(同上)
通信断(status 0、error コールバックなし) 捕まる 届く(HttpErrorResponse として) 届かない(同上)
HTTP エラー(画面側に error コールバックあり) 捕まる 届かない(画面側で処理済み) 届かない

表の「リスナー」は provideBrowserGlobalErrorListeners のことです。テンプレートとイベントハンドラーの例外は Angular 自身が捕まえるので、リスナーの有無に関係なく ErrorHandler に届きます。 一方、subscribe・Promise・setTimeout の中の例外は Angular の外(ブラウザの window)に出ていくため、リスナーが ErrorHandler へ橋渡しをします。

flowchart TD
    A[HttpClient の通信エラー] --> B[1. インターセプター catchError]
    B -->|throwError で再送出| C{画面側に error コールバック}
    C -->|あり| D[画面側で処理して終了]
    C -->|なし| E[window の error イベント]
    F[Promise の未処理 reject / setTimeout 内の例外 / subscribe 内の例外] --> E
    E --> G[3. provideBrowserGlobalErrorListeners]
    G --> H[2. ErrorHandler.handleError]
    I[テンプレート内の例外 / クリック処理の例外] --> H

3層を設定する app.config.ts の完成形

まず全体像です。3つの層はすべて app.config.ts の providers に並べます。

// src/app/app.config.ts
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { GlobalErrorHandler } from './core/global-error-handler';
import { httpErrorInterceptor } from './core/http-error.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
    provideHttpClient(withInterceptors([httpErrorInterceptor])),
    provideRouter(routes),
  ],
};
行 意味
provideBrowserGlobalErrorListeners() window の error と unhandledrejection を聞き、ErrorHandler へ転送する。ng new で作ると最初から入っている
{ provide: ErrorHandler, useClass: GlobalErrorHandler } Angular 標準の ErrorHandler を自作クラスに差し替える
provideHttpClient(withInterceptors([...])) HttpClient を有効にし、関数型インターセプターを登録する。配列に書いた順に実行される

以降のコードで使う通知サービスは、メッセージを signal の配列にためるだけの小さなものです。

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

@Injectable({ providedIn: 'root' })
export class Notice {
  readonly messages = signal<string[]>([]);

  show(message: string): void {
    this.messages.update((list) => [...list, message]);
  }
}

ルートコンポーネントで一覧表示します。

// src/app/app.ts
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Notice } from './core/notice';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    <router-outlet />
    <ul id="notice">
      @for (message of notice.messages(); track $index) {
        <li>{{ message }}</li>
      }
    </ul>
  `,
})
export class App {
  readonly notice = inject(Notice);
}

ErrorHandler を自作クラスに差し替える

ErrorHandler は handleError(error) というメソッドを1つ持つクラスです。標準の実装はコンソールに出力するだけなので、記録先や案内表示を足したいときに自作クラスへ差し替えます。

// src/app/core/global-error-handler.ts
import { ErrorHandler, Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private lastMessage = '';
  private lastTime = 0;

  handleError(error: unknown): void {
    if (error instanceof HttpErrorResponse) {
      // インターセプターで通知済みなので、ここでは記録だけにする
      console.error(`[GlobalErrorHandler] HTTP ${error.status} ${error.url}`);
      return;
    }
    const message = error instanceof Error ? error.message : String(error);

    // 同じエラーが1秒以内に続いたら、2回目以降は何もしない
    const now = Date.now();
    if (message === this.lastMessage && now - this.lastTime < 1000) {
      return;
    }
    this.lastMessage = message;
    this.lastTime = now;

    console.error(`[GlobalErrorHandler] ${message}`);
    this.showBanner('画面の処理でエラーが起きました。再読み込みをお試しください。');
  }

  // Angular の描画が止まっていても出せるよう、DOM に直接書く
  private showBanner(text: string): void {
    let banner = document.getElementById('app-error-banner');
    if (!banner) {
      banner = document.createElement('div');
      banner.id = 'app-error-banner';
      banner.setAttribute('role', 'alert');
      document.body.append(banner);
    }
    banner.textContent = text;
  }
}

このクラスには3つの工夫を入れています。

工夫 理由
HttpErrorResponse は記録だけにする 利用者への通知はインターセプターで済んでいるため、二重に出さない
同じメッセージが続いたら2回目以降は何もしない テンプレートの例外は、変更検知のたびに同じ例外がもう一度起きるため
案内表示は DOM に直接書く テンプレートの例外のあとは Angular の描画が途中で止まり、signal を使った通知が画面に出ないことがあるため(次の節で確認)

console.error の部分を、ログ収集サービスへの送信に置き換えれば、そのまま記録の入口になります。

確認用のコンポーネント

エラーを起こすボタンを並べたコンポーネントです。

// src/app/home.ts
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

@Component({
  selector: 'app-broken',
  template: `<p>{{ title().toUpperCase() }}</p>`,
})
export class Broken {
  // undefined なので、テンプレートの toUpperCase() で例外になる
  readonly title = signal<string>(undefined as unknown as string);
}

@Component({
  selector: 'app-home',
  imports: [Broken],
  template: `
    <button id="tpl" (click)="showBroken.set(true)">テンプレート内の例外</button>
    <button id="click" (click)="throwInHandler()">クリック処理の例外</button>
    <button id="sub" (click)="throwInSubscribe()">subscribe内の例外</button>
    <button id="promise" (click)="rejectPromise()">Promiseの未処理reject</button>
    <button id="timer" (click)="throwInTimer()">setTimeout内の例外</button>
    @for (code of codes; track code) {
      <button [id]="'s' + code" (click)="call('/api/status/' + code)">HTTP {{ code }}</button>
    }
    <button id="s0" (click)="call('http://127.0.0.1:8819/api/none')">通信断</button>
    <button id="flaky" (click)="call('/api/flaky')">2回500のあと200</button>
    <button id="handled" (click)="callHandled()">404を画面側で処理</button>
    @if (showBroken()) {
      <app-broken />
    }
    <p id="result">{{ result() }}</p>
  `,
})
export class Home {
  private readonly http = inject(HttpClient);
  readonly codes = [401, 403, 404, 500];
  readonly showBroken = signal(false);
  readonly result = signal('');

  throwInHandler(): void {
    throw new Error('クリック処理で例外');
  }

  throwInSubscribe(): void {
    of(1).subscribe(() => {
      throw new Error('subscribe の next で例外');
    });
  }

  rejectPromise(): void {
    Promise.reject(new Error('Promise の未処理 reject'));
  }

  throwInTimer(): void {
    setTimeout(() => {
      throw new Error('setTimeout の中で例外');
    });
  }

  call(url: string): void {
    // error コールバックなし:インターセプターの後、未処理エラーとして扱われる
    this.http.get<{ message: string }>(url).subscribe((body) => this.result.set(body.message));
  }

  callHandled(): void {
    this.http.get<{ message: string }>('/api/status/404').subscribe({
      next: (body) => this.result.set(body.message),
      error: () => this.result.set('該当するデータがありません'),
    });
  }
}

@Component({
  selector: 'app-login',
  template: `<h2 id="login">ログイン画面</h2>`,
})
export class Login {}

ルートは次の2つです。

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { Home, Login } from './home';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'login', component: Login },
];

/api/status/401 のようなパスは、指定したステータスコードをそのまま返す確認用の API です。/api/flaky は 500 を2回返したあと、3回目に 200 を返します。通信断は、何も待ち受けていないポートへのリクエストで再現しています。

画面側の例外5種類のコンソール出力

ボタンを1つずつ押したときのコンソール出力です(行頭の error: は console の種類、banner は DOM に書いた案内文です)。

--- テンプレート内の例外
  error: [GlobalErrorHandler] Cannot read properties of undefined (reading 'toUpperCase')
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- クリック処理の例外
  error: [GlobalErrorHandler] クリック処理で例外
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- subscribe内の例外
  error: [GlobalErrorHandler] subscribe の next で例外
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- Promiseの未処理reject
  error: [GlobalErrorHandler] Promise の未処理 reject
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- setTimeout内の例外
  error: [GlobalErrorHandler] setTimeout の中で例外
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"

5種類とも自作の ErrorHandler に届き、案内文が表示されました。

handleError の中で signal を更新すると呼び出しが続く例

最初は、案内文を Notice サービス(signal)で出す形にしていました。

// うまくいかない例:handleError の中で signal を更新する
console.error(`[GlobalErrorHandler] ${message}`);
setTimeout(() => this.notice.show('画面の処理でエラーが起きました。'));

この形でテンプレート内の例外を起こすと、0.5秒の間に handleError が50回以上呼ばれ続けました。

--- テンプレート内の例外
  error: [GlobalErrorHandler] Cannot read properties of undefined (reading 'toUpperCase')
  error: [GlobalErrorHandler] Cannot read properties of undefined (reading 'toUpperCase')
  error: [GlobalErrorHandler] Cannot read properties of undefined (reading 'toUpperCase')
  (同じ行が50回以上続く)
  notice=["","","","", …]

理由は次の流れです。

  1. テンプレートで例外が起き、handleError が呼ばれる
  2. handleError が signal を更新する
  3. signal の更新で変更検知がもう一度走る
  4. 例外を起こすテンプレートがもう一度評価され、1 に戻る

しかも通知の一覧は空の項目が増えるだけで、文言は表示されませんでした。例外が起きた時点でその回の描画が途中で終わるためです。ErrorHandler の中では signal を更新せず、案内は DOM へ直接書く(または専用のエラー画面へ移動する)形にすると、この繰り返しが起きません。 上の完成形に「同じメッセージは1秒間無視する」「DOM に直接書く」の2つを入れているのはこのためです。

provideBrowserGlobalErrorListeners の役割を出力で確かめる

providers から provideBrowserGlobalErrorListeners() の行だけを外してビルドし直し、同じボタンを押した結果です(pageerror: は、どこにも捕まらずブラウザの未捕捉エラーになったことを表します)。

--- テンプレート内の例外
  error: [GlobalErrorHandler] Cannot read properties of undefined (reading 'toUpperCase')
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- クリック処理の例外
  error: [GlobalErrorHandler] クリック処理で例外
  banner="画面の処理でエラーが起きました。再読み込みをお試しください。"
--- subscribe内の例外
  pageerror: subscribe の next で例外
  banner=null
--- Promiseの未処理reject
  pageerror: Promise の未処理 reject
  banner=null
--- setTimeout内の例外
  pageerror: setTimeout の中で例外
  banner=null
エラーの種類 リスナーあり リスナーなし
テンプレート内の例外 ErrorHandler ErrorHandler
クリック処理の例外 ErrorHandler ErrorHandler
subscribe 内の例外 ErrorHandler ブラウザの未捕捉エラー
Promise の未処理 reject ErrorHandler ブラウザの未捕捉エラー
setTimeout 内の例外 ErrorHandler ブラウザの未捕捉エラー

リスナーを外すと、subscribe・Promise・setTimeout の中の例外は ErrorHandler に届かなくなります。 zone.js を使わない構成では、これらの非同期処理を Angular が包んでいないため、window まで届いたエラーを拾う役がこのリスナーです。Angular の公式ドキュメント(未処理エラーのページ)でも、provideBrowserGlobalErrorListeners は window に error と unhandledrejection のリスナーを追加して ErrorHandler に転送するもので、Angular CLI が生成するアプリには既定で入っていると説明されています。

バージョンを上げてきたアプリで app.config.ts にこの行がない場合は、1行足すだけで受け皿がそろいます。

subscribe の中の例外がリスナー経由になる理由

RxJS 7 は、subscribe のコールバックの中で起きた例外を、その場では投げずに少しあとのタイミングで未捕捉エラーとして報告します。Node.js で次のファイルを動かすと、その様子がわかります。

// rx.mjs(node rx.mjs で実行)
import { of } from 'rxjs';

process.on('uncaughtException', (e) => console.log('あとから未捕捉エラーとして届いた:', e.message));

try {
  of(1).subscribe(() => {
    throw new Error('next で例外');
  });
  console.log('try を抜けた(catch には入らない)');
} catch (e) {
  console.log('catch で捕まえた');
}
try を抜けた(catch には入らない)
あとから未捕捉エラーとして届いた: next で例外

呼び出し元の try/catch では捕まらず、あとから未捕捉エラーとして届きます。 ブラウザではこれが window の error イベントになるため、上の出力で subscribe 内の例外が Promise や setTimeout と同じ動きになっています。error コールバックのない購読に HTTP エラーが流れてきた場合も、同じ経路で ErrorHandler に届きました(次の節の出力を参照)。

HTTP エラーを関数型インターセプターで共通処理する

HttpClient の通信エラーは、すべて HttpErrorResponse というオブジェクトで Observable のエラーとして流れてきます。インターセプターに catchError を1つ置けば、全画面の通信エラーを1か所で受け取れます。

// src/app/core/http-error.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, retry, throwError, timer } from 'rxjs';
import { Notice } from './notice';

const RETRY_STATUS = [0, 500, 502, 503, 504];

export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const notice = inject(Notice);

  return next(req).pipe(
    // GET だけ、通信断と 5xx を最大2回まで間隔をあけて再実行する
    retry({
      count: 2,
      delay: (error: HttpErrorResponse, retryCount) => {
        const canRetry = req.method === 'GET' && RETRY_STATUS.includes(error.status);
        if (!canRetry) {
          return throwError(() => error);
        }
        console.warn(`[interceptor] retry ${retryCount} status=${error.status} ${req.url}`);
        return timer(200 * retryCount);
      },
    }),
    catchError((error: HttpErrorResponse) => {
      console.warn(`[interceptor] status=${error.status} ${req.url}`);
      switch (error.status) {
        case 0:
          notice.show('通信できませんでした。接続を確認してください。');
          break;
        case 401:
          router.navigate(['/login'], { queryParams: { returnUrl: router.url } });
          break;
        case 403:
          notice.show('この操作を行う権限がありません。');
          break;
        case 404:
          // 画面ごとに意味が変わるので、共通処理は何もしない
          break;
        default:
          if (error.status >= 500) {
            notice.show('サーバーでエラーが起きました。時間をおいてお試しください。');
          }
      }
      return throwError(() => error); // 呼び出し元にも伝える
    }),
  );
};
書き方のポイント 内容
HttpInterceptorFn リクエスト(req)と次の処理(next)を受け取り、Observable を返す関数の型
inject() を関数の先頭で呼ぶ インターセプターの中で Router やサービスを取得できる
retry を catchError より前に置く やり直しても直らなかったときだけ catchError に進む
最後に throwError(() => error) 共通処理をしたうえで、画面側にもエラーを伝える。画面側は必要なときだけ error コールバックを書けばよい

ステータス別のコンソール出力

各ボタンを押した結果です(url はボタンを押したあとの画面のパス、notice は通知の一覧です)。

--- HTTP 401  url=/login?returnUrl=%2F
  error: Failed to load resource: the server responded with a status of 401 (Unauthorized)
  warning: [interceptor] status=401 /api/status/401
  error: [GlobalErrorHandler] HTTP 401 http://127.0.0.1:8810/api/status/401
  notice=[]
--- HTTP 403  url=/
  error: Failed to load resource: the server responded with a status of 403 (Forbidden)
  warning: [interceptor] status=403 /api/status/403
  error: [GlobalErrorHandler] HTTP 403 http://127.0.0.1:8810/api/status/403
  notice=["この操作を行う権限がありません。"]
--- HTTP 404  url=/
  error: Failed to load resource: the server responded with a status of 404 (Not Found)
  warning: [interceptor] status=404 /api/status/404
  error: [GlobalErrorHandler] HTTP 404 http://127.0.0.1:8810/api/status/404
  notice=[]
--- HTTP 500  url=/
  error: Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  warning: [interceptor] retry 1 status=500 /api/status/500
  error: Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  warning: [interceptor] retry 2 status=500 /api/status/500
  error: Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  warning: [interceptor] status=500 /api/status/500
  error: [GlobalErrorHandler] HTTP 500 http://127.0.0.1:8810/api/status/500
  notice=["サーバーでエラーが起きました。時間をおいてお試しください。"]
--- 通信断  url=/
  error: Failed to load resource: net::ERR_CONNECTION_REFUSED
  warning: [interceptor] retry 1 status=0 http://127.0.0.1:8819/api/none
  error: Failed to load resource: net::ERR_CONNECTION_REFUSED
  warning: [interceptor] retry 2 status=0 http://127.0.0.1:8819/api/none
  error: Failed to load resource: net::ERR_CONNECTION_REFUSED
  warning: [interceptor] status=0 http://127.0.0.1:8819/api/none
  error: [GlobalErrorHandler] HTTP 0 http://127.0.0.1:8819/api/none
  notice=["通信できませんでした。接続を確認してください。"]

出力から読み取れることを整理します。

  • 401 は /login へ移動し、元の画面のパスが returnUrl として付いた
  • 403 と 500 は共通の通知が出た
  • 404 は共通処理なしでそのまま通過した
  • 500 と通信断は2回やり直し、3回目もエラーだったので通知した
  • 「Failed to load resource」の行はブラウザ自身が出すもので、アプリのコードでは消せない
  • 画面側に error コールバックがないため、どれも最後は HttpErrorResponse として ErrorHandler に届いた

やり直しで成功する場合と、画面側で処理する場合

2回 500 を返したあと 200 を返す API では、利用者にはエラーが見えないまま成功します。

--- 2回500のあと200
  error: Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  warning: [interceptor] retry 1 status=500 /api/flaky
  error: Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  warning: [interceptor] retry 2 status=500 /api/flaky
  notice=[] result="3回目で成功"

404 を画面側の error コールバックで受けた場合は、インターセプターを通ったあと画面側で処理が終わり、ErrorHandler は呼ばれません。

--- 404を画面側で処理
  error: Failed to load resource: the server responded with a status of 404 (Not Found)
  warning: [interceptor] status=404 /api/status/404
  notice=[] result="該当するデータがありません"

通信断(status 0)の見分け方

サーバーに届かなかったリクエストは、ステータスコードが 0 の HttpErrorResponse になります。Angular の公式ドキュメント(HTTP リクエストのページ)では、ネットワークエラーやタイムアウトの場合は status が 0 になり、error プロパティに ProgressEvent が入ると説明されています。error.status === 0 を「サーバーからの応答ではない」目印として扱えば、「接続を確認してください」という案内と「サーバー側のエラーです」という案内を分けられます。

ステータス別の共通対応の決め方

どのステータスを共通処理にし、どれを画面に任せるかの目安です。

ステータス 意味 共通処理(インターセプター) 画面側
0 サーバーに届いていない GET はやり直し、直らなければ通知 必要なら再読み込みボタン
400 / 422 入力内容に問題がある 何もしない 入力欄ごとにメッセージを表示
401 ログインが必要 ログイン画面へ移動(戻り先を付ける) なし
403 権限がない 通知 なし
404 対象がない 何もしない 「該当するデータがありません」など画面に合う文言
409 他の更新と重なった 何もしない 再読み込みを案内
500 番台 サーバー側のエラー GET はやり直し、直らなければ通知 なし

考え方は2つです。

  • どの画面でも対応が同じもの(401・403・500 番台・通信断)はインターセプターに集める
  • 画面によって意味が変わるもの(400・404・409)は、共通処理では何もせず画面側に任せる

やり直しの対象を GET に絞る理由

POST のやり直しは、サーバー側で処理が済んでいた場合に同じ登録が2件できることがあります。上のコードでは req.method === 'GET' のときだけやり直すようにし、間隔も 200 ミリ秒、400 ミリ秒と少しずつ広げています。

特定のリクエストだけ共通処理を外す

「この通信だけは通知を出したくない」という場合は、HttpContext で目印を付けます。

// src/app/core/http-error.interceptor.ts に追加(import に HttpContextToken を足す)
export const SKIP_ERROR_NOTICE = new HttpContextToken<boolean>(() => false);

// catchError のコールバックの先頭に追加
if (req.context.get(SKIP_ERROR_NOTICE)) {
  return throwError(() => error); // 共通処理をせず、そのまま画面側へ
}

呼び出し側は、リクエストのオプションに context を渡します。次のテストを足して、403 でも通知が出ないことを確かめました。

// src/app/core/http-error.interceptor.spec.ts に追加(import に HttpContext と SKIP_ERROR_NOTICE を足す)
it('SKIP_ERROR_NOTICE を付けると通知を出さない', () => {
  let status = -1;
  const context = new HttpContext().set(SKIP_ERROR_NOTICE, true);
  http.post('/api/items', {}, { context }).subscribe({ error: (e: HttpErrorResponse) => (status = e.status) });

  backend.expectOne('/api/items').flush(null, { status: 403, statusText: 'Forbidden' });

  expect(status).toBe(403);
  expect(TestBed.inject(Notice).messages()).toEqual([]);
});

このテストの土台になるファイルは、後半の「インターセプターのテストを書く」に載せています。3件まとめて実行した結果は次のとおりです。

 Test Files  1 passed (1)
      Tests  3 passed (3)

HttpContextToken と HttpContext は @angular/common/http が公開している API で、リクエストごとの追加情報をインターセプターへ渡すためのものです。

ErrorHandler を差し替えない場合の出力

自作クラスを登録せず、標準の ErrorHandler のままにした場合の出力も載せておきます。

--- テンプレート内の例外
  error: ERROR TypeError: Cannot read properties of undefined (reading 'toUpperCase')
--- クリック処理の例外
  error: ERROR Error: クリック処理で例外
--- Promiseの未処理reject
  error: ERROR Error: Promise の未処理 reject
--- HTTP 404
  error: Failed to load resource: the server responded with a status of 404 (Not Found)
  warning: [interceptor] status=404 /api/status/404
  error: ERROR sn

標準の実装は「ERROR」に続けてエラーをコンソールへ出力します。最後の行の sn は、本番ビルドで短く変換された HttpErrorResponse のクラス名です。コンソールに出るだけで利用者には何も伝わらないため、記録の送信や案内表示を足したいときに自作クラスが役立ちます。

インターセプターのテストを書く

インターセプターは HttpTestingController を使うと、サーバーなしでステータスごとの動きを確かめられます。ng new で作ったアプリは Vitest が最初から設定されています。

// src/app/core/http-error.interceptor.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router, provideRouter } from '@angular/router';
import { httpErrorInterceptor } from './http-error.interceptor';
import { Notice } from './notice';

describe('httpErrorInterceptor', () => {
  let http: HttpClient;
  let backend: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([]),
        provideHttpClient(withInterceptors([httpErrorInterceptor])),
        provideHttpClientTesting(),
      ],
    });
    http = TestBed.inject(HttpClient);
    backend = TestBed.inject(HttpTestingController);
  });

  afterEach(() => backend.verify());

  it('403 は通知を出して、呼び出し元にもエラーを渡す', () => {
    let status = -1;
    http.post('/api/items', {}).subscribe({ error: (e: HttpErrorResponse) => (status = e.status) });

    backend.expectOne('/api/items').flush(null, { status: 403, statusText: 'Forbidden' });

    expect(status).toBe(403);
    expect(TestBed.inject(Notice).messages()).toEqual(['この操作を行う権限がありません。']);
  });

  it('401 はログイン画面へ移動する', () => {
    const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
    http.post('/api/items', {}).subscribe({ error: () => {} });

    backend.expectOne('/api/items').flush(null, { status: 401, statusText: 'Unauthorized' });

    expect(navigate).toHaveBeenCalledWith(['/login'], { queryParams: { returnUrl: '/' } });
  });
});

実行結果です。

$ ng test --watch=false

 RUN  v5.0.3

 Test Files  1 passed (1)
      Tests  2 passed (2)

テストでは POST を使っています。GET で 500 を返すとやり直しが入り、expectOne を3回書く必要があるためです。なお、Angular の公式ドキュメントには、TestBed は既定で未処理エラーを再スローし、テストの中でエラーが見過ごされないようにしていると書かれています。

導入時のチェックリスト

確認すること 見る場所
provideBrowserGlobalErrorListeners() が providers にある app.config.ts
ErrorHandler を自作クラスに差し替えている app.config.ts
handleError の中で signal を更新していない 自作の ErrorHandler
同じエラーの連続を抑える仕組みがある 自作の ErrorHandler
HttpErrorResponse を ErrorHandler で二重に通知していない 自作の ErrorHandler
インターセプターの最後で throwError を返している インターセプター
やり直しは GET に限定し、回数と間隔を決めている インターセプター
404 や 400 は画面側に任せている インターセプターと各画面
401 の移動先に戻り先(returnUrl)を付けている インターセプター

つまずきやすい点と直し方

起きること 原因 直し方
Promise や setTimeout の例外が ErrorHandler に来ない provideBrowserGlobalErrorListeners がない providers に1行足す
handleError が何十回も呼ばれる handleError の中で signal を更新している DOM に直接書く形に変え、同じメッセージの連続を抑える
通知が2回出る インターセプターと ErrorHandler の両方で通知している ErrorHandler 側は HttpErrorResponse を記録だけにする
画面側の error コールバックが呼ばれない catchError で of(...) や EMPTY を返してエラーを消している 共通処理のあと throwError(() => error) で再送出する
POST が2回送られる やり直しの対象にすべてのメソッドが入っている GET だけに絞る
try/catch で subscribe の中の例外が捕まらない RxJS が別のタイミングで window へ報告するため subscribe の中で処理するか、ErrorHandler に任せる

よくある質問

Q. ErrorHandler とインターセプターは、どちらか一方だけでよいですか? A. 役割が違うので両方置くのがおすすめです。インターセプターは HTTP のエラーをステータス別にさばく場所、ErrorHandler は想定していない例外を記録する最終的な受け皿です。今回の確認でも、テンプレートやクリック処理の例外はインターセプターを通らず、ErrorHandler にだけ届きました。

Q. クラスで書いたインターセプター(HttpInterceptor を実装するクラス)は使えますか? A. 使えます。Angular の公式ドキュメント(インターセプターのページ)では、クラスの形も同じ機能を持つとしたうえで、動きが予測しやすい関数型を推奨しています。新しく書くなら HttpInterceptorFn と withInterceptors の組み合わせがすっきりします。

Q. 画面側で error コールバックを書いたら、ErrorHandler には届きませんか? A. 届きません。今回の「404 を画面側で処理」の出力では、インターセプターの行だけが出て、ErrorHandler の行は出ませんでした。画面側で処理したエラーは「処理済み」として扱われます。

Q. provideBrowserGlobalErrorListeners は自分で足す必要がありますか? A. Angular CLI 22.2.1 の ng new で作ったアプリには最初から入っています。以前のバージョンから上げてきたアプリでは入っていないことがあるので、app.config.ts を見て、なければ providers に足してください。

まとめ

  • エラーを集める場所は3つ:関数型インターセプター、ErrorHandler、provideBrowserGlobalErrorListeners
  • テンプレートとクリック処理の例外は Angular が捕まえて ErrorHandler に届ける
  • subscribe・Promise・setTimeout の中の例外は、provideBrowserGlobalErrorListeners があると ErrorHandler に届く
  • HTTP のエラーはインターセプターの catchError でステータス別に対応し、最後に throwError で画面側にも伝える
  • 401 は再ログイン、403・500 番台・通信断は通知、通信断と 5xx の GET はやり直し、404 は画面に任せるのが整理しやすい
  • handleError の中では signal を更新せず、DOM への直接表示と連続エラーの抑制を入れておく
  • インターセプターは HttpTestingController でステータスごとにテストできる

参考・出典

  • Angular 公式ドキュメント「Angularにおける未処理エラー」 https://angular.jp/best-practices/error-handling
  • Angular 公式ドキュメント(HTTP:インターセプター) https://angular.jp/guide/http/interceptors
  • Angular 公式ドキュメント(HTTP:リクエストとエラー処理) https://angular.jp/guide/http/making-requests

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

(記載例)出典:株式会社RJC「Angularエラーハンドリング|ErrorHandlerとHTTP共通処理」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?