As A Node.js Novice, I Don't Understand Why Uncaught Exceptions Are So Dangerous
I've been digging into Node.js and Express.js a lot lately and one thing that I keep coming across is the general FUD (Fear, Uncertainty, Doubt) around uncaught exceptions. Specifically around what you should do in the process.on("uncaughtException") event handler in your application. As someone who is relative new to Node.js, I have trouble understanding this fear. Or rather, I don't understand why an uncaught exception is qualitatively more dangerous than a caught exception. Or, how a caught exception is more likely to leave my Node.js application in a known state.
From what I've read in many articles, the common thinking on uncaught exceptions in Node.js is that if they happen, you should just log them and then kill the process. The reasoning behind this perspective is that an uncaught exception leaves your application in an unknown state; and, that it would be dangerous to let any pending requests finish or to accept any new requests.
NOTE: There seems to be some wiggle-room on the "pending requests". Some articles say you should try to gracefully shutdown the process, giving pending requests time to complete. Other articles say that doing that is still dangerous and that all pending requests should be terminated immediately with the process (just giving the logs time to flush).
Now, I'm not trying to argue that uncaught exceptions are good - they aren't. Your application is breaking in a way that you either didn't anticipate or you didn't code for properly. My confusion around uncaught exceptions relates to their relative severity when compared to caught exceptions. I am not sure how caught exceptions have better guarantees.
To help me think this out, I've created a trivial Express.js application that throws three types of errors:
- Synchronous error inside the Express.js handler.
- Asynchronous error inside a Promise.
- Asynchronous error inside a Timer (ie, outside both the handler and the Promise).
There is no business logic behind any of these errors - they are being explicitly thrown in the "service layer". The point here is that the underlying business logic is a black-box to the Express.js application, so the details around the error should be irrelevant.
With the first two errors, I still have access to the current Request / Response model. As such, the contextual responses can be finalized in error. With the last error - the one in the Timer - the subsequent uncaught exception handler won't have access to the response and would otherwise leave the request hanging. As such, I've added some Express.js middleware that terminates long-running requests.
NOTE: Terminating long-running requests is a concept unrelated to uncaught exceptions. There are other reasons why you might want to enforce a request timeout.
For the sake of this thought experiment, I am making all three of the errors the same, "Cannot call foo on undefined". The point here is that a given error can happen anywhere and the business logic is a complete black-box from the point of the Express.js handlers. It's not like only certain types of errors lead to uncaught exceptions.
// Require the core node modules.
var chalk = require( "chalk" );
var express = require( "express" );
var http = require( "http" );
var onFinished = require( "on-finished" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var app = module.exports = express();
// I setup a long-running request middleware that will terminate requests that do not
// appear to have finished successfully.
app.use(
function( request, response, next ) {
console.log( chalk.green.bold( "GET" ), chalk.green( request.url ) );
var timer = setTimeout(
() => {
console.log( chalk.red.italic( "Terminating long-running request." ) );
response.status( 504 ).end();
},
5000
);
onFinished(
response,
() => {
clearTimeout( timer );
}
);
next();
}
);
// I demonstrate a successful request handler.
app.get(
"/",
function( request, response ) {
response.send( "Hello world!" );
}
);
// I demonstrate a request handler that will result in a caught error.
app.get(
"/error1",
function( request, response ) {
someSyncCode();
response.send( "Error 1 completed." );
}
);
// I demonstrate a request handler that will result in a caught error.
app.get(
"/error2",
function( request, response, next ) {
someAsyncCode()
.then(
() => {
response.send( "Error 2 completed." );
}
)
.catch( next )
;
}
);
// I demonstrate a request handler that will result in a UNCAUGHT error.
app.get(
"/error3",
function( request, response, next ) {
someDangerousAsyncCode()
.then(
() => {
response.send( "Error 3 completed." );
}
)
.catch( next )
;
}
);
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// I throw an error in the same tick of the event loop.
function someSyncCode() {
throw( new Error( "Cannot call foo on undefined (1)." ) );
}
// I throw an error inside the returned promise.
function someAsyncCode() {
var promise = new Promise(
( resolve, reject ) => {
throw( new Error( "Cannot call foo on undefined (2)." ) );
}
);
return( promise );
}
// I throw an error in the future, outside the bounds of the returned promise.
function someDangerousAsyncCode() {
var promise = new Promise(
( resolve, reject ) => {
setImmediate(
() => {
throw( new Error( "Cannot call foo on undefined (3)." ) );
}
);
}
);
return( promise );
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
app.listen( 3000 );
// Listen for uncaught exceptions - these are errors that are thrown outside the
// context of the Express.js controllers and other proper async request handling.
process.on(
"uncaughtException",
function handleError( error ) {
console.log( chalk.red.bold( "Uncaught Exception" ) );
console.error( error );
}
);
Now, if I run this Express.js application and try to call each one of the end-points in turn, I get the following terminal output:
The application works just as we expected it to - the first two errors were caught by the application code and the last error was caught by the process "uncaughtException" handler. So my question is, given the fact that the actual Error was same in all three cases and all of these represent a bug in the application regardless of the context, why does the Node.js community generally believe that the first two leave the application in a "known state" and the last one almost certainly leaves the application in a "dangerous unknown state"?
This is not a retorical question - I am honestly trying to understand the finer aspects of Node.js application development. I feel like I am missing something that is more obvious to seasoned Node.js developers. I keep looking at this code and I cannot figure out why the caught errors are any less dangerous than the uncaught errors?
I think part of my confusion may come from my ColdFusion programming background. In ColdFusion, uncaught exceptions can happen at any time and the ColdFusion application server just keeps on going, regardless of how you may have left the state of the application. And in the vast majority of cases, that's totally fine. But, of course, ColdFusion does a tremendous amount of work for you. I know that Node.js is a lot "closer to the metal"; so, maybe that's a big part of the disconnect for me; or rather, the unfamiliarity with how uncaught exceptions are extra dangerous.
Uncaught exceptions are bad. I'm not, in any way, pushing back against that concept. I'm just not sure why caught exceptions are any better or why they are more likely to leave your application in a safe, known state. If anyone can shed some light on this, I would be hugely grateful.
Want to use code from this post? Check out the license.
Reader Comments
@All,
I was chatting with Alexey Migutsky on Twitter about this ( https://twitter.com/BenNadel/status/854673939439792128 ) and he alluded to the idea that any unexpected error should crash the process, caught or uncaught. That's a very provocative idea; and, not one that I have seen in any of the articles I have read. But, just including it here for a breadth of ideas.
Could you clarify why /error1 counts as a caught error? Where is it caught? Does this mean that Node automatically catches all sync errors?
It's not just about application state, but about async execution. Exceptions are failures built in a way programmers can manage. Truly fatal errors can be removed from developer control via language design, e.g. stack overflows, segfaults, etc. In NodeJS, so much of the typical program design is in non-blocking callbacks on a single process. An uncaught exception will not necessarily crash it, and that could lead to even worse problems around memory leaks and other resource mismanagement. In other runtimes, concurrent programming is something you "opt-in" to when you know what you're doing, not the regular state of affairs.
@Šime,
Sure thing - since /error1 threw a *synchronous* error inside of the Express middleware, it was caught by Express.js and piped into the default Express.js error handler (which just dumps the error to the response). So, while I am not explicitly catching the error, the synchronous nature of it means that it is caught by the application itself (not the top-level process).
@LR,
I totally agree with everything you are saying. My only point is that I don't see how an explicitly *caught* exception is guaranteed to be any safer than an uncaught exception. Just because you catch it, it doesn't mean that the layer of the application that threw the error did it safely, in a way that cleaned up after itself. The bottom line is, if its a bug, then caught vs. uncaught doesn't make much of a difference. So, my only argument is that if any bug is unsafe, and we tend to - as a community - not crash the process for "caught bugs", then why bother crashing them for "uncaught bugs".
And its not like its a purely philosophical question - any one process might be handling many concurrent requests. So, crashing the process may terminate pending requests that would have otherwise finished successfully.
That said, I can understand if an uncaught error leads to a "graceful" shutdown of a process that disconnects from the Master and allows pending requests to finish. But, I have not done much experimentation with this approach just yet.
@Ben,
My understanding is that you should only continue if you can guarantee from the calling context that no resources are being left unreleased. If the calling context is synchronous, you can tell what the context is from the stack trace. With async calls, you lose the context of the caller.
So, while some synchronous errors can be safely handled as a caught/uncaught exception, it's almost never possible to safely handle an async error in the uncaught handler because you have lost the context of what triggered the async call.
// synchronous function which is unsafe to handle
function foo() {
const resource = Resource.create();
throw new Error();
resource.destroy();
}