The truth about IIS and ASP.NET custom error pages

19 Feb 2017

If you are a .NET web developer you must have played around with exception handling in a website. I certainly have and most of the times it has not been a easy exercise. There just seem to be to many levels of exception handling. And too many ways to notify the user that something went wrong. New (ok, some not so new anymore) technologies like MVC.NET, IIS integrated mode and .NET CORE adding their own options. What I won't be talking about is how to catch and handle exceptions in code here, I will focus on ways to display exception information to the user with customized error pages.

What is a good error page

A good error page should display all the necessary information about what went wrong and if possible what the user can do to fix the problem. With necessary information I mean just enough information to describe to the average user what the problem is, so don't flood the user with technical details like SQL query xxx on database yyyy failed due to a primary key violation. No average user of any website does need that kind of information (except if your average user is the developer of the site, but in that case you have another problem). A better message would be something like Your account could not be created, please try again. If the problem persists contact us at xxx-yyy. Besides the fact that your users don't need the technical details, it can also pose a big security problem when you expose those details to the public. So just don't.

It is also good practice to supply the user with options to either fix the problem or help them find a workaround. An example of this is an Internal Server Error (code 500) caused by an timeout, in this case you could tell the user to try again in x minutes. Another example is a Page Not Found (code 404) error, here it is good practice to give the user alternative links to pages that he might be looking for (this can sometimes be done by taking a closer look at the URL requested). If it its not possible to determine what the user was looking for you could just supply a link to your search page or to the first valid parent page of the requested URL. If you really want to help your users as best as possible, supply options on your error pages to contact you, either by mail, phone, chat or what other options work best for you. Last but not least you could add an unique Id to your error pages and display it to the user. This Id should allow you to get all available technical details from your logs. If a user supplies this Id to you when he contacts you about an error on your site you can easily retrieve all details you need to identify (and hopefully fix) the exact problem.

How to display an error page

