Handling errors in an ASP.NET Core Web API

This post looks at the best ways to handle exceptions, validation and other invalid requests such as 404s in ASP.NET Core Web API projects and how these approaches differ from MVC error handling.

Why do we need a different approach from MVC?

In .Net Core, MVC and Web API have been combined so you now have the same controllers for both MVC actions and API actions. However, despite the similarities, when it comes to error handling, you almost certainly want to use a different approach for API errors.

MVC actions are typically executed as a result of a user action in the browser so returning an error page to the browser is the correct approach. With an API, this is not generally the case.

API calls are most often called by back-end code or javascript code and in both cases, you never want to simply display the response from the API. Instead we check the status code and parse the response to determine if our action was successful, displaying data to the user as necessary. An error page is not helpful in these situations. It bloats the response with HTML and makes client code difficult because JSON (or XML) is expected, not HTML.

While we want to return information in a different format for Web API actions, the techniques for handling errors are not so different from MVC. Much of the time, it is practically the same flow but instead of returning a View, we return JSON. Let's look at a few examples.

The minimal approach

With MVC actions, failure to display a friendly error page is unacceptable in a professional application. With an API, while not ideal, empty response bodies are far more permissible for many invalid request types. Simply returning a 404 status code (with no response body) for an API route that does not exist may provide the client with enough information to fix their code.

With zero configuration, this is what ASP.NET Core gives us out of the box.

Depending on your requirements, this may be acceptable for many common status codes but it will rarely be sufficient for validation failures. If a client passes you invalid data, returning a 400 Bad Request is not going to be helpful enough for the client to diagnose the problem. At a minimum, we need to let them know which fields are incorrect and ideally, we would return an informative message for each failure.

With ASP.NET Web API, this is trivial. Assuming that we are using model binding, we get validation for free by using data annotations and/or IValidatableObject. Returning the validation information to the client as JSON is one easy line of code.

Here is our model:

public class GetProductRequest : IValidatableObject
{
    [Required]
    public string ProductId { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (...)
        {
            yield return new ValidationResult("ProductId is invalid", new[] { "ProductId" });
        }
    }
}

And our controller action:

[HttpGet("product")]
public IActionResult GetProduct(GetProductRequest request)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    ...
}

A missing ProductId results in a 400 status code plus a JSON response body similar to the following:

{
    "ProductId":["The ProductId field is required."]
}

This provides an absolute minimum for a client to consume our service but it is not difficult to improve upon this baseline and create a much better client experience. In the next few sections we will look at how simple it is to take our service to the next level.

Returning additional information for specific errors

If we decide that a status code only approach is too bare-bones, it is easy to provide additional information. This is highly recommended. There are many situations where a status code by itself is not enough to determine the cause of failure. If we take a 404 status code as an example, in isolation, this could mean:

If we could provide information to distinguish between these cases, it could be very useful for a client. Here is our first attempt at dealing with the last of these:

[HttpGet("product")]
public async Task<IActionResult> GetProduct(GetProductRequest request)
{
    ...

    var model = await _db.Get(...);

    if (model == null)
    {
        return NotFound("Product not found");
    }

    return Ok(model);
}

We are now returning a more useful message but it is far from perfect. The main problem is that by using a string in the NotFound method, the framework will return this string as a plain text response rather than JSON.

As a client, a service returning a different content type for certain errors is much harder to deal with than a consistent JSON service.

This issue can quickly be rectified by changing the code to what is shown below but in the next section, we will talk about a better alternative.

return NotFound(new { message = "Product not found" });

Customising the response structure for consistency

Constructing anonymous objects on the fly is not the approach to take if you want a consistent client experience. Ideally our API should return the same response structure in all cases, even when the request was unsuccessful.

Let's define a base ApiResponse class:

public class ApiResponse
{
    public int StatusCode { get; }

    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string Message { get; }

    public ApiResponse(int statusCode, string message = null)
    {
        StatusCode = statusCode;
        Message = message ?? GetDefaultMessageForStatusCode(statusCode);
    }

    private static string GetDefaultMessageForStatusCode(int statusCode)
    {
        switch (statusCode)
        {
            ...
            case 404:
                return "Resource not found";
            case 500:
                return "An unhandled error occurred";
            default:
                return null;
        }
    }
}

We'll also need a derived ApiOkResponse class that allows us to return data:

public class ApiOkResponse : ApiResponse
{
    public object Result { get; }

