Using ProblemDetails in .NET Core 3.1 with C#

Using ProblemDetails in .NET Core 3.1 with C#

Overview

What is Problem Details?

When you are an API developer, you often need to convey errors to the client or consumer of the API.

It is not always a happy path when it comes to using an API. Things do go wrong sometimes.

  • Networks fail.
  • Databases encounter issues.
  • Your application could encounter an unhandled exception due to another service downstream that failed to respond correctly!

When developing distributed applications, one must design for failure, as there are many points of failure. And when there are failures that we cannot immediately rectify, we must be able to let our consumers know what to do.

In order to achieve this, in the simplest possible way, we could just throw an exception or return a status code and error message. But does everyone understand this type of a response?

For a long time, there was no standard that was widely accepted in the industry for conveying errors from an API to a client who could then consume this information to decide how to act subsequently.

In order to standardise this problem for both consumers of APIs and for developers, the Internet Engineering Task Force, decided to make a proposal, RFC7807 - Problem Details.

An example:

1   {
2    "type": "https://example.com/probs/out-of-credit",
3    "title": "You do not have enough credit.",
4    "detail": "Your current balance is 30, but that costs 50.",
5    "instance": "/account/12345/msgs/abc",
6    "balance": 30,
7    "accounts": ["/account/12345",
8                 "/account/67890"]
9   }

The RFC is pretty detailed. But let me try and give you a TLDR;

ProblemDetails TLDR

To summarise, ProblemDetails is a type of an API response that is suitable in case of a problem. It has certain fields that have a very specific meaning, if present.

  • type : A string that contains the URI to the problem type. Although the specification recommends that the URI provides a human-readable documentation for the problem, though this aspect is completely optional. However, this is considered to be the primary identifier for the problem type.
  • title : A short human-readable summary of the problem type. And must be the same for any occurrence of the problem, except for localisation. This is purely for descriptive use not to be acted upon.
  • status : The HTTP status code generated by the server for this instance of the problem. This again, if present in the response, must be the same as the HTTP response code from the server.
  • detail : A human-readable explanation specific to this instance of the problem. This is to provide information to help the client fix the problem and it is not a stacktrace of what happened on the server. This is not intended to be parsed by a machine.
  • instance : a URI to the specific occurrence of the problem.

The RFC has defined the ProblemDetails to be extensible. This means those responsding, can add useful information to it in the form of additional properties.

What about using it in .NET?

Yes. We can use this concept of conveying information about errors in any web development framework. The RFC does not care about which language or framework you use. IETF RFCs are about creating standards that can be implemented by different vendors, in the language of their choice.

I am going to talk about using it in .NET Core APIs in this post today. Because over the past six months and probably for the forseeable future, I will be spending majority of my time developing APIs in .NET Core.

.NET Core support for ProblemDetails

Did you know that ASP.NET Core WebAPI comes with in-built support for ProblemDetails.

.NET 5.0 is out already but I have not yet trialled it myself to confirm if the solution that I use for this example is going to work. But I am sure, most of the basics will still say the same.

ProblemDetails out of the box

In case of certain errors like NotFound() or BadRequest(), the API already responds with a ProblemDetails instance. An example for this can be seen below:

 1 
 2{
 3  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
 4  "title": "Not Found",
 5  "status": 404,
 6  "traceId": "|bf32e848-4ebaf4a346dec76f."
 7}
 8
 9Response headers
10 content-type: application/problem+json; charset=utf-8 
11 date: Tue,04 May 2021 21:57:35 GMT 
12 server: Kestrel 

You could try it if you have a .NET Core WebApi. You can also spot that the implementation is pretty detailed. The type property points to the URL corresponding to the RFC proposal for the HTTP ERROR in this case 404.

This just works out of the box! So long as you have a controller declared like:

 1 
 2    [ApiController]
 3    [Route("[controller]")]
 4    public class WeatherForecastController : ControllerBase
 5    {
 6      ...
 7      ...
 8        [HttpGet("{regionId}")]
 9        public async Task<ActionResult<WeatherForecast>> Get(string regionId)
10        {
11            return NotFound();
12        }
13    }

Notice that I am returning a NotFound response and it magically gets serialized as a ProblemDetails instance.

