Quantcast
Viewing all articles
Browse latest Browse all 92

TNS #016: Global Error Handling in ASP.NET Core APIs

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(IGamesRepositoryrepository)=>{IEnumerable<Game>games=awaitrepository.GetAllAsync();returnResults.Ok(games.Select(game=>game.ToDto()));});app.MapGet("/games/{id}",async(IGamesRepositoryrepository,intid)=>{Game?game=awaitrepository.GetAsync(id);returngameisnotnull?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:

publicclassInMemGamesRepository:IGamesRepository{privatereadonlyList<Game>games=[newGame(1,"Street Fighter II",19.99M),newGame(2,"Final Fantasy XIV",59.99M),newGame(3,"FIFA 23",69.99M)];publicTask<IEnumerable<Game>>GetAllAsync(){// Simulate a database connection errorthrownewInvalidOperationException("The database connection is closed!");}publicasyncTask<Game?>GetAsync(intid){// 0 or negative ids are not allowedif(id<1){thrownewArgumentOutOfRangeException(nameof(id),"The id must be greater than 0!");}returnawaitTask.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.1500InternalServerErrorContent-Length:0Connection:closeDate:Fri,24Nov202315:48:16GMTServer: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:

varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddSingleton<IGamesRepository,InMemGamesRepository>().AddProblemDetails();varapp=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.1500InternalServerErrorConnection:closeContent-Type:application/problem+jsonDate:Fri,24Nov202316:07:26GMTServer:KestrelCache-Control:no-cache,no-storeExpires:-1Pragma:no-cacheTransfer-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:

publicclassGlobalExceptionHandler(ILogger<GlobalExceptionHandler>logger):IExceptionHandler{publicasyncValueTask<bool>TryHandleAsync(HttpContexthttpContext,Exceptionexception,CancellationTokencancellationToken){vartraceId=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);awaitResults.Problem(title:title,statusCode:statusCode,extensions:newDictionary<string,object?>{{"traceId",traceId}}).ExecuteAsync(httpContext);returntrue;}...}

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:

privatestatic(intStatusCode,stringTitle)MapException(Exceptionexception){returnexceptionswitch{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:

varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddSingleton<IGamesRepository,InMemGamesRepository>().AddProblemDetails().AddExceptionHandler<GlobalExceptionHandler>();varapp=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.1500InternalServerErrorConnection:closeContent-Type:application/problem+jsonDate:Fri,24Nov202316:38:38GMTServer:KestrelCache-Control:no-cache,no-storeExpires:-1Pragma:no-cacheTransfer-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]CouldnotprocessarequestonmachineJULIO-DESKTOP.TraceId:00-1a14f00c442cbe9c882d83e409f5513e-a8c14abf348ef27e-00System.InvalidOperationException:Thedatabaseconnectionisclosed!atHelloExceptions.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.1400BadRequestConnection:closeContent-Type:application/problem+jsonDate:Fri,24Nov202316:44:19GMTServer:KestrelCache-Control:no-cache,no-storeExpires:-1Pragma:no-cacheTransfer-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 2 ways I can help you:

  1. In-depth Courses For .NET Developers:​ Whether you want to upgrade your software development skills to find a better job, you need best practices for your next project, or you just want to keep up with the latest tech, my in-depth courses will help you get there, step by step. Join 700+ students here.

  2. Patreon Community. Get access to the source code I use in all my YouTube videos, plus get exclusive discounts for my in-depth courses. Join 30+ .NET developers here.


Viewing all articles
Browse latest Browse all 92

Trending Articles