Efficient and SEO friendly error handling in ASP.NET MVC

 

The sample code used throughout this blog post can be found here.

Application-level error handling in a .Net MVC project without updating the Web.config or using HandleError attributes that will act as a last resort and will allow your application to gracefully recover from all unexpected exceptions and errors.

To be honest, there are too many different approaches to error handling in ASP.NET MVC - you can, for example, use the section of the Web.config file and fiddle with the mapping, override the base Controller's OnException method, use/extend the HandleError attribute. All ASP.NET MVC stack traces essentially originate from the controllers and the previously mentioned approaches do a good job of dealing with those nasty exceptions but no matter what you do one will eventually creep its way towards the user in the form of the default yellow screen of death. Here comes your safety net - the Application_Error method in the global.asax file. This is your last resort to any errors that have slipped through your try/catch blocks to the outermost section of your ASP.NET MVC code. Every MVC application should override the Application_Error method and here's how to do it!

Advantages of implementing an override of the Application_Error method

  • it's what ASP.NET calls right before it displays its dreaded error screen and can be used as a safety net to gracefully handle all unexpected errors
  • it can be used in combination with the above-mentioned different approaches
  • very SEO friendly - returns the appropriate HTTP status code (different approaches might even return 200, which is wrong on many levels)
  • doesn't change or "aspxerrorpath" the URL
  • doesn't require the use of HandleError attributes or Web.config setup and is very easy to setup on a project level

Before we begin

Delete your default Error.cshtml view from the Shared folder. You will not need it with this error handling implementation and it might mess with you if you haven't de-registered your HandleError attribute as a global action filter. As a matter of fact, just go ahead an de-register it from the RegisterGlobalFilters in the Global.asax file.

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    //delete the below HandleError global filter registration
    filters.Add(new HandleErrorAttribute());
}

Implementing the Application_Error method and an Error controller

As I have already mentioned, the Application_Error method is the last ASP.NET step before the default error page and its role is to capture all the errors, which have escape all previous checks. However, once we catch those bad boys we need to handle them correctly and return the correct error page and appropriate status code. If you have error pages returning 200 those will eventually begin to get indexed and this is not good. Preferably, you'd like to keep the URL intact as well. Here comes the beauty and flexibility of this approach - Application_Error's role is to only catch the error and invoke the appropriate action of the Error controller based on how we'd want it to. My Error controller is pretty basic but you can add as many error pages and responses as you'd like. I've included the option to send the error and stack trace as an email to the admins for example, which can be pretty useful.

public class ErrorController : Controller
{
  public ActionResult CustomError()
  {
    this.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
    
    return this.View("InternalServer", this.GetError());
  }
  
  public ActionResult PageNotFound()
  {
    this.Response.StatusCode = (int)HttpStatusCode.NotFound;
    
    return this.View(this.GetError());
  }
  
  public ActionResult Forbidden()
  {
    this.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
    
    return this.View(this.GetError());
  }
  
  public ActionResult SendErrorEmail()
  {
    var error = this.GetError();
    
    GMailer.GmailUsername = "john@doe.com";
    GMailer.GmailPassword = Password.EmailPassword;
    
    var mailer = new GMailer
    {
        ToEmail = "john@doe.com",
        Subject = "Error Email",
        Body = error.StackTrace,
        IsHtml = true
    };
    mailer.Send();
    
    return this.RedirectToAction("Index", "Home");
  }
  
  private Exception GetError()
  {
    return this.Session["lastError"] as Exception;
  }
}

Finally, you can find my sample Application_Error implementation below. Again, you can account for as many error cases as you'd like based on the response code of the error. Then the Error controller is invoked with the appropriate action, which is going to return the specific view for this error.

 

namespace Custom_Error_Pages
{
    public class MvcApplication : HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
        
        //example implementation of the Application_Error method
        protected void Application_Error(object sender, EventArgs e)
        {
            System.Diagnostics.Trace.WriteLine("Enter - Application_Error");

            var httpContext = ((MvcApplication)sender).Context;

            var currentRouteData = RouteTable.Routes.GetRouteData(new HttpContextWrapper(httpContext));
            var currentController = string.Empty;
            var currentAction = string.Empty;

            if (currentRouteData != null)
            {
                if (!string.IsNullOrEmpty(currentRouteData.Values["controller"]?.ToString()))
                {
                    currentController = currentRouteData.Values["controller"].ToString();
                }

                if (!string.IsNullOrEmpty(currentRouteData.Values["action"]?.ToString()))
                {
                    currentAction = currentRouteData.Values["action"].ToString();
                }
            }

            //get information about the last exception
            var ex = this.Server.GetLastError();

            if (ex != null)
            {
                System.Diagnostics.Trace.WriteLine(ex.Message);

                if (ex.InnerException != null)
                {
                    System.Diagnostics.Trace.WriteLine(ex.InnerException);
                    System.Diagnostics.Trace.WriteLine(ex.InnerException.Message);
                }
            }

            //invoke an Error controller with the default action
            var controller = new ErrorController();
            this.Session["lastError"] = ex;
            var routeData = new RouteData();
            var action = "CustomError";
            var statusCode = 500;

            //determine Error controller action based on the response code
            if (ex is HttpException)
            {
                var httpEx = ex as HttpException;
                statusCode = httpEx.GetHttpCode();

                switch (httpEx.GetHttpCode())
                {
                    case 400:
                        action = "BadRequest";
                        break;

                    case 401:
                        action = "Unauthorized";
                        break;

                    case 403:
                        action = "Forbidden";
                        break;

                    case 404:
                        action = "PageNotFound";
                        break;

                    case 500:
                        action = "CustomError";
                        break;

                    default:
                        action = "CustomError";
                        break;
                }
            }
            else if (ex is AuthenticationException)
            {
                action = "Forbidden";
                statusCode = 403;
            }

            httpContext.ClearError();
            httpContext.Response.Clear();
            httpContext.Response.StatusCode = statusCode;
            httpContext.Response.TrySkipIisCustomErrors = true;
            routeData.Values["controller"] = "Error";
            routeData.Values["action"] = action;

            controller.ViewData.Model = new HandleErrorInfo(ex, currentController, currentAction);
            ((IController)controller).Execute(new RequestContext(new HttpContextWrapper(httpContext), routeData));
        }
    }
}

End result

To test the end result, I'm going to throw an exception (most .Net exceptions result in a 500 status code) in the Index action of the Home controller and see what happens. Let's see what happens.

 

public class HomeController : Controller
{
  public ActionResult Index()
  {
    throw new ArgumentOutOfRangeException();
    return View();
  }
}

The custom error page, which is displayed when my project encounters an error generating status code 500.

Success! We can see that the correct error page was displayed along with the appropriate HTTP status code without changing the URL. You might also notice that I have included the error message and stack trace in the view for demonstration purposes. I have also included a button, which sends the error to a predefined email address.

Try for yourself what would happen if you change the exception from ArgumentOutOfRange to an Authentication exception or if you input an incorrect URL.

public class HomeController : Controller
    {
        public ActionResult Index()
        {
            throw new AuthenticationException();
            return View();
        }
    }

That's it!!! A simple, efficient and SEO friendly approach to error handling in ASP.NET MVC. If you have managed to improve this approach or have any questions you can let me know by posting in the comment section below.

Alex Georgiev