RFC 9457 erläutert: Standardisierung der Fehlerantworten von HTTP-APIs
RFC 9457 Problem Details stellt HTTP-APIs ein standardisiertes Fehlerformat zur Verfügung. Was jedes Feld bedeutet und wie es richtig in NestJS mit einem globalen Filter implementiert werden kann.
Fast jede API erzeugt letztendlich ein Fehler-Payload, das ungefähr so aussieht:
{
"message": "Something went wrong",
"error": "Bad Request",
"statusCode": 400
}
Das Problem ist, dass diese Struktur fast immer mit dem jeweiligen Framework verbunden ist, das sie erzeugt hat. NestJS, Express, Spring, Rails sowie unzählige andere Frameworks erzeugen jeweils ihre eigene Variante des Fehlerobjekts mit unterschiedlichen Feldnamen und Strukturen.
RFC 9457 mit dem Titel Problem Details for HTTP APIs begegnet diesem Problem, indem es eine standardisierte Struktur für die Fehlerkörper von HTTP-APIs vorschlägt.
Die Spezifikation wurde im Juli 2023 veröffentlicht und ersetzt die frühere RFC 7807. Die Kernidee ist einfach: Anstatt dass jede API ihr eigenes, maßgeschneidertes Fehlerformat entwirft, kann jeder auf eine gemeinsame, maschinenlesbare Struktur zurückgreifen.
Das Problem, das es löst
Ein HTTP-Statuscode gibt Ihnen eine allgemeine Kategorie an darüber, was passiert ist:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error
Doch die Erkenntnis, dass man einen 400-Status erhält, erklärt nicht die tatsächliche Ursache des Fehlers.
Nehmen wir beispielsweise diese Antwort:
HTTP/1.1 409 Conflict
Diese einzige Statuszeile kann auf einen bereits genutzten Benutzernamen, eine bereits vorhandene Ressource, eine Versionsunterschiede oder zahlreiche andere Konflikte hindeuten.
RFC 9457 löst dieses Problem, indem es strukturierte, beschreibende Details direkt im Antwortkörper enthält, sodass ein Client herausfinden kann, was schiefgelaufen ist, ohne eine framework-spezifische Fehlerstruktur analysieren zu müssen.
Das Problemdetails-Objekt
Eine nach dieser Spezifikation formulierte Antwort ist ein JSON-Dokument, das mit diesem Medientyp bereitgestellt wird:
Content-Type: application/problem+json
Die Spezifikation definiert fünf Standardfelder:
{
"type": "https://example.com/problems/username-taken",
"title": "Username already exists",
"status": 409,
"detail": "The username 'ali' is already registered.",
"instance": "/users"
}
Keines dieser fünf Felder ist zwingend erforderlich, doch jedes erfüllt eine eigene Funktion.
type
Dieses Feld identifiziert die Kategorie des Problems, das gemeldet wird.
{
"type": "https://example.com/problems/username-taken"
}
Betrachten Sie es als maschinenlesbaren Label für den Fehler. Idealerweise verweist die URI in type auf echte Dokumentation, die das Problem sowie die richtige Reaktion des Clients darauf erläutert.
Wenn type weggelassen wird, gilt es als wäre es auf folgenden Wert gesetzt:
about:blank
Von Clients wird erwartet, dass sie sich an dieser type-URI als kanonischem Identifikator orientieren, anstatt versuchen, Bedeutungen aus dem detail-Text zu extrahieren.
title
Es handelt sich um ein kurzes, für Menschen verständliches Label, das die Art des Problems beschreibt.
{
"title": "Username already exists"
}
Da title die allgemeine Problemart und nicht einen bestimmten Fall beschreibt, wird erwartet, dass es bei verschiedenen Auftreten desselben Fehlers konstant bleibt.
status
Dies spiegelt den mit dem Fehler verbundenen HTTP-Statuscode wider.
{
"status": 409
}
Eine Nuance, die beachtet werden sollte: Dieses Feld ist nur als Empfehlung gedacht.
Der tatsächliche, verbindliche Status ist der, den die eigentliche HTTP-Antwort enthält:
HTTP/1.1 409 Conflict
Wenn der Server den Antwortkörper erstellt, sollte der status-Wert mit diesem echten Antwortcode übereinstimmen.
detail
Dieses Feld liefert eine für Menschen lesbare Beschreibung, die speziell auf diesen bestimmten Fehlerfall zutrifft.
{
"detail": "The username 'ali' is already registered."
}
Verlassen Sie sich nicht auf detail für irgendeine maschinelle Logik.
Vor allem sollten Sie Client-Code wie folgenden vermeiden:
if (error.detail.includes("already registered")) {
// ...
}
Für alles, was programmatisch überprüft werden muss, verlassen Sie sich lieber auf type oder auf ein benutzerdefiniertes Erweiterungsmitglied.
instance
Dieses Feld verweist auf den konkreten Fall des gemeldeten Problems.
{
"instance": "/users"
}
Es ist nützlich, wenn Sie einen Fehler nachverfolgen, einem Benutzer helfen, Protokolseinträge zusammenführen oder eine bestimmte Anfrage untersuchen müssen.
Es dient einem anderen Zweck als type:
type -> what kind of problem is this?
instance -> which occurrence of that problem is this?
Erweiterungen
Die Spezifikation lässt absichtlich Raum für zusätzliche Felder neben dem Kernset.
Zum Beispiel könnte eine Antwort auf einen Validierungsfehler so aussehen:
{
"type": "https://example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"errors": [
{
"field": "email",
"message": "Invalid email address"
},
{
"field": "password",
"message": "Password is too short"
}
]
}
Hier ist errors nicht Teil der RFC 9457-Spezifikation selbst – es handelt sich um ein Erweiterungsmitglied, das Ihre API selbst definiert.
Diese Flexibilität ist einer der größten Vorteile der Spezifikation: Sie erhalten eine gemeinsame, vorhersehbare Struktur, während Sie dennoch frei sind, alle für Ihre Anwendung notwendigen domänenspezifischen Details hinzuzufügen.
HTTP-Header
Antworten in diesem Format sollten folgendes festlegen:
Content-Type: application/problem+json
Kunden können ihre Unterstützung für dieses Format mit folgendem Signalisieren:
Accept: application/problem+json
Menschlich lesbare Felder wie title und detail können ebenfalls über standardmäßige HTTP-Header verhandelt werden:
Accept-Language: en
wobei der Server mit etwas Ähnlichem antwortet:
Content-Language: en
Wenn ein Client vor einem erneuten Versuch warten sollte, kann der standardmäßige Retry-After-Header verwendet werden:
Retry-After: 30
Als Beispiel:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30
Mit einem Inhalt wie folgt:
{
"type": "https://example.com/problems/rate-limit",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded."
}
RFC 9457 erfindet keinen neuen Wiederholungsmechanismus – es nutzt einfach den vorhandenen HTTP-Retry-After-Header, wo er für den jeweiligen Problemtyp sinnvoll ist.
Was RFC 9457 nicht definiert
In der Spezifikation wird zu folgendem Stillschweigen bewahrt:
- Fehlercodes auf Anwendungs-Ebene
Ihr Anwendungsbereich beschränkt sich darauf, Ihnen eine standardisierte Form zur Beschreibung von Problemen auf HTTP-Ebene zu bieten.
Das bedeutet, eine Antwort wie diese ist vollständig konform:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "Request validation failed.",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "email",
"message": "Invalid email address"
}
]
}
In diesem Beispiel sind code und errors Erweiterungen, die speziell für diese API bestimmt sind und nicht Teil der Basisspezifikation.
Praktische Regeln für Entwickler
Lassen Sie niemals Stacktraces oder rohe Ausnahmennachrichten in Produktionsantworten durchsickern.
Vermeiden Sie Folgendes:
{
"detail": "QueryFailedError: duplicate key value violates unique constraint..."
}
Ziehen Sie lieber etwas wie dieses vor:
{
"type": "https://api.example.com/problems/conflict",
"title": "Resource conflict",
"status": 409,
"detail": "The username is already in use."
}
Vermeiden Sie außerdem, dass Clients darauf angewiesen sind, den detail-String zu parsen.
Tun Sie das nicht:
if (error.detail === "User already exists") {
}
Prüfen Sie stattdessen das type-Feld:
if (error.type === "https://api.example.com/problems/user-exists") {
}
Betrachten Sie type als einen stabilen Identifikator.
Ein bestimmter type sollte immer dieselbe Kategorie von Problem darstellen, auch wenn sich detail bei den einzelnen Vorkommnissen natürlich unterscheiden kann.
Wehren Sie sich gegen den Drang, für jeden Controller oder Endpunkt eine neue Fehlerstruktur zu erfinden. Wählen Sie eine einheitliche Problem-Details-Schicht für Ihre API und erweitern Sie diese nur dann mit benutzerdefinierten Feldern, wenn es wirklich notwendig ist.
Zuletzt: Verwechseln Sie keine Problem-Details-Antwort mit einem normalen, erfolgreichen Payload. Dieses Format existiert speziell zur Beschreibung von Fehlern und gehört zu den Statuscodes 4xx sowie 5xx.
RFC 9457 in NestJS umsetzen
Eine ordentliche Methode, dies in NestJS zu handhaben, besteht darin, die Logik in einem globalen Ausnahmefilter zu zentralisieren.
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Request, Response } from 'express';
interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
[key: string]: unknown;
}
@Catch()
export class ProblemDetailsExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let detail = 'An unexpected error occurred.';
let extensions: Record<string, unknown> = {};
if (exception instanceof HttpException) {
status = exception.getStatus();
const errorResponse = exception.getResponse();
if (typeof errorResponse === 'string') {
detail = errorResponse;
} else if (typeof errorResponse === 'object' && errorResponse !== null) {
const body = errorResponse as Record<string, unknown>;
if (typeof body.message === 'string') {
detail = body.message;
}
const {
statusCode: _statusCode,
error: _error,
message: _message,
...rest
} = body;
extensions = rest;
}
}
const problem: ProblemDetails = {
type: 'about:blank',
title: HttpStatus[status] ?? 'HTTP Error',
status,
detail,
instance: request.originalUrl,
...extensions,
};
response
.status(status)
.type('application/problem+json')
.json(problem);
}
}
Dann verbinden Sie es im gesamten Anwendungsrahmen:
app.useGlobalFilters(
new ProblemDetailsExceptionFilter(),
);
Anstatt die Standardfehlerstruktur von NestJS zurückzugeben, wie zum Beispiel:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}
werden Ihre Endpunkte konsequent folgende Daten ausgeben:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User not found",
"instance": "/users/42"
}
Von dort aus können Sie eigene, stabile type-URI für spezifische Fehlerfälle definieren:
https://api.example.com/problems/user-not-found
https://api.example.com/problems/validation-error
https://api.example.com/problems/username-taken
https://api.example.com/problems/rate-limit
Diese Konfiguration verleiht Ihrer API ein vorhersehbares, einheitliches Fehlerkonzept, während NestJS’ eingebaute Ausnahmen sowie Ihre benutzerdefinierten Anwendungsfehler weiterhin funktionieren.