AI WisdomArchitecture & guides β†—
HT
How Things Work

Global Error Handling & Problem Details

Why you should never return raw exceptions to clients β€” and how RFC 9457 Problem Details make your API errors actually useful.

How It Works

Every ASP.NET Core API will throw exceptions in production. The question is not whether you'll handle them β€” it's whether you'll handle them consistently, safely, and in a way that gives clients enough information to respond intelligently without exposing your internals. RFC 9457 Problem Details combined with the IExceptionHandler pipeline in .NET 8+ is the production-grade answer.

1
Exception filters β€” first in the chain

IExceptionFilter and IAsyncExceptionFilter run before the middleware pipeline. They can handle exceptions thrown by controller actions and set the result. Useful for controller-scoped handling but they cannot catch exceptions from middleware or Razor Pages.

2
IExceptionHandler (.NET 8+) β€” the recommended approach

Multiple IExceptionHandler implementations are registered in DI and tried in registration order. Each returns a bool: true means 'I handled it, stop', false means 'try the next one'. This enables clean per-exception-type handling without a giant switch statement.

3
UseExceptionHandler middleware β€” the safety net

Catches any unhandled exception that escapes all IExceptionHandler implementations. Re-executes the request pipeline against a configured error path. The IExceptionHandlerFeature gives access to the original exception for logging β€” but never send it to the client.

4
UseStatusCodePages β€” handles non-exception 4xx responses

Middleware that intercepts responses with status codes in the 400-599 range that have no body yet (e.g., 404 from routing, 401 from auth middleware). Without it, clients receive an empty 404 response with no explanation.

5
IProblemDetailsService writes the RFC 9457 JSON

AddProblemDetails() registers IProblemDetailsService which handles the actual JSON serialization of ProblemDetails objects. You can customize the factory via AddProblemDetails(options => options.CustomizeProblemDetails = ...) to inject tracing context, environment info, or machine-parseable type URIs.

Key Concepts

πŸ“‹RFC 9457 Problem Details

IETF standard for HTTP API error responses. Defines a JSON format with type (URI), title, status, detail, and instance fields. Allows API clients to programmatically identify and handle error types by the type URI.

🎯IExceptionHandler

Interface introduced in .NET 8. Register multiple implementations with different exception-type specializations. Called in registration order β€” return true to short-circuit, false to pass to the next handler.

βš™οΈIProblemDetailsService

Registered by AddProblemDetails(). Handles serialization of ProblemDetails to application/problem+json. Customize with options.CustomizeProblemDetails to inject global properties like environment, version, or correlation IDs.

πŸ›‘οΈUseExceptionHandler

Middleware-level safety net. Catches all unhandled exceptions and re-executes the pipeline at a configured error handling path. Access the original exception via IExceptionHandlerFeature. Always comes last in the middleware chain.

πŸ”—TraceIdentifier

HttpContext.TraceIdentifier is a unique string per request (W3C trace ID format when OpenTelemetry is configured). Always include this in Problem Details responses β€” it is the correlation key that links a user's error report to your logs.

🏷️ProblemDetails type field

A URI that uniquely identifies the error type. Clients can switch on this value to handle specific error types programmatically. Use https://tools.ietf.org/html/rfc9110#section-... for standard HTTP errors, or your own URIs for domain errors.

βœ…ValidationProblemDetails

A ProblemDetails subclass with an additional errors dictionary (property name β†’ string[]). Built into ASP.NET Core β€” automatically returned by controllers with [ApiController] when ModelState is invalid (status 400).

πŸ“„UseStatusCodePages

Intercepts responses with status codes 400-599 that have no body. Without this, a 404 from routing returns an empty response. Combined with UseProblemDetails (Hellang middleware) or AddProblemDetails, converts these to Problem Details automatically.

