How to build custom error pages in .NET Core 6

How to build custom error pages in .NET Core 6

·

4 min read

This week, I built some custom error pages for a project in .NET Core 6. Since I'd done this many times before, it wasn't difficult for me to set it up this time. It might be good for me to write a blog post as it might be useful for everyone else.

Error handling hierarchy

Errors in a .NET application can be handled at the following levels:

  • Gateway level
  • Application level
  • Page level

Page level error handling

The solution described in this section only applies to page level errors. When an error occurs at the application level or gateway level, the application cannot be started and hence, we need a different way to handle such kinds of errors. We will discuss about it in the next section.

Firstly, I need to create a controller with two separate actions for 404 errors and generic errors. Let's call it ErrorController:

public class ErrorController : Controller
{
    [Route("error/404")]
    public IActionResult PageNotFound()
    {
    }

    [Route("error/{code}")]
    public IActionResult Index(int code)
    {
    }
}

Please note that I use the [RouteAttribute] to set up the route templates for these actions. I can also pass a code parameter to the Index action whereas I set it to 404 in the route template of the PageNotFound action. This is because I need to set different status code in the response headers and return different custom error page views for 404 and other types:

public class ErrorController : Controller
{
    [Route("error/404")]
    public IActionResult PageNotFound()
    {
        Response.Clear();
        Response.StatusCode = StatusCodes.Status404NotFound;
        return View("_PageNotFoundError");
    }

    [Route("error/{code}")]
    public IActionResult Index(int code)
    {
        Response.Clear();
        Response.StatusCode = code;
        return View("_GenericError");    
    }
}

Then, I need to add the following code to my Startup class:

public void Configure(IApplicationBuilder app)
{
    if (Environment.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseStatusCodePagesWithReExecute("/error/{0}");
        app.UseHsts();
    }
}

This adds the StatusCodePages middleware to the pipeline. When a page is returned with a status code other than 200, the request pipeline will be re-executed using the alternative path. Please note that the {0} is a placeholder of the status code.

As a security code warrior, I also learn that we should use the UseDeveloperExceptionPage middleware in the development environment only. This feature should be excluded from all staging and production environments and UseHsts should be used to add the Strict-Transport-Security header on these environments.

Since I also need to log the exceptions somewhere when they occur, I need to add the UseExceptionHandler middleware to the pipeline. I can do this in the Startup class.

public void Configure(IApplicationBuilder app)
{
    if (Environment.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/error/handle-exception");
        app.UseStatusCodePagesWithReExecute("/error/{0}");
        app.UseHsts();
    }
}

The UseExceptionHandler will catch exceptions, log them and re-execute the request in the alternative path specified in the UseStatusCodePagesWithReExecute. This method requires an error handling path that will be implemented in the following.

public class ErrorController : Controller
{
    [Route("error/handle-exception")]
    public IActionResult HandleException()
    {
        // log error
        var feature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
        if (feature != null)
        {
            _logger.LogException(feature.Error);
        }
        return StatusCode(StatusCodes.Status500InternalServerError);
    }
}

To see how it works, let's add some test actions to the same ErrorController.

public class ErrorController : Controller
{
    [Route("error/unhandled-exception")]
    public IActionResult TestNotImplementedException() => throw new NotImplementedException();

    [Route("error/bad-request")]
    public IActionResult TestBadRequest() => BadRequest();
}

You can test this solution by throwing a NotImplementedException or returning a BadRequestResult from a controller action.

When the NotImplementedException is thrown from an action, it will be handled by the exception handler (HandleException) before returning the generic error page with a 500 status code to the browsers.

When the BadRequest is returned from an action, the result will not be handled by the exception handler because it is not an exception type. However, since it returns a response code other than 200, the request will be re-executed to show the custom error page with a 400 status code.

Finally, to test the PageNotFound error handler, you can enter any non-existing URL to see the custom page not found error page in your browser. Optionally, you can log 404 errors as warnings and set up 301 redirects for SEO.

Application level and gateway level errors

In the previous section, I've shown you how to handle errors using MVC and middleware in .NET Core and that solution only applies to page level errors.

When an application level error occurs, the application cannot be started. In addition, when a server timed out occurs, the server cannot be reached. That said, I can upload some static HTML files to the CDN to show the custom errors to the public users. Most CDN providers provide this feature. For example, this link will show you how to configure custom error HTML pages if you're using Cloudflare. If you're using section.io, you can add these files to the following folders in your solution:

wwwroot\custom_errors\500.html
wwwroot\custom_errors\502.html
wwwroot\custom_errors\503.html

Please note that you should use internal or inline CSS, fonts and images in your markup only as any external reference might not work when the site goes down. You can use this tool to convert your log image to a base64 plain string or SVG to embed in the markup.

Conclusion

In this blog post, I've shown you different methods that I've used to handle different error types in an application. I hope you'll find it helpful.