In this post I show how to customise the ExceptionHandlerMiddleware to create custom responses when an error occurs in your middleware pipeline, instead of providing a path to "re-execute" the pipeline.

Exception handling in Razor Pages

All .NET applications generate errors, and unfortunately throw exceptions, and it's important you handle those in your ASP.NET middleware pipeline. Server-side rendered applications like Razor Pages typically want to catch those exceptions and redirect to an error page.

For example, if you create a new web application that uses Razor Pages (dotnet new webapp), you'll see the following middleware configuration in Startup.Configure:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Error");
    }

    // .. other middleware not shown
}

When running in the Development environment, the application will catch any exceptions thrown when handling a request, and display them as a web page using the very useful DeveloperExceptionMiddleware:

The developer exception page

This is incredibly useful during local development, as it lets you quickly examine the stack trace, request Headers, routing details, and other things.

Of course that's all sensitive information that you don't want to expose in production. So when not in development, we use a different exception handler, the ExceptionHandlerMiddleware. This middleware allows you to provide a request path, "/Error" by default, and uses it to "re-execute" the middleware pipeline, to generate the final response:

Re-executing the pipeline using

The end result for a Razor Pages app is that the Error.cshtml Razor Page is returned whenever an exception occurs in production:

The exception page in production

That covers the exception handling for razor pages, but what about for Web APIs?

Exception handling for Web APIs

The default exception handling in the web API template (dotnet new webapi) is similar to that used by Razor Pages, with an important difference:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // .. other middleware not shown
}

As you can see the DeveloperExceptionMiddleware is still added when in the Development environment, but there's no error handling added at all in production! That's not as bad as it sounds: even though there's no exception handling middleware, ASP.NET Core will catch the exception in its infrastructure, log it, and return a blank 500 response to clients:

An exception

If you're using the [ApiController] attribute (you probably should be), and the error comes from your Web API controller, then you'll get a ProblemDetails result by default, or you can customize it further.

That's actually not too bad for Web API clients. Consumers of your API should be able to handle error responses, so end users won't be seeing the "broken" page above. However, it's often not as simple as that.

For example, maybe you are using a standard format for your errors, such as the ProblemDetails format. If your client is expecting all errors to have that format, then the empty response generated in some cases may well cause the client to break. Similarly, in the Development environment, returning an HTML developer exception page when the client is expecting JSON will likely cause issues!

One solution to this is described in the official documentation, in which it's suggested you create an ErrorController with two endpoints:

[ApiController]
public class ErrorController : ControllerBase
{
    [Route("/error-local-development")]
    public IActionResult ErrorLocalDevelopment() => Problem(); // Add extra details here

    [Route("/error")]
    public IActionResult Error() => Problem();
}

And then use the same "re-execute" functionality used in the Razor Pages app to generate the response:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseExceptionHandler("/error-local-development");
    }
    else
    {
        app.UseExceptionHandler("/error");
    }

    // .. other middleware
}

This works fine, but there's something that's always bugged me about using the same infrastructure that generated an exception (e.g. Razor Pages or MVC) to generate the exception message. Too many times I've been bitten by a failure to generate the error response due to the exception being thrown a second time! For that reason I like to take a slightly different approach.

Using ExceptionHandler instead of ExceptionHandlingPath

When I first started using ASP.NET Core, my approach to tackling this problem was to write my own custom ExceptionHandler middleware to generate the responses directly. "It can't be that hard to handle an exception, right"?

Turns out it's a bit more complicated than that (shocking, I know). There's various edge cases you need to handle like:

  • If the response had already started sending when the exception occurred, you can't intercept it.
  • If the EndpointMiddleware had executed when the exception occurred, you need to do some juggling with the selected endpoints
  • You don't want to cache the error response

The ExceptionHandlerMiddleware handles all these cases, so re-writing your own version is not the way to go. Luckily, although providing a path for the middleware to re-execute is the commonly shown approach, there's another option - provide a handling function directly.

The ExceptionHandlerMiddleware takes an ExceptionHandlerOptions as a parameter. This option object has two properties:

public class ExceptionHandlerOptions
{
    public PathString ExceptionHandlingPath { get; set; }
    public RequestDelegate ExceptionHandler { get; set; }
}

When you provide the re-execute path to the UseExceptionHandler(path) method, you're actually setting the ExceptionHandlingPath on the options object. Instead, you can set the ExceptionHandler property and pass an instance of ExceptionHandlerOptions in directly to the middleware using UseExceptionHandler() if you wish:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseExceptionHandler(new ExceptionHandlerOptions
    {
        ExceptionHandler = // .. to implement
    });

    // .. othe middleware
}

Alternatively, you can use a different overload of UseExceptionHandler() and configure a mini middleware pipeline to generate your response:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseExceptionHandler(err => err.UseCustomErrors(env)); // .. to implement

    // .. othe middleware
}

