ASP.NET MVC · Production

Error Handling

Errors will happen in production. The difference between a professional app and an amateur one is what the user sees — and what the team can find out afterwards.

Advanced 7 min Lesson 32 of 37Covers ASP.NET MVC 5.2 on .NET Framework 4.8.
Before you start:Filters
0/37 in ASP.NET MVC
On this page · 7 min · Advanced

Learning objectives#

  • Turn on friendly error pages with customErrors
  • Use HandleErrorAttribute globally and for specific exceptions
  • Show a proper 404 page for unknown URLs
  • Log every unhandled exception in Application_Error

Explanation#

<customErrors mode="On"> shows friendly pages to everyone; RemoteOnly shows details only on the server itself (handy in development); Off shows the yellow error screen with stack traces to anyone — never use Off in production.

HandleErrorAttribute (registered globally in FilterConfig) catches exceptions thrown by actions and views and renders Views/Shared/Error.cshtml with a HandleErrorInfo model, but only when custom errors are on. [HandleError(ExceptionType = typeof(TimeoutException), View = "Busy")] picks a different view for one exception type.

HandleErrorAttribute ignores 404s and errors outside MVC (unknown controllers, static files). Application_Error in Global.asax catches everything that escapes, so log there and render a friendly NotFound page for 404s.

Set Response.TrySkipIisCustomErrors = true when you write your own error response, otherwise IIS may replace it with its own page; httpErrors existingResponse="PassThrough" does the same in config.

Error pages must not show exception messages or stack traces — they reveal table names, file paths and versions to attackers. Log the details; show the user a reference and a way back.

Syntax#

Razor / Markup Illustrative
<system.web>
  <customErrors mode="On" />            <!-- On | RemoteOnly | Off -->
</system.web>
<system.webServer>
  <httpErrors existingResponse="PassThrough" />
</system.webServer>

Code example: Friendly 500, 503-style and 404 pages#

C#Global.asax.cs Illustrative
<!-- Web.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
  </appSettings>
  <system.web>
    <compilation debug="false" targetFramework="4.8" />
    <httpRuntime targetFramework="4.8" />
    <customErrors mode="On" />
  </system.web>
  <system.webServer>
    <httpErrors existingResponse="PassThrough" />
  </system.webServer>
</configuration>


// Global.asax.cs
using System;
using System.Diagnostics;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using ShopSite.Controllers;


namespace ShopSite
{
    public class MvcApplication : HttpApplication
    {
        protected void Application_Start()
        {
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);   // adds HandleErrorAttribute
            RouteConfig.RegisterRoutes(RouteTable.Routes);
        }


        // Last line of defence: everything MVC did not handle ends up here
        protected void Application_Error()
        {
            var exception = Server.GetLastError();
            var status = (exception as HttpException)?.GetHttpCode() ?? 500;
            Trace.TraceError("Unhandled {0}: {1}", status, exception);     // use your logger here


            if (status != 404) return;                                     // customErrors handles the rest


            Server.ClearError();
            Response.Clear();
            Response.TrySkipIisCustomErrors = true;
            var route = new RouteData();
            route.Values["controller"] = "Error";
            route.Values["action"] = "NotFound";
            IController controller = new ErrorController();
            controller.Execute(new RequestContext(new HttpContextWrapper(Context), route));
        }
    }
}


// Controllers/ErrorController.cs
using System.Web.Mvc;


namespace ShopSite.Controllers
{
    public class ErrorController : Controller
    {
        public ActionResult NotFound()
        {
            Response.StatusCode = 404;
            Response.TrySkipIisCustomErrors = true;
            return View();
        }
    }
}


// Controllers/ShopController.cs
using System;
using System.Web.Mvc;


namespace ShopSite.Controllers
{
    public class ShopController : Controller
    {
        public ActionResult Crash()
        {
            throw new InvalidOperationException("Connection to SQL-PROD-01 failed for table dbo.Payments");
        }


        [HandleError(ExceptionType = typeof(TimeoutException), View = "Busy")]
        public ActionResult Slow()
        {
            throw new TimeoutException("Payment gateway timed out");
        }
    }
}


@* Views/Shared/Error.cshtml *@
@model HandleErrorInfo
@{ ViewBag.Title = "Error"; }
<h1>Sorry, something went wrong.</h1>
<p>We have been notified. (Failed in @Model.ControllerName.@Model.ActionName)</p>


