JavaScript 非同期通信の例外は捕捉されない

はじめに
XMLHttpRequest
や fetch
で発生した例外は、以下の error イベントリスナーでは捕捉できません。
window.addEventListener("error", function (event) {
// 何かの処理
})
MDN 1 では以下のように説明されています。
このイベントは、初期読み込み時やイベントハンドラ内など、同期的にスローされたスクリプトエラーに対してのみ生成されます。Promiseが拒否された場合(非同期関数内でキャッチされないスローを含む)、かつ拒否ハンドラがアタッチされていない場合は、代わりに unhandledrejection イベントが発行されます。
つまり、XMLHttpRequest
や fetch
のような非同期通信処理で発生した未処理例外はグローバルな error イベントリスナーの捕捉対象外です。fetch
は Promise ベースなので、未処理の reject は unhandledrejection
でグローバルに捕捉することができます。
例外の種類 | 捕捉できるイベント |
---|---|
JavaScript の実行エラー | window.onerror / window.addEventListener("error", …) |
画像・スクリプトの読み込みエラー | 同上 |
fetch の失敗 |
try...catch / .catch() / window.addEventListener("unhandledrejection", …) |
XMLHttpRequest の失敗 |
xhr.addEventListener("error", …) / "timeout" , "abort" |
問題となるのは XMLHttpRequest
の方で、XMLHttpRequest
のエラーをハンドリングするには、XMLHttpRequest
の各インスタンスに対してイベントリスナーを設定する必要があります。そこで、エラーハンドリングを共通化したい場合には以下のような方法で対処することができます。
XMLHttpRequest のエラー処理を共通化
// 指定されたウィンドウオブジェクトの XMLHttpRequest をパッチし、共通のエラー処理を実行
function patchXMLHttpRequest(targetWindow) {
const originalOpen = targetWindow.XMLHttpRequest.prototype.open;
// プロトタイプ拡張を使用して XMLHttpRequest の open メソッドを上書き
targetWindow.XMLHttpRequest.prototype.open = function (method, url, async) {
// error イベントリスナー
this.addEventListener("error", function () {
// ネットワークエラー時の処理
});
// load イベントリスナー
this.addEventListener("load", function () {
if (this.status >= 200 && this.status < 300) {
// HTTP ステータスコードが 200 番台であれば成功とみなす
} else if (this.status >= 400) {
// HTTP ステータスコードが 400 番以降はエラーとみなす
// エラー時の処理
}
});
// 元の open メソッドを実行
return originalOpen.call(this, method, url, async);
};
}
// patchXMLHttpRequest を window オブジェクトに適用
patchXMLHttpRequest(window);
// patchXMLHttpRequest を全 iframe に適用
const iframes = document.querySelectorAll("iframe");
iframes.forEach((iframe) => {
iframe.addEventListener("load", () => {
const iframeWindow = iframe.contentWindow;
patchXMLHttpRequest(iframeWindow);
});
});
プロトタイプ拡張を使用して XMLHttpRequest の open メソッドを上書き
元の open は originalOpen に保持しておき、originalOpen.call で元のメソッドが実行されるようにします。error
イベントリスナー
ネットワークエラー(network error
)発生時にこのイベントが発火します。ネットワークエラーとは以下のような Web サーバーとの TCP 通信に失敗した場合に該当します。
エラーメッセージ | 内容 | 考えられる原因 |
---|---|---|
ERR_NAME_NOT_RESOLVED | 名前解決失敗 | ホスト名の誤り、またはDNS設定の誤り |
ERR_ADDRESS_UNREACHABLE | IPアドレス到達不可 | DNSの名前解決は成功したが、IPアドレスに接続できない(ルーティング誤りなど) |
ERR_CONNECTION_REFUSED | サーバーが接続を拒否 | サーバーまで疎通できたがポートが閉じている、またはWebサーバー未起動 |
ERR_CONNECTION_TIMED_OUT | サーバーに接続要求を送ったが応答がなくタイムアウト | サーバーまで疎通できたが、Webサーバーが未起動または高負荷で応答できなかった |
なお、XMLHttpRequest
の error
イベントリスナーでは具体的なエラーメッセージやステータスコードを取得することはできません。(ブラウザの開発者ツールなどではネットワークエラー発生時のエラーメッセージを確認できますが、JavaScript にはセキュリティ的な理由によりエラーの原因となる情報は通知されません)
load
イベントリスナー
TCP 通信自体は正常に終了しても、HTTP ステータスコードがエラーの場合にはerror
イベントは発生しないので、load
イベントで捕捉する必要があります。this.status
に HTTP ステータスコードが設定されます。404 Not Found
や500 Internal Server Error
はこちらで処理することになります。patchXMLHttpRequest(window)
window オブジェクトの XMLHttpRequest プロトタイプを上書き。patchXMLHttpRequest(iframeWindow)
iframe を使用しているページの場合、それぞれの iframe ごとに XMLHttpRequest のプロトタイプがあるので、全てのプロトタイプを上書きする必要があります。(上書きできるのは同一オリジンの iframe のみ)timeout
、abort
について
サンプルコードには含めていませんが、error
と同様にハンドリングできます。
fetch の場合
fetch の場合は、unhandledrejection
イベントリスナーでエラーをグローバルに補足することができます。
window.addEventListener("unhandledrejection", (event) => {
// エラー時の処理
});
ただし、fetch
の場合も罠がありまして、unhandledrejection
というイベント名からも分かるとおり、ここで捕捉できるのは fetch
が reject
されたケースのみになります。具体的には TCP 通信に失敗した場合がこれに該当します。404 Not Found
や 500 Internal Server Error
などの HTTP エラーは、ここではハンドルできません。そのため、XMLHttpRequest
の error
/load
の使い分けと同様に fetch
でも使い分けが必要になります。
fetch
で HTTP エラーを判定するには Response
オブジェクトの ok
プロパティ を使用します。
fetch("https://hoge.com/fuga")
.then(res => {
if (!res.ok) {
// HTTP エラー時の処理
// res.status に HTTP ステータスコードが設定されている。
}
});
グローバルに HTTP エラーを補足する手段としては fetch
を上書きする方法があります。
// 元の fetch を退避
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const res = await originalFetch(...args);
// HTTP エラーも reject 扱いにする
if (!res.ok) {
return Promise.reject(new Error(`HTTP error ${res.status}`));
}
return res;
};
このようにすれば、HTTP エラーの場合でも unhandledrejection
を発火させることができます。
一つ注意点として、この方法は fetch
の動作を変えるので、fetch
を使っているコードがあった場合、その動作に影響を与える可能性があります。例えば、404 NotFound
でも正常扱いとして処理を継続する前提のコードがあった場合、この変更を適用すると reject
されてしまうので動作が変わってしまいます。
まとめ
XMLHttpRequest
と fetch
それぞれについて、各エラーの判定方法を表にまとめました。
エラーの種類 | XMLHttpRequest | fetch |
---|---|---|
TCP 通信エラー | error | try...catch / .catch() / unhandledrejection |
HTTP エラー | load | Response オブジェクトの ok プロパティ |
タイムアウト | timeout | setTimeout + AbortController.abort() 2 |
中断 | abort | AbortController.abort() 3 |
※fetch には XMLHttpRequest のような timeout プロパティや abort メソッドは存在しません。代わりに AbortController を使用します。
XMLHttpRequest
と fetch
についてグローバルなエラーハンドリングを実装する方法を紹介しました。用途としては後付けで共通的なエラーハンドリングやエラーログの出力などを行う必要が発生した場合を想定しています。本来あるべき実装としては XMLHttpRequest
や fetch
をラップする共通コンポーネントをあらかじめ作成し、共通コンポーネント経由で非同期通信を行う設計になると思います。参考にしていただければ幸いです。
脚注
-
This event is only generated for script errors thrown synchronously, such as during initial loading or within event handlers. If a promise was rejected (including an uncaught throw within an async function) and no rejection handlers were attached, an unhandledrejection event is fired instead.
-
タイムアウトの例
function fetchWithTimeout(url, ms) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), ms); return fetch(url, { signal: controller.signal }) .finally(() => clearTimeout(id)); } // 5秒でタイムアウトする fetch fetchWithTimeout("https://example.com/data", 5000) .then(response => console.log("成功:", response)) .catch(err => { if (err.name === "AbortError") { console.error("タイムアウトで中断されました"); } else { console.error("その他のエラー:", err); } });
-
中断の例
const controller = new AbortController(); fetch("https://example.com/data", { signal: controller.signal }) .then(response => console.log("成功:", response)) .catch(err => { if (err.name === "AbortError") { console.error("ユーザーが中断しました"); } }); // ユーザー操作などでキャンセル controller.abort();