There was no additional configuration involved for this purpose. All I did was create a new .NET Core WebApi project in Visual Studio.

Handling Exceptions with ProblemDetails responses

In most cases, when there is an exception in your application, you would like to return a helpful response with details on how to avoid the exception or mitigate the chances of a failure.

ASP.NET Core does not by default return all exceptions in the form of ProblemDetails, apart from the ones that I have already mentioned above, like basic error ActionResult responses and some ModelValidationErrors.

If you were to throw a custom exception, this would not be serialised as a problem+json. This is where you have to take control of the exception and make use of the ProblemDetails class.

ProblemDetails as a response for all unhandled exceptions

So let us start with a basic API that has absolutely no concept of error handling. You just created your first ASP .NET Core Web Api project using visual studio.

I created one just now and it created a WeatherForecastController.cs with some code like:

 1 
 2using Microsoft.AspNetCore.Mvc;
 3using Microsoft.Extensions.Logging;
 4using System;
 5using System.Collections.Generic;
 6using System.Linq;
 7using System.Threading.Tasks;
 8
 9namespace ProblemDetailsDemoWebApi.Controllers
10{
11    [ApiController]
12    [Route("[controller]")]
13    public class WeatherForecastController : ControllerBase
14    {
15        private static readonly string[] Summaries = new[]
16        {
17            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
18        };
19
20        private readonly ILogger<WeatherForecastController> _logger;
21
22        public WeatherForecastController(ILogger<WeatherForecastController> logger)
23        {
24            _logger = logger;
25        }
26
27        [HttpGet]
28        public IEnumerable<WeatherForecast> Get()
29        {
30            var rng = new Random();
31            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
32            {
33                Date = DateTime.Now.AddDays(index),
34                TemperatureC = rng.Next(-20, 55),
35                Summary = Summaries[rng.Next(Summaries.Length)]
36            })
37            .ToArray();
38        }
39    }
40}

Default behaviour - unhandled exception without ProblemDetails

Let us take a look.

I am going to assume that you already know what the Startup.cs file is for. So you will have your Configure(IApplicationBuilder, IWebHostEnvironment) method defined with the conditional use of a DeveloperExceptionPage.

 1public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
 2{
 3   if (env.IsDevelopment())
 4   {
 5       app.UseDeveloperExceptionPage();
 6   }
 7   app.UseHttpsRedirection();
 8   app.UseRouting();
 9   app.UseAuthorization();
10   app.UseEndpoints(endpoints =>
11   {
12       endpoints.MapControllers();
13   });
14}

Let us make the WeatherForecastController throw an exception and see how it works now.

Modify the Get method in the said Controller.

1[HttpGet]
2public IEnumerable<WeatherForecast> Get()
3{
4    throw new Exception("Stormy weather destroyed weather sensors! No ilable.");
5}

Run the App and you should be presented with something like:

The default Developer exception page with details of the error

This is great for local development. But you know what, you could really get it all in the form of ProblemDetails even while doing your local development.

Let us give that a shot.

Introducing Hellang.Middleware.ProblemDetails

Hellang.Middleware.ProblemDetails

If you use Nuget Package manager console in visual studio:

1Install-Package Hellang.Middleware.ProblemDetails

