CodeGym /Kurse /Spring REST & MVC /Framework ProblemDetail: 5 Felder

Framework ProblemDetail: 5 Felder

Spring REST & MVC
Level 21, Lektion 0
Verfügbar

1. 404 ist nicht genug: Fehler ist auch ein Vertrag

Wenn Sie eine API entwickeln, schließen Sie eigentlich einen Vertrag mit dem Client: „Wenn du diese Anfrage sendest – antworte ich so.“ Und dieser Vertrag muss sowohl im Happy-Path als auch in der „Hoppla“-Situation gleichermaßen verständlich sein. Wenn sich der Server darauf beschränkt: „Na, hier hast du 404, denk selbst nach“, muss der Client raten: ist es „nicht gefunden“, „falsch angefragt“, „falsches Format“ oder „der Server ist müde und im Urlaub“.

In der Realität sieht Client-Code selten wie ein Mensch mit Browser aus, der „Not Found“ liest und traurig ist. Häufiger ist es eine mobile App, Frontend, Integration, Tests, Monitoring. Sie brauchen keine künstlerische Beschreibung des Leids, sondern eine strukturierte Antwort: was passiert ist, wo es passiert ist und was man dagegen tun kann. Unsere Aufgabe ist es daher, ein minimales, aber in sich geschlossenes Fehlergerüst zu bauen, das formal immer gleich ist.

Und hier ist es wichtig, einen einfachen Gedanken zu erfassen: Der HTTP-Status ist nur die Spitze des Eisbergs. Er sagt „Problemklasse“, beschreibt aber keine Einzelheiten. Ein Fehler ohne Details ist wie ein Bug-Report „funktioniert nicht“ ohne Reproduktionsschritte. Formal lügen Sie nicht, aber der Nutzen … ist wie bei einem Knopf „Mach es gut“.

2. ProblemDetail in Spring MVC

ProblemDetail ist die Standardidee: einen Fehler nicht nur als Text, sondern als kleines JSON-Objekt mit festen Feldern zu beschreiben. In Spring MVC materialisiert sich diese Idee in der Klasse ProblemDetail. Wir erfinden nicht „unser eigenes Fehlerformat mit Feld message und success=false“, sondern verwenden ein verständliches Gerüst, das viele Clients bereits verarbeiten können (oder zumindest leicht lernen).

Stellt man sich die Anfrageverarbeitung als Förderband vor, sieht sie bei einem Fehler etwa so aus: Der Controller oder Service wirft eine Ausnahme, @ControllerAdvice fängt sie ab, wandelt sie in ein ProblemDetail um, und dann serialisiert Spring MVC dieses Objekt in JSON und setzt den Content-Type: application/problem+json. Keine Magie – einfach normale, erwachsene Disziplin für die Behandlung negativer Szenarien.

flowchart TD
    A[HTTP request] --> B[Controller]
    B --> C[Service]
    C --> D{Fehler?}
    D -->|nein| E[Response DTO]
    D -->|ja| F[Exception]
    F --> G["@ControllerAdvice / @ExceptionHandler"]
    G --> H[ProblemDetail]
    H --> I[HTTP response: application/problem+json]

Ein wichtiger Punkt für das Projekt: Spring Boot kann von Haus aus mit Problem Details arbeiten, aber Sie müssen konsequent sein. Wenn Sie einen Teil der Fehler als ProblemDetail zurückgeben, einen anderen Teil als „zufällige Map“, wird der Client leiden. Er muss Verzweigungen nach Antwortformat schreiben – das ist kein REST-API mehr, sondern ein „Errate das JSON“-Spiel.

Ein weiteres praktisches Detail: Selbst wenn der Client „bereits weiß, welche URL er aufgerufen hat“, macht das Feld instance in der Antwort den Fehler in sich geschlossen. Das fällt besonders auf, wenn Antworten protokolliert, in Traces geschickt werden, Tests auf CI scheitern und Sie die Logs ohne vollständigen Kontext betrachten. Ein Fehler, der minimale Daten zur Anfrage enthält, spart Zeit bei der Nachforschung.

