11 minutes read

Gracefully handling exceptions in ASP.NET Core Minimal APIs

You probably don’t need me to tell you your code should be as DRY as realistically possible. But let’s be honest: when it's error-handling time, we’ve all seen our colleagues toss the DRY rule right out the window! Simple API routes made way too complex by handling all possible error states.

A semi-transparent code editor displaying C# code with a visible error message, superimposed over the phrase 'Game Over' in bold letters.

Developers often grapple with the challenge of proper exception handling in their REST API. Especially when we need to add API routes to a code base that heavily relies on exceptions, we need to find a way to gracefully handle these exceptions. How can we streamline our error handling to maintain code readability, enhance user experience, and adhere to good software design principles?

When and how to use exceptions

Before we get into the details on how to catch custom exceptions, I want you to carefully think if this is the right approach for you.

Remember, exceptions aren't your go-to for flow control. Where possible, Result objects should be your first choice.

In general, exceptions make it harder to read your code. There is no way, other than comments, to communicate that a method might throw an exception. Plus, those try-catch blocks really tend to break up the flow of your code, upping the reading difficulty big time!

Besides the occasional ArgumentNullException, we should rarely throw an exception in our day-to-day business logic.

Result objects

For those unfamiliar with the Result Pattern:

Instead of throwing exceptions, your method returns an object that contains either the expected value or an error. This way, we force the consumer to check for errors and handle them accordingly.

Discussing the entire Result object and its implementation is beyond the scope of this article. But if you want to know more, check out this awesome explainer video by Nick Chapsas – he nails it in breaking down the concept.

Catching exceptions using Minimal API Filters

Now back to our problem. Using Result object patterns is generally better, but often we work in older codebases where exceptions are more appropriate. Refactoring to Result objects isn't always feasible or worth the effort.

Exception handling is a cross-cutting concern and we don’t want to re-implement the same logic for each endpoint in our API.

Luckily, ASP.NET Core has a thing called Filters. Filters enable us to create reusable logic that can be applied to any Routes as needed. This helps us improve our endpoints by executing code before or after the endpoint handler, examining and/or altering parameters, and even altering the response behavior. It's this last capability – altering response behavior – that is particularly useful for us.

Creating a catch-all Filter is a breeze: just implement the IEndpointFilter interface and wrap a try-catch around the EndpointFilterDelegate invocation, just like this:


_15
public class ExceptionFilter: IEndpointFilter
_15
{
_15
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
_15
{
_15
try
_15
{
_15
return await next(context);
_15
}
_15
catch (Exception exception)
_15
{
_15
//Catch all exceptions and respond with the error message
_15
return Results.Json(exception.Message, statusCode: 500);
_15
}
_15
}
_15
}

We can add this filter with the AddEnpointFilter method like:


_10
app.MapGet("hello", () => "world")
_10
.AddEndpointFilter<CustomExceptionFilter>();

Custom exceptions

Done! Our filter catches all exceptions and responds with the exception message and status 500 Internal Server Error. You might call it a day here, but I don’t envy the dung tornado you might find yourself in the very next day.

Our approach, similar to ASP.NET Core's default, can inadvertently reveal sensitive information. Imagine some ORM throwing an exception with your database's IP address whenever a query times out. With our current filter are at serious risk of leaking the implementation details of our application.

Right now we should narrow down our filter to exceptions we actually want to log. While you could do that by creating a catch for every exception type we want to catch, I personally prefer using custom exceptions.

Using custom exceptions offers us some serious benefits:

  • They are more descriptive than throwing InvalidOperationException everything something goes wrong
  • We can control what gets logged in the message and add more details where appropriate
  • Our filter only catches exceptions we ourselves intended to throw. Exceptions thrown by others, like the framework or some library, still result in the ambiguous 500 Internal Server Error we’re so used to seeing.

Let’s start by creating a base class for our own custom exceptions. That way, we can easily distinguish between system exceptions and the exceptions our own code throws:


_10
public abstract class CustomApiExceptionBase : Exception {}

And update our filter to catch our CustomApiExceptionBase instead of every conceivable Exception:


_10
try
_10
{
_10
return await next(context);
_10
}
_10
catch (CustomApiExceptionBase exception)
_10
{
_10
return Results.Json(exception.Message, statusCode: 500);
_10
}

Problem JSON

Next thing on the list is making sure we respond with the correct status code and some actual useful information. We want informative API error responses, not just the exception message with a status code of 500. Our goal is to make the response better, not worse. So let’s improve our API response with additional information.

Status codes are the default REST method for communicating why something failed. However, they are not always clear enough. As such, IETF has a proposed standard going on for Problem Details for HTTP APIs. Luckily for us, .NET already supports a version of this standard through the ProblemDetails class.

Let’s extend our CustomApiExceptionBase to make use of this standard. And while we’re there, we should also add a StatusCode property so we can have different status codes for different exceptions.


_16
public abstract class CustomApiExceptionBase : Exception
_16
{
_16
public abstract int StatusCode { get; }
_16
_16
public virtual ProblemDetails GetProblemDetails()
_16
{
_16
//We return a generic ProblemDetails,
_16
//but a derived class can override this for more specific scenarios
_16
return new ProblemDetails
_16
{
_16
Title = "Error",
_16
Detail = $"{GetType().Name}: {Message}",
_16
Status = StatusCode,
_16
};
_16
}
_16
}

Informative API Error Responses

Now that we've got ProblemDetails in our toolkit, let's wrap up our filter.

You’ll find the actual code below this section, but let’s get over what we have to do.

For starters, we should utilize our new GetProblemDetails method and StatusCode property we just added. We can now use them to return the right response in our filter:


_10
var details = exception.GetProblemDetails();
_10
var status = exception.StatusCode;
_10
return Results.Json(details, contentType: "application/problem+json", statusCode: status);

As you might have noticed I also changed the contentType to application/problem+json , according to the IETF proposed standard.

Finally, I think we should also log our exception before returning our new response. After all, an unlogged exception is a lesson lost!


_10
var endpoint = context.HttpContext.GetEndpoint();
_10
var name = endpoint is RouteEndpoint routeEndpoint ? routeEndpoint.RoutePattern.RawText : "Unknown route";
_10
logger.LogError(exception, "Uncaught exception in endpoint {EndpoinName}", name);

Besides simply logging the exception I also extract the endpoint name from the context. Without this, it would be harder to find out which endpoint actually threw the exception since the stacktrace will always point to the filter.

Catching a single exception type

While our solution does exactly what we need, we do have to keep security in mind. Information Leakage, the unintentional exposing of sensitive data, is listed by OWASP as a security risk.

It doesn't always make sense to simply log our internal exceptions, even when we control the problem details that are returned. There may be situations where returning details might expose sensitive data to a public endpoint.

Let’s add an option to only catch specific exception types on a route:

We achieve this by introducing a generic that reflects the exception we want to catch:


_18
public class CustomExceptionFilter<TException> : IEndpointFilter
_18
where TException : CustomApiExceptionBase
_18
{
_18
//class constructor, fields & properties
_18
_18
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
_18
{
_18
try
_18
{
_18
return await next(context);
_18
}
_18
//Instead of catching all CustomApiExceptionBase exceptions, we catch a specific derived exception
_18
catch (TException exception)
_18
{
_18
// same code as before
_18
}
_18
}
_18
}

Now, our filter only catches exceptions of the generic type we pass. If we still want our filter to catch all CustomApiExceptionBase exceptions we can still do that by registering the filter like this:


_10
app.MapGet(...).AddEndpointFilter<CustomExceptionFilter<CustomApiExceptionBase>>()

Wrapping up: Extension methods and OpenApi documentation

Awesome, our filter is all set! It’s now equipped to gracefully handle custom exceptions from our endpoints, ensuring responses align with IETF standards. For the Swashbuckle enthusiasts, here's a bonus: we can auto-document the responses in the OpenAPI specs.

Let’s go the extra mile and wrap this whole filter up in an extension method and auto-document the responses in OpenAPI.

Swashbuckle defaults to a 200 OK response in our specs for each endpoint. But adding additional responses is pretty easy with the Produces<TResponse> extension method on the RouteHandlerBuilder:


_10
app.MapGet("/hello/{name}", (string name) => $"Hello {name}")
_10
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)

But there’s a twist: our custom exceptions can determine their own status codes, so this solution doesn’t really cut it.

Instead, let’s wrap the documentation and the endpoint filter in an extension method so we can access the exception type and the status code it contains:


_12
public static class RouteHandlerBuilderExtensions
_12
{
_12
public static RouteHandlerBuilder FilterException<TException>(this RouteHandlerBuilder builder)
_12
where TException : CustomApiExceptionBase
_12
{
_12
var statusCode = ???;
_12
builder.Produces<ProblemDetails>(statusCode);
_12
builder.AddEndpointFilter<CustomExceptionFilter<TException>>();
_12
_12
return builder;
_12
}
_12
}

One hurdle remains: pinpointing the correct status code. Until now, we only needed to know the type of custom exception. Due to constraints on abstract classes, the StatusCode is only accessible from an instance of CustomApiExceptionBase, not just its type.

So, to obtain the status code we can temporarily create an instance of our TException using the Activator class. Once we have the instance we can access the StatusCode as usual, resulting in our FilterException extension method to look like:


_10
public static RouteHandlerBuilder FilterException<TException>(this RouteHandlerBuilder builder)
_10
where TException : CustomApiExceptionBase
_10
{
_10
var exception = Activator.CreateInstance<TException>();
_10
var statusCode = exception.StatusCode;
_10
builder.Produces<ProblemDetails>(statusCode);
_10
builder.AddEndpointFilter<CustomExceptionFilter<TException>>();
_10
_10
return builder;
_10
}

Conclusion

With this, I’d say we pretty much wrapped up our error handling. Besides simply catching all exceptions per route we can now catch specific exceptions, return detailed error responses to the consumers of our API, and as a bonus we automatically list the possible responses in our OpenAPI documentation.

And the beauty of this approach is: next time we add an endpoint that might throw an exception we simply slap on our FilterException extension method on the RouteHandlerBuilder and call it a day.

I think that was a 10 minutes well spent! If you have any questions or have something you want me to write about next, shoot me a message here!

What to read next:

I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.