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:
However, if your application throws an exception, you don't get a ProblemDetails
response:
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:
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:
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:
In the Production environment, the middleware doesn't include these details for obvious reasons, and instead returns the basic ProblemDetails
object only.
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:
With the ProblemDetailsMiddleware
in place, you get a ProblemDetails
response for these error codes too:
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
andType
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!