Repository: axios/axios
Pull request #5987 (2023-10-06 15:40:51)

fix: wrap errors to improve async stack trace
V8 has async stack traces across `await`s, so we're wrapping `request` and intentionally awaiting it to allow any error to "cross", then wrapping the exception therefore getting a stack trace that includes the caller, while at the same keep the original exception as the `cause`.

Fixes https://github.com/axios/axios/issues/2387.

ikonst: Does it help if you inspect the .cause?
ikonst: @DigitalBrainJS could you help with the review?
ikonst: @DigitalBrainJS could you help with the review?
alvarlagerlof: We've been running this PR as a patch in production for a few weeks now at work, and it works great. Sentry errors for timeouts report enough of where they are happening to be useful.
ikonst: @DigitalBrainJS could you approve CI again?
ikonst: @DigitalBrainJS ping
ikonst: Maybe we should add `--async-stack-traces` to the Node options of the `12.x` job? In Node 12, it still wasn't the default (https://thecodebarbarian.com/async-stack-traces-in-node-js-12.html).
ikonst: @DigitalBrainJS can you please approve the workflows?

Patch:
diff --git a/lib/core/Axios.js b/lib/core/Axios.js
index 465d765..486024c 100644
--- a/lib/core/Axios.js
+++ b/lib/core/Axios.js
@@ -35,7 +35,28 @@ class Axios {
    *
    * @returns {Promise} The Promise to be fulfilled
    */
-  request(configOrUrl, config) {
+  async request(configOrUrl, config) {
+    try {
+      return await this._request(configOrUrl, config);
+    } catch (err) {
+      const dummy = {}
+      if (Error.captureStackTrace) {
+        Error.captureStackTrace(dummy)
+      } else {
+        dummy.stack = new Error().stack;
+      }
+      // slice off the Error: ... line
+      dummy.stack = dummy.stack.replace(/^.+\n/, '');
+      // match without the 2 top stack lines
+      if (!err.stack.endsWith(dummy.stack.replace(/^.+\n.+\n/, ''))) {
+        err.stack += '\n' + dummy.stack
+      }
+
+      throw err;
+    }
+  }
+
+  _request(configOrUrl, config) {
     /*eslint no-param-reassign:0*/
     // Allow for axios('example/url'[, config]) a la fetch API
     if (typeof configOrUrl === 'string') {


Test patch:
diff --git a/test/unit/adapters/http.js b/test/unit/adapters/http.js
index 67e01b1..bbfd093 100644
--- a/test/unit/adapters/http.js
+++ b/test/unit/adapters/http.js
@@ -53,6 +53,9 @@ function toleranceRange(positive, negative) {
   }
 }
 
+const nodeVersion = process.versions.node.split('.').map(v => parseInt(v, 10));
+const nodeMajorVersion = nodeVersion[0];
+
 var noop = ()=> {};
 
 const LOCAL_SERVER_URL = 'http://localhost:4444';
@@ -446,6 +449,57 @@ describe('supports http with nodejs', function () {
     });
   });
 
+  it('should wrap HTTP errors and keep stack', function (done) {
+    if (nodeMajorVersion <= 12) {
+      this.skip(); // node 12 support for async stack traces appears lacking
+      return;
+    }
+    server = http.createServer(function (req, res) {
+      res.statusCode = 400;
+      res.end();
+    }).listen(4444, function () {
+      void assert.rejects(
+        async function findMeInStackTrace() {
+          await axios.head('http://localhost:4444/one')
+        },
+        function (err) {
+          assert.equal(err.name, 'AxiosError')
+          assert.equal(err.isAxiosError, true)
+          const matches = [...err.stack.matchAll(/findMeInStackTrace/g)]
+          assert.equal(matches.length, 1, err.stack)
+          return true;
+        }
+    ).then(done).catch(done);
+    });
+  });
+
+  it('should wrap interceptor errors and keep stack', function (done) {
+    if (nodeMajorVersion <= 12) {
+      this.skip(); // node 12 support for async stack traces appears lacking
+      return;
+    }
+    const axiosInstance = axios.create();
+    axiosInstance.interceptors.request.use((res) => {
+      throw new Error('from request interceptor')
+    });
+    server = http.createServer(function (req, res) {
+      res.end();
+    }).listen(4444, function () {
+      void assert.rejects(
+        async function findMeInStackTrace() {
+          await axiosInstance.get('http://localhost:4444/one')
+        },
+        function (err) {
+          assert.equal(err.name, 'Error')
+          assert.equal(err.message, 'from request interceptor')
+          const matches = [...err.stack.matchAll(/findMeInStackTrace/g)]
+          assert.equal(matches.length, 1, err.stack)
+          return true;
+        }
+      ).then(done).catch(done);
+    });
+  });
+
   it('should preserve the HTTP verb on redirect', function (done) {
     server = http.createServer(function (req, res) {
       if (req.method.toLowerCase() !== "head") {
@@ -1384,13 +1438,21 @@ describe('supports http with nodejs', function () {
       // call cancel() when the request has been sent, but a response has not been received
       source.cancel('Operation has been canceled.');
     }).listen(4444, function () {
-      axios.get('http://localhost:4444/', {
-        cancelToken: source.token
-      }).catch(function (thrown) {
-        assert.ok(thrown instanceof axios.Cancel, 'Promise must be rejected with a CanceledError object');
-        assert.equal(thrown.message, 'Operation has been canceled.');
-        done();
-      });
+      void assert.rejects(
+        async function findMeInStackTrace() {
+          await axios.get('http://localhost:4444/', {
+            cancelToken: source.token
+          });
+        },
+        function (thrown) {
+          assert.ok(thrown instanceof axios.Cancel, 'Promise must be rejected with a CanceledError object');
+          assert.equal(thrown.message, 'Operation has been canceled.');
+          if (nodeMajorVersion > 12) {
+            assert.match(thrown.stack, /findMeInStackTrace/);
+          }
+          return true;
+        },
+      ).then(done).catch(done);
     });
   });