Error Handling and Problem Details in ASP.NET Core

Handle ASP.NET Core error handling with RFC 9457 Problem Details: AddProblemDetails, IExceptionHandler, validation, trace ids, and no stack trace leaks.

Executive Summary: Error handling in ASP.NET Core should produce one error shape for every failure: a Problem Details document, the machine-readable standard from RFC 9457, with no stack traces and a trace id that connects the response to its log line. The framework does the work when you let it: AddProblemDetails standardizes responses, UseExceptionHandler plus IExceptionHandler classes turn unhandled exceptions into the right status codes, and [ApiController] validation failures already follow the format.

The production incident that sells this page usually arrives as a forwarded screenshot: a mobile app showing a full stack trace to an end user, including file paths, line numbers, and the SQL text of the query that failed. Two problems in one response: the client developer cannot parse it, and an attacker just received a map of the internals. Somewhere behind it sits a controller with catch (Exception e) { return StatusCode(500, e.ToString()); }, written helpfully, once, and then copy-pasted.

ASP.NET Core error handling is the machinery that turns failures into responses: Problem Details as the response format (RFC 9457, which obsoleted RFC 7807), the exception handler middleware as the safety net, IExceptionHandler classes for mapping your domain failures, and trace ids for the bridge to logs. The error handling fundamentals and the Web API error guide are the official references.

The examples assume .NET SDK 10.0 with ASP.NET Core 10 and continue the TaskApi, whose pipeline shape comes from the middleware guide. The exception handler sits first, so it sees everything downstream.

Reading too many bad error responses taught me this: the goal is not to avoid errors, it is to make every error a contract you honor. One shape, one log line, one trace id, and the incident is half-handled before anyone opens a terminal.

Problem Details: one shape for every failure

Problem Details is the standard error body defined by RFC 9457 (which obsoleted RFC 7807, the version most older articles cite). Every error response carries the same fields with the content type application/problem+json:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://taskapi.example/errors/task-not-found",
  "title": "Task not found",
  "status": 404,
  "detail": "Task 2f9a... does not exist or was deleted.",
  "instance": "/api/tasks/2f9a4b1e-...",
  "traceId": "00-abc123def456-0123456789-01"
}

The reserved fields have fixed meanings: type is a URI identifying the error class (clients can switch on it), title is a short human summary, status mirrors the HTTP code, detail explains this occurrence, instance identifies the request, and anything else goes in extensions like traceId. A client that parses one Problem Details response parses them all, which is why the format beats every bespoke “ok: false, message” dialect.

ASP.NET Core error handling with the built-in machinery

Two lines give the whole application consistent error responses:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();      // standardize error bodies

var app = builder.Build();

app.UseExceptionHandler();                 // last-chance safety net
app.MapControllers();

app.Run();

With this in place, an unhandled exception anywhere in the pipeline returns a 500 Problem Details document with no stack trace. Any empty-body status response (a bare return NotFound() from middleware) is filled with the standard shape. AddProblemDetails is also the service the [ApiController] validation pipeline uses, so validation 400s and exception 500s share one format automatically. The Web API error handling guide documents the default behaviors and customization points.

IExceptionHandler: one class per failure mode

Domain code should throw meaningful exceptions, and the boundary should translate them. Since .NET 8, that translation is one interface per failure mode:

public sealed class TaskNotFoundException : Exception
{
    public TaskNotFoundException(Guid id)
        : base($"Task {id} does not exist or was deleted.")
    {
    }
}

public sealed class TaskNotFoundExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not TaskNotFoundException)
        {
            return false;   // not mine; let the next handler run
        }

        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Type = "https://taskapi.example/errors/task-not-found",
            Title = "Task not found",
            Status = StatusCodes.Status404NotFound,
            Detail = exception.Message,
            Instance = httpContext.Request.Path
        }, cancellationToken);

        return true;    // handled; stop the chain
    }
}
builder.Services.AddExceptionHandler<TaskNotFoundExceptionHandler>();
builder.Services.AddProblemDetails();

Handlers run in registration order, each returning false for exceptions it does not own. So the chain ends with the catch-all default: a 500 that leaks nothing. The interface also receives a CancellationToken, consistent with the async conventions in the language track. Honor it, and a client disconnect cancels the response work.

Validation errors already follow the format

Validation is the most common failure an API serves. It is covered as a boundary mechanism in the Web API guide: with [ApiController], an invalid model short-circuits into a 400 whose body is a ValidationProblemDetails, the Problem Details shape with one extra field, errors, holding the field names and their messages. Nothing needs adding for that to happen once AddProblemDetails is registered. Your work is keeping validation attributes honest so the 400 tells the truth.

Trace ids: the bridge between the response and the log

A client reporting “it said 500” gives you nothing. The same 500 with a trace id gives you the exact log line. ASP.NET Core generates a request trace identifier (HttpContext.TraceIdentifier, surfaced to W3C tracing systems). The standard move is to write it into the response’s Problem Details and the structured log with the same key:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] =
            context.HttpContext.TraceIdentifier;
    };
});

The client can quote the id in a support ticket; the backend greps one field. Pairing this with request-scoped log enrichment, the pattern the Serilog guide builds, means the trace id appears on every log line the request produced, including the error.

What never leaves the server

WRONG: developer details in production responses.

app.UseDeveloperExceptionPage();   // shows stack traces: DEVELOPMENT ONLY
app.Run();

RIGHT: environment-aware configuration.

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler();
}

The rule is absolute because the leak is rarely the stack trace alone: exception messages can contain SQL text, connection strings with hosts, file system paths, and library versions. The developer exception page belongs to development, the default handler belongs to production, and exception messages are written for logs, not responses: translate at the boundary, log the full exception once, return the summary plus trace id.

