July 16, 2024

Einheitliche Fehlermeldungen in REST APIs: Implementierung von RFC 7807 Problem Details

Exceptions sind Laufzeitfehler, die den normalen Ablauf eines Programms stören. In .NET Core leiten sich Exceptions von der System.Exception-Basisklasse ab. Ein effektives Exception Handling ist unerlässlich, um Abstürze zu vermeiden und aussagekräftiges Feedback an den Client zu geben.

Der Stille Killer: Unbehandelte Exceptions

Stellen Sie sich vor, eine Anwendung ruft Benutzerdaten von einer externen API ab. Wenn die API nicht verfügbar ist und Exceptions nicht behandelt werden, kann die Anwendung abstürzen, was zu einer schlechten Benutzererfahrung führt. Unbehandelte Exceptions können zu Ausfallzeiten der Anwendung und Frustration bei Entwicklern und Benutzern führen.

Die Wichtigkeit von guten Fehlermeldungen

Gute Fehlermeldungen sind entscheidend für eine effiziente Fehlerbehebung und für das Verständnis des Clients, was schiefgelaufen ist. Ohne klare und präzise Fehlermeldungen kann es zu einer Vielzahl von Problemen kommen:

  1. Verwirrung und Frustration: Unklare Fehlermeldungen führen zu Unverständnis und verlängern die Fehlerbehebungszeit.
  2. Verlängerte Entwicklungszyklen: Schwierig zu diagnostizierende Fehler verlängern die Entwicklungszeit und erhöhen die Kosten.
  3. Schlechte Benutzererfahrung: Endbenutzer sind oft die Leidtragenden von schlechten Fehlermeldungen, was zu Kundenverlust führen kann.
  4. Instabilität der Anwendung: Fehlendes oder unzureichendes Exception Handling untergräbt die Stabilität und Zuverlässigkeit der Anwendung.
  5. Wiederholte Supportanfragen: Unklare Fehlermeldungen erhöhen die Anzahl der Supportanfragen und damit die Kosten für den technischen Support.
  6. Vertrauensverlust: Regelmäßig auftretende, schlecht behandelte Fehler führen zu einem Vertrauensverlust in die Anwendung und das Unternehmen.

HTTP-Statuscodes und Fehlerhandling

Das Verständnis und die korrekte Verwendung von HTTP-Statuscodes sind der Schlüssel zu effektivem API-Fehlerhandling. Sie helfen nicht nur dabei, dem Client den Status der Anfrage mitzuteilen, sondern auch, die Art des Fehlers zu kommunizieren, wodurch eine gezielte Fehlerbehebung möglich wird.

2xx Erfolg
  • 200 OK: Die Anfrage war erfolgreich und die erwarteten Daten wurden zurückgegeben.
  • 204 No Content: Die Anfrage war erfolgreich, aber es müssen keine Inhalte zurückgegeben werden, z.B. bei DELETE-Anfragen.
  • 202 Accepted: Die Anfrage wurde akzeptiert, aber die Bearbeitung ist noch nicht abgeschlossen.
4xx Clientfehler
  • 400 Bad Request: Die Anfrage war fehlerhaft oder konnte nicht verarbeitet werden.
  • 401 Unauthorized: Authentifizierung ist erforderlich und fehlgeschlagen oder noch nicht bereitgestellt.
  • 403 Forbidden: Der Server versteht die Anfrage, lehnt sie jedoch ab.
  • 404 Not Found: Die angeforderte Ressource konnte nicht gefunden werden.
5xx Serverfehler
  • 500 Internal Server Error: Ein generischer Fehler, wenn der Server auf ein unerwartetes Problem stößt.
  • 502 Bad Gateway: Der Server hat eine ungültige Antwort von einem Upstream-Server erhalten.
  • 503 Service Unavailable: Der Server ist derzeit nicht verfügbar, oft wegen Wartungsarbeiten oder Überlastung.
  • 504 Gateway Timeout: Der Server hat nicht rechtzeitig eine Antwort von einem Upstream-Server erhalten.

Domain-Driven Design (DDD) und Fehlerhandling

Im Kontext von DDD ist es wichtig, zwischen Domain-Fehlern (Geschäftslogik) und Anwendungsfehlern (technische Probleme) zu unterscheiden. Diese Unterscheidung hilft dabei, die richtigen Statuscodes zu wählen und klar zu kommunizieren, wo das Problem liegt.

Domain-Exceptions

Domain-Exceptions treten auf, wenn Geschäftsregeln oder Invarianten verletzt werden. Solche Fehler sollten in der Regel mit 4xx-Statuscodes zurückgegeben werden, um anzuzeigen, dass der Client die Anfrage korrigieren muss. Beispiele sind:

ValidationException: Daten, die vom Client gesendet wurden, sind ungültig.

