Problem Details + Error Handling + .NET Core 3

Goals for this experiment:

  • HTTP calls that route to /api/... (the API) should return errors in the Problem Details JSON format.
    • We should be able to map specific exceptions to specific HTTP status codes, and control the data returned.
    • When in developer mode, errors include full exception details
    • When not in developer mode, exception details are excluded
  • HTTP calls that do not route to /api/... (*.html, *.css, *.png, etc…) should redirect to an error page
    • When in developer mode, errors are displayed with the “developer exception page”
    • When not in developer mode, errors are displayed as user- friendly pages

The scope of this experiment does not extend beyond the requirements of a SPA project I’m working on. The stack is: Aurelia, .NET Core 3, TypeScript, Entity Framework Core, MS SQL.

There’s a lot of conflicting information and overlap out there. Much has changed over the years, but my final solution seems simple and elegant.

To facilitate this experiment, I decided to use VS Code’s new remote container development feature (learn more here. You should be able to open the repo in VS Code and F5 to run it in a fully containerized development environment. It’s SO rad.

Testing the API

The repo contains a single controller used for testing various scenarios. I used curl to exercise the API and show my work. With each test below, you can see the curl result, and the code that produced that result. Pay particular attention to the input passed to the --data parameter, the HTTP response codes, and the HTTP response headers, and the response. You can copy/paste the curl commands below directly into the VS Code terminal if you’ve pulled the repo and are connected to the remote container.

Testing validation attributes in the API

This test returns the same result when hitting all of the actions in the TestController. MVC automatically turns those errors into problem details. Here, we’re passing null for the name.

curl --location --request GET "http://127.0.0.1:5000/api/A" --header "Content-Type: application/json" --data "{
    \"Name\": null
}" -k -i

HTTP/1.1 307 Temporary Redirect
Date: Fri, 30 Aug 2019 20:21:00 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/A

HTTP/2 400 
date: Fri, 30 Aug 2019 20:21:00 GMT
content-type: application/problem+json; charset=utf-8
server: Kestrel
{"title":"One or more validation errors occurred.","status":400,"traceId":"|ff211a43-456dc8484f1a0fb7.","errors":{"Name":["The Name field is required."]}}

This works because the Data.Name property has a [Required] attribute:

    public class Data {
        [Required]
        public string Name { get; set; }
    }

Testing thrown exceptions in the API

TestController.A is the only method that throws an exception.

    [HttpGet("A")]
    public ActionResult A(Data data) {
        throw new System.Exception("testoo");
    }

Here’s the result of calling that action. (I trimmed some of the exception details to make this easier to read):

curl --location --request GET "http://127.0.0.1:5000/api/A" --header "Content-Type: application/json" --data "{
    \"Name\": \"something\"
}" -k -i

HTTP/1.1 307 Temporary Redirect
Date: Fri, 30 Aug 2019 20:18:39 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/A

HTTP/2 500 
cache-control: no-cache, no-store, must-revalidate
date: Fri, 30 Aug 2019 20:18:42 GMT
pragma: no-cache
content-type: application/problem+json; charset=utf-8
expires: 0
server: Kestrel
{
	"errors": [{
		"message": "testoo",
		"type": "System.Exception",
		"raw": "System.Exception: testoo\n   at TestController.A(Data data) in /workspaces/netcore3problemdetails/Controllers/TestController.cs:line 13\n   at lambda_method(Closure , Object , Object[] )\n   at Microsoft.Extensions.Internal.ObjectMethodExecutor.Execute(Object target, Object[] parameters)\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State\u0026 next, Scope\u0026 scope, Object\u0026 state, Boolean\u0026 isCompleted)\n   at...",
		"stackFrames": [{
			"filePath": "/workspaces/netcore3problemdetails/Controllers/TestController.cs",
			"fileName": "TestController.cs",
			"function": "TestController.A(Data data)",
			"line": 13,
			"preContextLine": 7,
			"preContextCode": ["[Route(\u0022api\u0022)]", "[ApiController]", "public class TestController : ControllerBase", "{", "    [HttpGet(\u0022A\u0022)]", "    public ActionResult A(Data data) {"],
			"contextCode": ["        throw new System.Exception(\u0022testoo\u0022);"],
			"postContextCode": ["    }", "", "    [HttpGet(\u0022B\u0022)]", "    public ActionResult B(Data data)", "    {", "        ModelState.AddModelError(\u0022\u0022, \u0022for reals\u0022);"]
		}...]
	}],
	"type": "https://httpstatuses.com/500",
	"title": "Internal Server Error",
	"status": 500,
	"detail": "testoo",
	"instance": null
}

