.NET 7's ProblemDetailsService

image

What are Problem Details?

Problem details is a spec (RFC7807) for returning standard errors from an API.

The existing .NET ProblemDetails class conforms to that standard. ASP.NET Core also has a Results.Problem method to return a ProblemDetails object from a controller.

You can handle getting ProblemDetails JSON back to the client in various ways. I explored several of them in this repo. One of the best ones is to use the library from Kristian Hellang: ProblemDetails. (Andrew Lock’s blog post about it has pretty good directions (better than the README).)

If you’re in an ASP.NET controller method you can return ProblemDetails with BaseController.Problem or BaseController.ValidationProblem. Likewise Minimal APIs can use Results.Problem or Results.ValidationProblem. The sample code demonstrates how to use those methods, but they are not the focus of this post.

New Toys!

With the new ASP.NET 7 ProblemDetailsService, as described in this MS blog post, I thought everything will be built in and I could start deleting code (the best way to eliminate bugs). But, alas, I still can’t do everything I need to do with ProblemDetails. (I was a bit surprised that at .NET Conf 2022, none or the ASP.NET presenters mentioned this new feature, which I thought was a big deal.)

Mainly, I want to be able to send a nice ProblemDetails object to the client with details about what went wrong. Ideally, be able to throw an exception that gets turned into ProblemDetails.

In .NET there’s no exception like that. Also, the JSON returned to the caller isn’t very useful. Here’s the production-mode payload from throwing an exception (one that even includes ProblemDetails). Not too useful for the caller.

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500
}

If you turn on UseDeveloperExceptionPage() you get much more information. But this is not what you want to send to the client.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
    "title": "ProblemDetailsTest.ProblemDetailsException",
    "status": 500,
    "detail": "Throwing ProblemDetailsException",
    "exception": {
        "details": "ProblemDetails:\r\n  Status: 500\r\n  Type: https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1\r\n  <snip>",
        "headers": {
            "Accept": [
                "application/json"
            ],
            "Host": [
                "localhost:5138"
            ],
            "User-Agent": [
                "Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.19044; en-US) PowerShell/7.3.1"
            ]
        },
        "path": "/api/throw/details/d106cf8e-3652-4914-b6e6-dbea785ffc13/1/4",
        "endpoint": "ProblemDetailsTest.Controllers.ExceptionController.ThrowProblemDetails (problem-details-test)",
        "routeValues": {
            "action": "ThrowProblemDetails",
            "controller": "Exception",
            "clientId": "d106cf8e-3652-4914-b6e6-dbea785ffc13",
            "marketEntityId": "1",
            "logLevel": "4"
        }
    }
}

The Sample Code

All of the source code for this blog is available here.

I created a sample to explore the .NET 7 features. It’s an ASP.NET 7 webapi with the new features enabled. There are just a few lines of code you need to add to program.cs

builder.Services.AddProblemDetails();
...
// --- after builder.Build()

// add the default as last chance handler. If not added, prod won't return RFC 7807 compliant responses
app.UseExceptionHandler();

// this returns problemDetails for other responses like 404, etc
app.UseStatusCodePages();

if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();

That all works, giving a RFC7807 JSON result (if the caller sets the Accept header to application/json). The output will be as shown above.

But I have two requirements that aren’t met by the default implementation.

  1. Send the client a ProblemDetails object I created, deep anywhere in my code (by throwing an exception)
  2. Control logging of the ProblemDetails exception

To solve these problems I created a ProblemDetailsException and middleware to catch the exception and use the new ProblemDetailsService.WriteAsync method to write the ProblemDetails to the response. The exception is just a wrapper around ProblemDetails with the addition of a LogLevel. Nothing too exciting there.

For testing, I created two endpoints: one that just throws a NotImplementedException and one throws the new exception.

When starting the sample app, you can pass in a number that’s a bit flag to turn on various features. e.g. dotnet run -- <0-7>