3. Minimales Gerüst ProblemDetail

Jetzt legen wir fest, was in jeder Error-Response vorhanden sein sollte, wenn wir die API zumindest in den Manieren als „production-like“ bezeichnen wollen. ProblemDetail hat Standardfelder, und genau diese sind unser minimales „Skelett“. Alles Zusätzliche (code, fieldErrors, timestamp, requestId) sind Aufbauten, die wir später sorgfältig hinzufügen, ohne die Grundform zu zerstören.

Unten ist eine kurze „Spickzettel“ für die fünf Felder. Versuchen Sie nicht, sie wie das Einmaleins auswendig zu lernen; betrachten Sie sie lieber als Antworten auf fünf Client-Fragen: „Was ist das?“, „Wie nennt man es?“, „Welcher Status?“, „Was genau ist passiert?“, „Auf welche Anfrage bezieht es sich?“

Feld Beantwortet die Frage Wichtig zu beachten Beispielwert
type „Welche Klasse des Problems ist das?“ Es ist ein Identifikator des Problemtyps (URI), kein Fehlertext /problems/task-not-found
title „Wie nennt man es kurz?“ Kurzer, stabiler Name für Menschen Task not found
status „Welchen HTTP-Status hat der Fehler?“ Im Body duplizieren wir den Status, damit die Antwort in sich geschlossen ist 404
detail „Was genau ist bei dieser Anfrage passiert?“ Hier sind konkrete Details angebracht: ID, aktueller Zustand, Parameterwert Task with id ... was not found
instance „Auf welche Anfrage bezieht sich das?“ URI der Anfrage (oder deren Pfad), um den Fehler an einen bestimmten Aufruf zu binden /api/v1/tasks/...

type: Identifikator der Problemklasse

Das Feld type wird oft als „na, da packen wir einfach irgendwas rein“ wahrgenommen. Dann packt man einen Nachrichtentext hinein, und der Sinn des Standards ist zerstört. Tatsächlich ist type ein Identifikator des Problemtyps, der stabil sein sollte. Er bezieht sich nicht auf den konkreten Fall, sondern auf die Kategorie: „task not found“, „malformed json“, „unsupported media type“.

Formal ist type ein URI (im Standard ist auch ein relativer URI erlaubt). In der idealen Welt ist es ein Link auf eine Dokumentationsseite, die beschreibt, was dieser Fehler ist und wie man ihn behandelt. In einem Lehrprojekt werden wir kein „Fehlerdokumentationsportal“ aufsetzen (sonst würde der Kurs zu „Spring + static hosting“), aber wir können type dennoch als stabilen Identifikator setzen, z.B. /problems/task-not-found.

Ein praktisches Gütemerkmal: Wenn Sie den Text von detail ändern (weil Sie die Formulierung verbessern möchten), sollte type gleich bleiben. Es ist der „Ordnername“, detail ist der „Dateiname im Ordner“. Wenn Sie es umgekehrt machen, erzeugen Sie Chaos.

Ein weiterer wichtiger Punkt: Ersetzen Sie type nicht durch Ihren anwendungsspezifischen code. type ist Teil des Problem-Details-Standards und drückt normalerweise einen „Problemtyp“ in Form eines URI aus. code ist eine interne, maschinenlesbare Konstante der Anwendung. Wir kommen später darauf zurück, aber jetzt ist es wichtig, die Rollen nicht zu vermischen, damit wir nicht zwei Felder „über dasselbe“ haben, aber unterschiedlich.

title: Kurzer Name des Problems

title ist nicht der Ort für einen Roman. Es ist eine kurze Bezeichnung, die man einem Menschen zeigen (oder ins Log schreiben) kann, um sofort zu verstehen, worum es geht. Sie sollte ausreichend stabil sein: Wenn Sie heute title = "Task not found" und morgen title = "No such task" zurückgeben, fällt es dem Client schwer, irgendwelche Benutzermeldungen oder Log-Suchen zu erstellen. Ja, der Client sollte title nicht als Code parsen, aber Stabilität ist dennoch nützlich.