IExceptionHandler + Problem Details (ASP.NET Core 9 / .NET 9)
tsx
1// Program.cs β€” global error handling pipeline
2var app = builder.Build();
3
4// 1. UseExceptionHandler catches ALL unhandled exceptions
5app.UseExceptionHandler(exceptionHandlerApp =>
6{
7 exceptionHandlerApp.Run(async context =>
8 {
9 var exceptionFeature = context.Features
10 .Get<IExceptionHandlerFeature>();
11
12 // Don't leak stack traces in production
13 var isDev = app.Environment.IsDevelopment();
14
15 await Results.Problem(
16 title: "An unexpected error occurred.",
17 detail: isDev ? exceptionFeature?.Error.Message : null,
18 statusCode: 500,
19 extensions: new Dictionary<string, object?>
20 {
21 ["traceId"] = context.TraceIdentifier,
22 }
23 ).ExecuteAsync(context);
24 });
25});
26
27// 2. IProblemDetailsService + IExceptionHandler (.NET 8+) β€” preferred approach
28builder.Services.AddProblemDetails();
29builder.Services.AddExceptionHandler<DomainExceptionHandler>();
30builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
31// Fallback catch-all MUST be last
32builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
33
34// IExceptionHandler implementations
35public sealed class ValidationExceptionHandler : IExceptionHandler
36{
37 private readonly IProblemDetailsService _pds;
38 public ValidationExceptionHandler(IProblemDetailsService pds) => _pds = pds;
39
40 public async ValueTask<bool> TryHandleAsync(
41 HttpContext httpContext,
42 Exception exception,
43 CancellationToken cancellationToken)
44 {
45 if (exception is not ValidationException ve)
46 return false; // let the next handler try
47
48 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
49
50 return await _pds.TryWriteAsync(new ProblemDetailsContext
51 {
52 HttpContext = httpContext,
53 ProblemDetails = new ValidationProblemDetails(
54 ve.Errors.GroupBy(e => e.PropertyName)
55 .ToDictionary(
56 g => g.Key,
57 g => g.Select(e => e.ErrorMessage).ToArray()))
58 {
59 Type = "https://tools.ietf.org/html/rfc9110#section-15.5.21",
60 Title = "One or more validation errors occurred.",
61 Status = 422,
62 Detail = "See the errors property for details.",
63 Instance = httpContext.Request.Path,
64 Extensions =
65 {
66 ["traceId"] = httpContext.TraceIdentifier,
67 }
68 }
69 });
70 }
71}
72
73public sealed class GlobalExceptionHandler(
74 ILogger<GlobalExceptionHandler> logger,
75 IHostEnvironment env) : IExceptionHandler
76{
77 public async ValueTask<bool> TryHandleAsync(
78 HttpContext httpContext,
79 Exception exception,
80 CancellationToken cancellationToken)
81 {
82 // Log the full exception β€” NEVER return it to the client
83 logger.LogError(exception,
84 "Unhandled exception on {Method} {Path}. TraceId: {TraceId}",
85 httpContext.Request.Method,
86 httpContext.Request.Path,
87 httpContext.TraceIdentifier);
88
89 httpContext.Response.StatusCode = 500;
90
91 await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
92 {
93 Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
94 Title = "An unhandled error occurred.",
95 Status = 500,
96 // In production, NEVER include exception.Message β€” it may contain
97 // SQL queries, connection strings, or internal paths
98 Detail = env.IsDevelopment() ? exception.Message
99 : "An unexpected error occurred. Please use the traceId to report this.",
100 Instance = httpContext.Request.Path,
101 Extensions =
102 {
103 ["traceId"] = httpContext.TraceIdentifier,
104 }
105 }, cancellationToken: cancellationToken);
106
107 return true;
108 }
109}
πŸ’‘
Why This Matters