    public ApiOkResponse(object result)
        :base(200)
    {
        Result = result;
    }
}

Finally, let's declare an ApiBadRequestResponse class to handle validation errors (if we want our responses to be consistent, we will need to replace the built-in functionality used above).

public class ApiBadRequestResponse : ApiResponse
{
    public IEnumerable<string> Errors { get; }

    public ApiBadRequestResponse(ModelStateDictionary modelState)
        : base(400)
    {
        if (modelState.IsValid)
        {
            throw new ArgumentException("ModelState must be invalid", nameof(modelState));
        }

        Errors = modelState.SelectMany(x => x.Value.Errors)
            .Select(x => x.ErrorMessage).ToArray();
    }
}

These classes are very simple but can be customised to your own requirements.

If we change our action to use these ApiResponse based classes, it becomes:

[HttpGet("product")]
public async Task<IActionResult> GetProduct(GetProductRequest request)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(new ApiBadRequestResponse(ModelState));
    }

    var model = await _db.Get(...);

    if (model == null)
    {
        return NotFound(new ApiResponse(404, $"Product not found with id {request.ProductId}"));
    }

    return Ok(new ApiOkResponse(model));
}

The code is slightly more complicated now but all three types of response from our action (success, bad request and not found) now use the same general structure.

Centralising Validation Logic

Given that validation is something that you do in practically every action, it makes to refactor this generic code into an action filter. This reduces the size of our actions, removes duplicated code and improves consistency.

public class ApiValidationFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(new ApiBadRequestResponse(context.ModelState));
        }

        base.OnActionExecuting(context);
    }
}

Handling global errors

Responding to bad input in our controller actions is the best way to provide specific error information to our client. Sometimes however, we need to respond to more generic issues. Examples of this include:

As with MVC, the easiest way to deal with global errors is by using StatusCodePagesWithReExecute and UseExceptionHandler.

We talked about StatusCodePagesWithReExecute last time but to reiterate, when a non-success status code is returned from inner middleware (such as an API action), the middleware allows you to execute another action to deal with the status code and return a custom response.

UseExceptionHandler works in a similar way, catching and logging unhandled exceptions and allowing you to execute another action to handle the error. In this example, we configure both pieces of middleware to point to the same action.

We add the middleware in startup.cs:

app.UseStatusCodePagesWithReExecute("/error/{0}");
app.UseExceptionHandler("/error/500");
...
//register other middleware that might return a non-success status code

Then we add our error handling action:

[Route("error/{code}")]
public IActionResult Error(int code)
{
    return new ObjectResult(new ApiResponse(code));
}

With this in place, all exceptions and non-success status codes (without a response body) will be handled by our error action where we return our standard ApiResponse.

Custom Middleware

For the ultimate in control, you can replace or complement built-in middleware with your own custom middleware. The example below handles any bodiless response and returns our simple ApiResponse object as JSON. If this is used in conjunction with code in our actions to return ApiResponse objects, we can ensure that both success and failure responses share the same common structure and all requests result in both a status code and a consistent JSON body:

public class ErrorWrappingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ErrorWrappingMiddleware> _logger;
    
    public ErrorWrappingMiddleware(RequestDelegate next, ILogger<ErrorWrappingMiddleware> logger)
    {
        _next = next;
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next.Invoke(context);
        }
        catch(Exception ex)
        {
            _logger.LogError(EventIds.GlobalException, ex, ex.Message);

            context.Response.StatusCode = 500;
        }            

        if (!context.Response.HasStarted)
        {
            context.Response.ContentType = "application/json";

            var response = new ApiResponse(context.Response.StatusCode);

            var json = JsonConvert.SerializeObject(response);

            await context.Response.WriteAsync(json);
        }            
    }
}

Conclusion

Handling errors in ASP.NET Core APIs is similar but different from MVC error code. At the action level, we want to return custom objects (serialised as JSON) rather than custom views.

For generic errors, we can still use the StatusCodePagesWithReExecute middleware but need to modify our code to return an ObjectResult instead of a ViewResult.

For full control, it is not difficult to write your own middleware to handle errors exactly as required.

Comments

Avatar for Michael Michael wrote on 06 Jul 2017

This is perfect! Thanks so much.

Avatar for Michael Michael wrote on 06 Jul 2017

I know it's not error handling but would you recommend doing something similar for the CreatedAtRoute reponse?

Avatar for Ali Ali wrote on 19 Jul 2017

