Webentwicklung

Problem Details für eine kleine PHP-API - Fehler stabil strukturieren, ohne HTTP zu verstecken

Problem Details für eine kleine PHP-API - Fehler stabil strukturieren, ohne HTTP zu verstecken

Eine API kann 422 zurückgeben, doch ein Client muss möglicherweise weiterhin wissen, ob ein Titel fehlt, ein Datum ungültig ist oder ein Zustandsübergang nicht mehr erlaubt ist. Ein Statuscode vermittelt die allgemeine HTTP-Bedeutung der Antwort. Für sich allein stellt er jedoch keinen stabilen Fehlervertrag auf Anwendungsebene bereit.

Die übliche Reaktion besteht darin, eine eigene JSON-Hülle zu erfinden: vielleicht {"error":"invalid"} an einem Endpunkt und {"success":false,"message":"..."} an einem anderen. Das funktioniert, bis Clients mehrere Formen erlernen müssen, menschliche Prosa versehentlich zur Kennung wird oder ein Vermittler 200 OK für eine tatsächlich fehlgeschlagene Anfrage erhält.

RFC 9457, Problem Details for HTTP APIs, bietet eine enger begrenzte Lösung. Der Standard definiert ein kleines Objekt zur Beschreibung von Fehlern in HTTP-APIs, ohne die HTTP-Statussemantik zu ersetzen oder die Geschäftsregeln einer Anwendung festzulegen. Dieser Artikel untersucht, wie eine kleine PHP-API dieses Format sachgerecht einsetzen kann: was jedes Feld bedeutet, was anwendungsspezifisch bleibt und welche Informationen nicht in die Antwort gehören.

Statuscode und Body haben unterschiedliche Aufgaben

HTTP besitzt bereits ein Vokabular für das allgemeine Ergebnis einer Anfrage. RFC 9110, Abschnitt 15 definiert Statuscode-Semantiken, die Clients, Proxys, Caches und andere generische Software verstehen können. 404 besagt, dass der Ursprungsserver keine aktuelle Repräsentation der Zielressource gefunden hat; 409 beschreibt einen Konflikt mit dem aktuellen Zustand der Ressource; 500 beschreibt einen unerwarteten Serverzustand.

Diese Kategorien sollten im tatsächlichen HTTP-Status erhalten bleiben. Problem Details ergänzt einen strukturierten Body für API-spezifische Bedeutung. Das Format rechtfertigt weder eine Antwort mit 200, in deren JSON ein Fehler verborgen wird, noch verlangt es einen Body, wenn der Statuscode bereits alles Nützliche ausdrückt.

RFC 9457 erklärt, dass das Format mit jedem HTTP-Statuscode verwendet werden kann, obwohl es am natürlichsten zu 4xx- und 5xx-Antworten passt. Der Standard weist außerdem darauf hin, dass ein vorhandenes domänenspezifisches Format geeigneter sein kann. Die Einführung ist daher eine Vertragsentscheidung und keine Behauptung, jede Fehlerantwort im Web müsse gleich aussehen.

Die fünf Standardfelder sind keine fünf Pflichtfelder

Ein JSON-Problem-Dokument verwendet den Medientyp application/problem+json. Der Standard definiert fünf Felder, die im JSON-Schema sämtlich optional sind und unterschiedliche Aufgaben haben:

FeldAufgabeWas ein Client annehmen sollte
typeEine URI-Referenz zur Identifikation des ProblemtypsNach der URI-Auflösung ist dies die primäre maschinenlesbare Kennung.
statusDer HTTP-Statuscode des Ursprungsservers für diesen VorfallEr ist informativ; generisches HTTP-Verhalten folgt der Statuszeile der Antwort.
titleEine kurze, menschenlesbare Zusammenfassung des TypsSie sollte außer bei Lokalisierung über verschiedene Vorfälle hinweg stabil bleiben.
detailEine menschenlesbare Erklärung dieses VorfallsBei Bedarf anzeigen, aber nicht als Kennung parsen.
instanceEine URI-Referenz zur Identifikation dieses konkreten VorfallsSie kann eine undurchsichtige Korrelationskennung sein und muss nicht abrufbar sein.

Fehlt type, wird sein Wert als about:blank behandelt. Dieser Typ bedeutet, dass das Problem keine zusätzliche Semantik über den HTTP-Statuscode hinaus besitzt. In diesem Fall sollte der Titel der empfohlenen Statusphrase entsprechen, gegebenenfalls lokalisiert. Eine einfache Not-found-Antwort kann diesen Standard verwenden, ohne eine aufwendige URI zu prägen, die lediglich 404 umbenennt.

Das Feld status wiederholt den tatsächlichen Status absichtlich, etwa wenn ein Body ohne seine HTTP-Metadaten gespeichert wird. Ein Erzeuger, der es angibt, muss denselben Status in der Antwort senden. Ändert ein Proxy später den Antwortstatus, können beide Werte voneinander abweichen. Auch deshalb dürfen Clients die JSON-Zahl nicht als Ersatz für HTTP behandeln.

