Read time: 5 minutes

Today I’ll show you how to implement a global exception handler in your ASP.NET Core APIs.

This is a common and essential technique that will help you provide a better experience to your clients and troubleshoot issues faster when something goes wrong.

Up to ASP.NET Core 7 you had to implement custom middleware to do this, but starting with ASP.NET Core 8 there’s a new IExceptionHandler interface that makes this much easier.

You can use it to not just map exceptions to the correct HTTP status code but also to log the exception details with a unique traceId that you can later use to troubleshoot issues.

Let’s see how to do that.


What happens when you don’t handle exceptions properly?

Let’s say you have an ASP.NET Core API with these two endpoints to retrieve a list of games or a single game by its ID:

app.MapGet("/games", async (IGamesRepository repository) =>
{
    IEnumerable<Game> games = await repository.GetAllAsync();
    return Results.Ok(games.Select(game => game.ToDto()));
});

app.MapGet("/games/{id}", async (IGamesRepository repository, int id) =>
{
    Game? game = await repository.GetAsync(id);
    return game is not null ? Results.Ok(game.ToDto()) : Results.NotFound();
});

Usually, they work fine, but what happens if your database is down? Or perhaps they invoke the second endpoint with an invalid ID?

To simulate those scenarios let’s use this simple in-memory repository class:

public class InMemGamesRepository : IGamesRepository
{
    private readonly List<Game> games =
    [
        new Game(1, "Street Fighter II", 19.99M),
        new Game(2, "Final Fantasy XIV", 59.99M),
        new Game(3, "FIFA 23", 69.99M)
    ];

    public Task<IEnumerable<Game>> GetAllAsync()
    {
        // Simulate a database connection error
        throw new InvalidOperationException("The database connection is closed!");
    }

    public async Task<Game?> GetAsync(int id)
    {        
        // 0 or negative ids are not allowed
        if (id < 1)
        {
            throw new ArgumentOutOfRangeException(nameof(id), "The id must be greater than 0!");
        }
        
        return await Task.FromResult(games.Find(game => game.Id == id));
    }
}

Now, by default, your clients will get something like this when invoking either of the endpoints:

HTTP/1.1 500 Internal Server Error
Content-Length: 0
Connection: close
Date: Fri, 24 Nov 2023 15:48:16 GMT
Server: Kestrel

Which is pretty bad for your clients since it provides no clue about what went wrong.

Let’s see how to improve that.


Add problem details support

It turns out be there’s a well-known standard for error responses called RFC 7807 that defines a common format for HTTP APIs to communicate errors.

By using that standard, your clients will get a much more useful response.

The good news is that ASP.NET Core already provides support for it by just adding a few lines of code to your Program.cs file:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IGamesRepository, InMemGamesRepository>()
                .AddProblemDetails();
                
var app = builder.Build();

app.UseStatusCodePages();
app.UseExceptionHandler();

Here’s what those new lines do:

  1. AddProblemDetails() registers the problem details middleware that will handle exceptions and return a problem details response.
  2. UseStatusCodePages() adds a middleware that will return a problem details response for common HTTP status codes.
  3. UseExceptionHandler() adds a middleware that will return a problem details response for unhandled exceptions.

Now, if you run the API again and invoke either of the endpoints, you’ll get a slightly more useful response:

HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: application/problem+json
Date: Fri, 24 Nov 2023 16:07:26 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked

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

The added JSON payload contains a few useful properties:

  • type: A URI reference that identifies the problem type.
  • title: A short, human-readable summary of the problem type.
  • status: The HTTP status code generated by the origin server for this occurrence of the problem.

A good start, but we can do better.


Implement a global exception handler

We could add a try/catch block to each endpoint and return a problem details response, but that would be a lot of duplicated code.

Instead, let’s create a global exception handler that will:

  • Catch all unhandled exceptions and return a problem details response
  • Map each exception to the correct problem details response
  • Logs the exception details to our logging provider

With the new IExceptionHandler interface available starting with .NET 8, implementing this global exception handler is pretty straightforward:

public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;

        logger.LogError(
            exception,
            "Could not process a request on machine {MachineName}. TraceId: {TraceId}",
            Environment.MachineName,
            traceId
        );

        var (statusCode, title) = MapException(exception);

        await Results.Problem(
            title: title,
            statusCode: statusCode,
            extensions: new Dictionary<string, object?>
            {
                {"traceId",  traceId}
            }
        ).ExecuteAsync(httpContext);

        return true;
    }

    ...
}

TryHandleAsync() is the method that will be invoked by the problem details middleware when any exception is thrown.

The first thing we do is capture a unique traceId that will be used to correlate the exception with the logs. We can get that either from the current activity or from the httpContext trace identifier

Then we log the exception details using the ILogger instance, making sure we include some important details like the machine name and the traceId.

Next, we use the MapException() method to map the exception to the correct status code and title.

Finally, we use the Problem() helper method to create a problem details response with the correct status code, title, and traceId.

Notice also that we return true at the end of the method, which means we handled the exception and the request pipeline can stop here.

Here’s the MapException() implementation:

private static (int StatusCode, string Title) MapException(Exception exception)
{
    return exception switch
    {
        ArgumentOutOfRangeException => (StatusCodes.Status400BadRequest, exception.Message),
        _ => (StatusCodes.Status500InternalServerError, "We made a mistake but we are on it!")
    };
}

Any ArgumentOutOfRangeException will return a 400 status code and the exception message as the title since this will be useful for clients.

Any other exception will return a 500 status code and a generic title since we don’t want to reveal too much of our internal details to clients.

There’s just one more step to make this work.


Register the global exception handler

To register the exception handler, all you need to do is invoke the AddExceptionHandler() method in your Program.cs file:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IGamesRepository, InMemGamesRepository>()
                .AddProblemDetails()
                .AddExceptionHandler<GlobalExceptionHandler>();
                
var app = builder.Build();

And, with that, you are pretty much ready to go.


Trying out the global exception handler

If we now send a request to the /games endpoint, here’s what we get:

HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: application/problem+json
Date: Fri, 24 Nov 2023 16:38:38 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "We made a mistake but we are on it!",
  "status": 500,
  "traceId": "00-1a14f00c442cbe9c882d83e409f5513e-a8c14abf348ef27e-00"
}

Not only did the InvalidOperationException get mapped to a 500 status code and a generic title, but also a handy traceId was included in the response.

We will be able to use that traceId to correlate the exception with the logs, which by the way look like this in the console:

fail: HelloExceptions.GlobalExceptionHandler[0]
      Could not process a request on machine JULIO-DESKTOP. 
      TraceId: 00-1a14f00c442cbe9c882d83e409f5513e-a8c14abf348ef27e-00
      System.InvalidOperationException: The database connection is closed!
         at HelloExceptions.Repositories.InMemGamesRepository.GetAllAsync()

Now, when you get a call from your client saying that the API is not working, you can ask them for the traceId and use it to quickly find the exception details in your logs!

What about the other endpoint? Well, now if an invalid id is sent to the /games/{id} endpoint, here’s what we get:

HTTP/1.1 400 Bad Request
Connection: close
Content-Type: application/problem+json
Date: Fri, 24 Nov 2023 16:44:19 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "The id must be greater than 0! (Parameter 'id')",
  "status": 400,
  "traceId": "00-d5fba5e4e5e16729533cb4a134c5008a-b600e9c246594bf4-00"
}

This time the ArgumentOutOfRangeException got mapped to a 400 status code and the exception message as the title, which is exactly what we wanted.

Mission accomplished!

And that’s it for today.

I hope it was useful.



Whenever you’re ready, there are 4 ways I can help you:

  1. .NET Cloud Developer Bootcamp:​ Everything you need to build production ready .NET applications for the Azure cloud at scale.

  2. ​All-Access Pass: A complete catalog of premium courses, with continuous access to new training and updates.

  3. ​Patreon Community: Join for exclusive discounts on all my in-depth courses and access my Discord server for community support and discussions.

  4. Promote yourself to 19,000+ subscribers by sponsoring this newsletter.