blog post image
Andrew Lock avatar

Andrew Lock

~5 min read

Handling Web API Exceptions with ProblemDetails middleware

In this short post I describe a handy error-handling middleware, created by Kristian Hellang, that is used to return ProblemDetails results when an exception occurs.

ProblemDetails and the [ApiController] attribute

ASP.NET Core 2.1 introduced the [ApiController] attribute which applies a number of common API-specific conventions to controllers. In ASP.NET Core 2.2 an extra convention was added - transforming error status codes (>= 400) to ProblemDetails.

Returning a consistent type, ProblemDetails, for all errors makes it much easier for consuming clients. All errors from MVC controllers, whether they're a 400 (Bad Request) or a 404 (Not Found), return a ProblemDetails object:

Problem details for NotFound requests

However, if your application throws an exception, you don't get a ProblemDetails response:

Developer exception page

In the default webapi template (shown below), the developer exception page handles errors in the Development environment, producing the error above.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // Only add error handling in development environments
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

In the production environment, there's no exception middleware registered so you get a "raw" 500 status code without a message body at all:

Production exception page

A better option would be to consistent, and return a ProblemDetails object for exceptions too. One way to achieve this would be to create a custom error handler, as I described in a previous post. A better option is to use an existing NuGet package that handles it for you.

ProblemDetailsMiddleware

The ProblemDetailsMiddleware from Kristian Hellang does exactly what you expect - it handles exceptions in your middleware pipeline, and converts them to ProblemDetails. It has a lot of configuration options (which I'll get to later), but out of the box it does exactly what we need.

Add the Hellang.Middleware.ProblemDetails to your .csproj file, by calling dotnet add package Hellang.Middleware.ProblemDetails. The latest version at the time of writing is 5.0.0:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Hellang.Middleware.ProblemDetails" Version="5.0.0" />
  </ItemGroup>

</Project>

You need to add the required services to the DI container by calling AddProblemDetails(). Add the middleware itself to the pipeline by calling UseProblemDetails. You should add this early in the pipeline, to ensure it catches errors from any subsequent middleware:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddProblemDetails(); // Add the required services
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseProblemDetails(); // Add the middleware

        app.UseHttpsRedirection();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

With this simple addition, if you get an exception somewhere in the pipeline (in a controller for example), you'll still get a ProblemDetails response. In the Development environment, the middleware includes the exception details and the Stack Trace:

Developer ProblemDetails page

This is more than just calling ToString() on the Exception though - the response even includes the line that threw the exception (contextCode) and includes the source code before (preContextCode) and after (postContextCode) the offending lines:

Context code

In the Production environment, the middleware doesn't include these details for obvious reasons, and instead returns the basic ProblemDetails object only.

Production ProblemDetails page

As well as handling exceptions, the ProblemDetailsMiddleware also catches status code errors that come from other middleware too. For example, if a request doesn't match any endpoints in your application, the pipeline will return a 404. The ApiController attribute won't catch that, so it won't be converted to a ProblemDetails object.

Similarly, by default, if you send a POST request to a GET method, you'll get a 405 response, again without a method body, even if you apply the [ApiController] attribute:

Method not found does not cause a ProblemDetails response

With the ProblemDetailsMiddleware in place, you get a ProblemDetails response for these error codes too:

With the middleware in place, method not found causes a ProblemDetails response

This behaviour gave exactly what I needed out-of-the-box, but you can also extensively customise the behaviour of the middleware if you need to. In the next section, I'll show some of these customization options.

Customising the middleware behaviour

You can customise the behaviour of the ProblemDetailsMiddleware by providing a configuration lambda for an ProblemDetailsOptions instance in the AddProblemDetails call:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddProblemDetails(opts => {
        // configure here
    });
}

There's lots of possible configuration settings, as shown below. Most of the configuration settings are Func<> properties, that give access to the current HttpContext, and let you control how the middleware behaves.

public class ProblemDetailsOptions
{
    public int SourceCodeLineCount { get; set; }
    public IFileProvider FileProvider { get; set; }
    public Func<HttpContext, string> GetTraceId { get; set; }
    public Func<HttpContext, Exception, bool> IncludeExceptionDetails { get; set; }
    public Func<HttpContext, bool> IsProblem { get; set; }
    public Func<HttpContext, MvcProblemDetails> MapStatusCode { get; set; }
    public Action<HttpContext, MvcProblemDetails> OnBeforeWriteDetails { get; set; }
    public Func<HttpContext, Exception, MvcProblemDetails, bool> ShouldLogUnhandledException { get; set; }
}

For example, by default, ExceptionDetails are included only for the Development environment. If you wanted to include the details in the Staging environment too, you could use something like the following:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddProblemDetails(opts =>
    {
        // Control when an exception is included
        opts.IncludeExceptionDetails = (ctx, ex) =>
        {
            // Fetch services from HttpContext.RequestServices
            var env = ctx.RequestServices.GetRequiredService<IHostEnvironment>();
            return env.IsDevelopment() || env.IsStaging();
        };
    });
}

Another thing worth pointing out is that you can control when the middleware should convert non-exception responses to ProblemDetails. The default configuration converts non-exception responses to ProblemDetails when the following is true:

  • The status code is between 400 and 600.
  • The Content-Length header is empty.
  • The Content-Type header is empty.

As I mentioned at the start of this post, the [ApiController] attribute from ASP.NET Core 2.2 onwards automatically converts "raw" status code results into ProblemDetails anyway. Those responses are ignored by the middleware, as the response will already have a Content-Type.

However, if you're not using the [ApiController] attribute, or are still using ASP.NET Core 2.1, then you can use the ProblemDetailsMiddleware to automatically convert raw status code results into ProblemDetails, just as you get in ASP.NET Core 2.2+.

The responses in these cases aren't identical, but they're very similar. There are small differences in the values used for the Title and Type properties for example.

Another option would be to use the ProblemDetailsMiddleware in an application that combines Razor Pages with API controllers. You could then use the IsProblem function to ensure that ProblemDetails are only generated for API controller endpoints.

I've only touched on a couple of the customisation features, but there's lots of additional hooks you can use to control how the middleware works. I just haven't had to use them, as the defaults do exactly what I need!

Summary

In this post I described the ProblemDetailsMiddleware by Kristian Hellang, that can be used with API projects to generate ProblemDetails results for exceptions. This is a very handy library if you're building APIs, as it ensures all errors return a consistent object. The project is open source on GitHub, and available on NuGet, so check it out!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?