{ }
GET
REST API · Fehlerbehandlung · RFC 9457 · Symfony
REST Fehler professionell modellieren
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.

15 Min. Lesezeit Problem Details · Error Codes · Feldfehler · Correlation IDs RFC 9457 · Symfony 7 · PHP 8.4

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.

9. FAQ: REST Fehlermodellierung

1Was ist RFC 9457 und warum nutzen?
IETF-Standard für strukturierte HTTP-Fehler mit type, title, status, detail, instance. Maschinenlesbar, standardisiert, mit API-Gateways und Monitoring-Systemen kompatibel.
2Wozu Error Codes wenn es HTTP-Statuscodes gibt?
HTTP-Statuscodes sind zu grob. Ein HTTP 422 kann viele verschiedene Fehler bedeuten. Error Codes wie DUPLICATE_EMAIL ermöglichen programmatische Fehlerbehandlung ohne Text-Parsing.
3Struktur des errors-Arrays für Feldfehler?
Jedes Element: field (Feldpfad), code (Error Code), message, optional rejected_value. Verschachtelt: shipping_address.street. Arrays: items[2].quantity.
4Status-Code im Body wiederholen?
Ja – RFC 9457 empfiehlt es. Proxies können HTTP-Statuscodes modifizieren, aus Message-Queues ist der HTTP-Statuscode oft nicht mehr vorhanden. Body-Status bleibt immer erhalten.
5Was ist eine Correlation ID?
Eindeutige Request-ID die alle Logeinträge verbindet. Format: req_ + ULID (zeitbasiert, sortierbar). In Response-Header und Problem-Details-Body exponieren.
6Interne Fehlermeldungen exponieren?
Nein. Exception-Messages, Stack Traces, DB-Fehler nicht in Antworten. Nur Correlation ID für Support. Domain-Fehlermeldungen sind explizit definiert und sicher.
7Problem Details in Symfony ohne API Platform?
kernel.exception-EventSubscriber der Accept-Header prüft und JsonResponse mit Content-Type application/problem+json zurückgibt. DomainExceptions und SystemExceptions separat behandeln.
8Error Codes in OpenAPI dokumentieren?
Im responses-Block mögliche Codes als examples oder Schema-Enum. In components/schemas ein ErrorCode-Enum. In components/responses Standard-Fehlerantworten referenzieren.
9Eigene Exception-Klasse für jeden Fehler?
Nicht für jeden, aber für jede fachliche Fehlerklasse. DomainException-Basisklasse mit ApiErrorCode-Property, spezifische Unterklassen. Listener übersetzt alle ohne Code in Controllern.
10application/problem+json oder application/json?
application/problem+json ist korrekt gemäß RFC 9457. Erlaubt Consumer-Code auf Basis des Content-Types zu unterscheiden. Trotzdem setzen – es ist der richtige Standard.