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

プログラミング
公開 2025年8月13日
Article Image

はじめに

XMLHttpRequestfetch で発生した例外は、以下の error イベントリスナーでは捕捉できません。

window.addEventListener("error", function (event) {
    // 何かの処理
})

MDN 1 では以下のように説明されています。

このイベントは、初期読み込み時やイベントハンドラ内など、同期的にスローされたスクリプトエラーに対してのみ生成されます。Promiseが拒否された場合(非同期関数内でキャッチされないスローを含む)、かつ拒否ハンドラがアタッチされていない場合は、代わりに unhandledrejection イベントが発行されます。

つまり、XMLHttpRequestfetch のような非同期通信処理で発生した未処理例外はグローバルな 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サーバーが未起動または高負荷で応答できなかった

なお、XMLHttpRequesterror イベントリスナーでは具体的なエラーメッセージやステータスコードを取得することはできません。(ブラウザの開発者ツールなどではネットワークエラー発生時のエラーメッセージを確認できますが、JavaScript にはセキュリティ的な理由によりエラーの原因となる情報は通知されません)

  • load イベントリスナー
    TCP 通信自体は正常に終了しても、HTTP ステータスコードがエラーの場合には error イベントは発生しないので、load イベントで捕捉する必要があります。this.status に HTTP ステータスコードが設定されます。404 Not Found500 Internal Server Error はこちらで処理することになります。

  • patchXMLHttpRequest(window)
    window オブジェクトの XMLHttpRequest プロトタイプを上書き。

  • patchXMLHttpRequest(iframeWindow)
    iframe を使用しているページの場合、それぞれの iframe ごとに XMLHttpRequest のプロトタイプがあるので、全てのプロトタイプを上書きする必要があります。(上書きできるのは同一オリジンの iframe のみ)

  • timeoutabort について
    サンプルコードには含めていませんが、error と同様にハンドリングできます。

fetch の場合

fetch の場合は、unhandledrejection イベントリスナーでエラーをグローバルに補足することができます。

window.addEventListener("unhandledrejection", (event) => {
    // エラー時の処理
});

ただし、fetch の場合も罠がありまして、unhandledrejection というイベント名からも分かるとおり、ここで捕捉できるのは fetchreject されたケースのみになります。具体的には TCP 通信に失敗した場合がこれに該当します。404 Not Found500 Internal Server Error などの HTTP エラーは、ここではハンドルできません。そのため、XMLHttpRequesterror/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 されてしまうので動作が変わってしまいます。

まとめ

XMLHttpRequestfetch それぞれについて、各エラーの判定方法を表にまとめました。

エラーの種類 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 を使用します。

XMLHttpRequestfetch についてグローバルなエラーハンドリングを実装する方法を紹介しました。用途としては後付けで共通的なエラーハンドリングやエラーログの出力などを行う必要が発生した場合を想定しています。本来あるべき実装としては XMLHttpRequestfetch をラップする共通コンポーネントをあらかじめ作成し、共通コンポーネント経由で非同期通信を行う設計になると思います。参考にしていただければ幸いです。

脚注

  1. Window: error event

    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.

  2. タイムアウトの例

    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);
        }
    });
    

  3. 中断の例

    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();