Testing unhandled exceptions in the API

TestController.C throws an unhandled exception.

    [HttpGet("C")]
    public ActionResult C(Data data)
    {
        // force a div by zero exception
        var x = 0;
        x = 0 / x;
        return Ok();
    }

And the result…

curl --location --request GET "http://127.0.0.1:5000/api/C" --header "Content-Type: application/json" --data "{
    \"Name\": \"something\"
}" -k -i

HTTP/1.1 307 Temporary Redirect
Date: Fri, 30 Aug 2019 20:19:21 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/C

HTTP/2 500 
cache-control: no-cache, no-store, must-revalidate
date: Fri, 30 Aug 2019 20:19:23 GMT
pragma: no-cache
content-type: application/problem+json; charset=utf-8
expires: 0
server: Kestrel
{
	"errors": [{
		"message": "Attempted to divide by zero.",
		"type": "System.DivideByZeroException",
		"raw": "System.DivideByZeroException: Attempted to divide by zero.\n   at TestController.C(Data data) in...",
		"stackFrames": [{
			"filePath": "/workspaces/netcore3problemdetails/Controllers/TestController.cs",
			"fileName": "TestController.cs",
			"function": "TestController.C(Data data)",
			"line": 28,
			"preContextLine": 22,
			"preContextCode": ["", "    [HttpGet(\u0022C\u0022)]", "    public ActionResult C(Data data)", "    {", "        // force a div by zero exception", "        var x = 0;"],
			"contextCode": ["        x = 0 / x;"],
			"postContextCode": ["        return Ok();", "    }", "", "    [HttpGet(\u0022D\u0022)]", "    public ActionResult\u003Cbool\u003E D(Data data)", "    {"]
		}, ...]
	}],
	"type": "https://httpstatuses.com/500",
	"title": "Internal Server Error",
	"status": 500,
	"detail": "Attempted to divide by zero.",
	"instance": null
}

Testing manual model state errors in the API

TestController.B manually adds a model validation error. To return the error as problem details, you just need return this.ValidationProblem();.

    [HttpGet("B")]
    public ActionResult B(Data data)
    {
        ModelState.AddModelError("", "for reals");
        return this.ValidationProblem();
    }

And the result:

curl --location --request GET "http://127.0.0.1:5000/api/B" --header "Content-Type: application/json" --data "{
    \"Name\": \"something\"
}" -k -i

HTTP/1.1 307 Temporary Redirect
Date: Fri, 30 Aug 2019 20:19:43 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/B

HTTP/2 400 
date: Fri, 30 Aug 2019 20:19:43 GMT
content-type: application/problem+json; charset=utf-8
server: Kestrel
{
	"title": "One or more validation errors occurred.",
	"status": 400,
	"errors": {
		"": ["for reals"]
	}
}

Another way to return validation results the same result is to throw a ProblemDetailsException

[HttpGet("B2")]
public ActionResult B2(Data data)
{
    ModelState.AddModelError("", "for reals");
    var validation = new ValidationProblemDetails(ModelState);
    throw new ProblemDetailsException(validation);
}
There's one big difference between this result the previous result. `return this.ValidationProblem();` returns HTTP status code 400 (which I believe is correct), and `throw new ProblemDetailsException(validation);` returns HTTP status code 500. 
curl --location --request GET http://127.0.0.1:5000/api/B2 --header Content-Type: application/json --data {
    "Name": "something"
} -k -i
HTTP/1.1 307 Temporary Redirect
Date: Tue, 03 Sep 2019 12:34:59 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/B2

HTTP/2 500 
cache-control: no-cache, no-store, must-revalidate
date: Tue, 03 Sep 2019 12:35:00 GMT
pragma: no-cache
content-type: application/problem+json; charset=utf-8
expires: 0
server: Kestrel

{"title":"One or more validation errors occurred.","status":500,"errors":{"":["for reals"]}}

If you’re curious why throwing a ProblemDetailsException doesn’t include the exception details, follow this thread.

Mapping an exception to a specific HTTP status code

Here’s a fun one. I created a custom TeapotException and mapped it to HTTP status code 418. Here’s where we throw the exception:

[HttpGet("F")]
public ActionResult<bool> F(Data data)
{
    throw new TeapotException();
}