Both approaches are equivalent so it's more a question of taste. In this post I'm going to use the second approach, and implement the UseCustomErrors() function.

Creating a custom exception handler function

For this example, I'm going to assume that we want to generate a ProblemDetails object when we get an exception in the middleware pipeline. I'm also going to assume that our API only supports JSON. That avoids us having to worry about XML content negotiation and the like. In development, the ProblemDetails response will contain the full exception stack trace, and in production it will just show a generic error message.

ProblemDetails is an industry standard way of returning machine-readable details of errors in a HTTP response. It's the generally supported way of return error messages from Web APIs in ASP.NET Core 3.x (and to an extent, in version 2.2).

We'll start by defining the UseCustomErrors function in a static helper class. This helper class adds a single piece of response-generating middleware to the provided IApplicationBuilder. In development, it ultimately calls the WriteResponse method, and sets includeDetails: true. In other environments, includeDetails is set to false.

using System;
using System.Diagnostics;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;

public static class CustomErrorHandlerHelper
{
    public static void UseCustomErrors(this IApplicationBuilder app, IHostEnvironment environment)
    {
        if (environment.IsDevelopment())
        {
            app.Use(WriteDevelopmentResponse);
        }
        else
        {
            app.Use(WriteProductionResponse);
        }
    }

    private static Task WriteDevelopmentResponse(HttpContext httpContext, Func<Task> next)
        => WriteResponse(httpContext, includeDetails: true);

    private static Task WriteProductionResponse(HttpContext httpContext, Func<Task> next)
        => WriteResponse(httpContext, includeDetails: false);

    private static async Task WriteResponse(HttpContext httpContext, bool includeDetails)
    {
        // .. to implement
    }
}

All that remains is to implement the WriteResponse function to generate our response. This retrieves the exception from the ExceptionHandlerMiddleware (via the IExceptionHandlerFeature) and builds a ProblemDetails object containing the details to display. It then uses the System.Text.Json serializer to write the object to the Response stream.

private static async Task WriteResponse(HttpContext httpContext, bool includeDetails)
{
    // Try and retrieve the error from the ExceptionHandler middleware
    var exceptionDetails = httpContext.Features.Get<IExceptionHandlerFeature>();
    var ex = exceptionDetails?.Error;

    // Should always exist, but best to be safe!
    if (ex != null)
    {
        // ProblemDetails has it's own content type
        httpContext.Response.ContentType = "application/problem+json";

        // Get the details to display, depending on whether we want to expose the raw exception
        var title = includeDetails ? "An error occured: " + ex.Message : "An error occured";
        var details = includeDetails ? ex.ToString() : null;

        var problem = new ProblemDetails
        {
            Status = 500,
            Title = title,
            Detail = details
        };

        // This is often very handy information for tracing the specific request
        var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
        if (traceId != null)
        {
            problem.Extensions["traceId"] = traceId;
        }

        //Serialize the problem details object to the Response as JSON (using System.Text.Json)
        var stream = httpContext.Response.Body;
        await JsonSerializer.SerializeAsync(stream, problem);
    }
}

You can record any other values you like on the ProblemDetails object that an be retrieved from the HttpContext before it's serialized

Be aware that the ExceptionHandlerMiddleware clears out the route values before calling your exception handler method so those are not available.

If your application now throws an exception in the Development environment, you'll get the full exception returned as JSON in the response:

ProblemDetails response in Development

While in production, you'll still get a ProblemDetails response, but with the details elided:

ProblemDetails response in Production

This approach obviously has some limitations compared to the MVC/re-execute path approach, namely that you don't easily get model binding, content-negotiation, easy serialization, or localization (depending on your approach).

If you need any of these (e.g. maybe you serialize from MVC using PascalCase instead of camelCase), then using this approach may be more hassle than its worth. If so, then the Controller approach described is probably the sensible route to take.

If none of that is a concern for you, then the simple handler approach shown in this post may be the better option. Either way, don't try and implement your own version of the ExceptionHandlerMiddleware - use the extension points available! 🙂

Summary

In this post I described the default exception handling middleware approach for Razor Pages and for Web APIs. I highlighted a problem with the default Web API template configuration, especially if clients are expecting valid JSON, even for errors.

I then showed the suggested approach from the official documentation that uses an MVC controller to generate a ProblemDetails response for APIs. This approach works well, except if the problem was an issue with your MVC configuration itself, in which trying to execute the ErrorController will fail.

As an alternative, I showed how you could provide a custom exception handling function to the ExceptionHandlerMiddleware that is used to generate a response instead. I finally showed an example handler that serializes a ProblemDetails object to JSON, including details in the Development environment, and excluding them in other environments.