Dotnetguides · Engineering guides and tools

ASP.NET Core

Preserve exception detail behind a handled 503 in ASP.NET Core 10

An exception handler can return an error response while its middleware stops logging the exception. Choose which failures should retain diagnostic detail, and test the signals your alerts actually consume.

· AI disclosure

Imagine an order API whose exception handler turns an unavailable dependency into a 503 response. After a .NET upgrade, its exception-log chart falls. Customers can still receive the same 503. This is a hypothetical application, not a measured customer incident.

  • Keep unexpected operational failures visible to the people responding to them.
  • Avoid duplicate error noise for named, expected validation outcomes.
  • Prove what the deployed alert observes before using a smaller chart to accept the upgrade.

Start with services whose registered IExceptionHandler returns true and whose alert uses middleware exception logs or the request-duration exception tag. A service already monitored adequately through HTTP status or the collected exception counter may need no policy change. For a VP of Engineering, this identifies where upgrade verification belongs. For the engineer implementing it, two mechanisms matter: the handler’s diagnostics policy and the distinction between request-duration tags and the separate exception counter.

Handling the response does not establish recovery

ASP.NET Core 10 changes the default when a registered IExceptionHandler returns true: the exception middleware suppresses its diagnostic branch. That return value reports that the exception was handled; it does not verify that the handler wrote a response or that the business operation succeeded. The example handler below explicitly writes its response. Microsoft documents the change in the ASP.NET Core 10 compatibility note.

In the tested 10.0.1 implementation, suppression skips the middleware error log and its exception error.type enrichment on http.server.request.duration. The HTTP response status is still recorded when request metrics are collected. The middleware and hosting metric implementation identify these separate paths.

A dashboard based on that error log or duration error.type can therefore show less exception detail without fewer failed HTTP responses. That is an inference about those query choices, not a claim that every telemetry product or alert loses failures.

The separate aspnetcore.diagnostics.exceptions counter is an important counterexample: when enabled, 10.0.1 records it outside the suppression branch, including error.type and aspnetcore.diagnostics.exception.result=handled. Subscribe to the Microsoft.AspNetCore.Diagnostics meter to collect it. Its tags do not include HTTP status or route; a useful query must distinguish the relevant exception class, or use a request signal for HTTP outcomes. The pinned counter source explains why suppressing diagnostics does not make every exception metric disappear.

Choose the signal policy before suppressing it

First name the expected condition. In this isolated example, ValidationRejectedException produces 400; DependencyUnavailableException produces 503. These are deliberately thrown test exceptions. No database or dependency failure was reproduced.

Then choose the policy. The default leaves SuppressDiagnosticsCallback unset. Returning false retains middleware diagnostics for both conditions. A selective callback returns true only for the named validation exception, leaving the unexpected dependency failure visible. The 10.0.1 options define true as suppression, not successful recovery.

C#
var options = new ExceptionHandlerOptions();
if (mode == "retain-all")
    options.SuppressDiagnosticsCallback = _ => false;
if (mode == "selective")
    options.SuppressDiagnosticsCallback = context =>
        context.Exception is ValidationRejectedException;
app.UseExceptionHandler(options);

Here mode is default, retain-all or selective. UseExceptionHandler must precede the downstream middleware and endpoint execution whose exceptions it should catch. The complete inline harness below supplies both exception types, the response handler and the observers; this policy is the central part of the executed program.

Use retain-all if the existing alert contract depends on middleware exception detail and that contract has not yet been replaced. Use selective only after agreeing which expected outcomes have adequate signals elsewhere. Exception classification is part of that agreement; a catch-all handler returning true is too broad to prove it.

Send the same failures through all three policies

The harness ran six real HTTP requests against Kestrel on loopback: one 400 and one 503 under each policy. An ILogger provider counted middleware event 1; a MeterListener collected request-duration and diagnostics-counter tags. The following is a projection of the retained raw output, not a production benchmark:

Code example
policy      input       HTTP  middleware error logs  duration error.type  exception counter error.type
default     validation  400   0                      absent               ValidationRejectedException
default     dependency  503   0                      absent               DependencyUnavailableException
retain-all  validation  400   1                      present              ValidationRejectedException
retain-all  dependency  503   1                      present              DependencyUnavailableException
selective   validation  400   0                      absent               ValidationRejectedException
selective   dependency  503   1                      present              DependencyUnavailableException

The selective policy retains the 503’s middleware error log and duration error.type while suppressing those two signals for the 400. Both responses keep their status. The diagnostics exception-counter measurement retains its exception type in all six cases.

That last column matters when interpreting an upgrade. If an existing alert already uses this counter or HTTP 5xx outcomes correctly, the default suppression may leave it useful. Verify the actual collector, query and notification path; the in-process listener cannot establish that a remote alert is configured or delivered.

Run the complete inline example