And here’s how we map the exception to the 418 status code (I’ll explain more below):

services.AddProblemDetails(options =>
{
    // This will map TeapotException to the 418 I'm a teapot status code
    options.Map<TeapotException>(ex => new ExceptionProblemDetails(ex, StatusCodes.Status418ImATeapot));
});

Here’s the truncated result:

curl --location --request GET http://127.0.0.1:5000/api/F --header Content-Type: application/json --data {
    "Name": "something"
} -k -i
HTTP/1.1 307 Temporary Redirect
Date: Mon, 02 Sep 2019 23:14:04 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/F

HTTP/2 418 
cache-control: no-cache, no-store, must-revalidate
date: Mon, 02 Sep 2019 23:14:06 GMT
pragma: no-cache
content-type: application/problem+json; charset=utf-8
expires: 0
server: Kestrel

{"errors":[{"message":"Exception of type \u0027TeapotException\u0027 was thrown.","type":"TeapotException","raw":"TeapotException: Exception of type \u0027TeapotException\u0027 was thrown.\n   at ApiController.F(Data data) in /workspaces/netcore3problemdetails/Controllers/TestController.cs:line 49\n   at lambda_method(Closure , Object , Object[] )\n   at Microsoft.Extensions.Internal.ObjectMethodExecutor.Execute(Object target, Object[] parameters)\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncObjectResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)\n   at Micr

Exception details not included in production mode

When the application is not in developer mode, exception information is not included with the problem details JSON. Here’s the same call as last time, except we’re in production mode:

curl --location --request GET http://127.0.0.1:5000/api/F --header Content-Type: application/json --data {
    "Name": "something"
} -k -i
HTTP/1.1 307 Temporary Redirect
Date: Mon, 02 Sep 2019 23:48:20 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/F

HTTP/2 418 
cache-control: no-cache, no-store, must-revalidate
date: Mon, 02 Sep 2019 23:48:21 GMT
pragma: no-cache
content-type: application/problem+json; charset=utf-8
expires: 0
server: Kestrel

{"type":"https://httpstatuses.com/418","title":"I\u0027m a teapot","status":418,"detail":null,"instance":null}

What doesn’t work

The following code will not return problem details.

    [HttpGet("E")]
    public ActionResult<bool> E(Data data)
    {
        ModelState.AddModelError("", "for reals");
        return BadRequest(ModelState);
    }

It simply returns the ModelStateDictionary with a HTTP 400 result. Note the lack of a problem details response header.

curl --location --request GET "http://127.0.0.1:5000/api/E" --header "Content-Type: application/json" --data "{
    \"Name\": \"something\"
}" -k -i

HTTP/1.1 307 Temporary Redirect
Date: Fri, 30 Aug 2019 20:21:38 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/E

HTTP/2 400 
date: Fri, 30 Aug 2019 20:21:38 GMT
content-type: application/json; charset=utf-8
server: Kestrel
{
    "": [
        "for reals"
    ]
}

I believe that’s a common pitfall for people trying to return validation results as problem details. The fix is to return this.ValidationProblem(); instead of return BadRequest(ModelState);

And yet, if you return BadRequest(); (without passing in ModelState), you get problem details again. In other words, this:

[HttpGet("D")]
public ActionResult<bool> D(Data data)
{
    return BadRequest();
}

…returns:

curl --location --request GET "http://127.0.0.1:5000/api/D" --header "Content-Type: application/json" --data "{
>     \"Name\": \"something\"
> }" -k -i
HTTP/1.1 307 Temporary Redirect
Date: Wed, 04 Sep 2019 16:53:13 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/D

HTTP/2 400 
date: Wed, 04 Sep 2019 16:53:13 GMT
content-type: application/problem+json; charset=utf-8
server: Kestrel

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"Bad Request","status":400,"traceId":"|b03ed286-4b7918f3c9895e87."}

If you want to return richer problem details when using return BadRequest(...);, you have to do a bit more work. You must create a class that derives from ProblemDetails, and pass that to BadRequest(...):

public class Deets : ProblemDetails
{
    public string Name { get; set; }
}

[HttpGet("D2")]
public ActionResult<bool> D2(Data data)
{
    return BadRequest(new Deets { Name = "Fred" });
}

And here’s the result:

curl --location --request GET "http://127.0.0.1:5000/api/D2" --header "Content-Type: application/json" --data "{
    \"Name\": \"something\"
}" -k -i
HTTP/1.1 307 Temporary Redirect
Date: Tue, 03 Sep 2019 14:04:08 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/D2