Gute Praxis: title ist die „Überschrift der Kategorie“, detail ist die „Beschreibung des konkreten Falls“. Daher enthält title normalerweise keine IDs, keinen aktuellen Status, keine Anfrageparameter. Maximal eine sorgfältige Formulierung auf der Ebene „Task not found“, „Invalid input“, „Unsupported media type“.

Es gibt noch einen menschlichen Grund: title ist das, was Sie in Swagger UI, in Postman, in Tests sehen. Wenn Sie dort alles Mögliche hineinschreiben, werden Sie nach einer Woche selbst nicht mehr verstehen, welcher Fehler welcher ist. Ein stabiler title macht die Diagnose einfacher. Und ja, das ist der seltene Fall, wo Disziplin nicht Langeweile, sondern Zeitersparnis ist.

status: Status sowohl im HTTP als auch im Body

Auf den ersten Blick wirkt die Duplizierung des Status seltsam: „Aber der Status ist doch schon im HTTP-Response!“. Ja, das ist er. Aber in einem vertraglichen Denken sind das keine „überflüssigen Bytes“, sondern Selbstständigkeit. Fehler leben oft länger als eine einzelne HTTP-Antwort: sie werden protokolliert, weitergeleitet, in Berichten gespeichert, in Tests analysiert. Und dort ist es nicht immer praktisch, „den Status aus den Metadaten zu holen“ – manchmal sieht man nur das JSON.

Außerdem hilft das Vorhandensein von status im Body, schnell zu überprüfen, dass Ihr @ControllerAdvice keinen Fehler gemacht hat. Es ist wie ein Sicherheitsgurt: Eigentlich fährt man schon vorsichtig, aber es ist gut, dass er da ist. Wenn Sie versehentlich HTTP 404 zurückgeben, aber im Body status: 500 steht, bemerken Sie sofort die Diskrepanz. Eine Diskrepanz der Status führt direkt zu Misstrauen des Clients: Er weiß nicht mehr, was er glauben soll.

In Spring setzt ProblemDetail.forStatusAndDetail(...) normalerweise den Status automatisch. Das ist praktisch, entbindet aber nicht von der Verantwortung. Man muss darauf achten, dass der tatsächliche HTTP-Status der Antwort mit dem übereinstimmt, den Sie serialisieren. Andernfalls spricht Ihre API gleichzeitig mit verschiedenen Stimmen – wie ein Chat, in dem einer „alles ok“ schreibt und ein anderer daneben „wir sterben alle“ schreit.

detail: Konkretheit des aktuellen Falls

detail ist der Ort, an dem Sie den konkreten Vorfall beschreiben. Wenn title „Wie heißt das Problem?“ ist, dann ist detail „Was genau ist gerade passiert?“. Hier gehört normalerweise die taskId, der Wert des falschen Parameters, der aktuelle Ressourcenstatus, das erwartete Format usw. Aber man sollte nicht übers Ziel hinausschießen und anfangen, interne Details preiszugeben.

Eine gute Formel für detail: Der Text sollte für einen externen Client verständlich sein, der Ihre Java-Klassen, Ihre Pakete und den Namen Ihres Repositorys nicht kennt. Eine Meldung wie "com.example.tasktracker.TaskServiceImpl threw NullPointerException" ist eine hervorragende Möglichkeit, der Welt mitzuteilen, was in Ihrem Inneren passiert, und gleichzeitig eine hervorragende Möglichkeit, den Client hilflos zu machen: Er versteht trotzdem nicht, was er tun soll.

Ein weiterer Punkt: detail muss nicht superstabil sein. Es kann von Anfrage zu Anfrage variieren, weil es unterschiedliche IDs und Werte enthält. Das ist normal. Stabilität werden wir später durch code sicherstellen; jetzt reicht es zu verstehen: detail ist eine nützliche Konkretheit für Menschen und für schnelle Diagnose.

instance: Bindung an die Anfrage