Unhandled exceptions that reach clients in their raw form are simultaneously a security vulnerability (they leak stack traces and connection strings), an API usability failure (clients can't parse HTML exception pages), and an operational nightmare (no TraceIdentifier means you can't correlate the user's error report to your logs). Problem Details solves all three: structured JSON that clients parse, no internal details in production, and TraceIdentifier for support correlation.

Common Pitfalls

⚠Leaking stack traces in production: exception.StackTrace and exception.Message routinely contain SQL queries, connection strings, internal service addresses, and file system paths. Never include these in HTTP responses. Log them server-side at Error level, return only a sanitized ProblemDetails with a TraceIdentifier.
⚠Inconsistent error formats across endpoints: mixing manual JSON error objects, plain text, and Problem Details in the same API forces clients to write defensive parsing for every endpoint. Adopt IProblemDetailsService globally via AddProblemDetails() and enforce it in middleware β€” not in individual controllers.
⚠Swallowing exceptions in middleware or background services: a catch block that logs at Warning and returns 200 hides failures from clients and monitoring systems. If you catch an exception and can't handle it meaningfully, re-throw it or write to a dead-letter queue. Silent success is worse than an explicit failure.
⚠Not including HttpContext.TraceIdentifier in error responses: when a user submits a support ticket saying 'it didn't work', you need a correlation key to find the relevant log entry across distributed services. Without TraceIdentifier in the response body, RCA requires guessing from timestamps β€” which is unreliable at scale.
Real-World Use Cases

1Stack Traces in Production Exposing Database Credentials

Scenario

A client integration engineer opened the browser developer tools while testing an API and found a 500 response body containing the full ASP.NET exception page β€” including the connection string with the production database password in plain text. The server had never been configured with app.UseExceptionHandler for production.

Problem

The development exception page (UseDeveloperExceptionPage) was active in production because the ASPNETCORE_ENVIRONMENT variable was not set, defaulting to 'Development'. Every unhandled exception dumped the full stack trace, local variable values, and environment variables β€” including secrets β€” directly into the HTTP response.

Solution

Always call app.UseExceptionHandler("/error") in production (or rely on IExceptionHandler) and UseStatusCodePages. The critical fix: check app.Environment.IsDevelopment() before including exception details in responses. In GlobalExceptionHandler, log the full exception internally but return only a sanitized ProblemDetails with the TraceIdentifier.

πŸ’‘

Takeaway: ASPNETCORE_ENVIRONMENT must be explicitly set to 'Production' in every production deployment. Never include exception.Message, exception.StackTrace, or InnerException in HTTP responses in production β€” these routinely leak connection strings, file paths, and internal architecture details.

2Inconsistent Error Formats Breaking Client SDK

Scenario

A platform had 15 microservices. Six returned XML errors, four returned plain text strings, three returned custom JSON, and two returned RFC 7807 Problem Details. The mobile client SDK had to handle 4 different error formats with conditional parsing code that broke whenever a new service was added.

Problem

Each team implemented error handling independently with no cross-service standard. The client SDK became a parsing zoo. When a new 503 from the API gateway returned an HTML Nginx error page, the SDK crashed with a JSON parse exception β€” which the client logged as 'unknown error', with no TraceIdentifier for support to correlate.

Solution

Mandate RFC 9457 Problem Details across all services with AddProblemDetails() in every service's Program.cs. Define a company-wide set of type URIs for common domain errors. The mobile client SDK now switches on type: if the status is 422 and type contains 'validation', show field errors; if 503, show maintenance message. One parsing path, consistent forever.

πŸ’‘

Takeaway: Consistency in error responses is an API design contract, not a nice-to-have. RFC 9457 exists precisely to give clients a stable format to depend on. Adopt it at the organization level with shared middleware packages, not per-team.

3Swallowed Exceptions Causing Silent Data Corruption

Scenario

An order processing service had a fire-and-forget background task that swallowed exceptions in a try/catch that only called _logger.LogWarning. Over three months, 847 orders silently failed to update their fulfillment status. The bug was discovered when a customer complained their order shipped 3 months ago showed as 'pending'.

Problem

The middleware chain had an unintended catch block that caught Exception and returned 200 OK regardless of outcome, logging only a Warning. The assumption was 'we log it, we'll see it'. But Warning-level logs were not monitored with alerts. The exception was swallowed β€” no error response, no dead letter queue, no compensation logic.

Solution

Never catch-all Exception in application code unless you re-throw or dead-letter. Use IExceptionHandler at the infrastructure boundary. Set up alerting on Error-level log entries. For background workers, use IHostedService with try/catch that logs at Error and writes to a dead-letter mechanism. Add distributed tracing so every failed operation has a TraceId in the support queue.

πŸ’‘

Takeaway: Swallowed exceptions are worse than unhandled exceptions β€” at least an unhandled exception returns a 500 that clients can detect. A silently-caught exception returns a 200 that corrupts client state. Log exceptions at Error level, alert on them, and never hide them behind generic Warning messages.