HTTP/2 400 
date: Tue, 03 Sep 2019 14:04:10 GMT
content-type: application/problem+json; charset=utf-8
server: Kestrel

{"name":"Fred","type":null,"title":null,"status":400,"detail":null,"instance":null}

That’s true for other built-in controller methods. For example, return NotFound(someIdentifier); also does not return results in problem details format.

I do not understand why Microsoft chose to do it that way, but I’m not the first to notice it. It makes it pretty painful to return problem details everywhere. Plus, the fact that they don’t automatically return problem details when passing data to those functions makes me wonder if I’m doing something wrong.

9/4/2019 12:56:20 PM EST UPDATE: I asked @khellang and he gave a perfect explanation as to why they’re different. Now I’m just curious if that’s by design, or if it’s a bug. :)

9/5/2019 1:12:10 PM EST UPDATE: .NET Core 3 Preview 9 was just released today, and along with it comes this new gem:

Helper methods for returning Problem Details from controllers
Problem Details is a standardized format for returning error information from an HTTP endpoint. We’ve added new Problem and ValidationProblem method overloads to controllers that use optional parameters to simplify returning Problem Detail responses.

I believe ValidationProblem was included with Preview 8, because I already demonstrated it before today. For that matter, return Problem(...); could have been there and I just didn’t see it.

In any case, it makes for a super method for returning problem details:

[HttpGet("G")]
public ActionResult<bool> G()
{
    // if some error condition...
    return Problem(title: "An error occurred while processing your request", statusCode: 400);
}

…returns:

curl --location --request GET "http://127.0.0.1:5000/api/G" -k -i
HTTP/1.1 307 Temporary Redirect
Date: Thu, 05 Sep 2019 17:10:50 GMT
Server: Kestrel
Content-Length: 0
Location: https://127.0.0.1:5001/api/G

HTTP/2 200 
date: Thu, 05 Sep 2019 17:10:50 GMT
content-type: application/problem+json; charset=utf-8
server: Kestrel

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"An error occurred while processing your request","status":400,"traceId":"|7015daf6-4330b4b8851b85a7."}

The only down side I can see to this Problem(...) method is that you can only return a base problem details object. You cannot add additional metadata to the value returned.

I’ve updated the remote container in the repo to use .NET Core Preview 9, so you can safely and easily test this new Problem(...) method.

Testing non-API calls

This is basically the same as testing the API, except we’re calling a few razor actions on the HomeController. In general, I’m trying to solve the problem of “What is the user experience when an error occurs during a request to a page in the browser?”

Requesting the home page

Simple test first

404 shows 404 page

Just point the browser at a page that doesn’t exist:

Exception in developer mode

See this doc for configuring the current environment. For this test, we’re in developer mode. Here’s how we’re throwing an exception:

public IActionResult Boom()
{
    throw new ApplicationException("sweeet");
}

And here’s the result:

Exception in production mode

Here, we’re not in developer mode so the user should see a “friendly” message:

Additional tests?

Did I cover everything you can think of? Can you think of a test or requirement I haven’t met? Please let me know.

Making it work

The most interesting code is in the startup.cs Configure() method. Instead of the usual code that looks like this:

if (Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/home/error");
}

…we replaced that code that branches based on whether or not the request is trying to access the API:

app.UseIfElse(IsUIRequest, UIExceptionMiddleware, NonUIExceptionMiddleware);

Basically, if the request isn’t going to /api/..., use that old code we’re using to seeing, otherwise use this code:

app.UseProblemDetails();

That’s what enables the API to respond differently from the rest of the site. For the record, I got that code from this repo.

Beyond that, it’s just a matter of configuring Hellang.Middleware.ProblemDetails to map exceptions to HTTP status codes as necessary.

Interestingly…

I didn’t need to write a bunch of obscure code in UseExceptionHandler or write exception filters as demonstrated here, here, here, here, and elsewhere. I’m not throwing shade at those posts — I’m genuinely concerned that I’m missing something here. Can someone tell me where I’m going wrong?

I also didn’t have to use services.Configure<ApiBehaviorOptions>(...) as Hellang.Middleware.ProblemDetails, and proper, consistent use of ASP.NET Core. The latter requirement is a bit unfortunate because it’s easy to accidentally write code that doesn’t return problem details.

Published by alexdresko

To learn more about me, check the "About Me" page on this site.

Scroll Up