Now that we have determined what our error pages should contain we need to tell our application how and when to use them. In a .NET web application (I'm leaving out .NET Core for the time being) you roughly have the following options:

  1. Using CustomErrors section via web.config
  2. Using HttpErrors section via web.config
  3. Routing to a custom error page from code

1. Using CustomErrors section via web.config

The CustomErrors section in the web.config has been around for some time. It is part of the system.web section. It handles unhandled exceptions which are thrown in the .NET code of your application and routes the requests which caused the exception to custom pages based on the status code of the error. You can set different pages for each error status code (>= 400). Usually the CustomErrors section looks something like this:

<customErrors mode="RemoteOnly" redirectMode="ResponseRewrite" defaultRedirect="~/error.aspx">
      <error statusCode="500" redirect="~/500.aspx" />
      <error statusCode="400" redirect="~/400.aspx" />
      <error statusCode="404" redirect="~/404.aspx" />
</customErrors>

mode attribute

The mode attribute can have one of three values:

  • Off
  • On
  • RemoteOnly

Off disables displaying the custom error pages, in this case the default ASP.NET detailed error pages are always shown. On always displays your custom error pages. RemoteOnly only shows your custom pages if the request comes from a remote server and not from the server itself, this in the recommended setting, although for testing your custom pages in your development environment you will want to use the On setting.

redirectMode attribute

This attribute has only 2 values:

  • Redirect
  • ResponseRewrite

Redirect does as it says and redirects your request to the URL you set in the redirect attribute of your custom error page. I would not recommend using this setting. The redirect might confuse the user (changing the URL), and is not a good idea from a SEO perspective. The better option is the ResponseRewrite, this renders the result of your given custom error page without changing the URL. This option has a catch however. The final response returns a statuscode 200. Your error page should always return the appropriate statuscode to the client. To fix this the easiest solution is to use a .ASPX page as your error page (yes, also when using a MVC application) and set the response status code manually in the code(behind) of this .ASPX.

<% Response.StatusCode = 500; %>

defaultRedirect attribute

Sets a default page for all errors for which you have not explicitly set a custom page, if you would get an error with status code 401 and you have not set a specific error page for this, the default page is used. It is advisable to always set this value.

2. Using HttpErrors section via web.config

Since the introduction of IIS Integrated Mode in IIS 7, we have another option to display custom error pages. Through the httpErrors section (under system.webServer) you can configure the IIS integrated mode error handling. The main difference with ASP.NET CustomErrors is the fact that httpErrors can handle all errors that occur in the entire request pipeline, not just in the ASP.NET code. This means that when something goes wrong while requesting a static resource (like an image) your custom error page will also be shown for this request. The config section for httpErrors normally looks like this:

<httpErrors errorMode="Custom" existingResponse="Auto" defaultResponseMode="ExecuteURL" defaultPath="/error.aspx">
      <clear />
      <error statusCode="500" path="/500.aspx" />
      <error statusCode="404" path="/404.aspx" />
</httpErrors>

Within the httpErrors section you can add different paths to error pages for specific status codes, just like with CustomErrors. You should first clear all error pages before you add your own. On the root httpErrors element the most important attributes are the following:

errorMode attribute

This attributes works like the mode attribute of CustomErrors, it has 3 values Custom, Detailed and DetailedLocalOnly. You will usually use either Custom or DetailedLocalOnly, the first always displaying your own custom error pages, the second one only when the request is coming from a remote source.

existingResponse attribute

This attribute determines what should be done with the original response. If the response has an error status code (code >= 400) the httpErrors section could handle it. With this attribute you set whether httpErrors actually should handle it. There are 3 options here, Auto, Passthrough and Replace. Passthrough lets the response pass through untouched, not displaying any error page. Replace always displays a (custom or detailed) errorpage in case of een error statuscode. The Auto option is a little fussy, it chooses between Passthrough and Replace based on whether a flag called SetStatus is true or false. This flag seems to correspond with the TrySkipIisCustomErrors property of the HttpResponse object in ASP.NET. More on this later.

defaultResponseMode attribute

With this attribute you set how the request pipeline routes to a custom error page. Again 3 options, File, ExecuteUrl and Redirect. File simply displays the content of the file you supply. ExecuteUrl actually executes the code under the given local URL, with this option you can use .ASPX files or other executable code. Redirect does as it claims and redirects the client to the givens URL, best not to use this option.

defaultPath attribute

Sets the default path for errors with a status code which has not been explicitly assigned a path. Works just like the defaultRedirect attribute of CustomErrors.

error element

The error element has only 2 required attributes, statusCode and path. Both attributes need little explaining. Keep in mind though that path should be set to either a filepath or a URL path depending on the setting of the defaultResponseMode attribute of the httpErrors element. You can also set the response mode at the error element level instead of at the httpErrors element level. Use the responseMode attribute for this.

How httpErrors handles errors

One of the big catches with httpErrors seems to be that while CustomErrors handles actual (.NET) exceptions, httpErrors handles status codes. This last functionality has given me some problems when I was working on a site which switched from IIS classic to integrated mode. Switching to integrated mode enabled the httpErrors functionality. This had some unexpected side effects with some of the handlers we were using in the site. Many handlers where designed to return a 500 statuscode and some JSON data in the response body whenever something went wrong. The httpErrors functionality picked up on those 500 status codes and started to display Html error pages in stead of the JSON result. It took me some time to figure this out and some more time to find a good fix.

The default settings of httpErrors set the existingResponse attribute to Auto which means for all responses returning an error status code the original response will be substituted with either a custom error page or a detailed IIS error page. Only if you explicitly set the TrySkipIisCustomErrors property of the response in ASP.NET to true the original response will remain untouched. I our case we had to set this property to true in all handlers which set the statuscode for the response to 500. The alternative would have been to disable the httpErrors entirely by setting the existingResponse property to Passthrough, this was not desirable for our site however.

Bypassing CustomErrors

HttpErrors could be a good replacement for CustomErrors, it handles all possible errors, not just those raised in .NET code. However, if you just set the mode property of CustomErrors to Off your site will (whenever an exception is thrown) display detailed ASP.NET error pages, not your httpError pages. CustomErrors seems to bypass httpErrors by setting TrySkipIisCustomErrors to true. The easiest fix for this is setting the existingResponse attribute of httpErrors to Replace. This would mean however that all response which do not throw an exception but do return an error status code will display a httpErrors error page instead of the original response. As mentioned earlier this might not always be desirable. An alternative solution is tweaking your global.asax to just return a 500 status code whenever an exception is thrown and not pass the exception itself.

void Application_Error(object sender, EventArgs e)
{
    Exception error = Server.GetLastError();

    // TODO : Log Exception

    Server.ClearError();

    var httpException = error as HttpException;
    Response.StatusCode = httpException != null ? httpException.GetHttpCode() : (int)HttpStatusCode.InternalServerError;
}

3. Routing to a custom error page from code

Apart from the 2 options to route to custom error pages through web.config settings, there are a few options to do the same from code. I won't explain these option in depth here, but just do a quick summary.

Using MVC Error attributes

You can add the HandleError attribute to actions you want to have custom error pages in the event of an exception.

[HandleError()]
public ActionResult Index()
{
}

All exceptions within the action are routed to the default Error.cshtml view. You can add a different view by setting the View parameter of the HandleError method, like this HandeError(View="500.cshtml"). HandleError only works if CustomErrors is not set to Off in your web.config.

Overriding the OnException method on a MVC controller

For individual controllers or for a base controller (if you have one) you can override the OnException method.

protected override void OnException(ExceptionContext filterContext)
{
    filterContext.ExceptionHandled = true;
             
    filterContext.Result = new ViewResult
    {
        ViewName = "~/Views/Error/500.cshtml"
    };
}

Using the Application_Error event

This is an event you can add in your Global.asax, it handles all exceptions which have not been handled at an earlier stage.

protected void Application_Error(object sender, EventArgs e)
{
    var raisedException = Server.GetLastError();

    // TODO : Log Exception

    Server.ClearError();

    Server.Transfer("~/500.aspx");
}

Using a HttpModule

If you add a HttpModule to your request pipeline you can build your error handling into this module. Many 3rd party error handling extensions like Elmah use this technique.

public class ErrorModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.Error += OnError;
    }

    private void OnError(object sender, EventArgs e)
    {
        // Handle error here, log, redirect or alter response
    }

    public void Dispose()
    {
        // TODO : Dispose
    }
}

Conclusion

From the techniques discussed httpErrors seems to have the best papers. It works for all requests, even for static files. CustomErrors has little to no use cases which httpErrors can't handle. MVC error pages are very easy to use but can only handle errors from within controllers, not in other parts of the code and not for static files. Error handling in Global.asax or in a HttpModule are good options, because they catch all exceptions anywhere in your code. But they still won't handle errors with static files (although you could configure all files, including static files, to pass through a custom HttpModule and thus handle errors for static files that way). But if you want to easily handle all errors for all request and maintain your custom errorpages in a single place, httpErrors is in most cases your best option. You do have to tweak to get it working properly (bypassing customErrors, fixing possible issues with handlers which should not display a custom html error page, etc.). Keep in mind though that in many cases custom error pages can be avoided altogether. Many site sites still return a full blown custom error page in situations like invalid user input in a form. Such cases should be handled by showing the user which field is invalid within the form not by routing to a custom error page. If you do have to route to a custom error page, try to provide the user with just enough information to help him on his way ('try again later', 'maybe the following links are helpfull...', 'if the problem persists contact us at ...'). Getting the right texts can be a challenge. In the end better custom error pages make your customer happy and can reduces calls to your service desk. Errors are bad but if they do happen, make them as clear and helpful as possible...