@* Views/Shared/Busy.cshtml *@
@{ ViewBag.Title = "Busy"; }
<h1>We are very busy right now</h1>
<p>Please try again in a minute.</p>


@* Views/Error/NotFound.cshtml *@
@{ ViewBag.Title = "Not found"; }
<h1>Page not found</h1>
<p><a href="/">Back to the shop</a></p>
Expected output
GET /Shop/Crash
500 Internal Server Error
<h1>Sorry, something went wrong.</h1>
<p>We have been notified. (Failed in Shop.Crash)</p>

GET /Shop/Slow
500 Internal Server Error
<h1>We are very busy right now</h1>
<p>Please try again in a minute.</p>

GET /Shop/Missing
404 Not Found
<h1>Page not found</h1>
<p><a href="/">Back to the shop</a></p>

GET /nothing/here
404 Not Found
<h1>Page not found</h1>
<p><a href="/">Back to the shop</a></p>

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Crash throws; the global HandleErrorAttribute catches it and, because customErrors is On, renders Error.cshtml with status 500. The message with the server and table names never reaches the browser.
  2. Slow throws a TimeoutException, so the more specific [HandleError] on the action renders the Busy view instead.
  3. Shop/Missing is an unknown action: MVC raises a 404 HttpException, HandleErrorAttribute ignores 404s, and Application_Error renders the NotFound page with a real 404 status.
  4. nothing/here has no controller at all, and takes the same Application_Error path.
  5. Every error is traced in Application_Error (or by HandleError for handled ones if you add logging there), so the team can investigate.

Real-world use case#

A travel site showed a yellow error page containing the database server name after a deployment. Switching customErrors to On, adding friendly pages and logging in Application_Error turned a security finding into a non-event — and the logs pinpointed the bad connection string in minutes.

Common mistakes#

customErrors Off in production

Stack traces, file paths and SQL text become visible to anyone. Use On (or RemoteOnly while debugging on the server).

Expecting HandleErrorAttribute to catch everything

It only sees exceptions inside MVC actions and views, and ignores anything that maps to a non-500 status — HttpException 404s, and even UnauthorizedAccessException, which ASP.NET maps to 401. Add Application_Error for the rest.

Friendly 404 page returning 200

Redirecting to /Error/NotFound with customErrors redirect returns 302 then 200, so search engines index the error page. Render it in place with status 404.

Best practices#

  • customErrors On in production, RemoteOnly on test servers
  • Log every unhandled exception with context
  • Return correct status codes on error pages
  • Keep error pages simple — no database calls that could fail again

Remember this#

customErrors On + HandleError = friendly 500.

Application_Error = the safety net (and 404s).

Users see an apology; logs see the details.

Practice exercise#

Your task: Create a PaymentDeclinedException, throw it from a Pay action and add a [HandleError] rule that shows a PaymentFailed view for it.

Need a hint?
[HandleError(ExceptionType = typeof(PaymentDeclinedException), View = "PaymentFailed")]
Show solution
C# Illustrative
<!-- Web.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
  </appSettings>
  <system.web>
    <compilation debug="false" targetFramework="4.8" />
    <httpRuntime targetFramework="4.8" />
    <customErrors mode="On" />
  </system.web>
</configuration>


// Controllers/PaymentsController.cs
using System;
using System.Web.Mvc;


namespace ShopSite.Controllers
{
    public class PaymentDeclinedException : Exception
    {
        public PaymentDeclinedException(string message) : base(message) { }
    }


    public class PaymentsController : Controller
    {
        [HandleError(ExceptionType = typeof(PaymentDeclinedException), View = "PaymentFailed")]
        public ActionResult Pay(int orderId) => throw new PaymentDeclinedException("Card ending 4242 declined: insufficient funds");
    }
}


@* Views/Shared/PaymentFailed.cshtml *@
<h1>Your payment did not go through</h1>
<p>No money was taken. Please try another card.</p>
Expected output
GET /Payments/Pay?orderId=5001
500 Internal Server Error
<h1>Your payment did not go through</h1>
<p>No money was taken. Please try another card.</p>

Mini quiz#

Error Handling quizMultiple choice Question 1 of 3

Which customErrors mode shows friendly pages to everyone?

Score so far: 0/0

Want more? Practise all ASP.NET MVC MCQs · ASP.NET MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.