Configure it now. Add the following changes to your Startup.cs:

 1public void ConfigureServices(IServiceCollection services)
 2{
 3    services.AddProblemDetails();
 4    services.AddControllers();
 5}
 6 // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
 7public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
 8{
 9    app.UseProblemDetails();
10    //if (env.IsDevelopment())
11    //{
12    //    app.UseDeveloperExceptionPage();
13    //}

Notice that I have commented out the use of the Developer Exception page. This is for demonstration so that you can clearly see what is being returned.

Run the solution again and let us take a look at what we get without any other change.

  1GET /weatherforecast HTTP/2
  2Host: localhost:44327
  3
  4RESPONSE:
  5{
  6    "type": "https://httpstatuses.com/500",
  7    "title": "Internal Server Error",
  8    "status": 500,
  9    "detail": "Stormy weather destroyed weather sensors! No data available.",
 10    "errors": [
 11        {
 12            "message": "Stormy weather destroyed weather sensors! No data available.",
 13            "type": "System.Exception",
 14            "raw": "System.Exception: Stormy weather destroyed weather sensors! No data available.\r\n   at ProblemDetailsDemoWebApi.Controllers.WeatherForecastController.Get() in D:\\Code\\ProblemDetailsDemoWebApi\\ProblemDetailsDemoWebApi\\Controllers\\WeatherForecastController.cs:line 29\r\n   at lambda_method(Closure , Object , Object[] )\r\n   at Microsoft.Extensions.Internal.ObjectMethodExecutor.Execute(Object target, Object[] parameters)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncObjectResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()\r\n--- End of stack trace from previous location where exception was thrown ---\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)\r\n   at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)\r\n   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)\r\n   at Hellang.Middleware.ProblemDetails.ProblemDetailsMiddleware.Invoke(HttpContext context)",
 15            "stackFrames": [
 16                {
 17                    "filePath": "D:\\Code\\ProblemDetailsDemoWebApi\\ProblemDetailsDemoWebApi\\Controllers\\WeatherForecastController.cs",
 18                    "fileName": "WeatherForecastController.cs",
 19                    "function": "ProblemDetailsDemoWebApi.Controllers.WeatherForecastController.Get()",
 20                    "line": 29,
 21                    "preContextLine": 23,
 22                    "preContextCode": [
 23                        "            _logger = logger;",
 24                        "        }",
 25                        "",
 26                        "        [HttpGet]",
 27                        "        public IEnumerable<WeatherForecast> Get()",
 28                        "        {"
 29                    ],
 30                    "contextCode": [
 31                        "            throw new Exception(\"Stormy weather destroyed weather sensors! No data available.\");"
 32                    ],
 33                    "postContextCode": [
 34                        "            var rng = new Random();",
 35                        "            return Enumerable.Range(1, 5).Select(index => new WeatherForecast",
 36                        "            {",
 37                        "                Date = DateTime.Now.AddDays(index),",
 38                        "                TemperatureC = rng.Next(-20, 55),",
 39                        "                Summary = Summaries[rng.Next(Summaries.Length)]"
 40                    ]
 41                },
 42                {
 43                    "filePath": null,
 44                    "fileName": null,
 45                    "function": "lambda_method(Closure , object , object[] )",
 46                    "line": null,
 47                    "preContextLine": null,
 48                    "preContextCode": null,
 49                    "contextCode": null,
 50                    "postContextCode": null
 51                },
 52                {
 53                    "filePath": null,
 54                    "fileName": null,
 55                    "function": "Microsoft.Extensions.Internal.ObjectMethodExecutor.Execute(object target, object[] parameters)",
 56                    "line": null,
 57                    "preContextLine": null,
 58                    "preContextCode": null,
 59                    "contextCode": null,
 60                    "postContextCode": null
 61                },
 62                {
 63                    "filePath": null,
 64                    "fileName": null,
 65                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor+SyncObjectResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, object controller, object[] arguments)",
 66                    "line": null,
 67                    "preContextLine": null,
 68                    "preContextCode": null,
 69                    "contextCode": null,
 70                    "postContextCode": null
 71                },
 72                {
 73                    "filePath": null,
 74                    "fileName": null,
 75                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()",
 76                    "line": null,
 77                    "preContextLine": null,
 78                    "preContextCode": null,
 79                    "contextCode": null,
 80                    "postContextCode": null
 81                },
 82                {
 83                    "filePath": null,
 84                    "fileName": null,
 85                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)",
 86                    "line": null,
 87                    "preContextLine": null,
 88                    "preContextCode": null,
 89                    "contextCode": null,
 90                    "postContextCode": null
 91                },
 92                {
 93                    "filePath": null,
 94                    "fileName": null,
 95                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()",
 96                    "line": null,
 97                    "preContextLine": null,
 98                    "preContextCode": null,
 99                    "contextCode": null,
100                    "postContextCode": null
101                },
102                {
103                    "filePath": null,
104                    "fileName": null,
105                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)",
106                    "line": null,
107                    "preContextLine": null,
108                    "preContextCode": null,
109                    "contextCode": null,
110                    "postContextCode": null
111                },
112                {
113                    "filePath": null,
114                    "fileName": null,
115                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)",
116                    "line": null,
117                    "preContextLine": null,
118                    "preContextCode": null,
119                    "contextCode": null,
120                    "postContextCode": null
121                },
122                {
123                    "filePath": null,
124                    "fileName": null,
125                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()",
126                    "line": null,
127                    "preContextLine": null,
128                    "preContextCode": null,
129                    "contextCode": null,
130                    "postContextCode": null
131                },
132                {
133                    "filePath": null,
134                    "fileName": null,
135                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)",
136                    "line": null,
137                    "preContextLine": null,
138                    "preContextCode": null,
139                    "contextCode": null,
140                    "postContextCode": null
141                },
142                {
143                    "filePath": null,
144                    "fileName": null,
145                    "function": "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)",
146                    "line": null,
147                    "preContextLine": null,
148                    "preContextCode": null,
149                    "contextCode": null,
150                    "postContextCode": null
151                },
152                {
153                    "filePath": null,
154                    "fileName": null,
155                    "function": "Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)",
156                    "line": null,
157                    "preContextLine": null,
158                    "preContextCode": null,
159                    "contextCode": null,
160                    "postContextCode": null
161                },
162                {
163                    "filePath": null,
164                    "fileName": null,
165                    "function": "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)",
166                    "line": null,
167                    "preContextLine": null,
168                    "preContextCode": null,
169                    "contextCode": null,
170                    "postContextCode": null
171                },
172                {
173                    "filePath": null,
174                    "fileName": null,
175                    "function": "Hellang.Middleware.ProblemDetails.ProblemDetailsMiddleware.Invoke(HttpContext context)",
176                    "line": null,
177                    "preContextLine": null,
178                    "preContextCode": null,
179                    "contextCode": null,
180                    "postContextCode": null
181                }
182            ]
183        }
184    ],
185    "traceId": "|9d778f7c-4520ebd52c5fd90f."
186}