How real systems do this

  • One exception handler chain, few handlers. Teams map a small set of domain exceptions (not-found, conflict, unauthorized) and let everything else fall through to the sanitized 500. The pipeline stays small and each handler is testable on its own.
  • Alerting on the 500 rate, not on individual errors. Business failures (404s, 409s) are normal traffic. A rising 500 rate is a system problem, and it is the metric to page on.
  • Errors asserted in integration tests. Tests trigger the real failure paths and assert the Problem Details shape, status, and trace id. As a result, a regression that leaks a stack trace fails CI. The WebApplicationFactory guide covers driving the full pipeline in tests.

A leak I remember catching in review was subtle: the exception handler logged the full exception with ToString(), fine. However, a fallback controller path returned exception.Message in the response. The underlying database library put raw SQL into its timeout exception messages. A customer’s API client, helpfully echoing error bodies into their own logs, had a week of our SQL queries in their log files. The lesson: exception messages are a developer channel, not an API response, and the boundary between them is exactly where error handling code lives.

Decision framework: catch, map, or let go

  1. Can the caller act on the failure differently? Map it to a status and a Problem Details type. A 404 or 409 is a contract, not an accident.
  2. Is it purely a server-side problem? Let the global handler return the sanitized 500. Catching it locally adds nothing but noise.
  3. Is this a business expectation, not an error? Do not throw: return the correct result (an empty list, a failed status). This is because exceptions cost more than results and confuse readers.
  4. Does the caller need to retry? Choose the status accordingly (409, 429, 503 with Retry-After) and put the retry guidance into the response, not the docs.
  5. Where is it logged? Once, at the boundary, with the full exception and trace id. A catch-log-rethrow chain produces the same incident three times in the log.

When NOT to write a handler

  • When a correct status return exists. “Task not found” can be a thrown exception or a simple NotFound() from the action. If the endpoint already knows the failure, returning the status directly is clearer and cheaper than a throw.
  • When it is control flow. Using exceptions to steer normal logic (“throw if exists”) makes happy paths slow and stack traces misleading. Return values do that job.
  • When the exception is already the contract. Cancellation (OperationCanceledException) and infrastructure timeouts have framework meaning. Wrap them only after you know what the framework does by default.

Common mistakes

  • Returning stack traces to clients. A raw exception in the response body is a security finding and a support problem in one. The global handler’s sanitized 500 is the correct default.
  • Catch, log, and rethrow. The same incident then appears multiple times in logs, once per layer, and the stack trace resets. Log at the boundary, once.
  • 200 responses with error bodies. “HTTP 200, { success: false }” breaks every monitoring and retry mechanism; use real status codes and Problem Details.
  • Per-controller try/catch blocks. Duplicated handling drifts and the one controller without it leaks; the pipeline owns failure translation.
  • Developer exception page in production. It exists for development and shows everything: stacks, source lines, headers, and environment values. Gate it on the environment.
  • Exceptions as control flow. Expected business outcomes (already-exists, not-found) as throws turn normal traffic into 500-shaped noise if a handler is missing, and cost far more than a returned result.

Key takeaways

  • Every failure response is one shape: Problem Details per RFC 9457, content type application/problem+json, with type, title, status, detail, and instance.
  • AddProblemDetails plus UseExceptionHandler standardize 400s, 404s, and 500s in two registrations; validation errors follow the shape through the same service.
  • IExceptionHandler classes map domain exceptions to statuses, run in registration order, and return false for exceptions they do not own.
  • The response carries the trace id, the logs carry the same id, and one support ticket becomes one log line.
  • Stack traces, SQL, paths, and connection strings never leave the server; the developer exception page is development-only.
  • Catch what you handle, return results for expectations, and let the global handler handle the rest. Log each incident exactly once.

FAQ

What is Problem Details in ASP.NET Core?

Problem Details is the standard error response format defined by RFC 9457 (which obsoleted RFC 7807). A failed request returns an application/problem+json body with fixed fields: type for the error class, title, status, detail, and instance, plus extensions like traceId. ASP.NET Core has first-class support through AddProblemDetails, so validation failures, empty status responses, and unhandled exceptions all share the shape.

What does AddProblemDetails do in ASP.NET Core?

It registers the service that writes Problem Details bodies for error responses: unhandled exceptions handled by UseExceptionHandler, empty status responses, and the automatic 400 validation failures from [ApiController]. It also offers a CustomizeProblemDetails hook to add fields like the trace id to every error response consistently.

What is IExceptionHandler in .NET?

An interface for centralized exception-to-response translation, added in .NET 8. A handler receives the exception and the HttpContext, returns true if it wrote the response, and false to let the next registered handler try. Register handlers with AddExceptionHandler in order, ending with the default catch-all that produces a sanitized 500.

Should I expose exception messages to API clients?

No. Exception messages are a developer channel and can contain SQL, paths, or configuration. The response should carry a purpose-written summary in the detail field, a stable type URI, and the trace id. The full exception belongs to the server log where the trace id can find it.

How do I add a trace id to error responses?

Use CustomizeProblemDetails to copy HttpContext.TraceIdentifier into the ProblemDetails extensions under a key like traceId, and enrich your logs with the same value. The client can quote the id in a ticket. The backend can find the exact log lines for that request, including the full exception behind a sanitized 500.

One shape for every failure

Errors are a contract, and Problem Details is the standard that makes the contract uniform: a client that parses one failure parses them all, an operator that greps one trace id finds the whole story, and nothing internal rides along by accident. Configure the two lines, write a handler per real failure mode, keep the developer page in development, and assert the shape in tests. Next, the data access track begins with getting started with EF Core code-first, and every guide in the series lives on the .NET series index. The rule of thumb: translate failures once at the boundary, log them once with the trace id, and give every client the same shape.

Last updated on 1 November 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *