c#

error-handling

asp.net-mvc

asp.net

user-experience

I have some basic code to determine errors in my MVC application. Currently in my project I have a controller called Error with action methods HTTPError404(), HTTPError500(), and General(). They all accept a string parameter error. Using or modifying the code below. What is the best/proper way to pass the data to the Error controller for processing? I would like to have a solution as robust as possible.

protected void Application_Error(object sender, EventArgs e)
{
    Exception exception = Server.GetLastError();
    Response.Clear();

    HttpException httpException = exception as HttpException;
    if (httpException != null)
    {
        RouteData routeData = new RouteData();
        routeData.Values.Add("controller", "Error");
        switch (httpException.GetHttpCode())
        {
            case 404:
                // page not found
                routeData.Values.Add("action", "HttpError404");
                break;
            case 500:
                // server error
                routeData.Values.Add("action", "HttpError500");
                break;
            default:
                routeData.Values.Add("action", "General");
                break;
        }
        routeData.Values.Add("error", exception);
        // clear error on server
        Server.ClearError();

        // at this point how to properly pass route data to error controller?
    }
}

Solution 1

Instead of creating a new route for that, you could just redirect to your controller/action and pass the information via querystring. For instance:

protected void Application_Error(object sender, EventArgs e) {
  Exception exception = Server.GetLastError();
  Response.Clear();

  HttpException httpException = exception as HttpException;

  if (httpException != null) {
    string action;

    switch (httpException.GetHttpCode()) {
      case 404:
        // page not found
        action = "HttpError404";
        break;
      case 500:
        // server error
        action = "HttpError500";
        break;
      default:
        action = "General";
        break;
      }

      // clear error on server
      Server.ClearError();

      Response.Redirect(String.Format("~/Error/{0}/?message={1}", action, exception.Message));
    }

Then your controller will receive whatever you want:

// GET: /Error/HttpError404
public ActionResult HttpError404(string message) {
   return View("SomeView", message);
}

There are some tradeoffs with your approach. Be very very careful with looping in this kind of error handling. Other thing is that since you are going through the asp.net pipeline to handle a 404, you will create a session object for all those hits. This can be an issue (performance) for heavily used systems.

Solution 2

To answer the initial question "how to properly pass routedata to error controller?":

IController errorController = new ErrorController();
errorController.Execute(new RequestContext(new HttpContextWrapper(Context), routeData));

Then in your ErrorController class, implement a function like this:

[AcceptVerbs(HttpVerbs.Get)]
public ViewResult Error(Exception exception)
{
    return View("Error", exception);
}

This pushes the exception into the View. The view page should be declared as follows:

<%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage<System.Exception>" %>

And the code to display the error:

<% if(Model != null) { %>  <p><b>Detailed error:</b><br />  <span class="error"><%= Helpers.General.GetErrorMessage((Exception)Model, false) %></span></p> <% } %>

Here is the function that gathers the all exception messages from the exception tree:

    public static string GetErrorMessage(Exception ex, bool includeStackTrace)
    {
        StringBuilder msg = new StringBuilder();
        BuildErrorMessage(ex, ref msg);
        if (includeStackTrace)
        {
            msg.Append("\n");
            msg.Append(ex.StackTrace);
        }
        return msg.ToString();
    }

    private static void BuildErrorMessage(Exception ex, ref StringBuilder msg)
    {
        if (ex != null)
        {
            msg.Append(ex.Message);
            msg.Append("\n");
            if (ex.InnerException != null)
            {
                BuildErrorMessage(ex.InnerException, ref msg);
            }
        }
    }

Solution 3

I found a solution for ajax issue noted by Lion_cl.

global.asax:

protected void Application_Error()
    {           
        if (HttpContext.Current.Request.IsAjaxRequest())
        {
            HttpContext ctx = HttpContext.Current;
            ctx.Response.Clear();
            RequestContext rc = ((MvcHandler)ctx.CurrentHandler).RequestContext;
            rc.RouteData.Values["action"] = "AjaxGlobalError";

            // TODO: distinguish between 404 and other errors if needed
            rc.RouteData.Values["newActionName"] = "WrongRequest";

            rc.RouteData.Values["controller"] = "ErrorPages";
            IControllerFactory factory = ControllerBuilder.Current.GetControllerFactory();
            IController controller = factory.CreateController(rc, "ErrorPages");
            controller.Execute(rc);
            ctx.Server.ClearError();
        }
    }

ErrorPagesController

public ActionResult AjaxGlobalError(string newActionName)
    {
        return new AjaxRedirectResult(Url.Action(newActionName), this.ControllerContext);
    }

AjaxRedirectResult

public class AjaxRedirectResult : RedirectResult
{
    public AjaxRedirectResult(string url, ControllerContext controllerContext)
        : base(url)
    {
        ExecuteResult(controllerContext);
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context.RequestContext.HttpContext.Request.IsAjaxRequest())
        {
            JavaScriptResult result = new JavaScriptResult()
            {
                Script = "try{history.pushState(null,null,window.location.href);}catch(err){}window.location.replace('" + UrlHelper.GenerateContentUrl(this.Url, context.HttpContext) + "');"
            };

            result.ExecuteResult(context);
        }
        else
        {
            base.ExecuteResult(context);
        }
    }
}

AjaxRequestExtension

public static class AjaxRequestExtension
{
    public static bool IsAjaxRequest(this HttpRequest request)
    {
        return (request.Headers["X-Requested-With"] != null && request.Headers["X-Requested-With"] == "XMLHttpRequest");
    }
}

Solution 4

I struggled with the idea of centralizing a global error handling routine in an MVC app before. I have a post on the ASP.NET forums.

It basically handles all your application errors in the global.asax without the need for an error controller, decorating with the [HandlerError] attribute, or fiddling with the customErrors node in the web.config.

Solution 5

Perhaps a better way of handling errors in MVC is to apply the HandleError attribute to your controller or action and update the Shared/Error.aspx file to do what you want. The Model object on that page includes an Exception property as well as ControllerName and ActionName.

Solution 6

This may not be the best way for MVC ( https://stackoverflow.com/a/9461386/5869805 )

Below is how you render a view in Application_Error and write it to http response. You do not need to use redirect. This will prevent a second request to server, so the link in browser's address bar will stay same. This may be good or bad, it depends on what you want.

Global.asax.cs

protected void Application_Error()
{
    var exception = Server.GetLastError();
    // TODO do whatever you want with exception, such as logging, set errorMessage, etc.
    var errorMessage = "SOME FRIENDLY MESSAGE";

    // TODO: UPDATE BELOW FOUR PARAMETERS ACCORDING TO YOUR ERROR HANDLING ACTION
    var errorArea = "AREA";
    var errorController = "CONTROLLER";
    var errorAction = "ACTION";
    var pathToViewFile = $"~/Areas/{errorArea}/Views/{errorController}/{errorAction}.cshtml"; // THIS SHOULD BE THE PATH IN FILESYSTEM RELATIVE TO WHERE YOUR CSPROJ FILE IS!

    var requestControllerName = Convert.ToString(HttpContext.Current.Request.RequestContext?.RouteData?.Values["controller"]);
    var requestActionName = Convert.ToString(HttpContext.Current.Request.RequestContext?.RouteData?.Values["action"]);

    var controller = new BaseController(); // REPLACE THIS WITH YOUR BASE CONTROLLER CLASS
    var routeData = new RouteData { DataTokens = { { "area", errorArea } }, Values = { { "controller", errorController }, {"action", errorAction} } };
    var controllerContext = new ControllerContext(new HttpContextWrapper(HttpContext.Current), routeData, controller);
    controller.ControllerContext = controllerContext;

    var sw = new StringWriter();
    var razorView = new RazorView(controller.ControllerContext, pathToViewFile, "", false, null);
    var model = new ViewDataDictionary(new HandleErrorInfo(exception, requestControllerName, requestActionName));
    var viewContext = new ViewContext(controller.ControllerContext, razorView, model, new TempDataDictionary(), sw);
    viewContext.ViewBag.ErrorMessage = errorMessage;
    //TODO: add to ViewBag what you need
    razorView.Render(viewContext, sw);
    HttpContext.Current.Response.Write(sw);
    Server.ClearError();
    HttpContext.Current.Response.End(); // No more processing needed (ex: by default controller/action routing), flush the response out and raise EndRequest event.
}

View

@model HandleErrorInfo
@{
    ViewBag.Title = "Error";
    // TODO: SET YOUR LAYOUT
}
<div class="">
    ViewBag.ErrorMessage
</div>
@if(Model != null && HttpContext.Current.IsDebuggingEnabled)
{
    <div class="" style="background:khaki">
        <p>
            <b>Exception:</b> @Model.Exception.Message <br/>
            <b>Controller:</b> @Model.ControllerName <br/>
            <b>Action:</b> @Model.ActionName <br/>
        </p>
        <div>
            <pre>
                @Model.Exception.StackTrace
            </pre>
        </div>
    </div>
}

Solution 7

Application_Error having issue with Ajax requests. If error handled in Action which called by Ajax - it will display your Error View inside the resulting container.

Solution 8

Brian, This approach works great for non-Ajax requests, but as Lion_cl stated, if you have an error during an Ajax call, your Share/Error.aspx view (or your custom error page view) will be returned to the Ajax caller--the user will NOT be redirected to the error page.

Solution 9

Use Following code for redirecting on route page. Use exception.Message instide of exception. Coz exception query string gives error if it extends the querystring length.

routeData.Values.Add("error", exception.Message);
// clear error on server
Server.ClearError();
Response.RedirectToRoute(routeData.Values);

Solution 10

I have problem with this error handling approach: In case of web.config:

<customErrors mode="On"/>

The error handler is searching view Error.shtml and the control flow step in to Application_Error global.asax only after exception

System.InvalidOperationException: The view 'Error' or its master was not found or no view engine supports the searched locations. The following locations were searched: ~/Views/home/Error.aspx ~/Views/home/Error.ascx ~/Views/Shared/Error.aspx ~/Views/Shared/Error.ascx ~/Views/home/Error.cshtml ~/Views/home/Error.vbhtml ~/Views/Shared/Error.cshtml ~/Views/Shared/Error.vbhtml at System.Web.Mvc.ViewResult.FindView(ControllerContext context) ....................

So

 Exception exception = Server.GetLastError();
  Response.Clear();
  HttpException httpException = exception as HttpException;

httpException is always null then customErrors mode="On" :( It is misleading Then <customErrors mode="Off"/> or <customErrors mode="RemoteOnly"/> the users see customErrors html, Then customErrors mode="On" this code is wrong too


Another problem of this code that

Response.Redirect(String.Format("~/Error/{0}/?message={1}", action, exception.Message));

Return page with code 302 instead real error code(402,403 etc)