Ein Problemtyp ist ein stabiler Vertrag, kein Satz

Angenommen, eine Redakteurin oder ein Redakteur versucht, einen Artikel zu veröffentlichen, bevor dessen Pflichtfelder gültig sind. Die API könnte diesen Zustand folgendermaßen identifizieren:

{
  "type": "https://example.com/problems/article-not-publishable",
  "title": "The article is not ready to publish.",
  "status": 422,
  "detail": "Correct the highlighted fields and submit again.",
  "instance": "https://example.com/problem-occurrences/7f3a2c"
}

Der Client sollte anhand der aufgelösten type-URI verzweigen, nicht anhand des englischen Titels oder Details. Die Formulierung für Menschen kann verbessert und durch Lokalisierung vollständig verändert werden, ohne einen neuen Problemtyp zu erzeugen. Umgekehrt ist eine Änderung der Typbedeutung eine Vertragsänderung, selbst wenn seine URI unverändert bleibt.

RFC 9457 empfiehlt auflösbare Typ-URIs. Öffnet ein Entwickler eine HTTP- oder HTTPS-Typ-URI, sollte sie menschenlesbare Dokumentation bereitstellen: ihre Bedeutung, den üblichen Statuscode, relevante Erweiterungen und mögliche Abhilfen. Normale Clients sollten diese Dokumentation bei der Behandlung eines Fehlers nicht automatisch abrufen. Die URI ist zuerst eine Kennung und keine Anweisung, eine weitere Netzwerkabhängigkeit einzuführen.

Vor der Definition eines Typs kann ein API-Designer außerdem das fortlaufend gepflegte IANA-Register für HTTP Problem Types prüfen. Seine Registrierungsrichtlinie ist für allgemeine, durch Spezifikationen gestützte Typen gedacht, nicht für jede private Validierungsbedingung einer Anwendung. Anwendungsspezifische Typen können in einem stabilen URI-Namensraum liegen, den die Anwendung kontrolliert.

Ein bewusst kleiner PHP-Responder

PHP stellt die drei grundlegenden Operationen bereits bereit: den Antwortstatus setzen, vor der Ausgabe einen Content-Type setzen und JSON kodieren. Die folgende Hilfsfunktion schützt die Namen der Standardfelder davor, durch Erweiterungen ersetzt zu werden, und weist json_encode() an, bei einem Kodierungsfehler eine Exception auszulösen:

<?php
declare(strict_types=1);

function sendProblem(
    int $status,
    string $type,
    string $title,
    string $detail,
    ?string $instance = null,
    array $extensions = []
): void {
    $problem = [
        'type' => $type,
        'title' => $title,
        'status' => $status,
        'detail' => $detail,
    ];

    if ($instance !== null) {
        $problem['instance'] = $instance;
    }

    foreach ($extensions as $name => $value) {
        if (array_key_exists($name, $problem) || $name === 'instance') {
            throw new InvalidArgumentException('Extension name is reserved.');
        }
        $problem[$name] = $value;
    }

    $json = json_encode(
        $problem,
        JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
    );

    http_response_code($status);
    header('Content-Type: application/problem+json');
    echo $json;
    exit;
}

Das PHP-Handbuch dokumentiert http_response_code() zum Setzen des Status, während header() vor jeder Ausgabe aufgerufen werden muss. Die Dokumentation zu json_encode() weist außerdem darauf hin, dass Eingabezeichenketten UTF-8-kodiert sein müssen. Die Kodierung vor dem Senden der Header verhindert, dass diese Hilfsfunktion absichtlich nur die Hälfte eines Problem-Dokuments ausgibt, falls die JSON-Kodierung eine Exception auslöst.

Dies ist kein vollständiger Exception-Handler. Eine reale Anwendung benötigt weiterhin eine zentrale Zuordnung bekannter Domänenfehler zu öffentlichen Problemtypen, einen getrennten Pfad für unerwartete Exceptions, Logging, Lokalisierung, Autorisierung und Tests über den tatsächlichen Webserver. Die Hilfsfunktion setzt außerdem voraus, dass der Aufrufer einen sinnvollen 4xx- oder 5xx-Status sowie sichere Zeichenketten liefert. Ihre geringe Größe macht diese fehlenden Verantwortlichkeiten sichtbar, statt vorzugeben, eine Funktion habe die Fehlerbehandlung vollständig gelöst.

Validierungsdetails gehören in eine dokumentierte Erweiterung

Ein Formular kann in mehreren Feldern fehlschlagen und trotzdem einen einzigen Problemtyp darstellen: Die eingereichte Repräsentation ist ungültig. RFC 9457 veranschaulicht dies mit einer errors-Erweiterung. Die Form dieser Erweiterung gehört zur Definition des Problemtyps; errors ist kein universelles Standardfeld, das jeder Client bereits versteht.

