- Angular
Angularエラーハンドリング|ErrorHandlerとHTTP共通処理
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=["","","","", …]
理由は次の流れです。
- テンプレートで例外が起き、handleError が呼ばれる
- handleError が signal を更新する
- signal の更新で変更検知がもう一度走る
- 例外を起こすテンプレートがもう一度評価され、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期連続の黒字決算。