Handling Global Exceptions in ASP.NET Core
AddProblemDetails and UseExceptionHandler catch every exception an action doesn't, log it, and answer with a tidy 500 problem details response that never shows the stack trace. An IExceptionHandler can answer chosen exceptions with a better status, such as 409 Conflict.
Freelancer · ASP.NET Core · REST API · Updated
After this lesson you can
- Turn unhandled exceptions into problem details responses with AddProblemDetails and UseExceptionHandler
- Write an IExceptionHandler that answers one kind of exception with its own status code
Key takeaways
AddProblemDetails()andUseExceptionHandler()catch every exception an action doesn't, log it, and answer with a500problem details response.- The stack trace goes to the app's log, never to the client, because it shows strangers how your code is built.
- Put
app.UseExceptionHandler()right afterbuilder.Build(), so it's in front of everything else. - An
IExceptionHandler, registered withAddExceptionHandler, answers the exceptions it knows, such as SQL error 547 with409 Conflict, and returnsfalsefor the rest. - Handle the expected in the action, such as
return NotFound();, and the unexpected in one place for the whole API.
In DELETE, deleting a category that still has products sent the client a 500 with the whole SqlException and its stack trace. That's two problems:
- It shows too much. A stack trace tells strangers how your code is built: class names, file paths, SQL. That's useful to an attacker and useless to a client.
- It says too little. Every other error from this API is problem details JSON that the client knows how to read. This one is a wall of text.
You could put a try and catch in every action. But exceptions you don't expect can happen anywhere, so the answer belongs in one place for the whole API.
One line each
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
AddProblemDetails()makes ASP.NET Core write its error responses as problem details.UseExceptionHandler()adds a step to the start of the request pipeline that catches any exception from anything after it, such as your controllers. It logs the exception, with its stack trace, and answers the client with a500problem details response. Put it right afterbuilder.Build(), so it's in front of everything else.
Now the same request:
DELETE {{HostAddress}}/api/categories/1
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
The client gets a clean, standard answer. The details haven't vanished: they're in the app's log, which you can see in the console window where the API is running, starting with An unhandled exception has occurred while executing the request. That's where you, not your clients, should look.
Choosing a better answer: IExceptionHandler
A foreign-key conflict isn't really the server's fault: the client asked to delete something that other data still uses. 409 Conflict would say that properly.
An IExceptionHandler gets the first look at each unhandled exception. It answers the ones it knows about, and leaves the rest for the standard 500:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
namespace NorthwindApi.Errors;
// Turns a foreign key conflict from SQL Server (error 547) into 409 Conflict.
public class DatabaseConflictHandler : IExceptionHandler
{
private readonly IProblemDetailsService problemDetailsService;
public DatabaseConflictHandler(IProblemDetailsService problemDetailsService)
{
this.problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not SqlException sqlException || sqlException.Number != 547)
{
return false;
}
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Conflict",
Detail = "Other data still uses this item, so it can't be changed or deleted."
}
});
}
}
IExceptionHandleris an interface with one method,TryHandleAsync, which gets the exception.- It returns
falsefor exceptions it doesn't handle.exception is not SqlException sqlException || ...reads: if it isn't aSqlException, or it's a different SQL error, leave it. SQL Server's error number 547 means a constraint, such as a foreign key, stopped the statement. - It returns
trueonce it has written the response: the status code, then a problem details body written byIProblemDetailsService, which it asked for in its constructor. ValueTask<bool>is a lighter cousin ofTask<bool>. Treat it the same way:async, andreturnabool.
Register it in Program.cs, next to AddProblemDetails():
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DatabaseConflictHandler>();
DELETE {{HostAddress}}/api/categories/1
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Conflict",
"status": 409,
"detail": "Other data still uses this item, so it can't be changed or deleted."
}
Every action in every controller now answers a foreign-key conflict with 409, and you didn't touch a single one. Any other exception still gets the standard 500. You can register several handlers; they're asked in the order they were added.
Where each error belongs
| Situation | Handled by |
|---|---|
| The body breaks a validation rule | [ApiController]: 400, automatically |
| No item with that id | The action: return NotFound(); |
| A rule the action checks, such as a name already taken | The action: return Conflict(...); |
| An exception you can turn into a meaningful status, anywhere in the API | An IExceptionHandler: such as 409 for SQL error 547 |
| Anything else | UseExceptionHandler(): logged, and a 500 with no details |
Check yourself
2 questions to try on the lesson page.
So what
Handle the expected in the action, and the unexpected in one place. With AddProblemDetails and UseExceptionHandler, every error your API sends is the same tidy shape, the details stay in your log, and an IExceptionHandler can turn a known exception into the right status everywhere at once.
Key terms
- Exception Handling Middleware
- Problem Details in ASP.NET Core
- IExceptionHandler