sendProblem(
    422,
    'https://example.com/problems/invalid-article',
    'The article is not valid.',
    'Correct the listed fields and submit again.',
    '/problem-occurrences/7f3a2c',
    [
        'errors' => [
            ['pointer' => '#/title', 'code' => 'required'],
            ['pointer' => '#/publish_at', 'code' => 'invalid_date'],
        ],
    ]
);

Hier lokalisiert pointer die Eingabe und code liefert ein stabiles Anwendungstoken. Menschenlesbare Erklärungen können ergänzt werden, wenn der Vertrag sie benötigt. Clients müssen unbekannte Erweiterungsfelder ignorieren. Dadurch kann ein Problemtyp später kompatible Metadaten erhalten. Server sollten ebenso vermeiden, eine neue optionale Erweiterung heimlich für alte Clients verpflichtend zu machen.

Mehrere nicht zusammengehörige Fehler sind schwieriger. RFC 9457 empfiehlt, das relevanteste oder dringendste Problem darzustellen, wenn die Fehler nicht denselben Typ haben. Eine generische Liste unterschiedlicher Probleme ist zwar möglich, lässt sich jedoch schlecht auf die Semantik einer einzelnen HTTP-Antwort abbilden. Batch-Operationen benötigen ein ausdrückliches Domänendesign und nicht die Annahme, Problem Details definiere eine universelle Batch-Hülle.

Nützliche Details können zum Informationsleck werden

Ein Fehlerformat macht Informationen konsistent; es entscheidet nicht, welche Informationen sicher sind. Der Sicherheitsabschnitt von RFC 9457 warnt davor, Details über Implementierung, Systemzugriff und Privatsphäre von Nutzern offenzulegen. Das OWASP REST Security Cheat Sheet empfiehlt ebenfalls allgemeine öffentliche Fehlermeldungen statt Call-Stacks oder interner Hinweise.

Ein öffentliches detail sollte dem Aufrufer helfen, die Anfrage zu korrigieren, nicht aber SQL-Anweisungen, Dateisystempfade, Access Tokens, private Datensätze oder Stack-Traces offenlegen. Unerwartete Fehler können einen stabilen generischen Typ und eine undurchsichtige instance-Kennung zurückgeben, während geschützte Logs den Diagnosekontext aufbewahren. Die Kennung selbst darf kein Geheimnis kodieren, und jeder Endpunkt, der sie auflösbar macht, benötigt eine eigene Autorisierungsrichtlinie.

Auch Fehlerunterschiede können Zustand preisgeben. Einem anonymen Login-Client mitzuteilen, dass „das Konto existiert, aber das Passwort falsch ist“, verrät mehr als „die Anmeldedaten wurden nicht akzeptiert“. Problem Details setzt Threat Modeling nicht außer Kraft. Manchmal sollten zwei interne Ursachen absichtlich denselben öffentlichen Typ, Titel und Detailtext verwenden.

Den Vertrag auf der Leitung testen, nicht nur das PHP-Array

Eine nützliche Testsuite sollte die Antwort so beobachten, wie ein Client sie empfängt:

  • Der tatsächliche HTTP-Status und der optionale JSON-Wert status stimmen überein.
  • Die Antwort verwendet application/problem+json und enthält gültiges JSON.
  • Bekannte Typen behalten ihre Semantik, Titelrichtlinie und dokumentierten Erweiterungen.
  • Lokalisierte Prosa ändert keine maschinenlesbaren Kennungen.
  • Clients tolerieren unbekannte Erweiterungsfelder.
  • Unerwartete Exceptions legen weder Stack, Query, Pfad, Zugangsdaten noch private Objekte offen.
  • Der Wert von instance unterstützt Korrelation, ohne unbeabsichtigten Zugriff zu gewähren.
  • Proxys und Framework-Middleware ersetzen weder den beabsichtigten Status noch den Content-Type.

Eine Migration verdient dieselbe Sorgfalt. Hat eine bestehende API bereits ein stabiles Fehlerformat, können die Kosten eines Austauschs größer als der Nutzen sein. Content Negotiation, ein versionierter Endpunkt oder ein dokumentierter Übergang können sicherer sein, als jeden Fehler-Body direkt zu ändern. RFC 9457 soll unnötige neue Formate vermeiden und nicht funktionierende Verträge beseitigen, ohne ihre Verbraucher zu berücksichtigen.

Fazit

Problem Details ermöglicht einer kleinen PHP-API eine nützliche Arbeitsteilung. HTTP-Statuscodes behalten ihre Protokollbedeutung. Ein stabiler type identifiziert das Anwendungsproblem. title und detail richten sich an Menschen, instance kann einen einzelnen Vorfall korrelieren, und dokumentierte Erweiterungen transportieren strukturierte Informationen, die zu diesem Typ gehören.

Das Format ist absichtlich unvollständig. Es wählt weder den richtigen Status noch entwirft es Validierungscodes, schützt Diagnosedaten oder macht eine instabile API stabil. Das bleiben Designaufgaben. Mit Blick auf diese Grenze kann RFC 9457 Fehler berechenbarer machen, ohne vorzugeben, eine einzige JSON-Form erkläre jeden Fehlschlag.

References