Error and Exception Handling in an ASP.NET Core API Project

Error and Exception Handling in an ASP.NET Core API Project

There are various places within an ASP.NET Core API project that an error, or exception can occur. This can make figuring out how best to handle them confusing. ASP.NET Core does come with some basic error handling out-of-the-box with its default templates, but this can be greatly improved upon, especially for production deployments.

ASP.NET Core since version 2.1 supports returning an RFC 7807 Problem Details object to the client. This is a standardized format for specifying problems that have occurred. The easiest way to return this to a client is with a simple return Problem() in the controller action. The Problem() method also supports supplying additional arguments to set the Title, Details and so on of the problem object. However, putting checks everywhere in your controller to return problem objects is not a maintainability solution. It also makes reading the code in an action difficult. Error handling is cross-cutting concern and so we should place the code to handle this somewhere else so that when working on an action, the developer can stay focused on what needs to happen in the action.

The following approach has served me well and should be sufficient for most projects. If you find it doesn’t work for you, or you think you have a better approach, I’d love to know about it. I use three “levels” of error handling:

  1. Validation Errors
  2. Controller-level errors
  3. Everything else (ASP.NET Core Framework, services, external libraries etc.)

Let’s look at how we can handle each of these. I’ve created a sample application, available on my GitHub account here: MorneZaayman/Error-Handling-In-Asp-Net-Core This application is based on the default API template but includes another action on the controller to update a list of WeatherForecast objects. The get action has also been modified to return the current list of WeatherForecast objects. These WeatherForecasts are stored as a static list on the controller – an in-memory database of sorts. Not very practical for a real-world application, but it serves its purpose for testing and learning.

Validation Errors

The easiest way to handle validation errors in ASP.NET Core is to use data annotations on a DTO-like model that is used when making a request. This will then use the built-in framework validation which will process the request before the methods in the controller are called. By modifying the WeatherForecast class with [Required] attributes we can let the ASP.NET Core framework know that it must validate requests to ensure these properties exist. Also note, that DateTime and int must be made nullable, otherwise they will default to their default value and not null because they are structs, and not reference-type objects. Without this the [Required] attribute will not work, as these properties would still have a non-null default value.

public class WeatherForecast
{
    [Required]
    public DateTime? Date { get; set; }

    [Required]
    public int? TemperatureC { get; set; }

    public string? Summary { get; set; }
}

When making a request without the Date and TemperatureC in our request JSON, the server sends the following response back with a 400 Bad Request error:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-16049cb80da3811bfa3346d213767696-863d4ca47758d80c-00",
    "errors": {
      "Date": [
        "The Date field is required."
      ],
      "TemperatureC": [
        "The TemperatureC field is required."
      ]
   }
}

Great – that handles the first error-level. Next up is to deal with errors that occur while processing a seemingly valid request object within the controller.

Controller-level errors

It doesn’t make sense to add a new forecast to the list if the date is in the past. This could also be considered a validation error, and an attribute could be used for it, but for this example we’re going to handle it within the Post method in the controller. At the start of the method, I’ve added the following code:

if (weatherForecast.Date.Value.ToUniversalTime() < DateTime.UtcNow)
{
    return Problem(title: "Invalid date", detail: "You cannot set a date in the past.", statusCode: StatusCodes.Status400BadRequest);
}

You’ll notice that I do not validate the WeatherForecast argument or its parameters for null. This is because this would already have been done by the build in validation, if the [Required] attributes are set.

This code will inspect the data in the request, and if the value of Date is earlier than the system’s current value, then a new ProblemDetails is returned by calling Problem, and specifying the title, detail and status code. Note: Not all these arguments are needed, and there are also others you can use in addition to these.

Right, lets make a request with a date in the past and see what happens.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "Invalid date",
    "status": 400,
    "detail": "You cannot set a date in the past.",
    "traceId": "00-aa60c178ba248508c823e0ea77f2b186-c1c8720540668cbe-00"
}

Again, we receive a 400 Bad Request status code, and the title and detail of the problem details object is what we set in the controller.

The final type of exception to error, is one we do not directly return ourselves within an action. It is usually caused from exceptions that are thrown somewhere deeper than controller level. We could surround all the code in our action methods with try/catch statements and then return a Problem within each catch, but this is cumbersome, bad for performance and not the best practise. There is a better method.

Everything else

What we’ll do now is set up a new route specifically to handle errors. We’ll name this route /Error. It will need its own controller and method to go along with it. Any exceptions that occur will be routed to this controller which can process and then present them to the user. We’ll also build in some functionality so that more details are provided when running in a development environment compared to when running in a production environment. Additionally, we’ll also log the error.

Firstly, create a new controller called ErrorController. Give it the [ApiController] attribute and inherit from ControllerBase.

[ApiController]
public class ErrorController : ControllerBase
{
}

Next, create a field to hold the ILogger and inject it in the constructor.

private readonly ILogger<ErrorController> _logger;

public ErrorController(ILogger<ErrorController> logger)
{
    _logger = logger;
}

Next, we need to create the action that will handle the error. The Route will be “Error” as it will route to the /Error endpoint. [ApiExplorerSettings(IgnoreApi = true)] is used so that this endpoint does not show up in the OpenApi generated documentation. We also need to pass in a IHostEnvironment argument using dependency injection with the [FromServices] annotation so that the code can tell if we’re in a development environment or not.

[Route("Error")]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult Index(
    [FromServices] IHostEnvironment hostEnvironment)
{
}

It will use the HttpContext to obtain an IExceptionHandlerFeature which will be used to retrieve the exception.

Firstly, let’s log the error. There are many options based on what logging framework you’re using. I find for general purpose logging on this action, the following works well:

_logger.LogError(context.Error, context.Path, context.RouteValues);

We also only want to handle exceptions we specifically throw here, so that other exceptions result in a 500 – Internal Server Error, because that’s what they are – these are likely bugs in our application that we need to investigate. To do that, we check that the exception type is not the type we threw, and then throw it again, if it isn’t. This can also be combined with your own exception type to ensure only exceptions you create are handled here.

if (context.Error is not InvalidOperationException)
{
    throw context.Error;
}

Then we need to check the environment and return a suitable response depending on what the environment is.

if (!hostEnvironment.IsDevelopment())
{
    return Problem(title: context.Error.Message, detail: context.Error.StackTrace);
}

return Problem(title: context.Error.Message);

We need to tell ASP.NET Core to route all exceptions to this endpoint. This can be achieved by adding the following line to the Program.cs class:

app.UseExceptionHandler("/error");

I changed the contents in appsettings.Development.json to

{ }

and changed the contents in appsettings.json to

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "None"
    }
  },
  "AllowedHosts": "*"
}

The only reason for this was so that it would be easier to see my own logging in the console output.

Then finally, we need an exception that will route to this new controller and action. I added the following code to the Post method that checks to see if the forecast in the request has already been sent by comparing the date in the request with the dates of all the forecasts already stored in the list.

if (WeatherForecasts.Any(wf => wf.Date == weatherForecast.Date))
{
    throw new InvalidOperationException($"A weather forecast has already been added for the date '{weatherForecast.Date}'.");
}

Right, time to test it out. After sending the same valid request for a forecast in the future a second time, the server returned the following response:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
    "title": "A weather forecast has already been added for the date '2022/08/09 18:14:47'.",
    "status": 500,
    "traceId": "00-776935a447542997c5d73a5661aa751e-b16999a72fcfac21-00"
}

Also, when looking at the console output, the following log was written:

fail: ErrorHandling.Controllers.ErrorController[0]
      /WeatherForecasts
      System.InvalidOperationException: A weather forecast has already been added for the date '2022/08/09 18:14:47'.
         at ErrorHandling.Controllers.WeatherForecastController.Post(WeatherForecast weatherForecast) in C:\Blog\ErrorHandling\ErrorHandling\Controllers\WeatherForecastController.cs:line 54
         at lambda_method2(Closure , Object , Object[] )
         at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()
      --- End of stack trace from previous location ---
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
      --- End of stack trace from previous location ---
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
         at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
         at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)

The /error endpoint can be modified to suit your needs, but I have found with these three “levels” of error-handling I have been able to handle all exceptions that my applications can throw in a graceful manner, while still providing me with information I need to figure out and solve the problems causing them in the first place.

Leave a comment

Your email address will not be published.