Einheitliche Fehlerbehandlung in ASP.NET Core: IExceptionHandler und Problem Details
Die Erfolgsantworten einer API werden meist sorgfältig entworfen. Die Fehlerantworten entstehen dagegen oft zufällig: Ein Endpunkt liefert Klartext, ein anderer { "error": "..." }, und ein dritter reicht den Stacktrace der Entwicklungsumgebung unverändert an den Client weiter. Entwickler auf der Client-Seite müssen dann für jeden Endpunkt eigenen Code zum Auswerten der Fehler schreiben.
In diesem Beitrag zeige ich, wie Sie in ASP.NET Core Fehler an einer einzigen Stelle abfangen, im Standardformat Problem Details zurückgeben und dabei keine internen Details preisgeben. Ziel ist es, das Fehlerverhalten als Vertrag zu behandeln, statt es auf die Endpunkte zu verteilen.
Warum ein einheitliches Fehlerformat nötig ist
Fehlerbehandlung im Client beruht auf drei Fragen: Was ist passiert, soll ich es erneut versuchen, und was zeige ich dem Nutzer? Wenn das Antwortformat je Endpunkt anders aussieht, lassen sich diese Fragen nicht verlässlich beantworten.
Ein gemeinsames Format hilft auch auf dem Server. Trägt jede Fehlerantwort dieselbe Trace-ID, finden Sie einen vom Nutzer gemeldeten Fehler in Sekunden in den Logs. Einheitlichkeit senkt die Kosten sowohl für Clients als auch für den Betrieb.
Problem Details: der Standard-Umschlag
Der Standard, der den Fehlerkörper für HTTP-APIs definiert, ist RFC 9457 (zuvor RFC 7807). Seine Kernfelder sind:
type: ein URI, der die Fehlerart kennzeichnet.title: eine kurze, menschenlesbare Zusammenfassung der Art.status: der HTTP-Statuscode.detail: eine Erklärung zu diesem konkreten Vorfall.instance: ein Wert, der den Vorfall selbst identifiziert.
Beliebige weitere Felder (extensions) können Sie ergänzen. ASP.NET Core unterstützt dieses Format über die Klasse ProblemDetails und den Inhaltstyp application/problem+json.
Den Dienst konfigurieren
Der erste Schritt besteht darin, auch die vom Framework erzeugten Fehlerantworten in Problem Details umzuwandeln:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
UseExceptionHandler fängt unbehandelte Ausnahmen ab, und UseStatusCodePages wandelt Antworten ohne Body, etwa ein einfaches 404 oder 405, in Problem Details um. Die traceId in CustomizeProblemDetails jeder Antwort hinzuzufügen, ist eine einzige Zeile mit großem Nutzen.
Ausnahmen mit IExceptionHandler abbilden
IExceptionHandler, eingeführt mit .NET 8, verlagert die Abbildung von Ausnahmen in eine testbare Klasse, ohne eigene Middleware zu schreiben:
public sealed class ApiExceptionHandler(
IProblemDetailsService problemDetails,
ILogger<ApiExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Ungültige Anfrage"),
NotFoundException => (StatusCodes.Status404NotFound, "Ressource nicht gefunden"),
ConflictException => (StatusCodes.Status409Conflict, "Konflikt"),
_ => (StatusCodes.Status500InternalServerError, "Unerwarteter Fehler")
};
if (status >= 500)
logger.LogError(exception, "Unbehandelte Ausnahme");
else
logger.LogInformation("Client-Fehler: {Type}", exception.GetType().Name);
context.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
Exception = exception,
ProblemDetails = { Status = status, Title = title }
});
}
}
Registriert wird der Handler mit builder.Services.AddExceptionHandler<ApiExceptionHandler>();. Fügen Sie mehrere Handler hinzu, werden sie in der Reihenfolge der Registrierung probiert; der erste, der true zurückgibt, erledigt die Aufgabe. Behandelt keiner die Ausnahme, greift das Standardverhalten.
Feldfehler getrennt behandeln
Bei Validierungsfehlern muss der Client wissen, welches Feld aus welchem Grund abgelehnt wurde. Dafür gibt es ValidationProblemDetails:
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["email"] = ["Geben Sie eine gültige E-Mail-Adresse ein."],
["quantity"] = ["Die Menge muss mindestens 1 betragen."]
});
Das Feld errors in der Ausgabe ist nach Feldnamen gruppiert. Ein Client kann diese Struktur direkt an Formularfelder binden. In Projekten mit Controllern liefert das Attribut [ApiController] Fehler der Modellvalidierung bereits in diesem Format.
Vermischen Sie Feldfehler nicht mit Fehlern gegen Geschäftsregeln. „Die E-Mail ist ungültig“ ist ein Feldfehler. „Diese Bestellung kann nicht mehr storniert werden“ ist ein Geschäftsregelfehler; ihn mit 409 oder 422 und einem maschinenlesbaren type-Wert zurückzugeben, ist angemessener.
Lecks verhindern
Eine Fehlerantwort darf einem Angreifer keine Informationen liefern. Einige Regeln:
- Schreiben Sie in der Produktion die Meldung der Ausnahme nicht in
detail. Sie kann einen Verbindungsstring oder ein SQL-Fragment enthalten. - Stacktraces gehören nur in die Logs; bei Bedarf können sie in der Entwicklung in die Antwort aufgenommen werden (dafür gibt es den
Exception-Kontext). - Geben Sie bei einer 500-Antwort einen allgemeinen Titel aus und suchen Sie die Details über die
traceIdin den Logs. - Überlegen Sie den Unterschied zwischen 404 und 403 so, dass er die Existenz einer Ressource nicht verrät.
Halten Sie die Meldungen Ihrer eigenen Ausnahmetypen für anzeigbar, tun Sie das bewusst: Übernehmen Sie nur die Meldung selbst definierter Typen wie ValidationException in detail und halten Sie alles andere allgemein.
Fehlerantworten testen
Wird der Fehlervertrag nicht getestet, verfällt er mit der Zeit. Senden Sie mit WebApplicationFactory eine Anfrage und prüfen Sie Statuscode, Inhaltstyp und Felder:
var response = await client.GetAsync("/orders/fehlt");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(404, problem!.Status);
Assert.True(problem.Extensions.ContainsKey("traceId"));
Fügen Sie außerdem einen Test hinzu, in dem ein Endpunkt absichtlich eine Ausnahme wirft, und prüfen Sie, dass die Ausnahmemeldung nicht in der Antwort steht. Regressionen bei Lecks werden meist durch einen solchen Test entdeckt.
Fazit
Einheitliche Fehlerbehandlung besteht aus drei Teilen: AddProblemDetails, das auch Framework-Fehler abdeckt, IExceptionHandler, das die Abbildung von Ausnahmen an einer Stelle bündelt, und einer traceId in jeder Antwort. Hinzu kommen die Trennung der Feldfehler mit ValidationProblemDetails und das Schreiben von Details ins Log statt in die Antwort in der Produktion.
Fangen Sie klein an: Bringen Sie zuerst mit AddProblemDetails und UseExceptionHandler alle bestehenden Fehler in ein Format und schreiben Sie dann Handler für die drei häufigsten Ausnahmetypen. Ihr Client-Team wird schon am ersten Tag merken, dass das Fehlerformat nun vorhersehbar ist.