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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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).
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.
1// Program.cs β global error handling pipeline2var app = builder.Build();34// 1. UseExceptionHandler catches ALL unhandled exceptions5app.UseExceptionHandler(exceptionHandlerApp =>6{7 exceptionHandlerApp.Run(async context =>8 {9 var exceptionFeature = context.Features10 .Get<IExceptionHandlerFeature>();1112 // Don't leak stack traces in production13 var isDev = app.Environment.IsDevelopment();1415 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});2627// 2. IProblemDetailsService + IExceptionHandler (.NET 8+) β preferred approach28builder.Services.AddProblemDetails();29builder.Services.AddExceptionHandler<DomainExceptionHandler>();30builder.Services.AddExceptionHandler<ValidationExceptionHandler>();31// Fallback catch-all MUST be last32builder.Services.AddExceptionHandler<GlobalExceptionHandler>();3334// IExceptionHandler implementations35public sealed class ValidationExceptionHandler : IExceptionHandler36{37 private readonly IProblemDetailsService _pds;38 public ValidationExceptionHandler(IProblemDetailsService pds) => _pds = pds;3940 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 try4748 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;4950 return await _pds.TryWriteAsync(new ProblemDetailsContext51 {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}7273public sealed class GlobalExceptionHandler(74 ILogger<GlobalExceptionHandler> logger,75 IHostEnvironment env) : IExceptionHandler76{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 client83 logger.LogError(exception,84 "Unhandled exception on {Method} {Path}. TraceId: {TraceId}",85 httpContext.Request.Method,86 httpContext.Request.Path,87 httpContext.TraceIdentifier);8889 httpContext.Response.StatusCode = 500;9091 await httpContext.Response.WriteAsJsonAsync(new ProblemDetails92 {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 contain97 // SQL queries, connection strings, or internal paths98 Detail = env.IsDevelopment() ? exception.Message99 : "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);106107 return true;108 }109}
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
1Stack Traces in Production Exposing Database Credentials
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.
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.
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
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.
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.
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
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'.
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.
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.