Thank you so much. Would you please tell me how can i read request body inside ExceptionHandler middleware? The problem is that when exception happens inside of a post action, request body will become null. It happens because request body has been already read.. My problem is how can i buffer body before exception acquired inside ExceptionHandler middleware? Is it possible or do i have to write custom middleware?

Avatar for Ajay Ajay wrote on 08 Aug 2017

Perfect!

Avatar for andrzejm andrzejm wrote on 06 Sep 2017

I like to make model validation be default using convention like in this answer: https://stackoverflow.com/a/37093926/669692

Avatar for Siraj Siraj wrote on 08 Sep 2017

[HttpGet("error/{code}")]
public IActionResult Error(int code)
{
return new ObjectResult(new ApiResponse(code));
}

Consider above code as Microsoft docs explains that it is not a good idea to explicitly decorate the error handler action method with HTTP method attributes, such as HttpGet. Using explicit verbs could prevent some requests from reaching the method.

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling

Avatar for Paul Hiles Paul Hiles wrote on 08 Sep 2017

@Michael - yes it is generally a good idea to implement it for all status codes that you are supporting.

@Ali - I believe that middleware is your only option here.

@Andrezejm - Very nice.

@Siraj - Indeed. Updated. Using [Route] rather than [HttpGet] is essential here or you will get problems with non-get requests not be handled. See Branislav's issue below.

Avatar for Alexandre Campinho Alexandre Campinho wrote on 11 Oct 2017

For the ApiValidationFilterAttribute:

This line:
context.Result = new BadRequestObjectResult(new ApiBadRequestResponse(ModelState)));

Shouldn't it be:
context.Result = new BadRequestObjectResult(new ApiBadRequestResponse(context.ModelState)));

Let me know if i am wrong!

Avatar for Paul Hiles Paul Hiles wrote on 11 Oct 2017

@Alexandre - thanks. I have fixed it.

Avatar for Branislav Petrovic Branislav Petrovic wrote on 16 Oct 2017

Hi Paul,

thanks you so much for a great post. This is almost everything that I was looking for that I need for my API error handling logic.

You have little typo in one of your return statements ($ character is missing in string interpolation expression). Instead of:

return NotFound(new ApiResponse(404, "Product not found with id {request.ProductId}"));

there must be:

return NotFound(new ApiResponse(404, $"Product not found with id {request.ProductId}"));

BR,
Branislav

Avatar for Paul Hiles Paul Hiles wrote on 16 Oct 2017

@Branislav - thanks. updated.

Avatar for Brandon Brandon wrote on 18 Oct 2017

Great article! Thanks a bunch!

Avatar for Sam Sam wrote on 04 Dec 2017

This is exactly what I was looking for. Can you also do an article on webapi unit testing? Thanks

Avatar for Sean Sean wrote on 08 Dec 2017

What about when the object is a list or IEnumerable? The generic object wont work when passing to the ApiOkResponse or at least I am not sure of how to get it to work.

Avatar for Paul Hiles Paul Hiles wrote on 08 Dec 2017

@Sean - ApiOkResponse declares the result as type object so it will work fine with lists or IEnumerable (or the vast majority of types in .NET for that matter.) Hope this helps.

Avatar for Sean Sean wrote on 08 Dec 2017

Thanks it was an issue with something I was doing. I appreciate the write up, I will be using this often.

Avatar for Abraham Abraham wrote on 14 Dec 2017

Great article. What I like about it is that it has real use. The points discussed are real world use cases and not some silly 'MyCustomExamleClass' stuff. Thanks

Avatar for Michael Freidgeim Michael Freidgeim wrote on 29 Dec 2017

You should include in the article reference to https://www.devtrends.co.uk/blog/conditional-middleware-based-on-request-in-asp.net-core, that has “one common example for this requirement is a project with both MVC and API actions where you want to error handling to be different for each.”

Avatar for Branislav Petrovic Branislav Petrovic wrote on 17 Jan 2018

Hi Paul,

I just want to mention that StatusCodePages middleware doesn't catch all 4xx status codes.

I did some test and noticed that line app.UseStatusCodePagesWithReExecute("/error/{0}") doesn't catch "415 - Unsupported Media Type" client error when I send POST request with unsupported content-type on my server side.

Do you have any findings about this issue or to go with custom middleware without StatusCodePages middleware?

Thanks,
Branislav

Avatar for Paul Hiles Paul Hiles wrote on 18 Jan 2018

@Branislav - what does your UseStatusCodePagesWithReExecute action look like? Are you using [HttpGet] here instead of [Route]?