Look at the level of detail in the response. All this for installing a package and writing two lines of code to configure! That my friend is great return on investment!

But that level of detail, is probably a bit too much for our clients

Configuration to fine tune details in response

Of course, you can tweak your response to your taste.

Mr. Hellang, did a great job thinking about the configuration bit. Let us take a stab at that now.

A full list of options that can be configured can be viewed in the github repository of the Hellang.Middleware.ProblemDetails

Let us make a small change to our Configuration to avoid sending stacktraces in Production environment. Make the following change in ConfigureServices() in Startup.cs.

 1public void ConfigureServices(IServiceCollection services)
 2{
 3    services.AddProblemDetails(opts =>
 4    {
 5        // Control when an exception is included
 6        opts.IncludeExceptionDetails = (ctx, ex) =>
 7        {
 8            // Fetch services from HttpContext.RequestServices
 9            var env = ctx.RequestServices.GetRequiredService<IHostEnvironment>();
10            return env.IsDevelopment() || env.IsStaging();
11        };
12    });

Let us try changing the ASPNETCORE_ENVIRONMENT variable's value when running the project this time. You can do this by updating the launchSettings.json file and modifying the value that corresponds to the property of that name.

 1{
 2  "$schema": "http://json.schemastore.org/launchsettings.json",
 3  "iisSettings": {
 4    "windowsAuthentication": false,
 5    "anonymousAuthentication": true,
 6    "iisExpress": {
 7      "applicationUrl": "http://localhost:19197",
 8      "sslPort": 44327
 9    }
10  },
11  "profiles": {
12    "IIS Express": {
13      "commandName": "IISExpress",
14      "launchBrowser": true,
15      "launchUrl": "weatherforecast",
16      "environmentVariables": {
17        "ASPNETCORE_ENVIRONMENT": "Production"
18      }
19    },

Now launching the application from Visual Studio, would give you:

1{
2    "type": "https://httpstatuses.com/500",
3    "title": "Internal Server Error",
4    "status": 500,
5    "traceId": "|7d28a4ed-479221c8689242b4."
6}

So in production, you will not receive all those additional stack trace. The moment you switch back to a non-production environment you still get all the details that it was giving you earlier.

Customising ProblemDetails

So far we have just done the bare minimum to get an understanding of what can be achieved using the ProblemDetails middleware.

Let us now take some time to think about how we could use ProblemDetails as our default response mechanism in an application.

Imagine developing the world's best weather forecast API and several clients use your API to report weather. So you would like to standardize the error responses with sufficient information to lead users to the right course of action.

How would you do it? There are many ways to do this.

  • You could create your own extension of ProblemDetails in order to convey the right information for a particular instead of relying on generic content. You could add custom properties to your extended ProblemDetails instance to give additional information for that particular use-case.
  • You could configure ProblemDetails middleware to map certain type of exceptions to certain HTTP status codes
  • You could also create a custom middleware, maybe to parse exceptions and log into specific stores that would then alert the team responsible for owning it. In this custom middleware, you could then choose to respond with a decent message after the logging, to help the client understand the problem.

The building blocks

Let us create some classes to try a Custom Exception scenario.

 1public class StormyWeatherProblemDetails : ProblemDetails
 2{
 3    public int LastReportedWindGust { get; set; }
 4}
 5
 6public class ProblemDetailsException : Exception
 7{
 8    public string Type { get; set; }
 9    public string Detail { get; set; }
10    public string Title { get; set; }
11    public string Instance { get; set; }
12}
13
14public class StormyWeatherException : ProblemDetailsException
15{
16    public int LastReportedWindGust { get; set; }
17    public StormyWeatherException(string instance, int lastReportedWindGust)
18    {
19        Type = "https://awesome-weather-forecasts.com/we-got-problems/storm-destruction";
20        Title = "Storm destroyed weather sensors.";
21        Detail = "Unable to retrieve latest weather forecast due to loss of hardware.";
22        LastReportedWindGust = lastReportedWindGust;
23        Instance = instance;
24    }
25}

We now have the following:

  • A standard base class for Problem Details type exceptions ProblemDetailsException
  • A custom Exception class that extends the base class - StormyWeatherException
  • A custom ProblemDetails class - StormyWeatherProblemDetails

These are our building blocks.

The configuration

What we now want to do is:

  • In case of an error, raise an instance of StormyWeatherException
  • Map the details from this custom exception to the StormyWeatherProblemDetails to be sent as a response to our client

The simplest way to do this is by configuring the ProblemDetailsMiddleware to map the exception to the corresponding ProblemDetails.

If you use an object mapper in your solution, you could define the mappings in whatever convention appropriate for the mapping library and use that.

In this example, I am going to explicitly write the mapping in the ConfigureServices method.

 1services.AddProblemDetails(opts =>
 2{
 3    // Control when an exception is included
 4    opts.IncludeExceptionDetails = (ctx, ex) =>
 5    {
 6        // Fetch services from HttpContext.RequestServices
 7        var env = ctx.RequestServices.GetRequiredService<IHostEnvironment>();
 8        return env.IsDevelopment() || env.IsStaging();
 9    };
10
11    opts.Map<StormyWeatherException>((swe) => new StormyWeatherProblemDetail()
12    {
13        Type = swe.Type,
14        Title = swe.Title,
15        Detail = swe.Detail,
16        Instance = swe.Instance,
17        LastReportedWindGust = swe.LastReportedWindGust,
18        Status = StatusCodes.Status500InternalServerError
19    });
20});

Now for the purpose of demonstration, we have to throw this exception from our Controller.

1[HttpGet]
2public IEnumerable<WeatherForecast> Get()
3{
4    throw new StormyWeatherException("/weatherforecast", 101);
5}

Run the app again and voila! You now have your exception in ProblemDetails format as below:

1{
2    "lastReportedWindGust": 101,
3    "type": "https://awesome-weather-forecasts.com/we-got-problems/storm-destruction",
4    "title": "Storm destroyed weather sensors.",
5    "status": 500,
6    "detail": "Unable to retrieve latest weather forecast due to loss of hardware.",
7    "instance": "/weatherforecast",
8    "traceId": "|ff2dbba9-41b908973f06fd2e."
9}

So now we have all responses in a standard ProblemDetails format because we are using the error handling given to us by the package.

It is possible that in your project, you probably already have a custom Error Handling pipeline implemented.

We will take a look at how we can integrate ProblemDetails responses in such a solution in another post.

Code

As I had to demonstrate snippets and how things worked, I obviously had to create my own solution. So I have pushed the repo to Problem Details Demo.

Feel free to take a read through the WeatherForecastController, Startup and other ProblemDetails specific files in the project.

comments powered by Disqus