{
  "type": "https://example.com/probs/validation",
  "title": "Ungültige Anfrageparameter",
  "status": 400,
  "detail": "Die gesendeten Daten sind ungültig. Bitte überprüfen Sie die folgenden Felder.",
  "instance": "/api/bookings",
  "errors": {
    "startDate": "Das Startdatum muss in der Zukunft liegen.",
    "endDate": "Das Enddatum muss nach dem Startdatum liegen.",
    "roomNumber": "Die angegebene Zimmernummer existiert nicht."
  }
}

EntityNotFoundException: Die angeforderte Entität existiert nicht.

{
  "type": "https://example.com/probs/entity-not-found",
  "title": "Entität nicht gefunden",
  "status": 404,
  "detail": "Die angeforderte Buchungs-ID '98765' wurde nicht gefunden.",
  "instance": "/api/bookings/98765"
}

BusinessRuleViolationException: Eine Geschäftsregel wurde verletzt.

{
  "type": "https://example.com/probs/business-rule-violation",
  "title": "Geschäftsregel verletzt",
  "status": 409,
  "detail": "Die Buchung kann nicht erstellt werden, da das Zimmer bereits für den angegebenen Zeitraum belegt ist.",
  "instance": "/api/bookings"
}

Application-Exceptions

Application-Exceptions betreffen technische Probleme oder unerwartete Fehler im Anwendungscode. Diese Fehler sollten mit 5xx-Statuscodes zurückgegeben werden, um anzuzeigen, dass ein Problem auf der Serverseite vorliegt. Beispiele sind:

TimeoutException: Ein Timeout ist aufgetreten, z.B. bei einer Datenbankabfrage.

{
  "type": "https://example.com/probs/timeout",
  "title": "Anfrage-Timeout",
  "status": 504,
  "detail": "Die Anfrage hat das Zeitlimit überschritten. Bitte versuchen Sie es später erneut.",
  "instance": "/api/bookings",
  "timestamp": "2024-06-30T12:34:56Z"
}

IOException: Ein Ein-/Ausgabefehler, z.B. beim Zugriff auf das Dateisystem.

{
  "type": "https://example.com/probs/io-error",
  "title": "Ein-/Ausgabefehler",
  "status": 500,
  "detail": "Ein Fehler ist beim Zugriff auf das Dateisystem aufgetreten. Bitte versuchen Sie es später erneut.",
  "instance": "/api/files/upload",
  "timestamp": "2024-06-30T12:34:56Z"
}

DatabaseException: Ein Fehler bei der Datenbankverbindung oder -abfrage.

{
  "type": "https://example.com/probs/database-error",
  "title": "Datenbankfehler",
  "status": 500,
  "detail": "Ein Fehler ist bei der Datenbankverbindung aufgetreten. Bitte versuchen Sie es später erneut.",
  "instance": "/api/bookings",
  "timestamp": "2024-06-30T12:34:56Z"
}

Warum diese Unterscheidung wichtig ist

Die Unterscheidung zwischen Domain- und Application-Exceptions ist entscheidend für eine klare Kommunikation und effiziente Fehlerbehebung:

  1. Präzise Fehlerdiagnose: Durch die Verwendung spezifischer Statuscodes und Fehlertypen können Clients genau erkennen, ob das Problem auf ihrer Seite (4xx) oder auf der Serverseite (5xx) liegt.
  2. Gezielte Fehlerbehebung: Domain-Exceptions geben klare Hinweise darauf, welche Eingaben oder Geschäftsregeln angepasst werden müssen. Application-Exceptions zeigen an, dass technische Probleme vorliegen, die oft nur serverseitig gelöst werden können.
  3. Verbesserte Benutzererfahrung: Klare und präzise Fehlermeldungen ermöglichen es den Benutzern und Entwicklern, schneller auf Probleme zu reagieren und sie zu beheben.
  4. Effizienz und Stabilität: Durch die genaue Unterscheidung und Handhabung von Fehlern können Entwicklungs- und Supportteams effizienter arbeiten und die Gesamtstabilität der Anwendung wird verbessert.

Implementierung von ProblemDetails für Fehlerbehandlung in .NET Core

Nachdem wir die Bedeutung von HTTP-Statuscodes und die Unterscheidung zwischen Domain- und Application-Exceptions behandelt haben, ist es nun an der Zeit zu zeigen, wie diese Prinzipien konkret in einer .NET Core Anwendung implementiert werden können.

Domain-Exceptions definieren

In einer Domain-Driven Design (DDD) Architektur ist es sinnvoll, spezifische Domain-Exceptions zu definieren, die von einer generischen DomainException erben. Diese Exceptions können dann in der Middleware korrekt verarbeitet und in standardisierte HTTP-Antworten mit der ProblemDetails-Klasse umgewandelt werden.

Schritt 1: Definieren Sie Domain-Exceptions