Avatar for Branislav Petrovic Branislav Petrovic wrote on 18 Jan 2018

The bug was in my Error controller. Instead of:

[Route("api/error")]
public class ErrorController : Controller
{
[HttpGet("{code}")]
public IActionResult Error(int code)
{
return new ObjectResult(new ApiErrorResponse(code));
}
}

I just had to remove [HttpGet] attribute above Error method and place [Route] attribute instead of that:

public class ErrorController : Controller
{
[Route("api/error/{code}")]
public IActionResult Error(int code)
{
return new ObjectResult(new ApiErrorResponse(code));
}
}

Thanks Paul for hint!

Avatar for John John wrote on 06 Feb 2018

Where are you referencing JsonProperty from? it doesn't exist in .net core 2 JsonPatch that is built in. Did you have to add a non .net standard reference to the original json.net?

Avatar for Priya Kale Priya Kale wrote on 23 Feb 2018

Brilliant article. The information I have been searching precisely. It helped every newbie who are going to face error handling in ASP.NET Core Web API. Keep coming with more such informative article.

Avatar for Arpan Arpan wrote on 04 Apr 2018

This is one useful article I found just in time.. Thank you!
One question - I want to be able to raise 424(Dependency Failed) error. How I can incorporate in the above package?

Thanks!

Avatar for Hector Mota Hector Mota wrote on 29 Aug 2018

I like the version of middleware but I can't change the status code because the response already started. I was testing this with a database connection, maybe could work with another type of errors.

Avatar for Sagan Marketing Sagan Marketing wrote on 25 Jan 2019

For some reason my 500 errors have context.Response.HasStarted already set to true! I am doing this as the first line in my Configure... above any MVC or whatever, still I can see that it starts to show the error in the browser already, while I still have my breakpoint... the browser is still spinning, but modifying the request doesn't help.

Avatar for Ankush Jain Ankush Jain wrote on 09 Feb 2019

Can't we pass additional data like error message in "UseStatusCodePagesWithReExecute" method.

Like: app.UseStatusCodePagesWithReExecute("/error/{code}/{message}");

Avatar for Jyotendra Sharma Jyotendra Sharma wrote on 23 Apr 2019

Thanks for this article - it worked great for me. And thanks to "MICHAEL FREIDGEIM" for mentioning that link to middleware application. It completed this article on implementation level - I didn't knew exactly how to apply middleware before. I ended up using: "app.UseMiddleware<ErrorWrappingMiddleware>()" in my Startup class.

Avatar for Glen Boonzaier Glen Boonzaier wrote on 09 Jun 2019

Could you include what "EventIds" is in the line:
_logger.LogError(EventIds.GlobalException, ex, ex.Message);

Avatar for Rodrigo Assis Rodrigo Assis wrote on 18 Jul 2019

I tried to use the ApiValidationFilterAttribute on ASP NET Core 2.2 but the filter was ignored.

Then I made a search and found out this:
services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});

Thank you man.

Avatar for Cristian Castro Cristian Castro wrote on 11 Nov 2019

Two days I've been searching for something like this. Thank you so much! Your post was very helpful!

Avatar for Kurt Gengenbach Kurt Gengenbach wrote on 28 Jan 2020

Is this still your preferred approach using .NET Core v3.1 with its built-in middleware for error handling? Would you replace none, some, or all of your sample code in the newer environment?

Avatar for Kurt Gengenbach Kurt Gengenbach wrote on 28 Jan 2020

FYI: You have an extra closing parenthesis under the "Centralising Validation Logic" code sample.

context.Result = new BadRequestObjectResult(new ApiBadRequestResponse(context.ModelState)));

Avatar for Majid Qafouri Majid Qafouri wrote on 19 May 2020

I'm very happy to find your blog. thank you Paul.

Avatar for İhsan Töre İhsan Töre wrote on 26 Jun 2020

Hi,
Thanks for the clear work.
First of all in .net core 3.1 :

[Route("error/{code}")]
did not work until modified into :
[Route("/error/{code:int}")]

And It does not capture 415.
Any workarounds?
Thanks in Advance.

Avatar for İhsan Töre İhsan Töre wrote on 26 Jun 2020

Sorry to bother you again. It rather seems to be a RTFM problem.
In ConfigureServices this solved the 415 issue:
services.AddControllers()
.ConfigureApiBehaviorOptions(
options => {
options.SuppressMapClientErrors = true;
}
);