A personal blog by Anas Mazioudi (@disklosr)

Consistent Error Response Format for ASP.NET Core Web APIs

— 32 min read
Room number 404 — Unsplash
Room number 404 — Unsplash

Error handling is an important part of web APIs. Striving to respond with consistent and informative responses in case of errors does not only greatly improve the developer experience, but it can accelerate adoption, integrations and debugging of said APIs.

Achieving a truly consistent error responses with ASP.NET Core is relatively simple to do, but it can, depending on your level of familiarity with the framework, take some trial and error to get things right and exactly the way you want. The default configuration is opinionated about the format of error responses and, as we shall see, isn't at all consistent. This article and this other one have touched on this same problem and are an interesting read if you need another perspective on the matter. I personally found them to be good but not exhaustive enough and lacking some finer details and subtleties where evil usually lies.

In this blog post, I will go over the most common error paths that can happen in the context of an ASP.NET Core web API. For each error path, I'll illustrate, using simple integration tests, what the default behavior of the framework is, then I'll propose a solution on how to configure the framework to consistently map those errors to a custom error response dto (Data Transfer Object).

The rest of this article will assume the use of ASP.NET Core 3.1, the current version at the time of this writing.

Setting up the stage

Our journey will start with this one simple requirement: Having a consistent custom error response format for an ASP.NET Core web API project.

ASP.NET Core already have a default error response format based on RFC 7807 aka "problem detail" format. However, there can be situations where customizing this opinionated format is needed. An obvious reason is to not introduce a breaking change when migrating to newer versions of the framework.

To illustrate my points We'll be using a bare-bone web API. It has a single endpoint /square that accept a positive integer and responds with its value squared. The nominal behavior of this API is not going to be the focus of this article as we're mainly interested in error paths. Nonetheless, here's an example of how this API behaves in normal circumstances:

POST /square HTTP/1.1
Accept: application/json

{
"input": 5
}

---

HTTP/1.1 200 OK
Content-Type: application/json

{
"output": 25
}

In case of errors, we want our API to return responses in this exact format:

{
"Error": "Error details here",
"StatusCode": "Http status code here",
"RequestId": "Some id here",
"Foo": "Bar"
}

This simple format only supports a single error. We don't need anything fancier for the purpose of this article, but in real web applications, the format can be tweaked to accommodate multiple errors at once.

The "Foo": "Bar" bit is a constant key-value pair to ease with my automated tests. If it's contained in the response I receive, I can assert with certitude that it's my custom format that was used.

Let's start implementing our requirement.

Starting fresh

I have created a fresh new ASP.NET Core project with the default Web API template. I slightly modified the generated Startup class to remove some irrelevant code like authentication services and developer exception page, while also adding indentation to the default Json formatter. Hence, our real starting point is this:

// Startup.cs
// Rest of the code omitted for brevity

public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.WriteIndented = true;
});
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

Let's then have our first failing integration test to assert the expected behavior of our API. I'll be using Xunit as my testing framework of choice:

[Fact]
public async Task HappyPath()
{
// Arrange
var payload = new JsonContent(@"{""input"": 5}");

// Act
var response = await _client.PostAsync($"/square", payload);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
((int)(await response.ReadJson<dynamic>()).output).Should().Be(25);
}

This previous test passes after adding the /square endpoint definition to my controller class. Notice that the controller is decorated with [ApiController] attribute (more on that later). I also took the opportunity to define a custom error dto represented by CustomErrorDto class:

[ApiController]
public class SquareController
{
[HttpPost]
[Route("square")]
public object Square(InputDto dto)
{
return new
{
Output = checked(dto.Input * dto.Input)
};
}
}

public class InputDto
{
// Input should be a positive integer
// Negative values are not accepted
[Range(0, Int32.MaxValue)]
public int Input { get; set; }
}

// Defines out custom error format
public class CustomErrorDto {
public string Error { get; set; }
public int StatusCode { get; set; }
public string RequestId { get; set; }
public string Foo { get; } = "Bar";
}

Everything is ready for us to start playing with error cases and see how the default API code behaves in case of failures.

Model Validation Errors

The obvious type of errors we're going to start with are validation errors.

