Centralizing The Error Response Handling For My ColdFusion Blog
If you've noticed that my blog has been quite quiet over the last few weeks, it's because I've dedicated December to modernizing and upgrading my blogging infrastructure. The refactoring has been extensive, to say the least; and, on the list of things that I've wanted to for a long time is centralizing my error response handling in my ColdFusion code. It took me several days to find, factor-out, and normalize my errors; but, I think I have it at a point that I can easily refine and evolve going forward.
A few years ago, I did a deep dive research project on error handling in web applications. As a result of that research, I codified a list of DO and DO NOT rules that effectively outline how to both throw errors and to generate error responses for the end-user. A critical part of this approach is the hard separation between Exceptions and Error Responses:
Exceptions are for developers.
Error responses are for users.
When a part of my ColdFusion application cannot fulfill an action that the user requested, it throws an error. It does this because it cannot adhere to the contract outlined for that module. The error that is thrown contains information about why the error is occurring (and where it occurred in the stack-trace). This information becomes visible to developers via logging - it is not shown to the user.
The boundary of the web application (aka, the "delivery mechanism", aka, the "Controller layer" in an MVC architecture) then catches these errors and translates them into error responses that can be shown to the user. Not only do these error responses contain a user friendly message about the error, they also carry the correct HTTP status code with which to deliver the error response.
ASIDE: People will often throw out the adage that "throwing errors for control flow" is an anti-pattern. My approach does not antagonize this concept. I am throwing errors because a part of the ColdFusion application cannot perform the expected operation - it is not "control flow", it is rejecting a bad request.
A few months ago, I did some thinking about naming conventions for error types within ColdFusion applications. I had mentioned that the error "types" that we use at work were often too generic; and that I'd like to create a naming convention that was based on the context in which an error was thrown. This way, I would get closer to the "perfect world solution" in which every unique error would have a unique type.
On my blog, as I started to refactor the code and create new ColdFusion components where once there had only been templates, I started to see a divergence in the type of contexts that I was creating (much of which mirrors my Software Application Layers And Responsibilities article from 2019):
Entities - these are the lowest-level services that create a domain of enforcement around what it means to be a "Thing" within my blog. For example, I have a
CommentService.cfc
ColdFusion component that has methods for all things comment-related; but, doesn't - for example - know anything really about "authors" of comments since it uses amemberID
opaque key reference.Partials - these are the query operations for the blog. That is, these enforce the access, authorization, and aggregation around loading data for a particular view. These can use lower-level "entity" services; but, often have their own dedicated data-access components. For example, I have a
RecentBlogPosts.cfc
ColdFusion component which is responsible for loading all the primary data for the home-view of the blog.Workflows - these are the command operations for the blog. That is, these enforce the access, authorization, and processing of requests that mutate the system. These only use lower-level "entity" and "infrastructure" services to fulfill the incoming request.
Routing - these map the incoming request to an invocation of either a "Partial" or a "Workflow". But, not always - a user can certainly make a request that doesn't map to any particular resource (which is why "routing" is its own unique context).
Using these four contexts, I created an error type nomenclature in which every error (99%) that I explicitly throw is prefixed with one of the following:
BenNadel.Entity.
BenNadel.Partial.
BenNadel.Workflow.
BenNadel.Routing.
The error type suffix (ie, that which comes after the above in the type
attribute) is then driven by the "thing" that is being acted upon in the current context. So, for example:
BenNadel.Entity.Comment.Content.Empty
... would be for when a Comment Entity cannot be created because the Content property is Empty. Another example would be:
BenNadel.Workflow.Comment.Validation.AuthorUrl.RestrictedProtocol
... which would be when a Comment Workflow (command operation) could not be completed because the Validation failed since the Author URL started with an unacceptable protocol (ex, ftp://
).
It's easy to look at these error types and consider them too granular. But, the nice feature that this approach unlocks is that I can now very easily map every single error onto a meaningful error response - one that I can show to the user. And, I can do this at the peripheral boundary of the web application (ie, in the Controller layer / delivery mechanism). All I need is a giant switch
statement and a little elbow grease.
Now, you may have heard that switch
statements are evil - that they violate the Open/Closed Principle (that code should be open to extension but closed to modification). It turns out, however, that in the real world, switch
statements are quite amazing and can greatly reduce complexity. In fact, my entire Controller layer is really just a series of switch
statements. And, to apply error handling to it, all I have to is wrap that switch
statement in a try/catch
block.
To see what I mean, here's my actual top-level routing controller (with some omissions). Note that is has a large try/catch
around a switch
. And, that in the catch
specifically, we're translating an exception into an error response:
<cfscript>
try {
param name="request.event[ 1 ]" type="string" default="";
switch ( request.event[ 1 ] ) {
case "about":
include "./content/about/_index.cfm";
break;
case "api":
include "./content/api/_index.cfm";
break;
case "blog":
include "./content/blog/_index.cfm";
break;
case "brucenadel":
include "./content/bruce/_index.cfm";
break;
case "coldfusion":
include "./content/coldfusion/_index.cfm";
break;
case "contact":
include "./content/contact/_index.cfm";
break;
case "error":
include "./content/error/_index.cfm";
break;
case "go":
include "./content/go/_index.cfm";
break;
case "invision":
include "./content/invision/_index.cfm";
break;
case "members":
include "./content/members/_index.cfm";
break;
case "people":
include "./content/people/_index.cfm";
break;
case "projects":
include "./content/projects/_index.cfm";
break;
default:
throw(
type = "BenNadel.Routing.InvalidEvent",
message = "Unknown routing event: root."
);
break;
}
// Now that we have executed the page, let's include the appropriate rendering
// template.
switch ( request.template.type ) {
case "blank":
include "./content/layouts/_blank_query.cfm";
include "./content/layouts/_blank.cfm";
break;
case "column":
include "./content/layouts/_column_query.cfm";
include "./content/layouts/_column.cfm";
break;
case "error":
include "./content/layouts/_error_query.cfm";
include "./content/layouts/_error.cfm";
break;
case "json":
include "./content/layouts/_json_query.cfm";
include "./content/layouts/_json.cfm";
break;
case "standard":
include "./content/layouts/_standard_query.cfm";
include "./content/layouts/_standard.cfm";
break;
}
// NOTE: Since this try/catch is happening in the onRequest() event handler, we know
// that the application has, at the very least, successfully bootstrapped and that we
// have access to all the application-scoped services.
} catch ( any error ) {
application.logger.logException( error );
errorResponse = application.services.errorService.getResponse( error );
request.template.type = "error";
request.template.statusCode = errorResponse.statusCode;
request.template.statusText = errorResponse.statusText;
request.template.title = errorResponse.title;
request.template.message = errorResponse.message;
include "./content/layouts/_error_query.cfm";
include "./content/layouts/_error.cfm";
}
</cfscript>
Each one of these CFInclude
tags could potentially invoke code that throws an error. Of course, We never want to show the user the error - not only would that confuse them, it could potentially leak sensitive information about the platform. As such, I use the ErrorService.cfc
ColdFusion component to translate the developer-oriented error
object into a user-oriented error response. Note that the error response includes:
statusCode
statusText
title
message
... which is then being used to render an error template with the appropriate HTTP headers.
Now, here's where the granularity of the error types comes into play - the ErrorService.cfc
uses yet another switch
statement to translate the error type
into something helpful to the user. The main workhorse here is the getResponse()
method, which appends overrides to a set of generic error responses:
component
output = false
hint = "I help translate application errors into appropriate response codes."
{
/**
* I return the generic 400 Bad Request response.
*/
public struct function getGeneric400Response() {
return({
statusCode: 400,
statusText: "Bad Request",
type: "BadRequest",
title: "Bad Request",
message: "Sorry, please validate the information in your request and try submitting it again."
});
}
/**
* I return the generic 404 Not Found response.
*/
public struct function getGeneric404Response() {
return({
statusCode: 404,
statusText: "Not Found",
type: "NotFound",
title: "Page Not Found",
message: "Sorry, it seems that the page you requested either doesn't exist or has been moved to a new location. I'll see if I can put in better redirect handling for future requests."
});
}
/**
* I return the generic 422 Unprocessable Entity response.
*/
public struct function getGeneric422Response() {
return({
statusCode: 422,
statusText: "Unprocessable Entity",
type: "UnprocessableEntity",
title: "Unprocessable Entity",
message: "Sorry, please validate the information in your request and try submitting it again."
});
}
/**
* I return the generic 500 Server Error response.
*/
public struct function getGeneric500Response() {
return({
statusCode: 500,
statusText: "Server Error",
type: "ServerError",
title: "Something Went Wrong",
message: "Sorry, something seems to have gone terribly wrong while handling your request. I'll see if I can figure out what happened and fix it."
});
}
/**
* I return the error RESPONSE for the given error object. This will be the
* information that is safe to show to the user.
*/
public struct function getResponse( required any error ) {
switch ( error.type ) {
// NOTE: If any "entity" level error bubbles-up to the top-level error
// handling and recovery logic, it means that a lower-level construct (such
// as a Workflow or a Partial) failed to properly catch-and-wrap the error in
// a more semantic error. Which means, the entity error is UNEXPECTED and
// should result in a 500 Server Error.
case "BenNadel.Entity.Comment.BlogEntryID.Invalid":
case "BenNadel.Entity.Comment.Content.Empty":
case "BenNadel.Entity.Comment.ContentMarkdown.Empty":
case "BenNadel.Entity.Comment.ID.Invalid":
case "BenNadel.Entity.Comment.MemberID.Invalid":
case "BenNadel.Entity.Comment.NotFound":
case "BenNadel.Entity.CommentEditToken.CommentID.Invalid":
case "BenNadel.Entity.CommentEditToken.EntryID.Invalid":
case "BenNadel.Entity.CommentEditToken.ID.Invalid":
case "BenNadel.Entity.CommentEditToken.NotFound":
case "BenNadel.Entity.CommentEditToken.Value.Empty":
case "BenNadel.Entity.Entry.ID.Invalid":
case "BenNadel.Entity.Entry.NotFound":
case "BenNadel.Entity.Member.Email.Empty":
case "BenNadel.Entity.Member.Email.Invalid":
case "BenNadel.Entity.Member.ID.Invalid":
case "BenNadel.Entity.Member.Name.Empty":
case "BenNadel.Entity.Member.NotFound":
case "BenNadel.Entity.Member.Url.MissingProtocol":
case "BenNadel.Entity.MemberApproval.IpAddress.Empty":
case "BenNadel.Entity.MemberApproval.IpAddress.RestrictedCharacters":
case "BenNadel.Entity.MemberApproval.MemberID.Invalid":
case "BenNadel.Entity.PendingComment.AuthorEmail.Empty":
case "BenNadel.Entity.PendingComment.AuthorEmail.Invalid":
case "BenNadel.Entity.PendingComment.AuthorIpAddress.Empty":
case "BenNadel.Entity.PendingComment.AuthorIpAddress.RestrictedCharacters":
case "BenNadel.Entity.PendingComment.AuthorName.Empty":
case "BenNadel.Entity.PendingComment.AuthorUrl.MissingProtocol":
case "BenNadel.Entity.PendingComment.BlogEntryID.Invalid":
case "BenNadel.Entity.PendingComment.Content.Empty":
case "BenNadel.Entity.PendingComment.ContentMarkdown.Empty":
case "BenNadel.Entity.PendingComment.ID.Invalid":
case "BenNadel.Entity.PendingComment.MemberID.Invalid":
case "BenNadel.Entity.PendingComment.NotFound":
case "BenNadel.Entity.Subscription.EntryID.Invalid":
case "BenNadel.Entity.Subscription.ID.Invalid":
case "BenNadel.Entity.Subscription.MemberID.Invalid":
case "BenNadel.Entity.Subscription.NotFound":
return( as500() );
break;
case "BenNadel.Partial.BlogPost.NotFound":
case "BenNadel.Partial.Go.NotFound":
case "BenNadel.Partial.Member.NoComments":
case "BenNadel.Partial.PeopleDetail.NotFound":
case "BenNadel.Partial.SitePhoto.NextNotFound":
case "BenNadel.Partial.SitePhoto.NotFound":
case "BenNadel.Partial.SitePhoto.PrevNotFound":
case "BenNadel.Partial.TagBlogPosts.NotFound":
case "BenNadel.Routing.About.InvalidEvent":
case "BenNadel.Routing.API.Blog.InvalidEvent":
case "BenNadel.Routing.API.InvalidEvent":
case "BenNadel.Routing.API.SitePhotos.InvalidEvent":
case "BenNadel.Routing.Blog.InvalidEvent":
case "BenNadel.Routing.Bruce.InvalidEvent":
case "BenNadel.Routing.ColdFusion.InvalidEvent":
case "BenNadel.Routing.Contact.InvalidEvent":
case "BenNadel.Routing.Go.InvalidEvent":
case "BenNadel.Routing.InvalidEvent":
case "BenNadel.Routing.InVision.InvalidEvent":
case "BenNadel.Routing.Members.InvalidEvent":
case "BenNadel.Routing.People.InvalidEvent":
case "BenNadel.Routing.Projects.InvalidEvent":
return(
as404({
type: error.type
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorEmail.Empty":
return(
as422({
type: error.type,
message: "Please enter your email address."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorEmail.Invalid":
return(
as422({
type: error.type,
message: "Please enter a valid email address."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorEmail.RestrictedCharacters":
return(
as422({
type: error.type,
message: "Your email contains restricted characters."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorName.Empty":
return(
as422({
type: error.type,
message: "Please enter your name."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorName.RestrictedCharacters":
return(
as422({
type: error.type,
message: "Your name contains restricted characters."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorName.Spam":
return(
as422({
type: error.type,
message: "There was a problem submitting your comment."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorUrl.RestrictedCharacters":
return(
as422({
type: error.type,
message: "Your URL contains restricted characters."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorUrl.RestrictedProtocol":
return(
as422({
type: error.type,
message: "Your URL contains a restricted protocol - only ""http://"" and ""https://"" protocols are accepted."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.AuthorUrl.Spam":
return(
as422({
type: error.type,
message: "There was a problem submitting your comment."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.Duplicate":
return(
as422({
type: error.type,
message: "It looks like your comment may have been submitted twice (accidentally). Try refreshing the page to see your new comment."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.Empty":
return(
as422({
type: error.type,
message: "Please enter a comment."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.InvalidMarkdown":
return(
as422({
type: error.type,
message: "There was a problem processing your markdown. Only basic text formatting and fenced code-blocks are allowed. Try removing some of your markdown syntax."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.RestrictedCharacters":
return(
as422({
type: error.type,
message: "Your comment contains restricted HTML elements (A)."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.RestrictedHtml":
return(
as422({
type: error.type,
message: "There was a problem processing your markdown. Only basic text formatting and fenced code-blocks are allowed. Try removing some of your markdown syntax."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.Comment.Spam":
return(
as422({
type: error.type,
message: "There was a problem submitting your comment."
})
);
break;
case "BenNadel.Workflow.Comment.Validation.ModerationSignature.Mismatch":
return(
as422({
type: error.type,
message: "The moderation signature does not match the expected signature."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Email.Empty":
return(
as422({
type: error.type,
message: "Please enter your email address."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Email.Invalid":
return(
as422({
type: error.type,
message: "Please enter a valid email address."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Message.Empty":
return(
as422({
type: error.type,
message: "Please enter your message."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Message.InvalidMarkdown":
return(
as422({
type: error.type,
message: "There was a problem processing your markdown. Only basic text formatting and fenced code-blocks are allowed. Try removing some of your markdown syntax."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Message.RestrictedHtml":
return(
as422({
type: error.type,
message: "There was a problem processing your markdown. Only basic text formatting and fenced code-blocks are allowed. Try removing some of your markdown syntax."
})
);
break;
case "BenNadel.Workflow.Contact.Validation.Name.Empty":
return(
as422({
type: error.type,
message: "Please enter your name."
})
);
break;
// Anything not handled by an explicit case becomes a generic 500 response.
default:
return( as500() );
break;
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I generate a 400 response object for the given error attributes.
*/
private struct function as400( struct errorAttributes = {} ) {
return( getGeneric400Response().append( errorAttributes ) );
}
/**
* I generate a 404 response object for the given error attributes.
*/
private struct function as404( struct errorAttributes = {} ) {
return( getGeneric404Response().append( errorAttributes ) );
}
/**
* I generate a 422 response object for the given error attributes.
*/
private struct function as422( struct errorAttributes = {} ) {
return( getGeneric422Response().append( errorAttributes ) );
}
/**
* I generate a 500 response object for the given error attributes.
*/
private struct function as500( struct errorAttributes = {} ) {
return( getGeneric500Response().append( errorAttributes ) );
}
}
As you can see, in that giant switch
statement, I have the option to translate an error into a generic response; or, to create a more meaningful error response based on the type. You may notice that all "Entity" errors result in a 500 Server Error
outcome. This is because entity errors are - by definition - too low-level to be tied in a known context. Really, only "Routing", "Partial", and "Workflow" errors can be translated into something more meaningful because they have enough context at the time the error is thrown.
Now, since "Partial" and "Workflow" contexts use lower-level entities when fulfilling their operations, it means that some low-level errors have to be caught and wrapped in order to provide the right context for error translation. For example, one of my validation methods in my CommentWorkflow.cfc
has to test the Author name. Some of the validation is based on the member entity; but, some of the validation is specific to the comment workflow:
component {
// ... truncated for blog post.
/**
* I test the given author name. If the value is invalid, an error is thrown;
* otherwise the method exits quietly.
*/
private void function testAuthorName( required string authorName ) {
try {
memberService.testName( authorName );
} catch ( BenNadel.Entity.Member.Name.Empty error ) {
throw(
type = "BenNadel.Workflow.Comment.Validation.AuthorName.Empty",
message = "Author name is empty.",
extendedInfo = utilities.serializeErrorForNesting( error )
);
}
if ( authorName.reFind( "[<>]" ) ) {
throw(
type = "BenNadel.Workflow.Comment.Validation.AuthorName.RestrictedCharacters",
message = "Author name contains restricted characters.",
extendedInfo = serializeJson( arguments )
);
}
var nameResponse = spamAnalyzer.analyzeUserName( authorName );
if ( nameResponse.isSpam ) {
throw(
type = "BenNadel.Workflow.Comment.Validation.AuthorName.Spam",
message = "Author name flagged as spam.",
extendedInfo = serializeJson( nameResponse )
);
}
}
// ... truncated for blog post.
}
Here, you can see that the low-level entity error:
BenNadel.Entity.Member.Name.Empty
... is caught and then wrapped in a high-level workflow error:
BenNadel.Workflow.Comment.Validation.AuthorName.Empty
... which is then used by the ErrorService.cfc
in order to translate the error into a 422 Unprocessable Entity
error response that the user can safely see.
Other errors, like Spam Detection, are specific to the Workflow and are not the responsibility of the lower-level entity service. In essence, "is it spam?" is not a question that relates to the concept, "what does it mean to be an author?"; and, as such, should not be a part of the entity-level logic.
Now, for this demo, I showed the root-level router and its try/catch
. And, in an ideal solution, there would only be a single, top-level handling of errors. However, what is "ideal" is not always possible. Thankfully, all of the error translation logic is encapsulated in a single ColdFusion components. Which means, when I need to add some additional controller-level (ie, delivery mechanism level) try/catch
statements, the logic is super simple.
For example, in my API subsystems, I use a subsystem-specific try/catch
in order to make sure the errors are all returned as JSON:
<cfscript>
// All API responses will be served up with the JSON template.
request.template.type = "json";
request.template.statusCode = 200;
request.template.statusText = "OK";
// Wrap the entire API subsystem in a try-catch because we want to make sure to
// always return a valid JSON response even if an error is thrown.
try {
// Param the default second event.
param name="request.event[ 2 ]" type="string" default="";
switch ( request.event[ 2 ] ) {
case "blog":
include "./blog/_index.cfm";
break;
case "sitePhotos":
include "./site_photos/_index.cfm";
break;
default:
throw(
type = "BenNadel.Routing.API.InvalidEvent",
message = "Unknown routing event: API."
);
break;
}
} catch ( any error ) {
application.logger.logException( error );
errorResponse = application.services.errorService.getResponse( error );
request.template.statusCode = errorResponse.statusCode;
request.template.statusText = errorResponse.statusText;
request.template.content = {
type: errorResponse.type,
message: errorResponse.message
};
}
</cfscript>
Again, since all my routing is performed via switch
statements, all I have to do is wrap the API-based switch
in a try/catch
and then translate the error into a predictable structure for the API sub-system.
All this refactoring work is still a work in progress. However, it's a million times cleaner and more maintainable than it was a month ago. I really enjoy seeing all the errors in one place; it's going to help keep the language consistent across errors; and, theoretically, it provides a single point of Internationalization (i18n) if I were ever to arrive at that point of sophistication.
Log Aggregation Using Error Type and Message
You may have noticed that while my ErrorService.cfc
produces type
and message
properties, so do all of my throw()
statements. At first glance, this may seem unnecessary. But remember, the data in the throw()
statements is for the engineers, not the users. So, when you see me making calls to my Logger, it's the throw()
properties that I'm using in my log aggregation. As such, the throw()
statement becomes the way I provide information to my future self when I am debugging issues.
Wait, WAT?! Ben Uses What is Essentially "Fuse Box" to Run His Blog?!
As I said (and demonstrated), my entire blog is powered by what is essentially a series of nested switch
and include
statements. It turns out, this is simple, flexible, and super easy to maintain. It worked great 15 years ago; and, works just as well today. I even have dependency injection (DI), which I just implement manually in my onApplicationStart()
ColdFusion application event handler.
It turns out, "simple code" is actually really easy to maintain. Especially for something as low-logic as a Blog.
Want to use code from this post? Check out the license.
Reader Comments
In the code for this blog, I've been using the whole Catch-and-wrap error handling approach in my Workflow layer. However, at work, there's too much existing code to try and normalize on such approach. As such, at work, I've been allowing the Service-level errors drive the error messages. And, honestly, it's been working fine. I'm now thinking that catching-and-wrapping as a rule doesn't really add much value. Instead, I'd like to do that only when it adds semantic value to the error handling.
I've also updated my approach at work to use a separate
validation
ColdFusion component. This component both validates and normalizes the entity properties:www.bennadel.com/blog/4308-updated-thoughts-on-validating-data-in-my-service-layer-in-coldfusion.htm
This is proving to be helpful especially for quite complex, nested data structures that don't fit nicely into the simple relational database row concept:
www.bennadel.com/blog/4298-validating-complex-nested-dynamic-data-structures-in-coldfusion.htm
I might go back to my blog code and continue to evolve this validation / error handling / workflow concept.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →