Node.js で axios とかから投げられる例外のスタックトレースが辛い

axios とかでエラーになったとき、

const axios = require('axios').default;

async function f1() {
    await axios.get('http://localhost:9999');
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));

素のままだとほとんど意味のあるスタックトレースが得られません。

Error: connect ECONNREFUSED 127.0.0.1:9999
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)

ので axios から投げられた例外を拾って新たに例外を投げればよいかと思ったのですが・・・

const axios = require('axios').default;

async function f1() {
    try {
        await axios.get('http://localhost:9999');
    } catch (err) {
        throw new Error(err.message);
    }
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));

期待したようなスタックトレースになりませんでした(f2 のスタックフレームが無い)。

Error: connect ECONNREFUSED 127.0.0.1:9999
    at f1 (/work/z.js:7:15)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)

axios が返す Promise の .catch で例外を投げればそれっぽいスタックトレースになりました。

const axios = require('axios').default;

async function f1() {
    await axios.get('http://localhost:9999')
        .catch(err => { throw new Error(err.message) })
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));
Error: connect ECONNREFUSED 127.0.0.1:9999
    at /work/z.js:5:31
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async f1 (/work/z.js:4:5)
    at async f2 (/work/z.js:9:5)

Node.js v14.4.0

↑は Node.js v12.18.1 で試した結果でした。v14.4.0 だと例外を catch して新たな Error オブジェクトで throw し直すだけでそれっぽいスタックトレースが得られました。

const axios = require('axios').default;

async function f1() {
    try {
        await axios.get('http://localhost:9999');
    } catch (err) {
        throw new Error(err.message);
    }
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));
Error: connect ECONNREFUSED 127.0.0.1:9999
    at f1 (/work/z.js:7:15)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async f2 (/work/z.js:12:5)

axios から直接投げられる例外に呼び出し元のスタックフレームが含まれないのは 12 でも 14 でも同じです。

const axios = require('axios').default;

async function f1() {
    await axios.get('http://localhost:9999');
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));
Error: connect ECONNREFUSED 127.0.0.1:9999
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)

Error.captureStackTrace

Error.captureStackTrace で axios が返す例外のスタックトレースを書き換える方法もあるようです。

const axios = require('axios').default;

async function f1() {
    try {
        await axios.get('http://localhost:9999');
    } catch (err) {
        Error.captureStackTrace(err);
        throw err;
    }
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));
Error: connect ECONNREFUSED 127.0.0.1:9999
    at f1 (/work/z.js:7:15)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async f2 (/work/z.js:13:5)

Node.js v12.18.1 なら try/catch ではなく Promise の .catch で書き換えを行います(前述の通り await の中から例外が飛ぶとそれを catch してもそれ以後にまともなスタックトレースが得られないため)。

const axios = require('axios').default;

async function f1() {
    await axios.get('http://localhost:9999').catch(function (err){
        Error.captureStackTrace(err, arguments.callee);
        throw err;
    });
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));
Error: connect ECONNREFUSED 127.0.0.1:9999
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async f1 (/work/z.js:4:5)
    at async f2 (/work/z.js:11:5)

さいごに

axios を例としてあげていますが fs.promises とかでも素のままだとまともなスタックトレースにならないのは同じです。

ちょっとした CLI とかだと、URL にアクセスできないとかファイルが読み書きできないとかの例外は特にハンドリングせずプロセスが死ぬに任せてたりするのですが、エラーの内容を見ても例外がどの位置で発生したのか全くわからず困ります。

async/await が無くてコールバックや Promise でやっていた頃はまあそういうものかなと思っていたのですが、async/await が使えるようになった結果、非同期でも try/catch 出来るようになったため、axios とか fs.promises とかその他いろいろたくさんあるとは思いますが、それっぽいスタックトレースが得られないことがすごく不便に感じてしまいます。

例えば次のようなコード、

function timeout() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('oops'));
        }, 1);
    });
}

async function f1() {
    await timeout();
}

async function f2() {
    await f1();
}

f2().catch(err => console.error(err.stack));

timeout 関数だけ見れば setTimeout のコールバックの中なので作成されている Error オブジェクトには timeout やその呼び出し元を含むようなスタックトレーすにならないだろうと思えるのですが、f1 や f2 だけを見ると timeout から上がってくる例外には f2 -> f1 -> timeout なスタックトレースを持っていてほしいと感じてしまいます。

最初に挙げた、例外を作り直したり、Error.captureStackTrace で書き換えたりする方法、これらの方法は逆に大本の例外の発生位置がわからなくなってしまうのでどっちもどっちですね・・