ASP.NET Core supports validating models through the use of Data Annotations. With every incoming request, the payload gets deserialized using an InputFormatter (Json in our case), then the framework proceeds with validating the deserialized model using constraints defined as Data Annotations. Any error found gets appended to a ModelState Dictionary.

Normally, when there are validation errors, the request processing continues up to the controller level, where the developer is responsible for handling any encountered errors before proceeding with application logic. However, when a controller is annotated with ApiController attribute, the error handling is automatically managed by the framework. This has the advantage of leaving your controllers skinny as they only deal with the happy path of processing valid input.

To see this in action, let's write a second test case that sends an invalid payload having a negative input number (recall that our API only accepts positive integers). As per our requirements, the test will first assert a 400 BadRequest http status code, before checking that the returned error response respects the custom format we defined earlier:

[Fact]
public async Task InvalidModel()
{
// Arrange
var payload = new JsonContent(@"{ ""input"": -1 }");

// Act
var response = await _client.PostAsync($"/square", payload);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
await ShouldBeOfTypeCustomErrorDto(response);
}

private async Task ShouldBeOfTypeCustomErrorDto(HttpResponseMessage response)
{
// Assert that the response body is a serialized form of `CustomErrorDto` class
// by checking the presence of "foo":"bar" key-value pair
}

I've used a helper method ShouldBeOfTypeCustomErrorDto that ensures the response contains the key-value pair "foo":"bar". As we haven't yet defined our custom error format, the previous test is expected to fail. Here's what the API returns as a response to our invalid query:

POST http://localhost/square HTTP/1.1
Content-Type: application/json; charset=utf-8

{ "input": "-1" }

------

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "|fe7d51b0-4a17b14e3ae657b9.",
"errors": {
"Input": [
"The field Input must be between 1 and 2147483647."
]
}
}

The error is clear and informative, indicating exactly what property have failed the validation. Sadly, it's not in the format we require. To customize this response, there are at least 2 different ways to go about it.

The first is to disable the automatic handling of errors done by the framework. This means we'll need to explicitly check for errors in our controllers and return the custom error dto in case of validation errors. But that's not an elegant solution as it will result in duplicated logic, plus I prefer my controllers to stay skinny and only deal with the happy path.

The second and, in my opinion, the better option is to use ApiBehaviorOptions.InvalidModelStateResponseFactory. It's a not-so-easy-to-discover setting that allows specifying a custom response specifically for invalid model state errors. It's a delegate that gets an ActionContext as input and returns an IActionResult:

public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(o =>
{
o.InvalidModelStateResponseFactory = context =>
{
var dto = new CustomErrorDto
{
Error = context.ModelState.First().Value.Errors.First().ErrorMessage,
StatusCode = 400,
RequestId = context.HttpContext.TraceIdentifier
};
return new ObjectResult(dto){ StatusCode = dto.StatusCode };
};
})
// ...
}

This new configuration will make our test happy. Here's what the API returns when running the previous test:

HTTP/1.1 POST http://localhost/square
Content-Type: application/json; charset=utf-8

{ "input": -1 }

###

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{
"error": "The field Input must be between 1 and 2147483647.",
"statusCode": 400,
"requestId": "0HM2FHEOVMN46",
"foo": "bar"
}

It's the exact same error message, just in a different shape. The presence of "foo":"bar" is a quick way to confirm it's our custom error dto that was used and not something else.

That was rather easy to do. However, as you'll see, this doesn't address all error cases of a web API. Let's now deal with our next type of errors.

Model Binding Errors

Model validation errors happen when the request doesn't conform to the API's validation rules. But in order to validate a request, we first need to translate it from its HTTP representation into .Net Core objects. In ASP.NET Core jargon, this translation process is known as Model Binding, and yes, this step can indeed fail in certain situations: A malformed payload, incompatible types (mapping a guid into an integer for instance) or simply an empty body. Let's write a failing test with a malformed Json payload:

