ProblemDetails and Global Exception Handling
Objective
Every API fails, and the question is whether clients can parse the failure.
ASP.NET Core ships the pieces for one machine-readable error shape, the
application/problem+json document of RFC 9457, but they live in three
different places and each one is optional: a service that builds the document
(AddProblemDetails), middleware that decides when to ask for it
(UseExceptionHandler, UseStatusCodePages), and a hook that maps your own
exception types to responses (IExceptionHandler). The REST concept introduced
the first two lines. This one is about wiring them so that a 404, a validation
failure and an unhandled exception all look the same, and about the ways the
setup quietly does less than you expect.
Use Cases
- Giving a front end one parser for every error the API can produce.
- Mapping domain exceptions (
GameNotFoundException) to404or409in one class instead of atry/catchin every handler. - Adding a correlation field or the request path to every error body.
- Making sure a database connection string in an exception message never reaches a client.
- Turning a downstream timeout into
503with aRetry-Afterheader.
Deep Dive
The document and who writes it
RFC 9457 (which obsoletes RFC 7807) defines type, title, status, detail
and instance, and allows extension members. ProblemDetails is that type in
ASP.NET Core, and IProblemDetailsService is the service that serializes it.
AddProblemDetails() registers the default service, nothing more. I ran the
same app (SDK 10.0.100, Kestrel, curl) with different wiring, and the body
for GET /nope (no such route) changed like this:
textAddProblemDetails() only 404, empty body + UseStatusCodePages() 404, application/problem+json {"type":".../rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-..."}
So registering the service is not enough: for responses that are not
exceptions, nothing asks it to write until UseStatusCodePages() runs. The
same app confirmed the other half. An endpoint that returns Results.NotFound()
or Results.BadRequest() produces an empty body until status code pages are in
the pipeline, while Results.Problem(...) and Results.ValidationProblem(...)
are written directly and already carry traceId once the service is registered.
Note the type URI points at RFC 9110 sections in .NET 10, one per status code.
Exceptions are the second path. With status code pages alone, GET /boom
(a handler that throws) still returned a bare 500, because that middleware
only looks at responses, and the exception goes straight past it. Adding
UseExceptionHandler() with no arguments, which is valid when problem details
are registered, produced:
csharpbuilder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // exception -> 500 problem+json, no stack, no message
app.UseStatusCodePages(); // empty 4xx/5xx -> problem+jsontextGET /boom -> 500 application/problem+json {"type":".../rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"..."}
Without the fallback, UseExceptionHandler() with no path and no
AddProblemDetails throws at startup, per the documentation.
Content negotiation can silently skip you
The default writer only answers when the Accept header allows JSON. In my run
Accept: application/problem+json and */* got the document, while
Accept: text/html and application/xml on /boom got a 500 with an empty
body, and a 404 for text/html got plain text (Status Code: 404; Not Found)
from status code pages. A browser hitting an API URL is the common case, so test
errors with the headers your real clients send, and add a fallback if you want
one body for everyone.
IExceptionHandler: one place for known exceptions
Register handlers with AddExceptionHandler<T>(). They are singletons, run in
registration order, and each returns true to say "handled, stop" or false to
pass the exception along. They are only called if UseExceptionHandler is in the
pipeline. A handler should write the whole response; asking
IProblemDetailsService to do it keeps the shape consistent:
csharpclass DomainExceptionHandler(IProblemDetailsService pds) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
(int status, string title) = ex switch
{
ArgumentException => (400, "Invalid request"),
GameNotFoundException => (404, "Game not found"),
_ => (0, "")
};
if (status == 0) return false; // not mine, next handler
ctx.Response.StatusCode = status;
return await pds.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx, Exception = ex,
ProblemDetails = { Status = status, Title = title, Detail = ex.Message }
});
}
}
builder.Services.AddExceptionHandler<DomainExceptionHandler>();Observed results with a second handler for TimeoutException registered after
it:
textGET /arg -> 400 {"title":"Invalid request","detail":"name is required",...} GET /nf -> 404 {"title":"Game not found","detail":"Game 42 not found",...} GET /timeout -> 503 Retry-After: 5 {"title":"Service temporarily unavailable",...} GET /boom -> 500 {"title":"An error occurred while processing your request.",...} (nobody claimed it)
A handler that returns true without writing anything leaves an empty
response: in my run GET /silent came back 500 with no body. A response that
already started cannot be replaced either: a handler that wrote partial...,
flushed, then threw ended as 200 with that text, and the log said
The response has already started, the error handler will not be executed.
CustomizeProblemDetails and instance
AddProblemDetails(o => o.CustomizeProblemDetails = ctx => ...) runs for every
document the service writes, whatever produced it: exception, status code page,
Results.Problem, even the AddValidation 400. That makes it the place for
fields that belong on all errors:
csharpbuilder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Instance ??= ctx.HttpContext.Request.Path;
ctx.ProblemDetails.Extensions["service"] = "games-api";
});After that, the 400 from an invalid DTO, the 404 from /nf and the 500
from /boom all carried "instance":"/..." and "service":"games-api". The
context also exposes ctx.Exception when there is one.
Not leaking details
Two separate leaks. The first is the body: ex.Message can hold a connection
string, a SQL fragment or a file path. In the run, /boom threw
secret: Server=db;Password=hunter2 and the client saw only the generic title,
because the default exception handler writes no detail. The leak appears when
you copy ex.Message into Detail, as the ArgumentException handler above
does on purpose: that message is written for callers. The timeout handler
omitted Detail for exactly that reason. Return messages only from exception
types you own and wrote for clients.
The second is the Developer Exception Page, which shows stack traces, headers
and cookies and must stay in Development. WebApplication enables it
automatically there, so UseExceptionHandler is what protects production.
A third, quieter change in .NET 10: the exception handler middleware no longer
writes diagnostics (the fail: log line) for exceptions an IExceptionHandler
handled. My log showed only the unhandled /boom; /arg, /nf and /timeout
were silent. Setting SuppressDiagnosticsCallback = _ => false on
ExceptionHandlerOptions brought all of them back. Silence is right for
expected domain exceptions and wrong for a timeout you want an alert on, so
log inside the handler when it matters.
Trade-offs
- A global handler hides control flow. Exceptions as
404mean athrowfar away decides the status code. Throwing for expected outcomes is slower than returning a result, so keepResults.NotFound()for the common path and the handler for the rest.csharpreturn game is null ? TypedResults.NotFound() : TypedResults.Ok(game); // no exception - One shape for every error needs every path wired. Status code pages,
exception handler and validation each have a switch; miss one and that error
is empty or a different shape. Add a test that hits a
404, a400and a throw and assertsapplication/problem+json. Detailis a contract. Puttingex.Messagein it ties clients to your exception wording and risks leaks. A stabletypeURI plus a shorttitleis what clients should branch on.- Handler order matters. The first handler that returns
truewins, so a broad handler registered early shadows specific ones. - Content negotiation is a trap. An
Accept: text/htmlrequest gets no JSON document. If browsers hit the API, decide what they should see.