Eine API, die nur generische Fehler zurückgibt, ist nutzlos. Eine API, die Stack Traces an Clients schickt, ist gefährlich. Gute Fehlerbehandlung liegt dazwischen: genug Information für Debugging, keine Information für Angreifer.
Dieser Artikel hilft dir, ein konsistentes Fehlerformat zu etablieren, das maschinenlesbar und menschenverständlich ist. Am Ende hast du einen Error-Katalog und klare Regeln für Validierung und Fehlermeldungen.
Zielbild
Nach diesem Artikel kannst du:
- Ein einheitliches Fehlerformat definieren, das für alle Endpoints gilt
- Maschinenlesbare Error Codes einführen, die Clients programmatisch verarbeiten
- Feldfehler strukturiert zurückgeben, damit UIs gezielt Feedback geben können
- Request-IDs und Trace-IDs für Debugging nutzen
- Sensitive Informationen aus Fehlermeldungen fernhalten
- Graceful Degradation bei Downstream-Ausfällen umsetzen
Kernfragen
Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:
- Welches Fehlerformat nutzt ihr? JSON? Problem Details (RFC 9457)?
- Welche Infos dürfen in Fehlern stehen? Welche nicht?
- Wie modelliert ihr Feldfehler? Flach oder strukturiert?
- Wie korreliert ihr Fehler mit Logs? Request-ID? Trace-ID?
- Was passiert bei Downstream-Ausfällen? 500? 502? 503?
Das Fehlerformat
Ein gutes Fehlerformat ist konsistent, maschinenlesbar und informativ – ohne zu viel preiszugeben.
Problem Details (RFC 9457)
RFC 9457 definiert ein standardisiertes Format für HTTP-Fehler:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"detail": "The request body contains invalid fields.",
"instance": "/orders/ord_abc123"
}
| Feld | Bedeutung | Pflicht (RFC) |
|---|---|---|
type |
URI, die den Fehlertyp identifiziert | Nein (Default about:blank) |
title |
Kurze, menschenlesbare Beschreibung | Nein (empfohlen) |
status |
HTTP-Statuscode | Nein (empfohlen) |
detail |
Detaillierte Beschreibung des konkreten Fehlers | Nein |
instance |
URI der betroffenen Ressource | Nein |
Hinweis: RFC 9457 macht diese Felder optional. Für konsistente APIs ist es
best practice, type, title und status immer zu setzen.
Vorteile von RFC 9457:
- Standardisiert und dokumentiert
- Erweiterbar für eigene Felder
- Content-Type
application/problem+jsonsignalisiert Fehlerformat
Praktisches Fehlerformat
In der Praxis erweitert man RFC 9457 um zusätzliche Felder:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"detail": "The request body contains invalid fields.",
"instance": "/v1/orders",
"error_code": "VALIDATION_FAILED",
"request_id": "req_abc123xyz",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
},
{
"field": "quantity",
"code": "OUT_OF_RANGE",
"message": "Must be between 1 and 100."
}
]
}
Warum error_code?
Die type-URI ist für Dokumentation, aber Clients brauchen einen stabilen
String zum Vergleichen:
// So nicht – URL kann sich ändern
if (error.type === 'https://api.example.com/errors/validation-failed') {
// ...
}
// Besser – stabiler Code
if (error.error_code === 'VALIDATION_FAILED') {
// ...
}
Regel: error_code ist ein stabiler, versionierter Identifier. Ändert sich
nie ohne Major-Version.
Error Codes: Der Katalog
Error Codes sind das Herzstück maschinenlesbarer Fehler. Sie müssen stabil, dokumentiert und sinnvoll gruppiert sein.
Namenskonvention
CATEGORY_SPECIFIC_ERROR
Beispiele:
| Code | Bedeutung |
|---|---|
VALIDATION_FAILED |
Allgemeiner Validierungsfehler |
VALIDATION_FIELD_REQUIRED |
Pflichtfeld fehlt |
VALIDATION_FIELD_INVALID |
Feldwert ungültig |
AUTH_TOKEN_EXPIRED |
Token abgelaufen |
AUTH_TOKEN_INVALID |
Token ungültig |
AUTH_INSUFFICIENT_SCOPE |
Fehlende Berechtigung |
RESOURCE_NOT_FOUND |
Ressource existiert nicht |
RESOURCE_ALREADY_EXISTS |
Duplikat |
RESOURCE_CONFLICT |
Versionskonflikt |
RATE_LIMIT_EXCEEDED |
Rate Limit erreicht |
QUOTA_EXCEEDED |
Quota erschöpft |
DOWNSTREAM_UNAVAILABLE |
Abhängiger Service nicht erreichbar |
INTERNAL_ERROR |
Unerwarteter Serverfehler |
Stabilität und Versionierung
Error Codes sind Teil des API-Vertrags:
- Niemals einen Code umbenennen (Breaking Change)
- Niemals die Bedeutung eines Codes ändern
- Neue Codes hinzufügen ist OK (Non-Breaking)
- Codes deprecaten mit Übergangszeit
HTTP-Status und Error Code
Jeder Error Code hat einen zugehörigen HTTP-Status:
| HTTP-Status | Typische Error Codes |
|---|---|
| 400 | VALIDATION_*, REQUEST_* |
| 401 | AUTH_TOKEN_*, AUTH_CREDENTIALS_* |
| 403 | AUTH_INSUFFICIENT_*, AUTH_FORBIDDEN |
| 404 | RESOURCE_NOT_FOUND |
| 409 | RESOURCE_CONFLICT, RESOURCE_ALREADY_EXISTS |
| 422 | BUSINESS_RULE_* |
| 429 | RATE_LIMIT_*, QUOTA_* |
| 500 | INTERNAL_ERROR |
| 502 | DOWNSTREAM_* |
| 503 | SERVICE_UNAVAILABLE |
Wichtig: Ein Error Code hat immer denselben HTTP-Status. Nie VALIDATION_FAILED
mit 500 zurückgeben.
Feldfehler strukturiert
Für Validierungsfehler reicht ein einzelner Error Code nicht. Clients brauchen Details pro Feld, um UIs zu aktualisieren.
Struktur für Feldfehler
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"error_code": "VALIDATION_FAILED",
"request_id": "req_abc123",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
},
{
"field": "password",
"code": "TOO_SHORT",
"message": "Must be at least 8 characters."
},
{
"field": "items[0].quantity",
"code": "OUT_OF_RANGE",
"message": "Must be between 1 and 100."
}
]
}
Feldpfad-Notation
Für verschachtelte Objekte und Arrays:
| Struktur | Pfad |
|---|---|
{ "email": "..." } |
email |
{ "address": { "city": "..." } } |
address.city |
{ "items": [{ "quantity": ... }] } |
items[0].quantity |
Regel: Der Feldpfad entspricht dem JSON-Pfad im Request Body.
Feld-Error-Codes
Spezifische Codes für Feldvalidierung:
| Code | Bedeutung |
|---|---|
REQUIRED |
Pflichtfeld fehlt |
INVALID_FORMAT |
Format falsch (E-Mail, URL, ...) |
INVALID_TYPE |
Falscher Datentyp |
TOO_SHORT |
String zu kurz |
TOO_LONG |
String zu lang |
OUT_OF_RANGE |
Zahl außerhalb des Bereichs |
INVALID_ENUM |
Wert nicht in erlaubter Liste |
DUPLICATE |
Wert bereits vergeben |
IMMUTABLE |
Feld darf nicht geändert werden |
Request-ID und Trace-ID
Ohne Korrelation zwischen Client-Fehler und Server-Logs ist Debugging ein Ratespiel.
Request-ID
Jeder Request bekommt eine eindeutige ID, die in der Response zurückkommt:
HTTP/1.1 400 Bad Request
X-Request-Id: req_abc123xyz
Content-Type: application/problem+json
{
"error_code": "VALIDATION_FAILED",
"request_id": "req_abc123xyz"
}
Regel: Request-ID immer in Response Header und Body zurückgeben. Client kann sie für Support-Anfragen nutzen.
Trace-ID (W3C Trace Context)
Für verteilte Systeme reicht eine Request-ID nicht. Trace-IDs verbinden alle Logs über Service-Grenzen hinweg:
X-Request-Id: req_abc123xyz
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Empfehlung:
- Request-ID: einfache UUID, vom API-Gateway generiert
- Trace-ID: W3C Trace Context (
traceparent), OpenTelemetry nutzt diesen Standard
Logging
Server-Logs müssen die Request-ID enthalten:
{
"timestamp": "2026-01-09T10:15:30Z",
"level": "error",
"request_id": "req_abc123xyz",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"error_code": "VALIDATION_FAILED",
"path": "/v1/orders",
"message": "Validation failed: email invalid"
}
Was nicht in Fehlern stehen darf
Fehlermeldungen sind öffentlich. Alles, was drinsteht, kann ein Angreifer sehen.
Verboten
| Kategorie | Beispiel | Risiko |
|---|---|---|
| Stack Traces | at OrderService.create(OrderService.java:42) |
Code-Struktur offengelegt |
| SQL-Queries | SELECT * FROM users WHERE id = '...' |
SQL Injection erleichtert |
| Interne IDs | Database connection pool_7 exhausted |
Infrastruktur offengelegt |
| Dateipfade | /var/www/app/config/secrets.yml |
Pfadstruktur offengelegt |
| Credentials | API key sk_live_... is invalid |
Key-Format offengelegt |
| Interne Fehler | NullPointerException in UserMapper |
Implementierung offengelegt |
Erlaubt
| Kategorie | Beispiel |
|---|---|
| Feldname | "field": "email" |
| Erwartetes Format | "message": "Must be a valid email address" |
| Bereich | "message": "Must be between 1 and 100" |
| Erlaubte Werte | "message": "Must be one of: pending, active, cancelled" |
| Request-ID | "request_id": "req_abc123" |
Produktions- vs. Development-Modus
Im Development-Modus sind mehr Details hilfreich. Aber niemals in Produktion:
// RICHTIG: Unterschiedliche Details je nach Umgebung
const errorResponse = {
error_code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred.',
request_id: requestId,
// Nur in Development:
...(isDevelopment && {debug: {stack: error.stack}})
};
Validierung: Schema und Business Rules
Validierung passiert auf zwei Ebenen, die unterschiedliche Fehler produzieren.
Schema-Validierung
Technische Prüfung des Request-Formats:
- Pflichtfelder vorhanden?
- Datentypen korrekt?
- Formate gültig (E-Mail, URL, Datum)?
- Längen im Rahmen?
HTTP-Status: 400 Bad Request
{
"error_code": "VALIDATION_FAILED",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
}
]
}
Business-Rule-Validierung
Fachliche Prüfung nach Schema-Validierung:
- Existiert die referenzierte Ressource?
- Hat der User genug Guthaben?
- Ist die Aktion im aktuellen Status erlaubt?
HTTP-Status: 422 Unprocessable Entity
{
"error_code": "INSUFFICIENT_BALANCE",
"detail": "Account balance is insufficient for this order.",
"request_id": "req_abc123"
}
Validierungsreihenfolge
- Schema-Validierung zuerst (schnell, ohne DB-Zugriff)
- Business-Rules danach (kann DB-Queries erfordern)
Warum? Schema-Fehler sind billig zu prüfen. Business-Rules können teuer sein. Bei ungültigem Schema lohnt sich die Business-Prüfung nicht.
Alle Fehler auf einmal
Gib alle Validierungsfehler in einer Response zurück, nicht nur den ersten:
{
"error_code": "VALIDATION_FAILED",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
}
]
}
Warum? Ein Roundtrip pro Fehler ist frustrierend für Clients.
Graceful Degradation
Wenn Downstream-Services ausfallen, sollte die API nicht einfach 500 zurückgeben.
HTTP-Status für Downstream-Fehler
| Situation | Status | Error Code |
|---|---|---|
| Downstream antwortet nicht | 502 Bad Gateway | DOWNSTREAM_UNAVAILABLE |
| Downstream ist überlastet | 503 Service Unavailable | SERVICE_UNAVAILABLE |
| Timeout bei Downstream | 504 Gateway Timeout | DOWNSTREAM_TIMEOUT |
Retry-After Header
Bei temporären Fehlern hilft Retry-After:
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/problem+json
{
"error_code": "SERVICE_UNAVAILABLE",
"detail": "The service is temporarily unavailable. Please retry later.",
"request_id": "req_abc123"
}
Partial Success
Manchmal ist ein Teil der Operation erfolgreich. Das ist komplexer zu modellieren:
{
"status": "partial",
"succeeded": [
{
"id": "item_001",
"status": "created"
}
],
"failed": [
{
"id": "item_002",
"error_code": "VALIDATION_FAILED",
"message": "Quantity out of range"
}
]
}
HTTP-Status: 207 Multi-Status (bei Batch-Operationen)
Empfehlung: Vermeide Partial Success wenn möglich. Transaktionale Operationen (alles oder nichts) sind einfacher zu verstehen.
Regeln & Anti-Patterns
Do
- Einheitliches Fehlerformat für alle Endpoints
- Maschinenlesbare Error Codes mit stabiler Namenskonvention
- Feldfehler strukturiert mit Pfad, Code und Message
- Request-ID in jedem Fehler
- Schema-Validierung vor Business-Rules
- Alle Validierungsfehler in einer Response
- Retry-After bei temporären Fehlern
Don't
- Stack Traces in Produktion zurückgeben
- SQL-Queries oder interne Pfade in Fehlern
- Unterschiedliche Fehlerformate pro Endpoint
- Error Codes ohne Dokumentation
- Nur den ersten Validierungsfehler zurückgeben
- 500 für alle Fehler (egal ob Client- oder Serverfehler)
- Sensitive Daten in Fehlermeldungen
Artefakt: Error-Katalog
# Error-Katalog
## Format
Alle Fehler folgen RFC 9457 (Problem Details) mit Erweiterungen:
```json
{
"type": "https://api.example.com/errors/{error_code}",
"title": "Human-readable title",
"status": 400,
"detail": "Specific details about this error instance.",
"instance": "/v1/resource/id",
"error_code": "ERROR_CODE",
"request_id": "req_xxx",
"errors": []
}
```
## Error Codes
### Validierung (400)
| Code | Title | Detail |
|----------------------|----------------------|---------------------------------------|
| VALIDATION_FAILED | Validation Failed | Request body contains invalid fields. |
| REQUEST_BODY_MISSING | Request Body Missing | Request body is required. |
| REQUEST_BODY_INVALID | Invalid Request Body | Request body is not valid JSON. |
### Feld-Validierung (in errors[])
| Code | Bedeutung |
|----------------|----------------------------------|
| REQUIRED | Pflichtfeld fehlt |
| INVALID_FORMAT | Format ungültig |
| INVALID_TYPE | Falscher Datentyp |
| TOO_SHORT | Zu kurz |
| TOO_LONG | Zu lang |
| OUT_OF_RANGE | Außerhalb des erlaubten Bereichs |
| INVALID_ENUM | Nicht in erlaubter Werteliste |
### Authentifizierung (401)
| Code | Title | Detail |
|--------------------|-------------------------|-----------------------------------|
| AUTH_TOKEN_MISSING | Authentication Required | No authentication token provided. |
| AUTH_TOKEN_INVALID | Invalid Token | The provided token is invalid. |
| AUTH_TOKEN_EXPIRED | Token Expired | The provided token has expired. |
### Autorisierung (403)
| Code | Title | Detail |
|-------------------------|--------------------------|---------------------------------------|
| AUTH_INSUFFICIENT_SCOPE | Insufficient Permissions | Token lacks required scope. |
| AUTH_FORBIDDEN | Access Denied | Access to this resource is forbidden. |
### Ressourcen (404, 409)
| Code | Status | Title |
|-------------------------|--------|-------------------------|
| RESOURCE_NOT_FOUND | 404 | Resource Not Found |
| RESOURCE_ALREADY_EXISTS | 409 | Resource Already Exists |
| RESOURCE_CONFLICT | 409 | Resource Conflict |
### Business Rules (422)
| Code | Title | Detail |
|----------------------|----------------------|-----------------------------------------|
| INSUFFICIENT_BALANCE | Insufficient Balance | Account balance is too low. |
| INVALID_STATE | Invalid State | Operation not allowed in current state. |
| LIMIT_EXCEEDED | Limit Exceeded | Operation exceeds allowed limit. |
### Rate Limiting (429)
| Code | Title | Headers |
|---------------------|-------------------|----------------------------|
| RATE_LIMIT_EXCEEDED | Too Many Requests | Retry-After, RateLimit-Policy, RateLimit |
| QUOTA_EXCEEDED | Quota Exceeded | Retry-After |
### Server (500, 502, 503, 504)
| Code | Status | Title |
|------------------------|--------|-----------------------|
| INTERNAL_ERROR | 500 | Internal Server Error |
| DOWNSTREAM_UNAVAILABLE | 502 | Service Unavailable |
| SERVICE_UNAVAILABLE | 503 | Service Unavailable |
| DOWNSTREAM_TIMEOUT | 504 | Gateway Timeout |
## Beispiel-Responses
### Validierungsfehler (400)
```json
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"error_code": "VALIDATION_FAILED",
"request_id": "req_abc123",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
},
{
"field": "quantity",
"code": "OUT_OF_RANGE",
"message": "Must be between 1 and 100."
}
]
}
```
### Authentifizierungsfehler (401)
```json
{
"type": "https://api.example.com/errors/auth-token-expired",
"title": "Token Expired",
"status": 401,
"error_code": "AUTH_TOKEN_EXPIRED",
"detail": "The provided token has expired. Please refresh your token.",
"request_id": "req_def456"
}
```
### Rate Limit (429)
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
RateLimit-Policy: "user";q=100;w=60
RateLimit: "user";r=0;t=60
{
"type": "https://api.example.com/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"error_code": "RATE_LIMIT_EXCEEDED",
"detail": "Rate limit exceeded. Please wait before retrying.",
"request_id": "req_ghi789"
}
```
Checkliste
Bevor du zum nächsten Artikel gehst, prüfe:
- [ ] Fehlerformat ist definiert (RFC 9457 oder Custom)
- [ ] Content-Type für Fehler ist festgelegt
- [ ] Error Codes sind dokumentiert und stabil
- [ ] Feldfehler-Struktur ist definiert (field, code, message)
- [ ] Request-ID wird in allen Fehlern zurückgegeben
- [ ] Keine sensitiven Informationen in Fehlermeldungen
- [ ] Schema-Validierung passiert vor Business-Rules
- [ ] Alle Validierungsfehler werden auf einmal zurückgegeben
- [ ] Downstream-Fehler haben eigene Status (502, 503, 504)
- [ ] Retry-After wird bei temporären Fehlern gesetzt
- [ ] Error-Katalog ist dokumentiert
Wie es weitergeht
Im nächsten Teil geht es um Authentifizierung: OAuth2 vs. API Keys, Token Lifetimes und warum JWT nicht immer die richtige Wahl ist.
Alle Teile der Serie: Serie: API-Design