Thinking About Exception Chaining And Error Reporting In JavaScript
CAUTION: This post is just me trying to talk though a problem. This is basically a stream-of-consciousness post. Thar be dragons.
In the past, I've written about "Russian Doll" error reporting in Node.js, which requires creating custom Error objects in JavaScript (which can, of course, be done in TypeScript as well). But, just because I've noodled on the topic in the past, it doesn't mean that I am confident in how to use it. In fact, just the opposite - while I love the idea of exception chaining, I have almost no confidence in how to use it effectively. As such, I thought I might try to "talk it out" and step through my confusion. Maybe something good will come of it. Maybe someone will have some helpful feedback.
One of the few places that I've seen an in-depth discussion on exception chaining is in the book, Elegant Objects by Yegor Bugayenko. In Elegant Objects, Yegor proposes that exception chaining should just be done as a matter of course:
I'm sure this is just obvious, but let me reiterate anyway: always chain exceptions, and never ignore original ones.
But why do we need exception chaining in the first place, you may ask. Why can't we just let exceptions float up and declare all our methods unsafe? In this example above, why catch IOException and throw it again, "wrapped" into Exception? What is wrong with the existing IOException? The answer is obvious: exception chaining semantically enriches the problem context. In other words, just receiving "Too many open files (24)" is not enough. It is too low-level. Instead, we want to see a chain of exceptions where the original one says that there are too many open files, the next one says that the file length can't be calculated, the next one claims that the content of the image can't be read, etc. If a user can't open his or her own profile picture, just returning "too many files" is not enough.
Ideally, each method has to catch all exceptions possible and rethrow them through chaining. Once again, catch everything, chain, and immediately rethrow. This is the best approach to exception handling. (Page 207)
To noodle on this, let me throw out some pseudo JavaScript / TypeScript code (pseudo in the sense that I didn't actually run this code to ensure it works). This code deals with a common use-case in which I have a service object that has to persist some data to a gateway, assuming it passes various validation steps:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
return( this.widgetGateway.createWidget( name ) );
}
)
.then( resolve, reject )
;
}
);
return( promise );
}
Here, we're persisting a Widget. First, we validate the Widget name for low-level format adherence. Then, we inspect the persistence gateway to ensure that the widget is uniquely named. And, if both validations pass, we persist the widget.
Of course, validation isn't the only thing that can fail here - the actual persistence could fail. Maybe there's some sort of HTTP network error. Or, perhaps we're using a local IndexedDB instance and we've run out of allocated space. The point is, the gateway action may fail; so, if we're chaining all exceptions, we have to catch that possible gateway error:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
// --
// NOTE: If the gateway fails (perhaps due to an HTTP failure or
// a maxed-out memory failure) we are wrapping the low-level error
// in a more semantic error that relates to this service layer.
var createPromise = this.widgetGateway
.createWidget( name )
.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
)
;
return( createPromise );
}
)
.then( resolve, reject )
;
}
);
return( promise );
}
Here, we're catching the .createWidget() error and wrapping in it a more semantic error about failure to save the Widget.
But wait, the .createWidget() isn't the only gateway interaction we have here. We're also going to the gateway to check to see if an existing widget already uses the given name. Just like the .createWidget() method, the .widgetExists() method could also fail for a variety of reasons as well. As such, we need to catch and wrap it:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
)
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
// --
// NOTE: If the gateway fails (perhaps due to an HTTP failure or
// a maxed-out memory failure) we are wrapping the low-level error
// in a more semantic error that relates to this service layer.
var createPromise = this.widgetGateway
.createWidget( name )
.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
)
;
return( createPromise );
}
)
.then( resolve, reject )
;
}
);
return( promise );
}
As this point, we have several error cases wrapped, but the code is starting to feel noisy. And, as you can see, both wrapped gateway errors are the same. We can try to reduce the noise and the duplication by moving the wrapping to the end of the internal Promise chain:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
return( this.widgetGateway.createWidget( name ) );
}
)
// This Promise chain could fail for a number of unexpected reasons: the
// check for existence could fail and the persistence of the widget could
// fail. Since both of these will [likely] indicate a failure to save, we
// can add a single catch at the end.
.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
)
.then( resolve, reject )
;
}
);
return( promise );
}
By moving the exception chaining to the end of the internal Promise chain, we remove the noise and the duplication. But, we also run into an unintended consequence - we start swallowing the "already exists" error, wrapping it in an "unexpected gateway error" message. There is some disconnect there, but I am not sure what do about it just yet.
But, let's punt on the above issue because we've missed some other points of failure: what happens if the call to .testName() or .widgetExists() fails synchronously due to an unexpected edge-case? For example, what if we pass in "null" to the .testName() method where it tries to performs a length check. That will throw a "Cannot read property 'length' of null" error, which we'll want to catch and wrap. To do this, we can try moving the exception chaining from the inner Promise chain to the outer Promise chain:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
return( this.widgetGateway.createWidget( name ) );
}
)
.then( resolve, reject )
;
}
);
// The Promise chain could fail for a number of unexpected reasons including
// issues with persistence but ALSO bugs in the gateway. For example, there might
// be a "name" value that actually causes a runtime error.
promise = promise.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
);
return( promise );
}
By moving the exception chaining from the inner Promise chain to the outer Promise chain, we can ensure that we're catching all errors raised during the core workflow. But, we still have a disconnect in the way we're describing errors. Both the .testName() method and the alreadyExists check will throw meaningful errors - not "unexpected gateway failures". To get around this, we can try to limit exception chaining to the case in which we don't have an AppError instance:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
detail: "A widget with the same name already exists.",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
return( this.widgetGateway.createWidget( name ) );
}
)
.then( resolve, reject )
;
}
);
// The Promise chain could fail for a number of unexpected reasons including
// issues with persistence but ALSO bugs in the gateway. For example, there might
// be a "name" value that actually causes a runtime error.
promise = promise.catch(
( error: any ) : void => {
// If the error is already a app-specific error, don't wrap it - just let
// it through as a rejection.
if ( error instanceof AppError ) {
return( Promise.reject( error ) );
}
throw(
new AppError({
message: "Widget could not be saved",
detail: "The widget could not be saved due to an unexpected gateway failure.",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
);
return( promise );
}
Here, you can see that we're only wrapping errors that are not already an instance of AppError. And, at first glance, this might look like it's doing what we want - letting the "known" errors through and wrapping only the unexpected errors. But, the problem with this approach is that our code is making an invalid assumption: that the Widget Gateway won't throw any AppErrors. If the Widget Gateway throws an AppError, then the guard statement in our .catch() handler will miss the chance to wrap the gateway error and semantically enrich it.
Part of the problem here is that I'm trying to solve a semantic disconnect between the explicitly thrown errors and the exception chaining. But, I think part of that disconnect is an error in my own perspective. I've been looking at the "Already exists" error as a more semantically meaningful error than the "Widget could not be saved" error. But, the more I noodle on it, the more I realize that I'm wrong. The "already exists" error just describes some state of the system - it's low-level. The "can't save widget" error is describing the outcome of the workflow, which is more meaningful to the action being performed.
If we take out the more detail-oriented portion of the AppError constructors, you can see that the semantic disconnect fades away:
public createWidget( name: string ) : Promise<string> {
var promise = new Promise<string>(
( resolve, reject ) : void => {
// Test the name for low-level validation - throws error if invalid.
this.testName( name );
// Once low-level validation has passed, we need to check the persisted data
// to ensure that widgets are uniquely named.
this.widgetGateway
.widgetExists( name )
.then(
( alreadyExists: boolean ) : Promise<string> => {
if ( alreadyExists ) {
throw(
new AppError({
message: "Widget already exists",
extendedInfo: JSON.stringify( name )
})
);
}
// If we made it this far, all pre-conditions have been met - we
// can save the widget.
return( this.widgetGateway.createWidget( name ) );
}
)
.then( resolve, reject )
;
}
);
// The Promise chain could fail for both EXPECTED and UNEXPECTED reasons. But,
// regardless of the low-level cause, the high-level outcome is that the widget
// could not be saved.
promise = promise.catch(
( error: any ) : void => {
throw(
new AppError({
message: "Widget could not be saved",
extendedInfo: JSON.stringify( name ),
rootCause: error
})
);
}
);
return( promise );
}
Once we remove the subtext from the AppError instances, the semantics makes sense. The operation to save to the Widget could fail for a number of reasons, both expected and unexpected. Perhaps the name is invalid or the gateway encounters an HTTP error. It doesn't matter - the low-level cause of the failure results in a common high-level outcome: failure to save the Widget. Now, our exception chaining makes sense.
When I stop and think about why I was fixating on the semantics of the error message itself, I think it's because the low-level error would have been easier to report to the User. I can take an "Already exists" error and report a more insightful error to the user - more insightful than one that simply says "Widget could not be saved."
In the book, Elegant Objects, Yegor kind of side-steps this problem, leaving it as an exercise for the software engineer:
The catch statement that finally decides to do something about the problem and rescue the situation will burst the bubble and take all other bubbles out of it. How that catch will handle the situation and reprot the problem to the user doesn't really matter. What is important is that we bring low-level root cause of the problem to the highest level of the entire software. (Page 206)
To Yegor, how this problem is handled "doesn't really matter"; but, to our users, the problem is, of course, front-and-center. If we can't tell a user that her operation failed because of a uniqueness constraint on the name, she will have no obvious steps to take towards a resolution. As such, it is important to be able to produce a user-relevant error message from a series of chained exceptions.
At this time, I don't really know what that looks like. Perhaps the root-level operation - the one that actually has a Try-Catch at the boundary of the application core - has to walk down the exception chain and use the nested error instances in its decision making. I know that we have two separate problems here: logging errors within the application and reporting errors to the user; but, I have to believe that the best solution will be one in which these two issue work together towards a common goal rather than strive to be overly independent and decoupled.
Clearly, I have more thinking to do on these topics - both exception chaining and reporting errors to the user; but, if anyone has any suggestions, I would love to hear it.
Want to use code from this post? Check out the license.
Reader Comments
@All,
The more I think about this, the more trouble I have connecting with it in a practical sense. Take a Gateway / Repository for example. Imagine that I have a Gateway that wraps a LocalStorage persistence mechanism and I have a method that is like:
storageGateway.get( id )
... let's say that this method can throw an error for two different reasons:
* The entity with the id doesn't exist (status: 404)
* The user is in Safari Private Mode and there is no LocalStorage (status: 500)
Now, these two errors have a very different semantic meaning. But, if I wrap this error inside the Gateway:
throw( new AppError( "Entity could not be loaded", rootError ) );
.... then, it seems that it makes it much harder to actually report a meaningful error to the user. Really, what I'd like to do is be able to report something like:
"The item you are looking for can't be found."
vs.
"Something completely unexpected happened - our engineers are looking into it."
But, if I wrap the root error, it seems like differentiating between expected and unexpected errors becomes much more difficult.