ASP.NET Core Middleware Handle Exceptions

We all would like to believe that our applications are bug-free and will run without exceptions. Those of us who’ve been around long enough will know that users will find a way to break our most meticulously tested codebases. In coordination with testing, we should also account for unexpected exceptions.

In ASP.NET Core, we can handle those exceptions using the ExceptionHandlerMiddleware that ships with the web framework. This post will show how we can use the middleware in three variations to handle global exceptions. All approaches function similarly, but we’ll talk about each approach’s advantages and why we may want to use it for our application.

Middleware and The Request Pipeline

We’ll do a quick overview for those unfamiliar with the inner-workings of ASP.NET Core.

Our application will process all incoming requests through our request pipeline, which we commonly establish in the Configure method of our Startup class. Each registered component is called by the previous component, and has the responsiblity of calling the next component. Our request pipeline can include middleware, ASP.NET Core endpoints, and high-level frameworks like ASP.NET Core MVC.

Each middleware has an opportunity to handle the incoming request, call the next middleware, or terminate the HTTP request by returning a response. Understanding the request pipeline is essential, as it dictates where we should register our exception handling middleware. If we register it too late, then there is an opportunity to miss exceptions.

The Problem Endpoint

To understand how to handle an exception, let’s first create a misbehaving endpoint in our ASP.NET Core application. In our UseEndpoints method call, we’ll add a randomized failing endpoint. We’ll see later how to retrieve the thrown exception using the IExceptionHandlerPathFeature feature.

app.UseEndpoints(endpoints =>
{
    endpoints.MapRazorPages();
    
    endpoints.MapGet("/", async context =>
    {
        var random = new Random();

        throw random.Next(1, 4) switch
        {
            1 => new ArgumentException("what were you thinking?!", nameof(random)),
            2 => new DataException("New exception, who dis?"),
            _ => new ArithmeticException("1 + 1 is 4")
        };
        
    });
});

When we start our application, we should immediately throw one of the three exceptions shown above.

Handling Exceptions By Request Path

The most straightforward approach to handling exceptions in our ASP.NET Core application is to register the ExceptionHandlerMiddleware using a PathString. We can add the middleware by invoking the UseExceptionHandler extension method in our Configure method. Remember, middleware ordering is essential. We need to place the registration call before all other middleware if we expect the handler to process errors appropriately.

app.UseExceptionHandler("/error");

In this scenario, we tell ASP.NET Core to execute the path we have provided in-place of the failed request. The ExceptionHandlerMiddleware will run a new request to our path, with the existing pipeline definition. In this example, we’ve defined a **Razor Pages page at this endpoint.

using System;
using System.Data;
using System.Net;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace WebApplication8.Pages
{
    public class Error : PageModel
    {
        public void OnGet()
        {
            var ctx = HttpContext;
            var feature = ctx.Features.Get<IExceptionHandlerPathFeature>();
            ctx.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
            var exception = feature.Error;
            string message = exception switch
            {
                ArgumentException ae => $"{ae.ParamName} & {ae.Message}",
                DataException de => $"{de.Message}",
                ArithmeticException are => $"{are.Message}",
                _ => "oops!"
            };

            Message = message;
        }

        public string Message { get; set; }
    }
}

Our Razor page can retrieve the exception using the IExceptionHandlerPathFeature feature. We can retrieve the feature from the HttpContext.Features collection, at which point we also have access to the Exception that our application has thrown.

Advantages To The PathString Approach

The most significant advantage of this approach is its simplicity. The exception handler is agnostic about what framework lies at the other end of the path. ASP.NET Core MVC, Carter, or other frameworks may be ready to process the exception. In our example, we used Razor Pages to display an error message. The additional advantage is that the handler executes in place, preserving the request path for the client.

Disadvantages To The PathString Approach

The most significant disadvantage is the exception handler will reuse the existing pipeline definition where the error occurred. The exception may have occurred due to a misconfigured middleware component. If this is the case, we could get stuck in a recursive error handling state, thus breaking our application. While it may not likely be the case, it is still a real possibility ASP.NET Core developers will need to keep in mind. So what can we do about this issue?

Handling Exceptions With A New Pipeline

A little known feature about the UseExceptionHandler method is its many overloads. One of those overloads takes an argument of IApplicationBuilder, which allows us to redefine a separate request pipeline. Here we can add or remove middleware that may be the source of exceptions. Let’s see it in action.

// define exception handler pipeline
// this will create a brand new pipeline 
// separate from the original failed pipeline
app.UseExceptionHandler(builder =>
{
    // define brand new pipeline
    
    builder.Use(async (ctx, next) =>
    {
        var feature = ctx.Features.Get<IExceptionHandlerPathFeature>();
        ctx.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
        var exception = feature.Error;

        string message = exception switch
        {
            ArgumentException ae => $"{ae.ParamName} & {ae.Message}",
            DataException de => $"{de.Message}",
            ArithmeticException are => $"{are.Message}",
            _ => "oops!"
        };

        await ctx.Response.WriteAsync(message);
        
    });
});

In the code above, we define our pipeline as a single RequestDelegate, with no mentions of other frameworks or middleware.

Advantages To The New Pipeline Approach

The most significant advantage of this approach is the ability to define a streamlined pipeline for exceptions. The method is useful for minimizing the occurrence of further failures and reducing the amount of time it takes to respond with an exception. The fewer middleware, the fewer method calls need to execute.

Disadvantages To The New Pipeline Approach

The most significant disadvantage is the need to redefine the pipeline again. In our redefinition, we may forget to include a critical piece of our application. Worse, we may inadvertently transpose two middleware, introducing a subtle ordering bug.

Conclusion

In this post, we showed how to handle exceptions using the ExceptionHandlerMiddleware, and for most folks, the PathString approach is the best option. We can define our exception handling path using our favorite approaches. This post shows how accessing the IExceptionHandlerPathFeature feature gives us access to the exception that interrupted our request. Finally, we utilized an overloaded method on UseExceptionHandler to redefine our request pipeline to decrease the chance of failure when handling an already failed request.

I hope you found this post helpful.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *