ASP.NET Core global exception handling gotchas
Implementing an ASP.NET global exception handler is usually done for purposes such as:
- Translating exception types into HTTP status codes, possibly with accompanying customized user-friendly messages (i18n/l10n)
- Logging aberrant behavior (domain errors that represent inconsistent state, unhandled exceptions)
- Notifying engineers in case critical conditions manifest themselves (e-mail, ChatOps etc.)
- Triggering background jobs in case of specific errors (e.g. refresh SQL indices in case of an SQL timeout exception)
There are several way how to do implement them, so let’s first investigate the available methods before we discuss corner cases and gotchas.
Exception middleware
Writing a middleware enables us to inject dependant services into Invoke
, and provides a direct access to the underlying HttpContext
. We just invoke the next delegate in the middleware stack, assuming that we are the first one to execute, and handle the exception if it occurs:
The try-catch
block can be extended to catch more specific exceptions first (including your domain exceptions). You can see the default ASP.NET implementation of the exception middleware ExceptionHandlerMiddleware
here. This implementation already contains fixes for some edge cases which we will cover later, and which we need to replicate if we want to build our own exception handling middleware.
Exception handler lambda
Microsoft-supplied example uses a more concise version of the previous method, by using the UseExceptionHandler
extension method which enables us to construct a terminal middleware by using a Run
method.
And there we already have our first gotcha - the IE padding issue, in case you have to care about IE at all, that is.
Under the hood, it’s just a wrapper for the default implementation mentioned previously. This is the recommended way by MS to implement global exception handlers.
ExceptionHandlerOptions
This is a variant of the previous method. The ExceptionHandlerMiddleware
takes an ExceptionHandlerOptions
as a parameter, which has two properties:
The second one is of interest (for the first one, see below) because it enables us to pass execution to our own middleware in a separate function, and we don’t need to use lambda. It can be used in Configure
either directly:
which we don’t want, or by passing our custom extension method:
Community.AspNetCore.ExceptionHandling.Mvc
This is a NuGet package written by Ihar Yakimush which allows to set a chain of exception handlers per exception type. Each exception type can have a policy registered, and a number of handlers that are executed in order. It comes with a bunch of built-in handlers, such as logging (Log
), retry (Retry
), for setting response headers and body (Response
) and others. It can be registered in ConfigureServices
like so:
ExceptionFilterAttribute, IExceptionFilter
This is an abstract filter that runs asynchronously after an action has thrown an Exception
. It can be useful as a means of providing different exception handlers for Web APIs and MVC/Razor Pages exceptions. By implementing a custom ExceptionFilterAttribute
we can access the ActionContext
since the ExceptionContext
implements it via the FilterContext
. This allows us to see what controller implementation that caused the exception. This is done by checking if the controller implements ControllerBase
(implemented by both APIs and Razor Pages) and or Controller
(not implemented by APIs).
It can be registered in ConfigureServices
like so:
IExceptionFilter
is simply the base interface that ExceptionFilterAttribute
derives from. There is also an async
version, IAsyncExceptionFilter
, which requires an implementation of the OnExceptionAsync
method instead.
MS in general discourages usage of the exception filter in favor of the exception middleware that was mentioned previously. Sending back different results based on which action method is called, such as JSON for API and HTML/error page for a Razor Page, is one notable usage case for the exception filter. An overview of pros and cons of exception filters is covered in MS docs.
Exception handler endpoint
Another method that takes advantage of the built-in exception handling middleware is specifying the exception handler API route in UseExceptionHandler
, which corresponds to the ExceptionHandlingPath
property of the ExceptionHandlerOptions
class. This method is useful for both API and Razor Pages handlers.
Then, we just bind our API to that route and process the exception:
As with other approaches built upon the Exception Handler middleware, the Error
action sends an RFC 7807-compliant payload to the client by reusing the built-in logic, which is not something that we get when using the custom middleware approach, where we must explicitly use ObjectResult
to get content negotiation.
This finishes the overview of the commonly used approaches to create global exception handlers. Now we can follow up with a list of gotchas that we should have in mind when writing handlers, which are listed in no particular order.
Disable response caching
For obvious reasons, response caching should be disabled when returning errors from exception handlers. This helper method clears cache headers:
and can be invoked for the context like so:
This method is used by the default exception handling middleware, and if you write your own middleware you should invoke it as well.
Reset the endpoint
If the Endpoint Middleware had executed when the exception occurred, we need to reset the endpoint and route values to ensure things are recalculated before we reinvoke the middleware pipeline. The built-in exception handling middleware does it by invoking a helper method:
Of course, if we implement our own middleware we need to do this as well.
Abort if the response had already started
If the response had already started sending when the exception occurred, you can’t intercept it and modify the response, and need to abort the handler. The best thing you can do is simply log the situation, and rethrow the exception, like the built-in middleware does:
Another option is to simply do nothing, like the Status Code Pages middleware does.
Demystifying stack traces
If you need to log or display the exception stack trace, make sure to clean it up first for human readership using the Demystify
method in the Ben.Demystifier NuGet package. It’s just a single method call that will get rid of a lot of junk. It is used like so:
ProblemDetails
For the fans of standards, beside the mandatory 4XX and 5XX status codes, textual responses in case of exceptions can be specified as conforming to the RFC 7808: Problem Details for HTTP APIs. It even has its own content type: application/problem+json
. The type described in the RFC is in fact a part of the ASP.NET, and is called ProblemDetails, and is used by default for all the controllers annotated with the [ApiController]
attribute. Other than the status code Status
, the following textual fields can be filled:
Title
- A short, localizable, human-readable summary of the problem type, that otherwise doesn’t change.Type
- A URI reference [RFC 3986] that identifies the problem typeDetail
- A human-readable explanation specific to this occurrence of the problemInstance
- A URI reference that identifies the specific occurrence of the problem. This would be some kind of a correlation/trace ID.
For Web APIs, only the Title
field (providing the error message) and Detail
field are of use. We can return it like so:
Verify the invoker
Usually you don’t want to share many exception details with the invoker, or do some other actions, unless it’s a development environment. For production, a trimmed-down version of the exception message only containing basic information and a status code would suffice, whereas for the development environment you are likely to include stuff such as a stack trace, the exact filename and the line of code that caused the exception, and have real-time notifications activated at all times. This verification can be done is several ways:
- By invoking the
IsDevelopment
extension method. For this you need to inject theIWebHostEnvironment
service. - Verify the request (by URI, identity, or whatever) manually. This is best done by writing an extension method for the
HttpRequest
, which can then be invoked, e.g.IsInvokerTrusted(context.Request)
.
Capture the trace/correlation ID
You want to trace the exception along with the execution context of the request that caused it. To do that, make sure you you log/return the corresponding trace/exception ID. This could be your program-generated GUID, the thread-specific Activity ID or the trace ID:
Handle Kestrel’s BadHttpRequestException
If we use Kestrel as a reverse proxy or edge server, we must convert the BadHttpRequestException
which Kestrel throws for low-server-level issues with the request, e.g. HTTP method not being allowed at server level, too large headers or too large payload body (which is configured by default as a policy at the server level for security reasons, to prevent e.g. too large files from being uploaded or large requests inducing a denial of service attack). These types of error should be converted from 500 to 413 status codes and treated as such, because they are not unhandled exceptions in our application. For that, we need to use reflection to get the underlying status code:
Organize your handler
If want to cover all the edge cases, implement logging, notifications, integrations with external services (e.g. localized strings, caching, exception-triggered background jobs etc.), and want to make the code clean and testable, handle potentially hundreds of specific domain errors (with different policies attached), exception handlers can grow very large, up to few thousands lines of code. This makes it inconvenient to utilize the approaches that require us to provide a lambda, or a single function. In fact, writing such exception handlers in a single file alone would go against software engineering principles. There should thus be separate methods/classes for each specific functionality implemented, organized in different files.
If the handler is to be reused among many projects, which is often the case, it is best to publish it in a shared NuGet package.
Only use async methods
Using synchronous methods for the request I/O will throw an exception. The code will compile all right, but it will throw a runtime error. Suppose you want to read the body of the PUT/POST requests. This will not work:
Instead, you must do:
CORS
We must set CORS inside the handler, otherwise our handler will return CORS errors to the client when middleware exceptions are thrown. Any origin would be fine in most cases:
This can lead to crazy errors where some of your endpoints work just fine, and some don’t, throwing CORS errors. According to MS this is “by design” in order to prevent leaking of exception details across origins.
Order
Exception handler must always be registered as the very first middleware, prior to authentication, HTTPS redirection, CORS or whatever, so that it catches any exceptions that occur in later calls. Unfortunately there is no way to check this either statically or dynamically, and you’ll have to check this manually from time to time as your middleware pipeline evolves.
Sanitize your secrets
Sometimes your endpoints can revel user secrets as a part of the URI or the payload, for example login credentials for the login API, or bearer token or API keys that are a part of the request headers, or user-sourced secrets such as credit card numbers, tokens for integration to other services and similar. This point could also cover the GDPR-protected data, which you don’t want to randomly leak into logs and notification platforms. You must sanitize such data within the exception handler before logging them, by e.g. replacing them with asterisks. This sanitization logic can either be centralized in the handler, or exposed as a service provided by the app itself, that can be matched against an endpoint pattern.
In case of bearer token/API keys the user authentication context should be decoded before sanitization, and provided as a part of the structured log, so that it can be easily looked up.
try-catch everything
You don’t want your centralized, global exception handling code to throw exceptions itself, so you must put everything even remotely suspicious of a behavior that could throw runtime exceptions into try-catch-finally blocks. That means everything dealing with JSON, string processing, potentially nullable properties etc. Finally blocks should be where you provide the default state for your variables, in case something breaks. Personally I just prefer assuming nothing will break, and use catch/finally blocks to alert the maintainer of the exception handler-related exception which requires urgent attention, and return immediately.
Unwrap inner exception
In case of nested exceptions, you don’t care about outer layers. You need to manually unwrap the exception like so:
Log debug information
On a development environment you are likely to do non-release builds, which can provide a deeper insight into the exception, such as which exactly line of code caused it in which source code file. This is in most cases far more valuable information than e.g. full stack trace, which is usually useless without a complete execution history and a program state dump. Just seeing which line of code broke, for which API inputs, is usually enough to diagnose, and in many cases even fix the error immediately.
We can get the debug info like so:
Help link mapping
Exception.HelpLink
property can be used to set a link to the help file associated with this exception. Its HTTP equivalent is the Location
header. If the exception thrower sets the HelpLink
property, the exception handler should map it to the Location
header that the UI client can use to show/open a help link when displaying error message details.
Reason phrase
In the HTTP response that is sent to a client, the three-digit status code is accompanied by a reason phrase (also known as status text) that summarizes the meaning of the code. Along with the HTTP version of the response, these items are placed in the first line of the response, which is therefore known as the status line. HTTP errors also have standardized response strings associated to their numeric codes which are generated automatically. In case of unhandled exception the status code 500 is used, and we would prefer that the exception type name becomes the reason phrase in lieu of the default message. This reason phrase is then viewable in the Debug console together with the status code.
Note that HTTP/2 doesn’t have reason phrases.