rafaelalmeidatk / TIL

I learn something new almost everyday. Here I post stuff to my future self and to other people that may need it
4 stars 0 forks source link

Improving axios errors with useful stack traces #4

Open rafaelalmeidatk opened 4 years ago

rafaelalmeidatk commented 4 years ago

Axios has some issues with errors, and it is even worse on SSR since the only thing you get when accessing the page is something like this:

Error: Request failed with status code 500
    at createError (/home/rafael/project/node_modules/axios/lib/core/createError.js:16:15)
    at settle (/home/rafael/project/node_modules/axios/lib/core/settle.js:17:12)
    at IncomingMessage.handleStreamEnd (/home/rafael/project/node_modules/axios/lib/adapters/http.js:236:11)
    at IncomingMessage.emit (events.js:203:15)
    at endReadableNT (_stream_readable.js:1129:12)
    at process._tickCallback (internal/process/next_tick.js:63:19)

Which is not useful at all, look at this stack trace! We have no clue where the request was done, how can we fix this? We have some issues like axios/axios#2387 and axios/axios#2069 but they don't have any solutions, so we need to solve this ourselves.

First of all, it is very advised to have a module to abstract axios and provide functions for each operation, so I will consider you have a get function that is similar to this:

export const get = (endpoint, params, config) => {
  // instance here is the return of axios.create
  return instance.get(endpoint, { params, ...config });
};

My first attempt was this:

export const get = (endpoint, params, config) => {
  return instance.get(endpoint, { params, ...config }).catch(error => {
    throw new Error("hello!");
  });
};

But turns out even my error can't maintain the stack trace, this is happening because the function is called asynchronously, so the old stack trace is already lost. The trick here is to maintain the old stack trace before throwing the error, and I learned something:

The stack trace isn't something special, it is just a string.

Since it is just a string, we can store it before doing the request, and assign it to the error before throwing it:

export const get = (endpoint, params, config) => {
  const { stack } = new Error();

  return instance.get(endpoint, { params, ...config }).catch(error => {
    error.stack = stackTrace;
    throw error;
  });
};

And now our error contains the old stack trace, so we know exactly where the code has been called! To improve the current code we can move our catch function to somewhere else and output the response body in the error message, this is the full code:

export const axiosCatch = stackTrace => error => {
  let errorMessage = error.message;

  if (error.response) {
    const responseBody = JSON.stringify(error.response.data, null, 2);
    errorMessage = `Request failed with status code ${error.response.status}\n`;
    errorMessage += `Response body: ${responseBody}`;
  }

  error.message = errorMessage;
  error.stack = stackTrace;

  throw error;
};

And to use it:

export const get = (endpoint, params, config) => {
  const stackTrace = getStackTrace();

  return instance
    .get(endpoint, { params, ...config })
    .catch(axiosCatch(stackTrace));
};

I also moved the stack trace code into another function to repeat it in the other methods and I am manipulating it to remove the getStackTrace function from the stack trace, you can tweak it as you like:

const getStackTrace = () => {
  const { stack } = new Error();
  let split = stack.split('\n');

  // Remove the above "new Error" line from the stack trace
  if (split[1].includes('at getStackTrace')) {
    split = [split[0], ...split.splice(2)];
  }

  return split.join('\n');
};
SiestaMadokaist commented 4 years ago

since this solutions works, It should also be possible to be done inside httpAdapter.

// lib/adapters/http.js
module.exports = function httpAdapter(config){
   const { stack: stackTrace } = new Error();
   // outside the new Promise
   config.stackTrace = stackTrace;
   // or use some symbols or _stackTrace if it needs to be hidden.
   return new Promise((function dispatchHttpRequest(resolvePromise, rejectPromise) {
      ....// long codes
   })
}
// lib/core/enhanceError.js

module.exports = function enhanceError(error, config, code, request, response) {
  error.config = config;
  if (code) {
    error.code = code;
  }

  error.request = request;
  error.response = response;
  error.isAxiosError = true;
  error.stack = `${error.stack}${config.stackTrace}`;
  ....

how about it?

SiestaMadokaist commented 4 years ago

this is my sample code:

import axios, { AxiosInstance } from 'axios';

async function main(): Promise<void> {
  const url = 'https://example.com/test';
  const data = await axios.get(url).catch((error: Error) => {
    console.error(error);
  });
}

if (process.argv[1] === __filename) {
  main();
}

and the console output:

Error: Request failed with status code 404
    at createError (/Users/Ramadoka/development/side/ts/playground/node_modules/axios/lib/core/createError.js:16:15)
    at settle (/Users/Ramadoka/development/side/ts/playground/node_modules/axios/lib/core/settle.js:17:12)
    at IncomingMessage.handleStreamEnd (/Users/Ramadoka/development/side/ts/playground/node_modules/axios/lib/adapters/http.js:239:11)
    at IncomingMessage.emit (events.js:208:15)
    at IncomingMessage.EventEmitter.emit (domain.js:476:20)
    at endReadableNT (_stream_readable.js:1168:12)
    at processTicksAndRejections (internal/process/task_queues.js:77:11)Error: 
    at httpAdapter (/Users/Ramadoka/development/side/ts/playground/node_modules/axios/lib/adapters/http.js:20:33)
    at dispatchRequest (/Users/Ramadoka/development/side/ts/playground/node_modules/axios/lib/core/dispatchRequest.js:59:10)
    at processTicksAndRejections (internal/process/task_queues.js:85:5)
    at main (/Users/Ramadoka/development/side/ts/playground/src/fetch.ts:5:16) { // <- the caller
  config: {
    url: 'https://example.com/test',
    method: 'get',
    ... // more stuffs ...
}
SiestaMadokaist commented 4 years ago

Oh, I just realized that axios provided an adapter parameters inside the AxiosRequestConfig. so, you can do it in userland like this:

import axios, { AxiosRequestConfig, AxiosAdapter, AxiosPromise } from 'axios';
const httpAdapter: AxiosAdapter = require('axios/lib/adapters/http');

function customAdapter(config: AxiosRequestConfig): AxiosPromise<any> {
  const { stack: stackTrace } = new Error();
  return httpAdapter(config).catch((error) => {
    error.stack = `${error.stack}${stackTrace}`;
    throw error;
  });
}

async function main(): Promise<void> {
  const i = axios.create({ adapter: customAdapter });
  const resp = await i.get('https://api.cryptoket.io/test').catch((error) => {
    console.error(error);
  });
}

if (process.argv[1] === __filename) {
  main().catch(() => {});
}