UseExceptionHandler and IExceptionHandler

Map exceptions to status codes with IExceptionHandler, keep one log entry per failure and know what .NET 10 changed about handled exceptions.

Syntaxbuilder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler<DomainExceptionHandler>(); app.UseExceptionHandler(); // ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)

app.UseExceptionHandler() adds middleware that wraps everything registered after it in a try/catch, so register it first. When an exception escapes, it works through these cases in order:

  1. Response already started (headers flushed, e.g. a streamed export): it cannot change anything. It logs a warning, rethrows, the client sees a truncated 200, and Kestrel logs the exception again.
  2. Client went away (RequestAborted cancelled and the exception is an OperationCanceledException or IOException): it logs at Debug and records status 499. No error entry.
  3. Otherwise it clears the response (status, headers, buffered body), sets Cache-Control: no-store, no-cache, sets status 500 or whatever StatusCodeSelector returns, exposes the exception through IExceptionHandlerFeature and asks each registered IExceptionHandler in registration order. The first to return true wins.
  4. If none handles it, the configured ExceptionHandler delegate or ExceptionHandlingPath runs, otherwise IProblemDetailsService writes a generic 500 problem.

UseExceptionHandler() without arguments needs AddProblemDetails(), a path or a delegate. Registering only an IExceptionHandler is not enough: the app builds, then fails at start-up with InvalidOperationException: An error occurred when configuring the exception handler middleware.

IExceptionHandler

Available since .NET 8. ValueTask<bool> TryHandleAsync(HttpContext, Exception, CancellationToken) maps one family of exceptions to a response and returns true, or returns false to pass to the next. AddExceptionHandler<T>() registers it as a singleton, so resolve scoped services from httpContext.RequestServices. Set Response.StatusCode yourself: the problem writer copies the status into the body only, so a handler that sets ProblemDetails.Status = 404 and forgets the response sends a 500 whose body says 404.

Logging exactly once

Before .NET 10 the middleware logged every exception at Error, including ones a handler had deliberately turned into a 404 or 409, so each expected failure appeared twice and error alerts fired on business outcomes. In .NET 10, when an IExceptionHandler returns true, the middleware no longer emits its log and diagnostics for that exception; exceptions that fall through to the problem-details fallback are still logged at Error. ExceptionHandlerOptions.SuppressDiagnosticsCallback decides per exception, and its context exposes ExceptionHandledBy: ExceptionHandlerService, ProblemDetailsService, ExceptionHandlerDelegate, ExceptionHandlingPath or Unhandled. The consequence: your handler now owns logging for what it handles. Log expected domain failures at Warning or Information with structured fields, and leave real bugs to the middleware's single Error entry.

Choosing a mechanism

OptionUse when
IExceptionHandlerSeveral exception families, custom bodies, needs DI or logging
StatusCodeSelectorOnly the status differs (timeout to 504); the default problem body is fine
ExceptionHandler delegateOne small inline handler in a tiny service
ExceptionHandlingPathRe-executing an MVC or Razor error page; rarely right for APIs

Production failure modes

  • A catch-all handler that returns 400 for every exception: bugs look like client errors and never reach 5xx alerts.
  • A handler that throws: the middleware logs that, rethrows the original exception, the server logs it again and the client gets an empty 500.
  • Exceptions for expected outcomes on hot paths. A stack capture per out-of-stock attempt adds up during a sale; return results instead.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turn OrderNotFoundException into a 404 problem and InsufficientStockException into a 409 with the available quantity, from one handler class.
  • Stop expected 404s and 409s from appearing as Error entries in the log dashboard after moving a service to .NET 10.
  • Return 504 Gateway Timeout instead of 500 when the shipping carrier's API times out, without writing a handler class.

More examples

Status codes without a handler class

When the only thing that varies is the status code, <code>StatusCodeSelector</code> is less code than a handler class: the generic problem body is kept and its <code>type</code> and <code>title</code> follow the selected status. An <code>HttpClient</code> timeout does not throw a bare <code>TimeoutException</code>: it throws <code>TaskCanceledException</code> with the <code>TimeoutException</code> as <code>InnerException</code>, so match that shape or a carrier timeout falls to the 500 arm. The carrier is a typed client registered with <code>AddHttpClient&lt;T&gt;</code>; a bare <code>HttpClient</code> parameter is not in DI and minimal APIs would try to bind it from the body. These exceptions are not handled by an <code>IExceptionHandler</code>, so the middleware still logs them at Error, which is right for a failing upstream carrier.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddHttpClient<CarrierClient>(client =>
{
    client.BaseAddress = new Uri("https://carrier.example.com");
    client.Timeout = TimeSpan.FromSeconds(5);
});

