Fehlerantworten
Wofür eine Fehlerantwort da ist
Eine Schnittstelle wird von einem Programm benutzt, nicht von einem Menschen. Wenn etwas schiefgeht, muss dieses Programm allein entscheiden können, was jetzt zu tun ist:
| Was das Programm wissen muss | Was es daraus schließt |
|---|---|
| Habe ich einen Fehler gemacht? | Anfrage korrigieren — noch einmal senden bringt nichts |
| Hat der Server einen Fehler? | Später noch einmal versuchen |
| Welches Feld war falsch? | Dem Benutzer genau dieses Feld rot markieren |
Deshalb ist eine gute Fehlerantwort keine Höflichkeit. Sie ist die Hälfte der Schnittstelle.
HTTP 200 OK
{ "erfolg": false, "meldung": "Lieferant nicht gefunden" }
Der Statuscode sagt „alles in Ordnung", der Rumpf sagt das Gegenteil. Jeder Client, jeder Proxy und jeder Zwischenspeicher glaubt dem Statuscode.
Der Fehler steht im Statuscode. Der Rumpf erklärt ihn nur.
Den richtigen Statuscode wählen
Die erste Ziffer trennt die Verantwortung: 4xx — der Client war es. 5xx — der Server war es.
400 heißt: Deine Anfrage ist kaputt. Ein Pflichtfeld fehlt, ein Preis ist negativ, das JSON ist unvollständig. Der Client kann sie reparieren, ohne dass sich sonst etwas ändert.
409 heißt: Deine Anfrage ist in Ordnung — sie passt nur gerade nicht. Der Lieferant soll gelöscht werden, hat aber noch Artikel. Am Feldinhalt ist nichts zu reparieren; die Daten müssen sich ändern.
Ein einheitliches Format: ProblemDetail
Früher hat sich jedes Projekt sein eigenes Fehlerformat ausgedacht. Seit RFC 9457 gibt es dafür einen Standard, und Spring bringt ihn als Klasse ProblemDetail mit.
Eine Antwort sieht dann so aus — das ist eine echte Antwort aus dem Webshop-Projekt:
{
"title": "Nicht gefunden",
"status": 404,
"detail": "Es gibt keinen Lieferanten mit der Kennung 999999.",
"instance": "/api/v1/suppliers/999999"
}
| Feld | Bedeutung |
|---|---|
title | Kurzbezeichnung der Fehlerart — für alle Fehler dieser Art gleich |
status | derselbe Code wie im HTTP-Kopf |
detail | die Erklärung für diesen einen Fall |
instance | die Adresse, bei der es passiert ist |
type | optional: eine URL zur Beschreibung der Fehlerart |
Eingeschaltet wird das Format in der application.properties:
spring.mvc.problemdetails.enabled=true
server.error.include-stacktrace=never
Die zweite Zeile ist kein Schönheitsfehler-Fix, sondern Sicherheit: Ein Stacktrace verrät Paketnamen, Bibliotheksversionen und Dateipfade. Das ist Aufklärungsarbeit, die man Angreifern nicht abnehmen sollte.
Fachliche Ausnahmen
Der Service kennt kein HTTP. Wenn er einen Lieferanten nicht findet, wirft er eine fachliche Ausnahme:
public class SupplierNotFoundException extends RuntimeException {
public SupplierNotFoundException(Long id) {
super("Es gibt keinen Lieferanten mit der Kennung " + id + ".");
}
}
Drei Eigenschaften machen sie brauchbar:
- sie erbt von
RuntimeException— keinthrowsin jeder Signatur darüber - ihr Name sagt, was fachlich passiert ist, nicht welcher Statuscode herauskommen soll
- die Meldung entsteht dort, wo die Information vorliegt
ResponseStatusException?// verlockend, aber es zieht HTTP in den Service
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nicht gefunden");
Damit weiß der Service plötzlich von Statuscodes. Wird derselbe Service später von einem Zeitplan-Auftrag oder einer anderen Schnittstelle benutzt, trägt er HTTP-Wissen mit sich herum, das dort niemanden interessiert.
Für einen ersten Prototyp ist die Abkürzung in Ordnung. Sobald es einen Service gibt, gehört die Übersetzung an die Grenze — siehe DTOs und Schichten.
Die Übersetzungsstelle: @RestControllerAdvice
Genau eine Klasse übersetzt fachliche Ausnahmen in HTTP-Antworten. Sie gilt für alle Controller gleichzeitig:
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
@ExceptionHandler(SupplierNotFoundException.class)
public ProblemDetail handleNotFound(SupplierNotFoundException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Nicht gefunden");
return problem;
}
@ExceptionHandler(SupplierHasArticlesException.class)
public ProblemDetail handleConflict(SupplierHasArticlesException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Löschen nicht möglich");
return problem;
}
}
Ohne diese Klasse käme aus jeder fachlichen Ausnahme eine 500 — der Server würde sich für einen Fehler entschuldigen, den der Client gemacht hat.
@Order ist hier nicht optionalWeil spring.mvc.problemdetails.enabled=true gesetzt ist, bringt Spring bereits einen eigenen Ausnahmebehandler mit. Der liegt auf @Order(0) und fängt unter anderem die Verstöße der Bean Validation ab.
Ohne @Order(Ordered.HIGHEST_PRECEDENCE) kommt deine Methode für diese Fälle nie an die Reihe — und zwar lautlos: kein Fehler, keine Warnung, nur eine Antwort, die anders aussieht als geplant. Das ist die Sorte Fehler, die man eine Stunde lang sucht.
Validierungsfehler feldweise
@Valid am Parameter lässt die Bean Validation zuschlagen, bevor eine Zeile Fachlogik läuft:
@PostMapping
public ResponseEntity<SupplierDto> createSupplier(
@Valid @RequestBody CreateSupplierDto dto) { … }
Spring antwortet daraufhin von sich aus mit 400 — allerdings nur mit "detail": "Invalid request content.". Welches Feld gemeint war, erfährt der Client nicht. Deshalb bekommt auch dieser Fall eine eigene Methode:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(
error -> errors.put(error.getField(), error.getDefaultMessage()));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Die Anfrage enthält ungültige Felder.");
problem.setTitle("Ungültige Eingabe");
problem.setProperty("errors", errors);
return problem;
}
setProperty hängt ein zusätzliches Feld an das Standardformat — erlaubt und genau dafür vorgesehen. Die Antwort auf einen leeren Namen und eine leere Straße sieht dann so aus:
{
"title": "Ungültige Eingabe",
"status": 400,
"detail": "Die Anfrage enthält ungültige Felder.",
"instance": "/api/v1/suppliers",
"errors": {
"contact.street": "Die Straße darf nicht leer sein.",
"name": "Der Name darf nicht leer sein."
}
}
Zwei Dinge daran sind bemerkenswert:
- Es kommen alle Verstöße auf einmal, nicht der erste. Der Benutzer korrigiert das Formular einmal statt viermal.
- Verschachtelte Felder tragen ihren Pfad:
contact.street. Voraussetzung ist ein@Validauch am inneren Feld des DTOs — ohne das prüft die Validierung nur die äußere Hülle.
Damit die Meldungen brauchbar sind, gehören sie an die Annotation:
@NotBlank(message = "Der Name darf nicht leer sein.")
String name
Ohne message steht dort „must not be blank" — technisch richtig, im Formular unbrauchbar.
"name": "Der Name darf nicht leer sein." — der Schlüssel ist ein Bezeichner aus dem Quelltext und bleibt englisch. Der Text daneben ist für Menschen und darf in der Sprache der Anwendung stehen.
Was der Server nie preisgeben darf
| Nicht in die Antwort | Warum |
|---|---|
| Stacktraces | verraten Bibliotheken, Versionen und Pfade |
| SQL-Fragmente | zeigen Tabellen- und Spaltennamen |
| „User admin existiert nicht" | verrät, welche Benutzernamen es gibt |
| Interne Kennungen fremder Datensätze | lädt zum Durchprobieren ein |
Die Regel: So viel, dass der Client seinen Fehler beheben kann. Kein Wort mehr.
Prüfliste für eine Fehlerantwort
- Der Statuscode passt zur Ursache — und ist nie 200
- 4xx nur, wenn der Client etwas ändern kann; sonst 5xx
- Die Antwort ist ein
ProblemDetail, kein selbst erfundenes Format -
detailbeschreibt diesen Fall,titledie Fehlerart - Bei Validierungsfehlern steht drin, welches Feld es war
- Kein Stacktrace, kein SQL, keine internen Pfade
- Die Übersetzung steht in einer
@RestControllerAdvice-Klasse, nicht in jedem Controller -
@Order(Ordered.HIGHEST_PRECEDENCE), wennproblemdetailseingeschaltet ist - Für jeden Fehlerfall gibt es einen Test, der den Statuscode prüft
Das Wichtigste in Kürze
- Der Fehler steht im Statuscode, der Rumpf erklärt ihn nur.
- 4xx = der Client kann etwas ändern, 5xx = er kann nur warten.
- 400 = Anfrage kaputt, 409 = Anfrage in Ordnung, passt nur nicht zum Zustand.
ProblemDetail(RFC 9457) ist das Standardformat — nicht selbst eines erfinden.- Fachliche Ausnahmen heißen nach der Fachlichkeit, nicht nach dem Statuscode.
- Eine
@RestControllerAdvice-Klasse übersetzt sie — mit@Order(Ordered.HIGHEST_PRECEDENCE). - Validierungsfehler nennen jedes betroffene Feld, mit eigener
message.
Weiterlesen
- HTTP kompakt — die Statuscodes im Überblick
- DTOs und Schichten — warum HTTP nicht in den Service gehört
- Automatisiert testen — Fehlerfälle prüft man am besten im Slice-Test
- Gästebuch-Tutorial — dort baust du diese Übersetzungsstelle zum ersten Mal