Nested exceptions with elaborate stack traces for Node.js.
What | Where |
---|---|
Discussion | #1 |
Documentation | https://bigeasy.github.io/interrupt |
Source | https://github.com/bigeasy/interrupt |
Issues | https://github.com/bigeasy/interrupt/issues |
CI | https://travis-ci.org/bigeasy/interrupt |
Coverage: | https://codecov.io/gh/bigeasy/interrupt |
License: | MIT |
Interrupt installs from NPM.
npm install interrupt
Exceptions are nice. I like the concept. I've always done my best to employ them in JavaScript and Node.js but it isn't always easy.
Interrupt is a module I created to address the problems with JavaScript's limited exception mechanism, solving the challenges I've encountered over the years that I'm sure you've encountered as well. It is not an elegant solution, but it is a solution none the less.
Advocacy
Interrupt allows you to get exceptions that have a file name and line number from your program, instead of a stubby stack trace that points to the wilderness of the Node.js source, or no stack trace at all. It does this with some syntactical struggle, but without the expensive superfluous stack trace generation of the long stack trace modules.
Interrupt allows you to nest exceptions so you can provide application context to system and library exceptions.
Interrupt allows you to cite multiple nested exceptions as the cause of your contextual exception which is necessary if you're doing any sort of parallel asynchronous programming where multiple parallel paths can raise exceptions.
Interrupt uses and abuses the Error.stack
property provided by Google V8 to
generate an elaborate report from Error.stack
including the Error
type,
message, context properties, and stack trace along with the error messages and
stack traces of all the nested errors.
Interrupt's elaborate Error.stack
is machine readable so you could conceivably
process these stack traces programmatically after gathering them from production
logs.
Interrupt can report it's elaborate stack trace de-duped with a count of similar exceptions so that when great many parallel operations raise the same you don't have wade through the repetitive stack traces to see if there is anything unique about one of them.
Interrupt endeavours to do all this with a minimum of extra code and code paths
so you can format exception messages with sprintf-js
, set properties, specify
nested expressions the constructor, often as a one-liner. TODO Basically
advocating one-liners, poorly worded.
This README.md
is also a unit test using the
Proof unit test framework. We'll use the
Proof okay
function to assert out statements in the readme. A Proof unit test
generally looks like this.
require('proof')(4, async okay => {
okay('always okay')
okay(true, 'okay if true')
okay(1, 1, 'okay if equal')
okay({ value: 1 }, { value: 1 }, 'okay if deep strict equal')
})
You can run this unit test yourself. The --async-stack-traces
flag is not
necessary on Node.js 14 or above.
git clone git@github.com:bigeasy/interrupt.git
cd interrupt
npm install --no-package-lock --no-save
node --async-stack-traces test/readme.t.js
The only way to see the elaborate stack trace output is to run this test at the command line, so please do so. TODO No, we're going to fix that.
Interrupt is targeted for Node.js 12 or greater. Note that we are running with
--async-stack-traces
enabled and to enjoy all the features discussed in this
readme you need to be running Node.js 14.
The Interrupt module exports an Interrupt
constructor.
const Interrupt = require('interrupt')
All of the examples in this code are contrived, and in practice, I'm never quite this zealous with my use of exceptions or Interrupt.
Happy Path vs Error Path
Throughout we'll be referring to the happy path and the error path.
The happy path is the path of code execution where everything goes as expected. It is the code path that gets the most exercise, the path that receives the most robust testing.
The error path is the path of code excution that is followed when things go sideways. One would hope that the error path is not often executed during normal operation. One would expect the error path to be followed as a result unforseen circumstances that might not have been considered during development. Sadly, the error path usually does not receive a lot of testing. One should tread lightly on the error path.
Interrupt endevours to be exacting on the happy path and accommodating on error path. It has a lot of assertions on functions that are executed during normal operation and a lot of fallbacks on the functions that are executed during exception handling.
When we speak of the error path in this documenation, we are urging caution and justifing our accommodations. When we speak of the happy path in this documentation we are urging rigor and justifying our assertions.
Errors by Code
Errors in JavaScript have very little context information. The only
properties defined by the spec are name
and message
.
TODO As of this writing, Interrupt is targeted for Node.js and Google V8 only, but can probably be ported to other JavaScript engines if someone would like to recommend a cross-browser development setup. I'm open to porting if someone is interested.
The message
is supposed to be human readable and because of this it
doesn't serve well as a programmatic indication of error type.
console.log('\n--- message only Errors ---\n')
const path = require('path')
const fs = require('fs').promises
async function loadJSONConfiguration (filename) {
let json
try {
json = await fs.readFile(filename, '')
} catch (error) {
const e = new Error('file unreadable: ' + filename)
e.cause = error
throw e
}
let config
try {
config = JSON.parse(json)
} catch (error) {
const e = new Error('unable to parse configuration')
e.cause = error
throw e
}
if (config == null || typeof config != 'object' || Array.isArray(object)) {
throw new Error('JSON must be an object')
}
if (config.size == null) {
throw new Error('memory is a require configuration parameter')
}
if (config.size == null) {
throw new Error('memory configuration parameter must be a number')
}
return config
}
let config
try {
config = await loadJSONConfiguration(path.join(__dirname, 'missing.txt'))
} catch (error) {
console.log(`${error.stack}\n`)
// If the file doesn't exist, use a default configuration, otherwise
// rethrow any configuration exceptions.
if (/file unreadable/.test(error.message) && error.cause.code == 'ENOENT') {
config = { size: 5 }
} else {
throw error
}
}
okay(config, { size: 5 }, 'used a default configuration (example)')
Other languages have the ability to catch an exception by type. This ability to catch by type is where the idea for an exception class for each type of error comes from.
JavaScript does not have this ability. Once the exception is caught it
must be filtered through an if
/else
ladder with instanceof
to
determine the type of exception. Using entire classes for what is
essentially a flag is a heavyweight approach. The user now has to import
the module's exceptions into the namespace of their application to use
them as test conditions. Not only do we have to add this if
/else
ladder, we have to our require
statements start to look like this.
const { ConfigParseError, ConfigIOError, loadJSONConfiguration } = require('./config')
This is so foreign to JavaScript, to use type information directly, instead of using ploymorphism. Kinda feels like we're moving the internals of a dependency into our module to check a flag.
Node.js itself doesn't extend the error class heirarchy by much. In
fact, in our code we further test the cause of the I/O error by checking
a code
property to see if it is a ENOENT
, the POSIX code for a
missing file.
The Node.js libraries use a base Error
class (with the exception of the
assert
module) and simply set a code
on the error object. All of the
errors eminating from the standard Node.js modules have a code
property
and each code
property has associated documentation.
If you use codes your module can adhere to this practice.
Interrupt prefers to use codes as well. Interrupt encourages you to create a set of error codes for your module.
You can still create an Error object heirarchy using Interrupt, but once you have a set of codes you start to see how they're easier to work with.
You declare your codes when you create your Interrupt derived class. You
can obtain a list of declared codes using the static codes
property.
In the example below we declare a ConfigError
class with an object
that maps the error codes to an error message. We can get a list of the
codes defined using the codes
property of the generated class.
okay(Interrupt.prototype instanceof Error, '`Interrupt` is an `Error`')
const ConfigError = Interrupt.create('ConfigError', {
IO_ERROR: 'unable to read config file',
PARSE_ERROR: 'unable to parse config file'
})
const codes = ConfigError.codes
okay(codes.sort(), [ 'IO_ERROR', 'PARSE_ERROR' ], 'set of generated error codes')
okay(typeof ConfigError.IO_ERROR, 'symbol', 'constant that maps an error code name to a symbol')
okay(typeof ConfigError.PARSE_ERROR, 'symbol', 'one for each error code')
okay(ConfigError.prototype instanceof Interrupt, 'generated error is an `Interrupt`')
okay(ConfigError.prototype instanceof Error, 'generated error is therefore also an `Error`')