Introduction
The default configuration for errors in ASP.NET MVC leaves a lot to be desired. There is an error attribute that handles redirecting any errors thrown by an action method, but this constrains everything to be handled in a single view. It also feels (subjectively) to be more magic, instead of allowing everything to be described explicitly. In this post, a flexible and easily understood error handling mechanism will be detailed.
Getting Started: Outside of ASP.NET
The first step is to make sure everything outside of the ASP.NET pipeline is
handled. If an error occurs while IIS is processing a request, but it does not
occur inside of the ASP.NET pipeline, then the configuration specified in the
httpErrors tag in the server's web.config handles the error. Inside
of the system.webServer
tag (direct child of the configuration
tag), configure the httpErrors
tag:
<httpErrors errorMode="DetailedLocalOnly">
</httpErrors>
This will configure the behaviour for HTTP errors occuring in IIS to only be used when the client is not local. This means that development on a local machine will receive the default detailed error messages. If a request is made from a non-local client, then the configuration specified will be used instead.
Add the configuration for the errors to customize as child tags of the
httpErrors
tag:
<remove statusCode="404" />
<error statusCode="404" path="404.html" responseMode="File" />
<remove statusCode="500" />
<error statusCode="500" path="500.html" responseMode="File" />
This removes any existing error handling for both 404 and 500 errors which may
have been set up at a higher level configuration file (like the user-level or
machine-level config). After removing any existing configuration, it adds a
fresh entry to the configuration, specifying the html file and response mode
for the given error. The responseMode
attribute value of File tells
the IIS server to rewrite the response with the contents of the specified file.
This is important to do, in order to maintain the original request URL. It
would be confusing to send a request to /foo/bar
, encounter a 404 error,
and then be redirected to /404.html
. Not only will the user see a URL
that was not requested, but the original URL will be lost.
The 404.html
and 500.html
files should be saved in the project
root, not in the Views
directory. IIS may have trouble finding them if
they are saved elsewhere.
The final configuration should look something like this:
<httpErrors errorMode="DetailedLocalOnly">
<remove statusCode="404" />
<error statusCode="404" path="404.html" responseMode="File" />
<remove statusCode="500" />
<error statusCode="500" path="500.html" responseMode="File" />
</httpErrors>
What must be noted here, is that the error is handled outside of ASP.NET. This means the view engine and session data will not be accessible. This error page must be a simple html page. Ideally, the client will never see this page. However, in the event it is seen, it should have a consistent theme with the rest of the site.
Clearing Defaults: Remove the Error Attribute
ASP.NET projects come pre-configured with default error handling via the
HandleErrorAttribute
and the Error.cshtml
view. Remove the addition
of a new HandleErrorAttribute
instance to the global filter collection in
the RegisterGlobalFilters
method of FilterConfig.cs
. This will
prevent any exceptions thrown in controller actions from being handled by the
HandleErrorAttribute
. The Error.cshtml
may now be removed from the
Views/Shared folder as well.
ASP.NET Error Configuration
Back in the web.config, custom errors must be enabled in the ASP.NET pipeline:
<customErrors mode="RemoteOnly"></customErrors>
This little snippet is similar to the IIS configuration. It tells ASP.NET that
custom errors are enabled, but should only be used when the client is not
local. The customErrors
element also allows for individual error page
configuration, similar to the httpErrors
tag above. However, that method
will be skipped in favor of a more flexible approach.
Configuring Error Behaviour
The next step is to configure what ASP.NET should do when an
HTTPException
is thrown. To begin, add an Application_Error
stub to
Global.asax.cs
:
protected void Application_Error(object sender, EventArgs e)
{
}
This method is called any time an exception is caught by ASP.NET. It will be the primary mechanism by which the error response is controlled.
The first step is to check if custom errors are enabled:
var app = (MvcApplication)sender;
var context = app.Context;
if (!context.IsCustomErrorEnabled)
return;
If they are not enabled, no further action is required. The error will continue and eventually render the familiar detailed error page. If custom errors are enabled, the error is retrieved, and the context's response is cleared.
var ex = app.Server.GetLastError();
context.Response.Clear();
context.ClearError();
This takes any existing response (namely, the default detailed error page), and clears it from the context. There is now a clean slate to work with, and routing can be handled:
var httpException = ex as HttpException;
var routeData = new RouteData();
routeData.Values["controller"] = "Error";
routeData.Values["exception"] = ex;
routeData.Values["action"] = "InternalServerError";
if (httpException != null)
{
switch (httpException.GetHttpCode())
{
case 403:
routeData.Values["action"] = "Forbidden";
break;
case 404:
routeData.Values["action"] = "NotFound";
break;
case 500:
routeData.Values["action"] = "InternalServerError";
break;
}
}
IController controller = new ErrorController();
controller.Execute(new RequestContext(new HttpContextWrapper(context), routeData));
First, the exception is cast into an HttpException, and saved for later. The
default routing behaviour is then set. Here, an Error
controller is used,
with several actions defined therein. The default action is to use the 500
error, which is mapped to the InternalServerError
action. The exception
is also added to the route data, in case it is needed in the controller action.
The exception must now be checked. If it is not an HttpException
, then the
default action of InternalServerError
is executed. If the exception
is an HttpException
, then the the HTTP code can be tested. Three
actions are specified in this controller, but any number can be added.
The Application_Error
method should look something like this:
protected void Application_Error(object sender, EventArgs e)
{
var app = (MvcApplication)sender;
var context = app.Context;
if (!context.IsCustomErrorEnabled)
return;
var ex = app.Server.GetLastError();
context.Response.Clear();
context.ClearError();
var httpException = ex as HttpException;
var routeData = new RouteData();
routeData.Values["controller"] = "Error";
routeData.Values["exception"] = ex;
routeData.Values["action"] = "InternalServerError";
if (httpException != null)
{
switch (httpException.GetHttpCode())
{
case 403:
routeData.Values["action"] = "Forbidden";
break;
case 404:
routeData.Values["action"] = "NotFound";
break;
case 500:
routeData.Values["action"] = "InternalServerError";
break;
}
}
IController controller = new ErrorController();
controller.Execute(new RequestContext(new HttpContextWrapper(context), routeData));
}
If additional routing data needs to be sent to the action, then all the power
of C# is available. A subclass of HttpException
could be made in order to
customize 404 behaviour by tracking the entity type that was not found. A
customized 404 page for each entity in the application would be an easy step
from here.
Custom Error Templates
Error templates can now be made. Assuming the configuration above, create a new
controller named Error
, and add action methods according to the actions
defined in Application_Error
. Add view templates that will be rendered
according to the required action methods, and everything will be ready. The
best part about this setup is that there is no magic! The controller, actions,
and templates are all exactly the same as any other in the application. Nothing
is special-cased.
How to Handle Errors In Code
Now that everything is configured, whenever a request is received for an action with an ID that is not found:
throw new HttpException(404, "Foo entity not found.");
If a client tries to reach an unauthorized action:
throw new HttpException(403, "Forbidden");
Any other error that is thrown will be handled as an internal server error, and the server will render the appropriate response.
Adding a Catch-All Not Found Route
The previously mentioned way to handle 404 errors will work in any case where
the controller exists. However, what if the client requests a URL that does not
match any controllers? In this case, ASP.NET sees no route that matches in its
routing table, so it passes the request up to IIS. This is where the previously
configured IIS error pages would normally come in. However, that can be
avoided. Add a catch-all route to the routing table after all existing
MapRoute
calls in RouteConfig.cs
:
routes.MapRoute(
name: "404",
url: "{*url}",
defaults: new { controller = "Error", action = "CatchAllAction" }
);
This will define a catch-all route that is only matched if no other route
defined matches the request. Note that the action is set to
CatchAllAction
. This could just as easily use the existing NotFound
action, if there is no need to distinguish between an object not being found
and a route not being recognized.
Carry On!
Use this new error routing process to handle routes in a flexible and obvious manner, and don't worry about how it will be resolved down the road!.