Problem Details, Error Codes, Feldfehler und Correlation IDs
Schlechte API-Fehlerantworten kosten Entwicklungszeit – auf beiden Seiten. Wenn Consumers nur {"error": "Something went wrong"} bekommen, können sie weder programmatisch reagieren noch gezielt debuggen. RFC 9457 Problem Details for HTTP APIs, maschinenlesbare Error Codes, strukturierte Feldfehler und Correlation IDs machen Fehlerfälle zu einem First-Class-Feature der API – nicht zu einem Afterthought.
Inhaltsverzeichnis
- 1. Warum Fehlermodellierung ein API-Design-Problem ist
- 2. RFC 9457 Problem Details: der Standard für HTTP-Fehler
- 3. Maschinenlesbare Error Codes: beyond HTTP-Statuscodes
- 4. Strukturierte Feldfehler für Validierungsantworten
- 5. Correlation IDs: Fehler über Systemgrenzen verfolgen
- 6. Vollständige Symfony-Implementierung
- 7. Fehlerantworten im Vergleich
- 8. Zusammenfassung
- 9. FAQ
1. Warum Fehlermodellierung ein API-Design-Problem ist
Fehlerantworten in REST APIs werden häufig als Nebensache behandelt – schnell ein {"error": "Invalid input"} zurückgeben und weiterarbeiten. Das Ergebnis sind APIs, bei denen Consumer-Entwickler die Fehlerfälle nicht programmatisch verarbeiten können, bei denen das Debugging eines Produktionsfehlers Stunden dauert, und bei denen Fehlermeldungen mal auf Englisch, mal auf Deutsch kommen, mal mit einem message-Feld, mal mit error, mal mit errors als Array. Inkonsistente Fehlermodelle sind einer der häufigsten Auslöser für Reibung zwischen API-Anbietern und Consumer-Teams.
Eine professionelle Fehlermodellierung für REST APIs hat drei Ziele: Erstens müssen Fehler maschinenlesbar sein – Consumer-Code muss ohne Text-Parsing entscheiden können, wie er auf einen Fehler reagiert. Zweitens müssen Fehler debuggierbar sein – ein Entwickler muss anhand der Fehlerantwort den zugehörigen Logeintrag in Sekunden finden. Drittens müssen Fehler konsistent sein – jeder Endpunkt der API liefert Fehler im selben Format. RFC 9457 "Problem Details for HTTP APIs" ist der Industriestandard, der diese drei Ziele adressiert.
2. RFC 9457 Problem Details: der Standard für HTTP-Fehler
RFC 9457 (vormals RFC 7807) definiert ein JSON-Format für HTTP-Fehlerantworten: application/problem+json. Das Format definiert fünf Standard-Properties und erlaubt Erweiterungen. Das type-Feld ist eine URI, die den Fehlertyp eindeutig identifiziert und auf eine Dokumentationsseite zeigen kann. Das title-Feld ist ein menschenlesbarer, stabiler Kurztext für den Fehlertyp. Das status-Feld wiederholt den HTTP-Statuscode im Body für Systeme, die ihn aus dem Kontext verlieren. Das detail-Feld liefert eine kontextspezifische Beschreibung des konkreten Fehlers. Das instance-Feld ist eine URI, die auf die spezifische Fehlerinstanz zeigt – ideal für Log-Links.
Das Format erlaubt beliebige Erweiterungsfelder auf der Top-Level-Ebene. Hier fügt man correlation_id, code für maschinenlesbare Error Codes und errors für strukturierte Feldfehler ein. Der Content-Type der Antwort muss application/problem+json sein – nicht application/json. Das erlaubt Consumer-Code, auf Basis des Content-Types zu entscheiden, ob er eine Problem-Details-Antwort parsen soll. In der Praxis setzen viele APIs den regulären JSON-Content-Type aus Bequemlichkeit – ein Kompromiss, der die programmatische Unterscheidung erschwert.
// RFC 9457 Problem Details – vollständiges Beispiel mit Erweiterungen
{
"type": "https://mironsoft.de/api/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid or missing fields.",
"instance": "/api/products",
"correlation_id": "req_01HZX9KBQT4XZRMJ6K8VWPY7A",
"code": "VALIDATION_FAILED",
"errors": [
{
"field": "price",
"code": "MUST_BE_POSITIVE",
"message": "Der Preis muss größer als 0 sein.",
"rejected_value": -10.5
},
{
"field": "name",
"code": "MAX_LENGTH_EXCEEDED",
"message": "Der Name darf maximal 200 Zeichen lang sein.",
"rejected_value": "Ein sehr langer Produktname...",
"meta": { "max_length": 200, "actual_length": 347 }
}
]
}
3. Maschinenlesbare Error Codes: beyond HTTP-Statuscodes
HTTP-Statuscodes allein sind zu grob für programmatische Fehlerbehandlung in Client-Systemen. Ein HTTP 422 kann bedeuten: Pflichtfeld fehlt, Wert außerhalb des erlaubten Bereichs, ungültiges Format, Geschäftsregel verletzt, oder Duplicate-Key-Fehler. Maschinenlesbare Error Codes unterscheiden diese Fälle ohne Text-Parsing. Der Consumer kann auf code === "DUPLICATE_EMAIL" prüfen und dem Nutzer eine spezifische Fehlermeldung zeigen – unabhängig davon, in welcher Sprache die API-Antwort kommt.
Error Codes sollen als Konstanten definiert werden – idealerweise als PHP-Enum in Symfony. Das erzwingt einen kontrollierten Katalog aller möglichen Fehlercodes und verhindert, dass Codes ad-hoc als Magic Strings vergeben werden. Der Fehlerkatalog wird dann auch in der OpenAPI-Dokumentation referenziert, sodass Consumer-Entwickler alle möglichen Codes für einen Endpunkt kennen und ihre Fehlerbehandlung vollständig implementieren können. Das macht Error Codes zu einem expliziten API-Vertrag, nicht zu einem impliziten Implementierungsdetail.
<?php
// src/Api/Error/ApiErrorCode.php
// Typed error code enum for machine-readable error responses
declare(strict_types=1);
namespace App\Api\Error;
enum ApiErrorCode: string
{
// Validation errors
case VALIDATION_FAILED = 'VALIDATION_FAILED';
case FIELD_REQUIRED = 'FIELD_REQUIRED';
case MAX_LENGTH_EXCEEDED = 'MAX_LENGTH_EXCEEDED';
case INVALID_FORMAT = 'INVALID_FORMAT';
case VALUE_OUT_OF_RANGE = 'VALUE_OUT_OF_RANGE';
// Business logic errors
case DUPLICATE_EMAIL = 'DUPLICATE_EMAIL';
case INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK';
case ORDER_ALREADY_SHIPPED = 'ORDER_ALREADY_SHIPPED';
case PAYMENT_DECLINED = 'PAYMENT_DECLINED';
// Authorization errors
case ACCESS_DENIED = 'ACCESS_DENIED';
case RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
case TOKEN_EXPIRED = 'TOKEN_EXPIRED';
// System errors
case UPSTREAM_TIMEOUT = 'UPSTREAM_TIMEOUT';
case RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED';
public function toTitle(): string
{
return match ($this) {
self::VALIDATION_FAILED => 'Validation Failed',
self::DUPLICATE_EMAIL => 'Email Already Registered',
self::INSUFFICIENT_STOCK => 'Insufficient Stock',
self::ORDER_ALREADY_SHIPPED => 'Order Already Shipped',
self::RESOURCE_NOT_FOUND => 'Resource Not Found',
self::RATE_LIMIT_EXCEEDED => 'Too Many Requests',
default => 'API Error',
};
}
}
4. Strukturierte Feldfehler für Validierungsantworten
Wenn ein Nutzer ein Formular mit zwölf Feldern abschickt und die API mit {"error": "Validation failed"} antwortet, muss das Frontend alle Felder einzeln prüfen oder den Nutzer auffordern, alles nochmal zu überprüfen. Strukturierte Feldfehler liefern stattdessen eine Liste von Fehlern mit Feldpfad, Error Code und menschenlesbarer Meldung. Das erlaubt dem Frontend, jeden Fehler direkt am betroffenen Eingabefeld anzuzeigen – ohne eigene Logik für die Fehlerverteilung.
Der Feldpfad muss bei verschachtelten Objekten die vollständige Hierarchie abbilden: shipping_address.street ist präziser als street. Bei Array-Feldern gehört der Index dazu: items[2].quantity. In Symfony übersetzt man die ConstraintViolationList des Validators in diese Struktur. Das propertyPath der Violation wird direkt als Feldpfad verwendet. Das rejected_value hilft beim Debugging, sollte aber keine sensitiven Werte enthalten und bei langen Strings auf eine sinnvolle Länge gekürzt werden.
5. Correlation IDs: Fehler über Systemgrenzen verfolgen
Eine Correlation ID ist ein eindeutiger Bezeichner, der einem Request für sein gesamtes Leben zugewiesen wird – von der Annahme am API-Gateway über alle Microservices und Datenbankabfragen bis zur Antwort. Wenn ein Nutzer einen Fehler meldet und die Correlation ID aus seiner Antwort angibt, kann ein Entwickler in Sekunden alle zugehörigen Logeinträge über alle Systeme finden. Ohne Correlation IDs ist das Debugging von Produktionsfehlern in verteilten Systemen ein mühsames Durchsuchen von Logs nach Zeitstempeln und IP-Adressen.
Die Correlation ID wird entweder vom Client mitgesendet (X-Correlation-ID-Header) oder vom API-Gateway generiert. Der empfohlene Ansatz: Wenn der Client einen Header sendet, wird er akzeptiert und weiterverwendet. Wenn nicht, generiert der Server eine neue ID. Die ID wird in den Monolog-Kontext eingetragen, sodass jeder Logeintrag diese ID enthält. Sie erscheint im Response-Header und im Problem-Details-Body. Für die ID selbst empfiehlt sich ein Prefix-basiertes Format wie req_01HZX9KBQT4XZRMJ6K8VWPY7A (ULID oder UUID), das sofort als Correlation ID erkennbar ist.
<?php
// src/EventSubscriber/CorrelationIdSubscriber.php
// Correlation ID propagation through request lifecycle
declare(strict_types=1);
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Uid\Ulid;
final class CorrelationIdSubscriber implements EventSubscriberInterface
{
private const HEADER = 'X-Correlation-ID';
private string $correlationId = '';
public function __construct(
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 100],
KernelEvents::RESPONSE => ['onResponse', -100],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
// Accept client-provided ID or generate a new one
$this->correlationId = $request->headers->get(self::HEADER)
?? 'req_' . (new Ulid())->toBase32();
$request->attributes->set('correlation_id', $this->correlationId);
// Inject into logger context for all subsequent log entries
$this->logger->pushProcessor(static function (array $record) use (&$correlationId): array {
$record['extra']['correlation_id'] = $correlationId;
return $record;
});
}
public function onResponse(ResponseEvent $event): void
{
// Always expose correlation ID in response header
$event->getResponse()->headers->set(self::HEADER, $this->correlationId);
}
public function getCorrelationId(): string
{
return $this->correlationId;
}
}
6. Vollständige Symfony-Implementierung
In Symfony implementiert man die Problem-Details-Fehlerbehandlung am besten als zentralen Exception-Listener. Alle Domain-Exceptions werden in Problem-Details-Antworten übersetzt – keine try/catch-Blöcke in jedem Controller. Der Exception-Listener empfängt alle nicht behandelten Exceptions, prüft den Content-Type des Requests (Accept: application/json) und gibt eine strukturierte Problem-Details-Antwort zurück. Symfony API Platform macht das automatisch – für eigene Implementierungen braucht man einen kernel.exception-Listener.
Die Fehler-Hierarchie im Domain-Layer sollte von einer abstrakten DomainException erben, die bereits den Error Code als Property trägt. Das macht die Übersetzung im Listener trivial: Der Listener prüft ob es eine DomainException ist, liest den Error Code, mappt ihn auf einen HTTP-Statuscode und baut die Problem-Details-Antwort. System-Exceptions (Datenbankfehler, Netzwerkfehler) werden auf einen generischen HTTP 500 gemappt, ohne interne Details zu exponieren. Nur der Correlation-ID-Link erlaubt dem Support-Team, die internen Logs zu finden.
<?php
// src/EventSubscriber/ProblemDetailsExceptionListener.php
// Central exception-to-ProblemDetails translator
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Api\Error\ApiErrorCode;
use App\Exception\DomainException;
use App\Exception\ValidationException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ProblemDetailsExceptionListener implements EventSubscriberInterface
{
public function __construct(
private readonly CorrelationIdSubscriber $correlationIdSubscriber,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['onException', 0]];
}
public function onException(ExceptionEvent $event): void
{
$request = $event->getRequest();
$exception = $event->getThrowable();
// Only intercept JSON API requests
if (!str_contains($request->headers->get('Accept', ''), 'application/json')) {
return;
}
$correlationId = $this->correlationIdSubscriber->getCorrelationId();
[$status, $body] = match (true) {
$exception instanceof ValidationException => [
Response::HTTP_UNPROCESSABLE_ENTITY,
$this->buildValidationProblem($exception, $correlationId, $request->getPathInfo()),
],
$exception instanceof DomainException => [
$this->mapDomainStatus($exception->getErrorCode()),
$this->buildDomainProblem($exception, $correlationId, $request->getPathInfo()),
],
default => [
Response::HTTP_INTERNAL_SERVER_ERROR,
$this->buildGenericProblem($correlationId, $request->getPathInfo()),
],
};
$response = new JsonResponse($body, $status);
$response->headers->set('Content-Type', 'application/problem+json');
$event->setResponse($response);
}
private function buildValidationProblem(ValidationException $e, string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/validation-failed',
'title' => 'Validation Failed',
'status' => 422,
'detail' => 'The request body contains invalid or missing fields.',
'instance' => $path,
'correlation_id' => $correlationId,
'code' => ApiErrorCode::VALIDATION_FAILED->value,
'errors' => $e->getFieldErrors(),
];
}
private function mapDomainStatus(ApiErrorCode $code): int
{
return match ($code) {
ApiErrorCode::RESOURCE_NOT_FOUND => Response::HTTP_NOT_FOUND,
ApiErrorCode::ACCESS_DENIED => Response::HTTP_FORBIDDEN,
ApiErrorCode::RATE_LIMIT_EXCEEDED => Response::HTTP_TOO_MANY_REQUESTS,
default => Response::HTTP_UNPROCESSABLE_ENTITY,
};
}
private function buildDomainProblem(DomainException $e, string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/' . strtolower($e->getErrorCode()->value),
'title' => $e->getErrorCode()->toTitle(),
'status' => $this->mapDomainStatus($e->getErrorCode()),
'detail' => $e->getMessage(),
'instance' => $path,
'correlation_id' => $correlationId,
'code' => $e->getErrorCode()->value,
];
}
private function buildGenericProblem(string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/internal-server-error',
'title' => 'Internal Server Error',
'status' => 500,
'detail' => 'An unexpected error occurred. Please contact support with the correlation ID.',
'instance' => $path,
'correlation_id' => $correlationId,
'code' => 'INTERNAL_ERROR',
];
}
}
7. Fehlerantworten im Vergleich
| Merkmal | Naiver Ansatz | RFC 9457 Problem Details | Vorteil |
|---|---|---|---|
| Fehlertyp erkennbar | Nur per Text-Parsing | Maschinell via code |
Programmatische Fehlerbehandlung ohne Text-Parsing |
| Feldfehler | Kein / flacher String | Strukturiertes errors-Array |
Frontend kann Fehler direkt am Feld anzeigen |
| Debugging | Log-Suche nach Zeitstempel | Correlation ID verknüpft Antwort mit Log | Support findet Logeinträge in Sekunden |
| Dokumentierbar | Undokumentierte Strings | Error-Code-Katalog in OpenAPI | Consumer kennt alle möglichen Fehlercodes |
| Standardkonformität | Proprietär | RFC 9457 / IETF Standard | Interoperabilität mit Tools die Problem Details kennen |
8. Zusammenfassung
Professionelle Fehlermodellierung für REST APIs ist kein Luxus, sondern Grundlage für eine wartbare API mit zufriedenen Consumer-Teams. RFC 9457 Problem Details definiert ein standardisiertes JSON-Format mit type, title, status, detail und instance und erlaubt Erweiterungen für Error Codes, Feldfehler und Correlation IDs. Maschinenlesbare Error Codes als PHP-Enum ermöglichen programmatische Fehlerbehandlung ohne Text-Parsing. Strukturierte Feldfehler ermöglichen Frontend-Teams, Validierungsfehler direkt am betroffenen Feld anzuzeigen. Correlation IDs verbinden API-Antworten mit Logeinträgen und machen Debugging in Produktionssystemen praktikabel.
In Symfony lässt sich diese gesamte Infrastruktur mit einem zentralen Exception-Listener, einem Correlation-ID-Subscriber, einem Error-Code-Enum und einer DomainException-Hierarchie aufbauen. Das Ergebnis: jeder Endpunkt der API liefert Fehler im selben Format, jede Exception aus dem Domain-Layer wird automatisch in eine Problem-Details-Antwort übersetzt, und Debugging von Produktionsfehlern wird von Stunden auf Sekunden reduziert.
REST Fehlermodellierung — Das Wichtigste auf einen Blick
RFC 9457 Problem Details
Standard-Format für HTTP-Fehler: type, title, status, detail, instance. Content-Type: application/problem+json. Erweiterbar für Error Codes und Feldfehler.
Error Codes als Enum
PHP-Enum mit allen möglichen Fehlercodes – kein Magic-String-Chaos. Maschinenlesbar, in OpenAPI dokumentierbar, bildet expliziten API-Vertrag.
Strukturierte Feldfehler
Jeder Validierungsfehler mit Feldpfad, Error Code, Meldung und rejected_value. Frontend kann Fehler direkt am Feld anzeigen ohne eigene Mapping-Logik.
Correlation IDs
Eindeutige Request-ID in Response-Header und Problem-Details-Body. Verbindet API-Antwort mit Logeinträgen über Systemgrenzen. Support-Debugging in Sekunden.