Unknown error tracking in Node.js with ExceptionBag
Did you ever receive the following error with stack trace you had no idea where it came from in your system?
Error: socket hang up
at createHangUpError (http.js:1445:15)
at Socket.socketOnEnd [as onend] (http.js:1541:23)
at Socket.g (events.js:175:14)
at Socket.EventEmitter.emit (events.js:117:20)
at _stream_readable.js:910:16
at process._tickCallback (node.js:415:13)
We will see in this article how we can improve the message and/or stack trace of such errors and look into the exceptionbag library which we can use for better tracking of these errors and gathering related data along the way.
Where do these errors come from?
These errors usually come from older libraries in the npm ecosystem or Node sdk that rely heavily on callbacks.
It’s important to know that with callbacks we are just passing the error through one or many function in the state it is:
callback(new Error('failure')) // callback function is usually named 'cb'
with its original stack trace, depending on where it was thrown, that stack trace will get recorded but up to it. Further propagation of stack trace stops as it’s not being re-thrown (bubble up) but passed as is.
Code in the old days would look like this:
function myHandler(fileName, cb) {
fs.readFile(fileName, 'utf8', (err, data) => {
if (err) {
return cb(err);
}
processData(data, (err, processedData) => {
if(err) {
return cb(err);
}
cb(undefined, data);
})
});
}
myHandler('/Users/joe/test.txt', (err, data) => {
if(err) {
return console.error(err);
}
console.log('Read data', data);
})
And when an error would occur, we would not be able to distinguish if it came from reading of the file or processing of the data from the file. Now imagine this chained 5 or more times, it would be a nightmare to debug where the problem comes from.
If you are working solely with async/await and try/catch you will not run into this issue, BUT only if you don’t relly on an underlying library at the source that will throw you an error that was somewhere passed via callback.
How to remedy this?
Manually extending the error object
Some of the ways developers would try to improve readability would be to extend the message:
const id = 123;
oldLibrary.execute(id, (data, err) => {
if (err) {
err.message = `failed executing old library with id: ${id} cause: ${err.message}}`;
cb(err);
return;
}
cb(undefined, data);
});
But this is a bit dirty, hard to maintain and does not record proper stack trace, plus the passing of the id if unique would produce massive amounts of unique log messages.
Node 16.9.0 Error
If you are using the newest versions of node, you can now utilize a new constructor to pass the cause:
try {
// ... some old library execution
} catch (err) {
throw new Error('failed executing old library', {cause: err, values: [id]});
}
This is a good helping option, especially since it’s integrated in the Node sdk, though it helps basically as some advanced functionality like chaining of similar/same errors does not exist.
ExceptionBag
Library exceptionbag
is meant to provide more functionality and flexibility with wrapping errors, data and
accumulating it throughout the function calls and provide easy interface for interacting with the error.
npm install exceptionbag
Example in practice:
import {ExceptionBag} from 'exceptionbag';
const getUser = async (userId) => {
try {
// fetch user
} catch (error) {
throw ExceptionBag.from('failed fetching user from database', error)
.with('userId', userId);
}
}
const doSomeBusinessLogic = async (userId, membership) => {
try {
// handle some business logic with user's membership
} catch (error) {
throw ExceptionBag.from('failed some business logic', error)
.with('userId', userId)
.with('membership', membership);
}
}
try {
await doSomeBusinessLogic(1234, 'standard')
} catch (error) {
if (error instanceof ExceptionBag) {
console.log(error.message, error.getBag());
} else {
console.log(error);
}
}
// This will produce an error message:
// "failed some business logic: failed fetching user from database: Error Something failed"
// and log the metadata:
// { userId: 1234, membership: 'standard' }
With this when the error bubbles up we can get a full message with all the data of the flow accumulated and with
original stack trace throughout our business logic. Also, we can get the original cause
:
try {
// something failed
} catch (error) {
const cause = error.cause;
// or we can check the original cause
if (error.isCauseInstanceOf(CustomError)) {
// handle custom error case
}
}
Now it’s important to remind that the above example of usage of ExceptionBag
throws the error and throwing records
a stack trace! When we have callbacks we don’t throw them, we pass them as value, thus there is no stack
trace
generated. In order to solve this, there is a helper method captureStackTrace
during the creation for doing just that!
oldLibrary.execute(id, (data, err) => {
if (err) {
cb(
ExceptionBag.from('failed executing old library', err)
.with({ id })
.captureStackTrace()
);
return;
}
cb(undefined, data);
});
Decorators to improve readability
It is also possible to use decorators when in TypeScript for wrapping functionality with the ExceptionBag class so the code ends up being more readable. These decorators can wrap standard functions, async/promise functions and returned observables.
It also provides a lot of flexibility like option to ignore wrapping of certain errors.
import {ThrowsExceptionBag} from "exceptionbag/decorators";
class CustomError extends Error {
public constructor(msg?: string) {
super(msg);
this.name = CustomError.name;
}
}
class MyService {
@ThrowsExceptionBag('failed to do some business logic', {ignore: CustomError}) // Re-throw CustomError instead of wrapping
async doSomeBusinessLogic(@InBag('userId') userId, @InBag('membership') membership) {
// handle some business logic with user's membership
}
}
This is identical to
const doSomeBusinessLogic = async (userId, membership) => {
try {
// handle some business logic with user's membership
} catch (error) {
if (error instanceof CustomError) {
throw error;
}
throw ExceptionBag.from('failed some business logic', error)
.with('userId', userId)
.with('membership', membership);
}
}
Extending ExceptionBag
You can easily extend the base ExceptionBag
and create your own custom errors for more precise handling
import { ExceptionBag } from 'exceptionbag';
class CustomExceptionBag extends ExceptionBag {
public responseStatus: number;
public constructor(msg: string, responseStatus: number, cause?: Error) {
super(msg, cause);
this.responseStatus = responseStatus;
this.name = CustomExceptionBag.name;
}
}
// The later use it
try {
// ...
throw CustomExceptionBag.from('custom failure', new Error('failure')).with({ status: 303 });
} catch (error) {
if(error instanceof CustomExceptionBag) {
// ... check response status
}
}
And even create your own decorators for that class in the following way:
import { createExceptionBagDecorator } from 'exceptionbag/decorators';
function ThrowsCustomExceptionBag<T extends Constructable>(options?: ThrowsOptions<T>): DecoratedFunc;
function ThrowsCustomExceptionBag(message?: string): DecoratedFunc;
function ThrowsCustomExceptionBag<T extends Constructable>(message?: string | ThrowsOptions<T>): DecoratedFunc {
return createExceptionBagDecorator(CustomExceptionBag.from.bind(CustomExceptionBag))(message);
}
// And then use it
class BusinessClass {
@ThrowsCustomExceptionBag('failed doWork')
doWork(@InBag('value') value) {
// some work...
}
}
So the flexibility of tuning it for your specific needs is high.
AxiosExceptionBag
One example of a custom error already exists in the library and is tied to the axios npm library that extracts as much information as possible from the error and provides a convenient API for handling.
import {ThrowsAxiosExceptionBag} from "exceptionbag/decorators";
class MyApiHandler {
@ThrowsAxiosExceptionBag('failed MyApiHandler getData')
async getData(@InBag('userId') userId): Promise<any> {
// fetch data
}
}
So when you are handling the error it’s much easier to debug or handle specific cases
try {
const data = await new MyApiHandler().getData('1234');
} catch (error) {
if(error instanceof AxiosExceptionBag) {
// has more type safe methods to easy handling
const response = error.getResponseData<any>();
const isBadRequest = error.hasStatus(400);
const headers = error.getHeaders();
// and the base getBag() with all key - value details
const bag = error.getBag();
}
}
Nest.js exception handling
Final example of real world usage in Nest.js framework is via registration to catch only the
error ExceptionBag
through ExceptionFilter
interface.
Note that all custom errors that extend the base will be caught here, so you can add specific handling or logging of them as well.
@Catch(ExceptionBag)
class ExceptionBagFilter implements ExceptionFilter {
catch(exception: ExceptionBag, host: ArgumentsHost): any {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
// Your custom logger
console.log({
message: exception.message,
name: exception.name,
stack: exception.stack,
details: exception.getBag()
});
// You can check for specific error classes that extend the ExceptionBag if needed so
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
timestamp: new Date().toISOString(),
path: req.url,
});
}
}
This way we declare in one place how to log all the details of the failure, so the error would look something like this based on response:
{
message: 'failed MyApiHandler getData: Error connect ECONNREFUSED 127.0.0.1:3000',
name: 'ExceptionBag',
stack: 'ExceptionBag: failed getData HRERE: Error connect ECONNREFUSED 127.0.0.1:3000\n
at ClientRequest.http.get.on.err (/Users/<redacted>/node-sandbox/index.js:25:15)\n
at emitOne (events.js:116:13)\n
at ClientRequest.emit (events.js:211:7)\n
at Socket.socketErrorListener (_http_client.js:401:9)\n
at emitOne (events.js:116:13)\n
at Socket.emit (events.js:211:7)\n
at emitErrorNT (internal/streams/destroy.js:73:8)\n
at _combinedTickCallback (internal/process/next_tick.js:139:11)\n
at process._tickCallback (internal/process/next_tick.js:181:9)',',
details:
{
errno: 'ECONNREFUSED',
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3000,
userId: '1234'
}
}
Depending on the cause of the error there could be more or less information. Headers are not present in the bad, but can be retrieved via method call.
Summary
When dealing with legacy libraries or codebase, using the exceptionbag library can greatly help with debugging when errors passed as values start to occur providing a better message of the failure and important metadata of that context.
If you are using a newer version of Node.js you can use the new Error constructor, though some features and methods
that the exceptionbag
library has won’t be there.