Use SDK 10.0.101 and ASP.NET Core/.NET runtime 10.0.1 to reproduce this exact boundary. Create these four files in an isolated directory. global.json selects the SDK; runtime.json binds both shared frameworks at 10.0.1 with runtime roll-forward disabled for this test. The source prints and checks the loaded framework versions. The project uses the installed shared framework and no added package. The empty builder keeps the experiment independent of application configuration and file watchers; Kestrel and the exception middleware are real.

global.json
{"sdk":{"version":"10.0.101","rollForward":"disable"}}
Harness.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
runtime.json — exact test binding
{
  "runtimeOptions": {
    "tfm": "net10.0",
    "rollForward": "Disable",
    "frameworks": [
      {"name": "Microsoft.NETCore.App", "version": "10.0.1"},
      {"name": "Microsoft.AspNetCore.App", "version": "10.0.1"}
    ]
  }
}
Program.cs — complete executed source
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Runtime.InteropServices;
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;

var aspnetAssembly = typeof(WebApplication).Assembly;
var aspnetPatch = Path.GetFileName(Path.GetDirectoryName(aspnetAssembly.Location));
var aspnetProduct = aspnetAssembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion;
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription}; aspnetSharedFramework={aspnetPatch}; aspnetProduct={aspnetProduct}");
if (RuntimeInformation.FrameworkDescription != ".NET 10.0.1" || aspnetPatch != "10.0.1" || !aspnetProduct.StartsWith("10.0.1+"))
    throw new InvalidOperationException("This reproduction requires both shared frameworks at 10.0.1.");
foreach (var mode in new[] { "default", "retain-all", "selective" })
{
    Console.WriteLine($"preparing={mode}");
    var samples = new ConcurrentQueue<Sample>();
    using var listener = new MeterListener();
    listener.InstrumentPublished = (instrument, owner) =>
    {
        if (instrument.Name is "http.server.request.duration" or "aspnetcore.diagnostics.exceptions")
            owner.EnableMeasurementEvents(instrument);
    };
    void Record(Instrument instrument, ReadOnlySpan<KeyValuePair<string, object?>> tags)
    {
        var values = new Dictionary<string, string>();
        foreach (var tag in tags) values[tag.Key] = tag.Value?.ToString() ?? "<null>";
        samples.Enqueue(new Sample(instrument.Name, values));
    }
    listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) => Record(instrument, tags));
    listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) => Record(instrument, tags));
    Console.WriteLine("starting-listener");
    listener.Start();
    Console.WriteLine("creating-builder");
    var logs = new CaptureLogs();
    var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions { EnvironmentName = "Production" });
    builder.WebHost.UseKestrel();
    builder.Services.AddRouting();
    Console.WriteLine("builder-created");
    builder.Logging.ClearProviders();
    builder.Logging.SetMinimumLevel(LogLevel.Error);
    builder.Logging.AddProvider(logs);
    builder.WebHost.UseUrls("http://127.0.0.1:0");
    builder.Services.AddExceptionHandler<DemoHandler>();
    builder.Services.AddProblemDetails();
    await using var app = builder.Build();
    Console.WriteLine("app-built");
    var options = new ExceptionHandlerOptions();
    if (mode == "retain-all") options.SuppressDiagnosticsCallback = _ => false;
    if (mode == "selective") options.SuppressDiagnosticsCallback = context => context.Exception is ValidationRejectedException;
    app.UseExceptionHandler(options);
    app.MapGet("/failure/{kind}", (string kind) => Fail(kind));
    Console.WriteLine($"starting={mode}");
    using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    await app.StartAsync(startup.Token);
    var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
    Console.WriteLine($"listening={address}");
    using var client = new HttpClient(new HttpClientHandler { UseProxy = false }) { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(5) };
    foreach (var kind in new[] { "validation", "dependency" })
    {
        while (samples.TryDequeue(out _)) { }
        logs.Entries.Clear();
        using var response = await client.GetAsync($"/failure/{kind}");
        await response.Content.ReadAsStringAsync();
        // A response body may be delivered before request-finalization metrics.
        for (var attempt = 0; attempt < 100 && !samples.Any(x => x.Instrument == "http.server.request.duration"); attempt++)
            await Task.Delay(10);
        var observed = samples.ToArray();
        var duration = observed.Single(x => x.Instrument == "http.server.request.duration");
        var exceptions = observed.Single(x => x.Instrument == "aspnetcore.diagnostics.exceptions");
        var middlewareLogs = logs.Entries.Count(x => x.Category == "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware" && x.EventId == 1);
        var expectedRetain = mode == "retain-all" || (mode == "selective" && kind == "dependency");
        if ((int)response.StatusCode != (kind == "validation" ? 400 : 503) ||
            middlewareLogs != (expectedRetain ? 1 : 0) ||
            duration.Tags.ContainsKey("error.type") != expectedRetain ||
            !exceptions.Tags.ContainsKey("error.type") || exceptions.Tags.GetValueOrDefault("aspnetcore.diagnostics.exception.result") != "handled")
            throw new InvalidOperationException($"Unexpected behavior: {mode}/{kind}");
        Console.WriteLine(JsonSerializer.Serialize(new { mode, kind, status = (int)response.StatusCode, middlewareErrorLogs = middlewareLogs, duration = duration.Tags, exceptions = exceptions.Tags }));
    }
    await app.StopAsync();
}
Console.WriteLine("PASS: six real HTTP requests; status, middleware log, duration tag and diagnostics counter assertions passed.");