[Flags]
enum ProblemDetailsEnum
{
    Vanilla                = 0, // just AddProblemDetails, and UseExceptionHandler
    DeveloperExceptionPage = 1, // turn on UseDeveloperExceptionPage
    CustomProblemDetails   = 2, // use CustomizeProblemDetails when calling AddProblemDetails to see what that affects
    UseMyMiddleware        = 4  // turn on my middleware
}

Testing the Sample

There’s a little PowerShell script to call the test endpoints and dump the returned object. It also calls a non-existent endpoint to see what the default behavior is.

Vanilla (0)

Both endpoints that throw returned this:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
    "title": "An error occurred while processing your request.",
    "status": 500
}

The 404 returned this and will return this for all the other tests.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
    "title": "Not Found",
    "status": 404
}

DeveloperExceptionPage (1)

Turning on the DeveloperExceptionPage returns much more, including the entire call stack (truncated here). The exception.details does show the ToString() of the exception, but not very useful to a caller.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
    "title": "ProblemDetailsTest.ProblemDetailsException",
    "status": 500,
    "detail": "Throwing ProblemDetailsException",
    "exception": {
        "details": "ProblemDetails:\r\n  Status: 500\r\n  Type: https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1\r\n...",
        "headers": {
            "Accept": [
                "application/json"
            ],
            "Host": [
                "localhost:5138"
            ],
            "User-Agent": [
                "Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.19044; en-US) PowerShell/7.3.1"
            ]
        },
        "path": "/api/throw/details/abe0e20f-b8b2-4759-8e77-567af36554b0/1/4",
        "endpoint": "ProblemDetailsTest.Controllers.ExceptionController.ThrowProblemDetails (problem-details-test)",
        "routeValues": {
            "action": "ThrowProblemDetails",
            "controller": "Exception",
            "clientId": "abe0e20f-b8b2-4759-8e77-567af36554b0",
            "marketEntityId": "1",
            "logLevel": "4"
        }
    }
}

CustomProblemDetails (2)

I wasn’t clear on what adding this to AddProblemDetails does. They say it controls the creation of the ProblemDetails before it’s written out. In the sample, I set the type with it.

{
    "type": "set in customproblemdetails",
    "title": "An error occurred while processing your request.",
    "status": 500
}

DeveloperExceptionPage + CustomProblemDetails (3)

This is the same as DeveloperExceptionPage (1) above, but the type in the payload is set to the value I set in CustomProblemDetails.

MyMiddleware (4)

This is just my middleware, and yay!, we get nice output when throwing the ProblemDetailsException. You can now send the caller nice details about an error. In addition to the ProblemDetails, the exception also includes a log level so you can control what level to log the exception (if at all). It defaults to Error. (Maybe that should be Warning?)

{
    "type": "https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1",
    "title": "Throwing ProblemDetailsException",
    "status": 500,
    "detail": "My detail message, look for a and status of 500 and log level of Error",
    "extension_value_int": 1232,
    "extension_value_string": "Some value",
    "extension_value_now":"2023-01-08T17:26:17.8379708-05:00",
    "method_name": "ThrowProblemDetails"
}

Throwing the not implemented exception is pretty much the same as the default.

{
    "type": "https://www.rfc-editor.org/rfc/rfc7231#section-6.6.1",
    "title": "Unhandled exception of type NotImplementedException",
    "status": 500,
    "detail": "Throwing NotImplementedException"
}

DeveloperExceptionPage + MyMiddleware (5)

Since I have UseDeveloperExceptionPage after my middleware in Program.cs, it catches the exception instead of my middleware, so the output is like DeveloperExceptionPage (1) above.

CustomProblemDetails + MyMiddleware (6)

This is just like MyMiddleware (4) above, but the type is set to the value I set in CustomProblemDetails.

DeveloperExceptionPage + CustomProblemDetails + MyMiddleware (7)

Same as DeveloperExceptionPage + CustomProblemDetails (3) above.

Summary

This was a fun playing with the new feature, but a little disappointed with the documentation and not being able to easily send your own ProblemDetails object from anywhere in your app. By adding an exception class and middleware you can get something pretty useful, which is what Hellang’s NuGet package does.