Skip to content

fix: wrap errors to improve async stack trace#5987

Merged
DigitalBrainJS merged 13 commits intoaxios:v1.xfrom
ikonst:propagate-stack-trace-async
Jan 24, 2024
Merged

fix: wrap errors to improve async stack trace#5987
DigitalBrainJS merged 13 commits intoaxios:v1.xfrom
ikonst:propagate-stack-trace-async

Conversation

@ikonst
Copy link
Copy Markdown
Contributor

@ikonst ikonst commented Oct 6, 2023

V8 has async stack traces across awaits, 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 #2387.

@andrew0
Copy link
Copy Markdown

andrew0 commented Oct 6, 2023

thanks for putting this together in a PR @ikonst. I did a bit more testing and was thinking it might actually be better to only adjust the stacktrace in lib/core/dispatchRequest. The current stack traces only seem to be a problem when dispatching the actual request, but there could be other errors in the interceptor chain that we don't want to mess with.

Using the test script I shared in the original issue and this PR:

const axios = require('axios');

async function makeRequest() {
  await axios.get('https://httpstat.us/500');
}

async function main() {
  await makeRequest();
}

main().catch((err) => {
  console.error(err.stack);
});

I get this stack trace:

AxiosError: Request failed with status code 500
    at AxiosError.from (node_modules/axios/dist/node/axios.cjs:837:14)
    at Axios.request (node_modules/axios/dist/node/axios.cjs:3820:61)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async makeRequest (test.js:4:3)
    at async main (test.js:8:3)

But if I add an interceptor that throws an error, you lose the context of which interceptor failed:

const axios = require('axios');

axios.interceptors.request.use(() => {
  throw new Error('kaboom');
});

async function makeRequest() {
  await axios.get('https://httpstat.us/500');
}

async function main() {
  await makeRequest();
}

main().catch((err) => {
  console.error(err.stack);
});
Error: kaboom
    at AxiosError.from (node_modules/@fds/request/node_modules/axios/dist/node/axios.cjs:837:14)
    at Axios.request (node_modules/axios/dist/node/axios.cjs:3820:61)
    at async makeRequest (test.js:8:3)
    at async main (test.js:12:3)

This was the patch I was testing (for node.js):

diff --git a/dist/node/axios.cjs b/dist/node/axios.cjs
index 8d8b06a66eb5b72fb5d4cbe2bdbd085a1c490aa0..10cd79013e612870f24c08d56765fa154eeab2ab 100644
--- a/dist/node/axios.cjs
+++ b/dist/node/axios.cjs
@@ -3570,6 +3570,17 @@ function dispatchRequest(config) {
 
     return response;
   }, function onAdapterRejection(reason) {
+    if (reason instanceof Error) {
+      const carrier = {};
+      if (Error.captureStackTrace) {
+        Error.captureStackTrace(carrier);
+      } else {
+        carrier.stack = new Error().stack;
+      }
+
+      reason.stack = reason.stack + carrier.stack.replace(/\S.*/, '');
+    }
+
     if (!isCancel(reason)) {
       throwIfCancellationRequested(config);
 

With this patch I get these stack traces:

AxiosError: Request failed with status code 500
    at settle (node_modules/axios/dist/node/axios.cjs:1913:12)
    at IncomingMessage.handleStreamEnd (node_modules/axios/dist/node/axios.cjs:2995:11)
    at IncomingMessage.emit (node:events:525:35)
    at endReadableNT (node:internal/streams/readable:1359:12)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
    at onAdapterRejection (node_modules/axios/dist/node/axios.cjs:3576:15)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async makeRequest (test.js:4:3)
    at async main (test.js:8:3)
Error: kaboom
    at test.js:4:9
    at async makeRequest (test.js:8:3)
    at async main (test.js:12:3)

So for the case where the interceptor throws, it still keeps the callsite at line 4 (inside the interceptor) where the error originates.

@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Oct 6, 2023

Does it help if you inspect the .cause?

@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Oct 6, 2023

P.s. I also had a version where I mutate the stack trace instead of wrapping. I thought wrapping is generally the more idiomatic way.

If we do splice the stack, then perhaps we could check that the stack we add to doesn't already contain the stack we're adding.

@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Oct 20, 2023

@DigitalBrainJS could you help with the review?

1 similar comment
@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Nov 28, 2023

@DigitalBrainJS could you help with the review?

@alvarlagerlof
Copy link
Copy Markdown

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.

Comment thread lib/core/Axios.js Fixed
Comment thread lib/core/Axios.js Fixed
Comment thread test/unit/adapters/http.js Fixed
Comment thread lib/core/Axios.js Outdated
dummy.stack = new Error().stack;
}
// slice off the Error: ... line
dummy.stack = dummy.stack.replace(/^.+\n/g, '');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

g flag has no effect here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're right! I think I was assuming multiline mode by default and thought g negates that. 🤦

Comment thread lib/core/Axios.js Outdated
// slice off the Error: ... line
dummy.stack = dummy.stack.replace(/^.+\n/g, '');
// match without the 2 top stack lines
if (!err.stack.endsWith(dummy.stack.replace(/^.+\n.+\n/g, ''))) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

g flag has no effect here

Comment thread lib/core/Axios.js Outdated
}
}

_dispatchRequest(configOrUrl, config) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be better to call this _request for now since we are wrapping the original request function

Comment thread test/unit/adapters/http.js Outdated
assert.equal(thrown.message, 'Operation has been canceled.');
done();
});
assert.rejects(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh, looks like a floating promise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nm, it wasn't since we call .then(done).catch(done) on it, but I added void before it to clarify (per convention here)

@ikonst ikonst force-pushed the propagate-stack-trace-async branch from e4e102b to 09575e9 Compare December 6, 2023 18:39
@ikonst ikonst force-pushed the propagate-stack-trace-async branch from ff00a78 to 77430cf Compare December 6, 2023 18:40
@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Dec 7, 2023

@DigitalBrainJS could you approve CI again?

@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Jan 4, 2024

@DigitalBrainJS ping

@ikonst
Copy link
Copy Markdown
Contributor Author

ikonst commented Jan 8, 2024

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
Copy link
Copy Markdown
Contributor Author

ikonst commented Jan 16, 2024

@DigitalBrainJS can you please approve the workflows?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve axios stack traces

5 participants