instance ist ein URI, der den konkreten Fall des Problems identifiziert. Am einfachsten interpretiert man ihn in einer REST-API als Pfad der Anfrage, z.B. /api/v1/tasks/{taskId}. Dadurch sehen der Client und Sie selbst (in den Logs), zu welchem Endpunkt der Fehler gehört. Das hilft sogar dann, wenn Sie aus Versehen eine Anfrage an die falsche Adresse gesendet haben oder wenn derselbe code/title an verschiedenen Stellen auftreten kann.

In unserem Lehrprojekt deckt instance oft das Bedürfnis nach einem separaten Feld path ab. Ja, man könnte path als extension field hinzufügen, aber das ist eine unnötige Duplizierung, wenn instance bereits den URI der Anfrage zeigt. Je weniger doppelte Felder, desto einfacher ist es, den Vertrag zu pflegen: weniger Stellen, an denen man versehentlich voneinander abweichen kann.

Praktischer Tipp: instance sollte so nah wie möglich am tatsächlichen URI der Anfrage sein. Wenn Sie die Zeichenkette manuell zusammenbauen und einen Fehler machen (z.B. /api/v1 vergessen oder taskId vertauschen), erhalten Sie einen „Fehler über den Fehler“ – die Antwort enthält einen falschen Verweis auf die Anfrage. Daher nimmt man im echten Code oft den URI aus der Anfrage (z.B. über HttpServletRequest). Wir zeigen vorerst eine vereinfachte, lehrbuchhafte Variante – manuell, aber mit der richtigen Idee.

4. Beispiele und Einschränkungen in der Task Tracker API

Minimale Implementierung: TaskNotFoundException → ProblemDetail

Jetzt bauen wir ein minimales Beispiel in unserem Projekt Task Tracker API, ohne zusätzliche Felder und ohne das Modell zu verkomplizieren. Wir haben bereits eine globale Fehlerbehandlungsschicht auf Basis von @ControllerAdvice. Wir fügen (oder präzisieren) einen Handler für TaskNotFoundException hinzu, der ein ProblemDetail mit gefüllten type, title, status, detail, instance zurückgibt.

Unten ist genau das minimale Basis-Set: nur die Standardfelder von ProblemDetail. Darauf können später code, fieldErrors und diagnostische Felder gesattelt werden, aber das Gerüst type/title/status/detail/instance bleibt von nun an unverändert.

Beginnen wir mit der Domänenausnahme. Sie lebt in com.example.tasktracker.domain.exception und soll die taskId mitteilen können, damit @ControllerAdvice ein menschenlesbares detail zusammenbauen kann. Beachten Sie: Wir speichern die taskId als separates Feld und versuchen nicht, sie aus dem Nachrichtentext zu parsen (Zeichenketten parsen ist immer ein Weg des Leidens).

public class TaskNotFoundException extends RuntimeException {

    // Identifier der Aufgabe, die nicht gefunden wurde – wird für ProblemDetail.detail benötigt
    private final String taskId;

    public TaskNotFoundException(String taskId) {
        // Wir speichern die ID als separates Feld, um sie nicht aus dem Nachrichtentext parsen zu müssen
        this.taskId = taskId;
    }

    public String getTaskId() {
        // Geben die ID zurück, damit der Fehlerhandler eine menschenlesbare Detailangabe erstellen kann
        return taskId;
    }
}

Jetzt der Handler in unserem GlobalExceptionHandler (der in der Web-Schicht liegt, z.B. im Paket com.example.tasktracker.api.error). Hier erstellen wir ein ProblemDetail und füllen die Felder. status und detail werden über forStatusAndDetail gesetzt, die restlichen manuell, damit das Gerüst vollständig definiert ist.

@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
    // Die Basis (status + detail) lässt sich bequem über die ProblemDetail-Factory setzen
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "Task with id " + ex.getTaskId() + " was not found"
    );

    // type – stabiler Identifikator der Problemklasse
    pd.setType(URI.create("/problems/task-not-found"));

    // title – kurzer, stabiler Name für Menschen
    pd.setTitle("Task not found");

    // instance – Bindung an die konkrete Anfrage/Ressource
    pd.setInstance(URI.create("/api/v1/tasks/" + ex.getTaskId()));

    return pd;
}

