Skip to main content

How to Write an IExceptionHandler in ASP.NET Core

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

An IExceptionHandler is one class that decides what clients see when an exception escapes an endpoint. It answers the ones it recognises, and leaves everything else alone. Here's a complete one, and the setting I add since .NET 10.

Prerequisites​

Step 1: Write the Class​

Implement IExceptionHandler, which has one method:

src/Dewiride.Inventory.Api/Infrastructure/DatabaseUnavailableExceptionHandler.cs
using System.Globalization;
using Dewiride.Inventory.Data;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Storage;

namespace Dewiride.Inventory.Api.Infrastructure;

/// <summary>
/// Answers 503 Service Unavailable with a Retry-After header when the database can't do the work
/// right now, so a client knows to try again. Any other exception stays a 500.
/// </summary>
public sealed class DatabaseUnavailableExceptionHandler(IProblemDetailsService problemDetails)
: IExceptionHandler
{
private const int RetryAfterSeconds = 5;

public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (!IsTemporary(exception))
{
return false;
}

httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
httpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture);

return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "The database can't do this right now.",
Detail = $"Try again in {RetryAfterSeconds} seconds. Send the same Idempotency-Key, so a change can't be applied twice.",
},
});
}

private static bool IsTemporary(Exception exception) =>
exception is RetryLimitExceededException
|| exception.FindMySqlException() is { Number: 0 or 1205 or 3024 };
}

The handler class in a macOS-style code window, with TryHandleAsync returning false for other errors, and writing a problem details body for temporary ones.

  • return false means "not mine". The next handler gets it, or else the middleware answers 500.
  • IProblemDetailsService comes from dependency injection, and writes the body in the same format as your other errors.
  • Its return value is what TryHandleAsync returns: true if the body was written.

Step 2: Register It​

src/Dewiride.Inventory.Api/Program.cs (excerpt)
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DatabaseUnavailableExceptionHandler>();

And turn on the middleware, early in the pipeline:

src/Dewiride.Inventory.Api/Program.cs (excerpt)
app.UseExceptionHandler(new ExceptionHandlerOptions
{
// In Development, a request the endpoint can't read, such as broken JSON, throws
// BadHttpRequestException. That's the client's mistake, so keep its 400 instead of a 500.
StatusCodeSelector = exception => exception is BadHttpRequestException badRequest
? badRequest.StatusCode
: StatusCodes.Status500InternalServerError,

// Keep logging the exceptions an IExceptionHandler answers, so an outage shows up in the logs.
SuppressDiagnosticsCallback = _ => false,
});

Several handlers run in the order you register them, until one returns true.

Step 3: See What It Handles​

Response (MySQL stopped)
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.4",
"title": "The database can't do this right now.",
"status": 503,
"detail": "Try again in 5 seconds. Send the same Idempotency-Key, so a change can't be applied twice.",
"traceId": "00-0d0b17fbe59f130e78f5555cbee67b94-a6ae5b85e2201e17-00"
}

Step 4: See What It Leaves Alone​

An exception it doesn't recognise goes on to the middleware:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-4dc7c29958cba8aeaf25575cb0ee5ca7-c08cdb82b3f0c7a5-00"
}
API log
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
System.InvalidOperationException: The connection is already in a transaction and cannot participate in another transaction.

A response of 500 with no details, and the log line An unhandled exception has occurred while executing the request.

The client gets a plain 500, with no stack trace, and the details stay in your log.

Common Mistakes​

MistakeWhat happensDo this instead
Returning true for everythingReal bugs look handledName the exceptions
Registering it and forgetting UseExceptionHandlerIt never runsAdd the middleware
Putting the exception's message in the responseIt can leak internal detailsWrite your own words
Throwing inside the handlerThe error handling fails tooKeep it simple

Troubleshooting​

SymptomLikely causeFix
Nothing in the log for a handled errorThat's the .NET 10 defaultThe fix
The developer exception page shows insteadThe middleware is added after it, or not at allAdd UseExceptionHandler early
A body in another formatIt isn't using IProblemDetailsServiceUse it

Conclusion​

Implement TryHandleAsync, return false for what isn't yours, and register it with AddExceptionHandler and UseExceptionHandler. Since .NET 10, set SuppressDiagnosticsCallback if you want those exceptions in your logs.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.