Erstellen Sie eine Basisklasse DomainException und spezifische Domain-Exceptions, die davon erben:

public abstract class DomainException : Exception
{
    protected DomainException(string message) : base(message) { }
}

public class ValidationException : DomainException
{
    public IDictionary<string, string[]> Errors { get; }

    public ValidationException(string message, IDictionary<string, string[]> errors) : base(message)
    {
        Errors = errors;
    }
}

public class EntityNotFoundException : DomainException
{
    public EntityNotFoundException(string message) : base(message) { }
}

public class BusinessRuleViolationException : DomainException
{
    public BusinessRuleViolationException(string message) : base(message) { }
}

Schritt 2: Middleware zur Fehlerverarbeitung erstellen

Erstellen Sie eine Middleware-Klasse, die diese Domain-Exceptions abfängt und entsprechend in ProblemDetails-Antworten umwandelt:

public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionMiddleware> _logger;

    public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (DomainException ex)
        {
            _logger.LogError($"A domain exception occurred: {ex.Message}");
            await HandleDomainExceptionAsync(httpContext, ex);
        }
        catch (Exception ex)
        {
            _logger.LogError($"An unexpected error occurred: {ex.Message}");
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private static Task HandleDomainExceptionAsync(HttpContext context, DomainException exception)
    {
        ProblemDetails problemDetails = exception switch
        {
            ValidationException validationEx => new ValidationProblemDetails(validationEx.Errors)
            {
                Title = "Ungültige Anfrageparameter",
                Status = StatusCodes.Status400BadRequest,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            EntityNotFoundException => new ProblemDetails
            {
                Title = "Entität nicht gefunden",
                Status = StatusCodes.Status404NotFound,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            BusinessRuleViolationException => new ProblemDetails
            {
                Title = "Geschäftsregel verletzt",
                Status = StatusCodes.Status409Conflict,
                Detail = exception.Message,
                Instance = context.Request.Path
            },
            _ => new ProblemDetails
            {
                Title = "Domainfehler",
                Status = StatusCodes.Status400BadRequest,
                Detail = exception.Message,
                Instance = context.Request.Path
            }
        };

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status400BadRequest;
        return context.Response.WriteAsJsonAsync(problemDetails);
    }

    private static Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var problemDetails = new ProblemDetails
        {
            Title = "Ein unerwarteter Fehler ist aufgetreten",
            Status = StatusCodes.Status500InternalServerError,
            Detail = exception.Message,
            Instance = context.Request.Path
        };

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return context.Response.WriteAsJsonAsync(problemDetails);
    }
}

Schritt 3: Middleware registrieren

Registrieren Sie die Middleware in Ihrer Startup- oder Program-Datei:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseMiddleware<ExceptionMiddleware>();

    app.UseRouting();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

In .NET 6 und späteren Versionen sieht es so aus:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
app.MapControllers();
app.Run();

Schritt 4: Verwendung in Controllern

Werfen Sie spezifische Domain-Exceptions in Ihren Controllern:

[ApiController]
[Route("api/[controller]")]
public class BookingsController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateBooking([FromBody] BookingDto booking)
    {
        var errors = new Dictionary<string, string[]>();

        if (booking.StartDate <= DateTime.Now)
        {
            errors.Add(nameof(booking.StartDate), new[] { "Das Startdatum muss in der Zukunft liegen." });
        }
        if (booking.EndDate <= booking.StartDate)
        {
            errors.Add(nameof(booking.EndDate), new[] { "Das Enddatum muss nach dem Startdatum liegen." });
        }

        if (errors.Count > 0)
        {
            throw new ValidationException("Die gesendeten Daten sind ungültig.", errors);
        }

        // TODO: Implement further booking logic here

        return Ok();
    }
}

Durch die Implementierung dieser Struktur können Sie Domain-Exceptions in Ihrer Anwendung klar und standardisiert handhaben, einschließlich der detaillierten Angabe von Validierungsfehlern. Diese Vorgehensweise stellt sicher, dass Fehler richtig kategorisiert und den Clients entsprechend kommuniziert werden, was die Diagnose und Behebung von Fehlern erleichtert.

Fazit

In diesem Artikel haben Sie gelernt, wie Sie in .NET Core APIs effektives Fehlerhandling mit der ProblemDetails-Klasse und Domain-Driven Design (DDD) Prinzipien implementieren. Domain-Exceptions wie ValidationException und EntityNotFoundException werden durch eine Middleware in standardisierte HTTP-Responses umgewandelt. Die Middleware fängt spezifische Domain- und Application-Exceptions ab und wandelt sie in ProblemDetails-Format um, um klare und konsistente Fehlermeldungen zu gewährleisten. Durch diese Struktur wird die Diagnose und Behebung von Fehlern erleichtert, was zu einer verbesserten Benutzererfahrung und Systemstabilität führt.