Wenn der Client nun GET /api/v1/tasks/{taskId} mit einer nicht existierenden ID aufruft, erhält er eine Antwort etwa in dieser Form (vereinfachtes JSON-Beispiel). Und das sieht bereits wie ein Vertrag aus: klar, was passiert ist, zu welcher Anfrage es gehört, welcher Status und welcher Problemtyp.

{
  "type": "/problems/task-not-found",
  "title": "Task not found",
  "status": 404,
  "detail": "Task with id 7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12 was not found",
  "instance": "/api/v1/tasks/7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12"
}

Und noch ein kleiner, aber wichtiger Punkt: Der Client sollte sehen, dass es sich um Problem Details handelt. Das heißt, der Content-Type der Antwort wird application/problem+json sein. Spring MVC kann das, wenn Sie den richtigen Weg über ProblemDetail gehen und die Unterstützung für Problem Details in der Projektkonfiguration aktiviert haben.

Ein Gerüst für 404, 400 und 409

Natürlich möchte man sofort ein „ideales Fehlermodell für alle Lebenslagen“ haben. Aber das heutige Ziel ist bescheidener und wichtiger: sich zu vergewissern, dass das Basisgerüst gleichermaßen für verschiedene Problemtypen passt. Sowohl 404 Not Found als auch 400 Bad Request und 409 Conflict – unterschiedliche Bedeutungen, aber die Antwortform sollte wiedererkennbar bleiben. Der Client soll den Fehler an seiner Form so leicht „erkennen“, wie Sie eine JSON-Antwort einer Liste am Feld items erkennen.

Das heißt, die Werte und zusätzlichen extension fields ändern sich, aber der Basis-Satz type/title/status/detail/instance bleibt derselbe. Zum Beispiel kann dasselbe Gerüst auch für einen Konflikt verwendet werden. Wir gehen noch nicht darauf ein, wann genau ein 409 nötig ist, aber wir zeigen, dass sich die Struktur nicht ändert: Es ändern sich nur status, type, title, detail, instance.

// Beispiel: Konflikt des Geschäftszustands, wenn die Operation im aktuellen Aufgabenstatus nicht erlaubt ist
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT,
        "Current task state does not allow this operation"
);

// Klassifizieren des Problems mit einem stabilen type, damit es gruppiert werden kann
pd.setType(URI.create("/problems/state-conflict"));
pd.setTitle("State conflict");

// Binden des Fehlers an die konkrete Ressource/Anfrage
pd.setInstance(URI.create("/api/v1/tasks/42"));

Und dasselbe für 400, wenn die Anfrage insgesamt „nicht der richtigen Form“ ist (z.B. der Anfragetext die grundlegende Validierung nicht besteht). Wieder ohne Vertiefung in fieldErrors – nur das Gerüst.

// Beispiel: Anfrage ist formal ungültig (z.B. besteht die Basisvalidierung nicht)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Request body is invalid"
);

// type und title bleiben stabil, damit der Client die Fehlerkategorie leichter versteht
pd.setType(URI.create("/problems/invalid-input"));
pd.setTitle("Invalid input");

// instance hilft zu verstehen, zu welchem Endpunkt der Fehler gehört (besonders in Logs/Tests)
pd.setInstance(URI.create("/api/v1/tasks"));

Das Wichtigste, was man spüren sollte: Wir machen kein separates „404-Format“, separates „409-Format“ und drittes „400-Format“. Wir bauen eine einzige Fehlersprache, bei der sich der Inhalt, aber nicht die Grammatik ändert.

Was nicht in ProblemDetail landen sollte

Selbst das schönste ProblemDetail kann man mit einer Angewohnheit verderben: „Ach, lassen Sie uns einfach ex.getMessage() nach außen geben“. Manchmal ist das sicher (wenn die Ausnahme Ihre eigene ist und die Nachricht vorbereitet ist), aber oft führt es zum Auslaufen interner Details. Und das ist nicht Paranoia, sondern Vertrag: Der Client darf nicht von Ihren internen Texten abhängen und erst recht keine technischen Klassennamen und Stacktraces sehen.