var app = builder.Build();

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    // The body still comes from IProblemDetailsService: title "Gateway Timeout", type rfc9110 15.6.5.
    StatusCodeSelector = exception => exception switch
    {
        // HttpClient reports its own timeout as TaskCanceledException wrapping a TimeoutException.
        TaskCanceledException { InnerException: TimeoutException } => StatusCodes.Status504GatewayTimeout,
        TimeoutException => StatusCodes.Status504GatewayTimeout,
        HttpRequestException { StatusCode: >= System.Net.HttpStatusCode.InternalServerError } => StatusCodes.Status502BadGateway,
        _ => StatusCodes.Status500InternalServerError
    }
});

app.MapGet("/shipping/quote", async (CarrierClient carrier, CancellationToken ct) =>
    Results.Ok(await carrier.GetQuoteAsync("94107", ct)));

app.Run();

public sealed class CarrierClient(HttpClient http)
{
    public async Task<string> GetQuoteAsync(string zip, CancellationToken cancellationToken)
    {
        using var response = await http.GetAsync($"/quotes?zip={zip}", cancellationToken);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}

Choosing which handled exceptions are still logged

<code>SuppressDiagnosticsCallback</code> returns <code>true</code> to stay silent. Mapping an exception to a clean response does not make it unimportant: a payment provider outage is handled for the client but must still count as an error in logs and metrics. Keep business rejections suppressed and infrastructure failures visible, and decide this explicitly rather than inheriting whichever default your version has.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<PaymentGatewayExceptionHandler>();

var app = builder.Build();

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    // Default in .NET 10: suppress when an IExceptionHandler handled it.
    // Here a gateway failure stays visible to error dashboards even though it is mapped to 502.
    SuppressDiagnosticsCallback = context =>
        context.ExceptionHandledBy == ExceptionHandledType.ExceptionHandlerService
        && context.Exception is not PaymentGatewayException
});

app.MapPost("/payments", () =>
{
    throw new PaymentGatewayException("Gateway returned 503 for charge ch_1042");
});

app.Run();

sealed class PaymentGatewayException(string message) : Exception(message);

sealed class PaymentGatewayExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not PaymentGatewayException)
        {
            return false;
        }
        httpContext.Response.StatusCode = StatusCodes.Status502BadGateway;
        await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = { Title = "Payment provider unavailable", Type = "https://errors.example.com/payment-provider" }
        });
        return true;
    }
}

Scoped services from a singleton handler

Injecting a scoped service such as a <code>DbContext</code>-backed audit trail into the constructor would capture one instance for the life of the app, and scope validation in Development rejects it at start-up. <code>httpContext.RequestServices</code> is the request's scope, so the handler gets the same instances the endpoint used. Do not save through the request's <code>DbContext</code> here: after a failed <code>SaveChanges</code> it still tracks the stale entity, the next save repeats the same UPDATE and throws inside the handler, which turns the 409 into an empty 500. Use a fresh context from <code>IDbContextFactory</code>, or record the conflict in the application layer. The detail tells the user what to do, not what failed inside.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public sealed class OrderConcurrencyException(int orderId) : Exception($"Order {orderId} was changed by another request.")
{
    public int OrderId { get; } = orderId;
}

public interface IAuditTrail
{
    Task RecordConflictAsync(int orderId, string user, CancellationToken cancellationToken);
}

// Registered with AddExceptionHandler<T>(), which makes it a singleton.
public sealed class ConcurrencyExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not OrderConcurrencyException conflict)
        {
            return false;
        }

        // IAuditTrail is scoped (it reads the current tenant), so take it from the request scope.
        // It writes through a fresh context from IDbContextFactory: the request's DbContext still
        // tracks the stale Order, and saving it again would throw inside this handler.
        var audit = httpContext.RequestServices.GetRequiredService<IAuditTrail>();
        await audit.RecordConflictAsync(conflict.OrderId, httpContext.User.Identity?.Name ?? "anonymous", cancellationToken);

        httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
        await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Type = "https://errors.example.com/order-modified",
                Title = "Order was modified",
                Detail = "Reload the order and apply your change again."
            }
        });
        return true;
    }
}

Discussion

  • Be the first to comment on this lesson.