static IResult Fail(string kind)
{
    if (kind == "validation") throw new ValidationRejectedException();
    throw new DependencyUnavailableException();
}
sealed class ValidationRejectedException : Exception { }
sealed class DependencyUnavailableException : Exception { }
sealed record Sample(string Instrument, Dictionary<string, string> Tags);
sealed record LogEntry(string Category, int EventId);
sealed class CaptureLogs : ILoggerProvider
{
    public ConcurrentBag<LogEntry> Entries { get; } = [];
    public ILogger CreateLogger(string categoryName) => new CaptureLogger(categoryName, Entries);
    public void Dispose() { }
    private sealed class CaptureLogger(string category, ConcurrentBag<LogEntry> entries) : ILogger
    {
        public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
        public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        { if (IsEnabled(logLevel)) entries.Add(new LogEntry(category, eventId.Id)); }
    }
}
sealed class DemoHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken token)
    {
        context.Response.StatusCode = exception is ValidationRejectedException ? 400 : 503;
        await context.Response.WriteAsJsonAsync(new { error = exception.GetType().Name }, token);
        return true;
    }
}
Build and run
dotnet --version
dotnet build Harness.csproj --configuration Release
dotnet exec --runtimeconfig runtime.json bin/Release/net10.0/Harness.dll

The program binds only to 127.0.0.1, disables the HTTP client proxy and sends requests to its own ephemeral port. It asserts status, middleware error-log count, duration error.type presence and handled counter tags. Allow local socket access in the authorized isolated environment; a build alone does not execute these checks.

    Keep the counterexample in the release decision

    Expected validation exceptions already counted by an application may not need a second middleware error log. Retaining every diagnostic unconditionally can increase noise and cost. Keep the default where independent signals already meet the operational need, or select a named-exception policy when those outcomes should be treated differently. Neither choice establishes recovery of the failed order.

    This advice stops at exceptions fully handled by the middleware. In the 10.0.1 source, exceptions after the response starts and unsuccessful error handling take different paths. The callback is not a universal switch for every application log or server failure. Those paths were source-inspected, not exercised by the six-request harness.

    If your handler writes its own error log, suppression does not remove that application log. If your monitoring uses only reliable HTTP status signals, restoring middleware logs may add no decision value. Either observation can overturn a blanket recommendation to retain everything.

    Accept the upgrade against an explicit observation

    • Name the operations and error responses that should still alert; distinguish expected validation from operational failure.
    • Run a representative request through the actual registered handlers and check status, logs and the specific metric tags the alert consumes.
    • Keep the selected policy and its failure test with the release; verify the real exporter/query/notification route separately before accepting monitoring continuity.

    The leader’s useful evidence is a demonstrated failure still reaching the agreed signal, plus an explained suppression policy for expected conditions. A drop in one exception chart alone establishes neither fewer customer failures nor successful recovery.

    Record the following with the service owner before approving monitoring continuity. This is an illustrative decision record to fill from the actual environment; it is not an executed collector or alert test.

    Code example
    Context: target runtime, registered handler and selected suppression policy
    Signal: actual collected instrument/log plus the alert’s classification filter
    Alert condition: the team’s existing threshold, window and response route
    Failure control: representative operational 503 reaches the selected query
    Expected control: named validation 400 has the agreed non-paging behavior
    Evidence: timestamped query results and notification/response-route observation
    Owner and decision: accountable service owner; accept or hold with the evidence

    Drive enough controlled traffic to meet the existing alert condition; one request need not cross its threshold. If the exporter removes error.type, a locally retained tag cannot prove that a query filtering on it still works. Missing query evidence or missing response-route evidence leaves continuity unproved. Keep the release decision open until the actual observation resolves it.

    Verification boundary

    Executed on October 7, 2026: SDK 10.0.101, .NET and ASP.NET Core runtime 10.0.1, six loopback HTTP requests with an in-process logger and metric listener. All checks across the six requests passed. This is an exact runtime experiment, not advice to select that patch for a new deployment. No external dependency, telemetry exporter, production workload or live customer system was tested.

    The test-only runtime configuration uses the documented dotnet exec runtimeconfig option and Disable roll-forward setting. Keep that exact binding confined to reproduction; choose an appropriately supported and patched runtime for deployment.

    The loaded ASP.NET assembly reports product version 10.0.1+fad253f51b461736dfd3cd9c15977bb7493becef. Its .NET build source manifest maps ASP.NET Core to commit 373d4826c58ac2f1c8d49fdaf31bc9eddf9e7437, which pins the code references above. Tag names described here belong to that tested release; check the target runtime and collector when applying the result.

    Sources

    Written with AI and not reviewed by a human. It may contain mistakes.