[Fact]
public async Task MalformedJson()
{
// Arrange
var request = new JsonContent(@"{ broken json }");

// Act
var response = await _client.PostAsync($"/square", request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
await ShouldBeOfTypeCustomErrorDto(response);
}

Surprisingly, this test did pass! The reason is that in ASP.NET Core, model binding errors gets appended to the same ModelState Dictionary as the one use for model validation errors. So the custom error factory we've setup in the last step should pick up model binding errors too and respond with our custom error dto. Here's the http exchange generated by our test case:

HTTP/1.1 POST http://localhost/square
Content-Type: application/json; charset=utf-8

{ this ain't json }

------

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{
"error": "'b' is an invalid start of a property name. Expected a '\"'. Path: $ | LineNumber: 0 | BytePositionInLine: 2.",
"statusCode": 400,
"requestId": "0HM2FJ1NSBHKG",
"foo": "Bar"
}

This one was a piece of cake. The takeaway is to be aware of the differences between Model Binding and Model Validation. They both have the same strategy which is to report any found errors in the same ModelState Dictionary, but they are nonetheless two separate steps in the request pipeline that should not be confused.

We're not done yet, let's move on to the next class of errors.

Non-Existing route errors

This is one of those trivial errors that can easily be overlooked. When you send a request to a non-existing endpoint, the framework responds with a 404 status code, but how does the response look like? Let's find out with our next test case:

[Fact]
public async Task NonExistingEndpoint()
{
// Act
var response = await _client.GetAsync("/non_existing_endpoint");

// Assert
response.StatusCode.Should().Be(404);
await ShouldBeOfTypeCustomErrorDto(response);
}

This test does fail complaining that the response doesn't include a body. The default behavior of ASP.NET Core, when it can't match a route to an endpoint, is to return a 404 with an empty body. This makes sense as the framework can't tell if the client intended to request a web API endpoint, a static file, or a dynamically generated HTML view.

But since we know better than the framework (we know we're building a pure web API), we'd like to include our custom error dto in the response. I found that the cleanest solution to achieve this is by using a catch-all middleware at the very end of the request processing pipeline.

When incoming requests do match an existing endpoint, the RoutingEndpoint middleware, which is responsible for mapping requests to endpoints, does short-circuit the request pipeline and return a response without invoking middleware placed further down in the pipeline. When there are no matching endpoints, the short-circuiting doesn't occur and processing continues down the pipeline until it reaches our catch-all middleware, which we'll configure to send a 404 response with our custom error format. Here's how this translates into code:

// Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

// Catch-all middleware
// Arriving here means no endpoint has been matched by router
app.Use(next => async context =>
{
var dto = new CustomErrorDto()
{
Error = "The requested endpoint doesn't exist",
StatusCode = 404,
RequestId = context.TraceIdentifier
};

var result = new ObjectResult(dto){ StatusCode = dto.StatusCode };
await context.WriteObjectResult(result);
});
}

This will make sure that any API request targeting a non-existing route will receive an error message informing the caller that a wrong route has been used. Developers that need to integrate with your APIs will thank you for that. Bonus points if you, much like what Github does, include a link to your official docs in the response payload.

There's another related error type worth mentioning here. What if the route matches, but the method doesn't? If I try to PATCH our /square endpoint, the API will respond with yet another empty-body error we'll have to fix. More on that later!

Next please!

Unhandled Exception Errors

This is a classic one. Wether it's due to temporary network errors, or unexpected software bugs, you should be prepared to deal with unhandled exceptions, expecting the unexpected. If you can't recover from them, the best you can do is respond with a properly formatted error message providing developers with as much helpful information as possible.

Let's exploit a bug I intentionally left in our API for testing this kind of errors. If you recall the endpoint definition, our square function does wrap the squaring operation inside a checked() operator. This means that integer computation will throw in case of an overflow. Let's write a test case that sends a large enough input to trigger an overflow, and see what the response looks like:

[Fact]
public async Task OverflowException()
{
// Arrange
var request = new JsonContent(@"{ ""input"": 2147483647 }");

// Act
var response = await _client.PostAsync($"/square", request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
await ShouldBeOfTypeCustomErrorDto(response);
}

As expected, this test fails. The API responds with a 500 status code but doesn't include any payload in its body. To send out formatted error responses in this case, we can try adding a catch-all exceptions middleware, this time at the very beginning of the request pipeline. Here's how it looks in code:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Catch-all exceptions middleware
app.Use(async (context, next) =>
{
try
{
await next();
}

catch (Exception e)
{
var dto = new CustomErrorDto()
{
Error = "Server encountered an unexpected error",
StatusCode = 500,
RequestId = context.TraceIdentifier
};

var result = new ObjectResult(dto) { StatusCode = dto.StatusCode };
await context.WriteObjectResult(result);
}
});
// ...
}

This new configuration makes our test happy. The official docs recommend another way of defining a global exception handler using app.UseExceptionHandler("/error");. The difference being that, instead of writing a custom middleware, you'll write a controller that gets called in case of unhandled exceptions. The two approaches are functionally similar, but this latter one is simpler and does automatically handle some edge cases for you. Make sure you use it instead of rolling up your own middleware as I did.

Also, there's a caveat with exception handling. Some client errors caught at the server level can manifest as unhandled exceptions. For instance, when sending an extremely large payload, Kestrel will throw a BadHttpRequestException. We don't want this to be translated to a 500 error as it's clearly the caller's fault, so we need to inspect the caught exception, and decide if it's a true unexpected error in which case we return a default 500 response, or if it's a false positive client error in which case we'll return a 4xx response.

If you want to read more about this, I recommend this excellent article on the different ways you can implement an exception handler.

Other Types Of Errors

So far we've made sure we have a consistent format for the following error responses:

There are many other error cases we'll need to handle, including but not limited to:

Clearly, there's a lot more work to be done. It's time to grab a large cup of coffee as we'll have to deal with each one of those individually! Just kidding, there's of course a generic way of dealing with all these type of errors. Before writing some failing tests, let's cheat a little bit and introduce a new endpoint to simulate returning arbitrary status codes from our api:

[HttpGet]
[Route("status/{statusCode}")]
public ActionResult Square(int statusCode)
{
// Return only the status code. The response body will be empty
return new StatusCodeResult(statusCode);
}

We can now start writing our failing test. I've added all the cases mentioned earlier, and as you can see, it's easy to add in any status code worth testing:

[Theory]
[InlineData(StatusCodes.Status403Forbidden)]
[InlineData(StatusCodes.Status401Unauthorized)]
[InlineData(StatusCodes.Status405MethodNotAllowed)]
[InlineData(StatusCodes.Status408RequestTimeout)]
[InlineData(StatusCodes.Status429TooManyRequests)]
[InlineData(StatusCodes.Status415UnsupportedMediaType)]
public async Task ArbitraryStatusCode(int statusCode)
{
// Act
var response = await _client.GetAsync($"/status/{statusCode}");

// Assert
response.StatusCode.Should().Be(statusCode);
await ShouldBeOfTypeCustomErrorDto(response);
}

To no one's surprise, all these tests fail. Also, they all return responses formatted in the default ProblemDetails format. The following observations can be made:

I don't know about you, but for me this screams the existence of a middleware that intercepts error responses with an empty body and enriches them with a ProblemDetails payload. If we can replace this middleware, we can have control over the response format and customize it to our needs.

The middleware responsible for this behavior is an internal ActionFilter named ClientErrorResultFilter. It does intercept status-code only responses and transform them into ProblemDetails format. Note that since this behavior is performed by an action filter, it will only work for errors returned from controllers. If an error response is returned from a middleware higher up in the chain, the filter won't have a chance to execute and we'll end up with an undefined behavior.

Since we can't modify this filter, we'll have to remove it and replace it with a custom middleware positioned higher than filters in the request pipeline. One way to disable this built-in filter is to simply remove [ApiController] annotation from our controller, but this can have other undesirable side effects and will remove more than we intended to. Luckily, there's a setting to disable exactly this one filter: ApiBehaviorOptions.SuppressMapClientErrors is a boolean switch that allows activating or disabling it. In our case we'll simply set this switch to true:

public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(o =>
{
// Disable `ClientErrorResultFilter`
o.SuppressMapClientErrors = true;

// ...
})
// ...
}

Our tests are still failing, but we've made some progress as this time there's no body returned in the response. A proof that ClientErrorResultFilter filter didn't run. Now we need a custom middleware to format all error responses. The good news is that we won't have to write one from scratch, ASP.NET Core comes with a pre-built middleware for this exact purpose, called StatusCodePagesMiddleware. It's easy to add it to our request pipeline:

// Startup.cs
app.UseStatusCodePages(async context =>
{
var dto = new CustomErrorDto
{
Error = ReasonPhrases.GetReasonPhrase(context.HttpContext.Response.StatusCode),
StatusCode = context.HttpContext.Response.StatusCode,
RequestId = context.HttpContext.TraceIdentifier
};

var result = new ObjectResult(dto) {StatusCode = dto.StatusCode };
await context.HttpContext.WriteObjectResult(result);
});

I put this middleware right after the global exception handler so it can intercept all HTTP responses. Also, note the usage of ReasonPhrases.GetReasonPhrase(), it's a handy little built-in helper to retrieve reason phrase for a specific status code. We could go further and customize the response with even more details specific to each error case for a better developer experience, and we could have also done the same with the previous global exception handler in order to return custom messages per type of exception. This, however, falls outside the scope of this article, so I'll leave that as an exercise for the reader.

Here's an example of the HTTP exchange for the 415 UnsupportedMediaType test case:

HTTP/1.1 GET http://localhost/status/415

------

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json; charset=utf-8

{
"error": "Unsupported Media Type",
"statusCode": 415,
"requestId": "0HM2G2ID76QA4",
"foo": "Bar"
}

Recap

We've seen how there can be multiple components responsible for returning error responses. The default behavior of ASP.NET Core can return inconsistent error payloads, but the framework is flexible enough to allow simple customization of that behavior, although I think it could be even easier.

All the tests we've written since the beginning of this article are green. Meaning that in almost all error cases, our API is going to return a consistent custom error response payload. You can throw whatever you want at it, it'll behave in a consistent manner. This is really important for web APIs.

I've use the word Almost earlier, because what we've done so far only handles errors that happen inside the web api itself. Errors can happen before the ASP.NET Core pipeline gets a chance to run (Gateway errors, HTTP server errors...), in which case the returned response won't be using our custom format. That's not actually a bad thing since these errors happen outside the scope of ASP.NET Core and it doesn't make sense to handle them inside it.

Here's how Startup.cs file looks like after all the changes we've done:

// Startup.cs
public class StartupBare
{
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(o =>
{
// Disable `ClientErrorResultFilter`
o.SuppressMapClientErrors = true;

// Custom response format for model validation and binding errors
o.InvalidModelStateResponseFactory = context =>
{
var dto = new CustomErrorDto
{
Error = context.ModelState.First().Value.Errors.First().ErrorMessage,
StatusCode = 400,
RequestId = context.HttpContext.TraceIdentifier
};
return new ObjectResult(dto){ StatusCode = dto.StatusCode };
};
})
.AddJsonOptions(o => { o.JsonSerializerOptions.WriteIndented = true; });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Global exception handler
app.Use(async (context, next) =>
{
try
{
await next();
}

catch (Exception e)
{
var dto = new CustomErrorDto()
{
Error = "Server encountered an unexpected error",
StatusCode = 500,
RequestId = context.TraceIdentifier
};

var result = new ObjectResult(dto) { StatusCode = dto.StatusCode };
await context.WriteObjectResult(result);
}
});

// Global error status code middleware
app.UseStatusCodePages(async context =>
{
var dto = new CustomErrorDto
{
Error = ReasonPhrases.GetReasonPhrase(context.HttpContext.Response.StatusCode),
StatusCode = context.HttpContext.Response.StatusCode,
RequestId = context.HttpContext.TraceIdentifier
};

var result = new ObjectResult(dto) { StatusCode = dto.StatusCode };
await context.HttpContext.WriteObjectResult(result);
});

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


// Catch-all middleware for non-existing routes
app.Use(next => async context =>
{
var dto = new CustomErrorDto
{
Error = "The requested endpoint doesn't exist",
StatusCode = 404,
RequestId = context.TraceIdentifier
};

var result = new ObjectResult(dto){ StatusCode = dto.StatusCode };
await context.WriteObjectResult(result);
});
}
}

I tried as much to be exhaustive and to handle the most popular errors developers can encounter, but I might just have missed some of them. Contributions and remarks in this regard are very much welcomed.

Found this post interesting? You can share it on
, , Hackernews, Reddit,
mail it to a friend, or save it to Pocket