Wenn Sie einen Stacktrace ausgeben, tun Sie gleich zwei Dinge. Erstens geben Sie potenziell sensible Systeminformationen preis. Zweitens machen Sie den Client von zufälligem Text abhängig: Morgen ändern Sie die Nachricht in der Ausnahme ein wenig, und plötzlich fallen Frontend-Tests, weil jemand (nicht Sie natürlich, sondern „jemand“) Zeichenketten verglichen hat. Das ist, als würde man eine Integration auf der Farbe eines Buttons in einer fremden Anwendung aufbauen: Solange der Button blau ist, ist man glücklich, sobald er grün wird, bricht alles zusammen.

Daher halten wir im minimalen Gerüst den externen Text bewusst unter Kontrolle. title und type sind stabil. detail ist konkret, aber extern. instance ist an die Anfrage gebunden. Interne Details bleiben in den Server-Logs, wo sie hingehören.

5. Typische Fehler beim Zusammenbau von ProblemDetail

An dieser Stelle möchte man sagen: „Na, das ist ja alles einfach.“ Und ja, mechanisch ist es einfach. Aber genau die einfachen Dinge brechen den Vertrag am häufigsten – nicht weil es schwer wäre, sondern weil „ach, das bessern wir später aus“. Bei API-Fehlern wird „später“ schnell zu „nie“, weil sich die Clients bereits an das krumme Format gewöhnt haben und eine Änderung schmerzhaft wird.

Fehler Nr. 1: title und detail verwechseln.
Ein sehr verbreitetes Bild: In title steckt „Task with id ... not found“ und detail wird allgemein „Not found“. Dadurch ist der Titel jedes Mal anders, er kann nicht als stabile Bezeichnung verwendet werden, und detail verliert seine konkrete Bedeutung. Die Heilung ist einfach: title kurz und allgemein, detail konkret und „über diesen Fall“.

Fehler Nr. 2: instance nicht füllen, weil „der Client kennt die URL ja“.
Ja, der Client kennt sie. Aber wenn der Fehler in Logs, Bug-Reports, Testberichten, Monitor-Alarmen landet, kann die URI verloren gegangen oder nicht offensichtlich sein. instance macht den Fehler in sich geschlossen. Das ist eine kostengünstige Gewohnheit, die viel Zeit spart.

Fehler Nr. 3: type standardmäßig lassen und nicht über die Fehlerklassifizierung nachdenken.
Wenn type immer gleich ist oder nicht gesetzt wird, verlieren Sie die Möglichkeit, Fehler sinnvoll zu gruppieren. Selbst wenn Sie keine Dokumentationsseite zu type erstellen, hilft bereits die Existenz stabiler type – in Logs, Tests, zukünftiger Dokumentation.

Fehler Nr. 4: Versuchen, eine rohe Ausnahmemeldung, insbesondere von Frameworks, in detail zu packen.
Framework-Meldungen können lang, technisch und zwischen Versionen instabil sein. Heute so, morgen anders – und Sie werden sich selbst wundern, warum Ihr "Fehlervertrag" nach einem Abhängigkeitsupdate "kaputt" ist. detail sollte Ihr eigener, externer und kontrollierter Text sein.

Fehler Nr. 5: Unterschiedliche Fehlerformate für verschiedene Controller machen.
Ein Controller gibt ProblemDetail zurück, ein anderer { "error": "..." }, ein dritter einen einfachen String. Dem Client bleibt nur Weinen und das Schreiben von drei Parsern. Der einzig vernünftige Ausweg ist, die Fehlerbehandlung zu zentralisieren und das Gerüst überall gleich zu halten. Dann wird die API wirklich vorhersagbar.

1
Aufgabe
Spring REST & MVC, Level 21, Lektion 0
Gesperrt
404 für ein fehlendes Buch mit minimalem ProblemDetail
404 für ein fehlendes Buch mit minimalem ProblemDetail
1
Aufgabe
Spring REST & MVC, Level 21, Lektion 0
Gesperrt
Fehler des Query-Parametertyps im einheitlichen ProblemDetail-Gerüst
Fehler des Query-Parametertyps im einheitlichen ProblemDetail-Gerüst
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION