Alle HTTP-Statuscodes der i-Planner REST API v3 mit ihren JSON-Response-Bodies, möglichen `error`-Werten und wann sie ausgelöst werden. Konsolidierte Referenz für defensive Client-Implementierungen.
Zuletzt geprüft: 14. September 2026
Überblick
Reguläre API-Fehler enthalten den HTTP-Status und die Fehlerdetails im Feld data. Beispiel eines abgelaufenen Tokens:
json
{
"statusCode": 401,
"statusMessage": "Token expired",
"data": {
"error": "token_expired",
"message": "The API token has expired."
}
}
{
"error": "token_expired",
"message": "The API token has expired."
}
Defensive Clients sollten beide Formen verarbeiten: const details = body.data ?? body. Die Beispiele bei den einzelnen Statuscodes zeigen anschließend die Fehlerdetails aus data, nicht den gesamten Response. Bei einem allgemeinen Serverfehler können die Details fehlen; nutze dann den HTTP-Status und die Request-ID. Fehler-Responses enthalten keine Stack-Traces.
Konventionen:
error ist ein stabiler Machine-Code — defensive Clients sollten auf diesen Wert matchen, nicht auf den message-Text.
message ist menschenlesbar (englisch) und kann zwischen Versionen leicht variieren.
Zusätzliche Felder je nach Fehler-Typ — z. B. required_scope/hint bei 403, retry_after bei 429.
HTTP-Statuscode trägt die grobe Kategorie (4xx Client, 5xx Server). Der error-String präzisiert.
400 Bad Request
Der Request ist syntaktisch oder logisch unzulässig — typisch bei fehlendem/ungültigem Body, falschen IDs oder Limit-Überschreitungen.
json
// Request-Body fehlt oder ist kein Objekt (POST/PATCH).
{
"error": "empty_body",
"message": "Request body must contain at least one customer object."
}
// Array wurde gesendet, war aber leer (POST mit Batch-Body).
{
"error": "empty_array",
"message": "Array must contain at least one customer object."
}
// Mehr als 100 Items im POST-Array. Hard-Cap pro Resource.
{
"error": "batch_too_large",
"message": "Maximum 100 items per request."
}
// Sub-Ressource-`<id>` ist 0, negativ, nicht-numerisch oder > 2^31-1.
// Echte Datensätze haben immer positive Integer-IDs innerhalb des
// Postgres-int4-Bereichs (1 bis 2147483647).
{
"error": "invalid_id",
"message": "Path parameter \"id\" must be a positive integer between 1 and 2147483647."
}
// Body schlägt gegen das Zod-Schema fehl oder enthält Felder, die in
// der DB-Spaltenliste nicht existieren. Sammelt ALLE Probleme in
// einem `issues[]`-Array — pro Eintrag `path` (Feldname), `code`
// (Maschinen-Code) und `message` (englische Erklärung). Ein Request
// kann mehrere Issues gleichzeitig haben.
//
// Bekannte `issues[].code`-Werte:
// - `unrecognized_key` — Feld ist im Schema nicht definiert (strict).
// - `protected_field` — Server-kontrolliertes Feld (`id`,
// `edit_datum`, `insert_datum`, …) darf nicht im Body stehen.
// - `invalid_select_value` — Feld ist im Form-Editor mit `optionsource`
// registriert, der Wert liegt aber nicht in der `system_select_options`-
// Allowlist. Die `message` enthält die ersten erlaubten Werte als Hilfe
// (z.B. `gd_status: "aktiv"` → erlaubt sind `"Angebot"`, `"Antrag"`,
// `"laufend"`, `"storniert"`, …). Beachte: `null` und leere Strings
// passieren (= Feld zurücksetzen), nur konkrete Werte werden geprüft.
// - `invalid_type` / `too_small` / weitere Zod-Codes — Standard
// Schema-Verletzungen (siehe https://zod.dev für die volle Liste).
{
"error": "validation_failed",
"message": "Request body has invalid or unknown fields.",
"issues": [
{ "path": "betrag", "code": "invalid_type", "message": "Expected number, received string" },
{ "path": "ungewuenscht", "code": "unrecognized_key", "message": "Unrecognized key: \"ungewuenscht\"" },
{ "path": "gd_status", "code": "invalid_select_value", "message": "Value \"aktiv\" is not allowed for \"gd_status\". Allowed: \"Angebot\", \"Antrag\", \"laufend\", \"storniert\", … (4 more)." }
]
}
// Ein Create-Aufruf benötigt die Zuordnung zu einem Kunden oder
// Produktpartner, z. B. POST /v3/contracts mit kid im Body.
// Bei parent-scoped Routen wird kid stattdessen aus dem Pfad übernommen.
// Org-weite Listen benötigen nicht generell einen kid-Query-Parameter.
{
"error": "missing_kid",
"message": "A valid customer kid is required."
}
// Der Path-`<kid>` ist syntaktisch ungültig — entweder 0, negativ,
// nicht-numerisch oder > 2^31-1 (Postgres-int4-Limit). Tritt bei
// allen top-level customer-/product-/user-Routen auf, sobald `<kid>`
// die Integer-Range-Validierung verfehlt.
{
"error": "invalid_kid",
"message": "Path parameter \"kid\" must be a positive integer between 1 and 2147483647."
}
// Seit dem Path-Refactor (Mai 2026): bei allen customer-/product-scoped
// Routen wie `/v3/customers/<kid>/<sub-resource>` und
// `/v3/products/<kid>/<sub-resource>` ist `kid` im Body verboten —
// Path-`<kid>` ist Source-of-Truth. Wer einen Body mit `kid` schickt,
// bekommt diese Antwort, damit Cross-Tenant-Schreibfehler nicht
// stillschweigend passieren.
//
// Gilt für Single-POST UND Batch-POST (jedes Item im Array darf kein
// `kid` tragen). Bei Relations gilt das auch für `kid_von` (steckt
// jetzt im Path); `kid_zu` bleibt im Body.
//
// Allgemeine Form: `body_<field>_forbidden` — dasselbe Pattern greift
// für jedes Path-Parameter-Feld (z. B. `body_vid_forbidden` bei
// Routes mit `<vid>` im Pfad). Der Body darf nichts wiederholen,
// was schon in der URL steht.
{
"error": "body_kid_forbidden",
"message": "Field \"kid\" must not be sent in the request body when using a path-scoped URL. Use the URL path to scope the request."
}
{
"error": "missing_bearer_token",
"message": "Authorization header must contain a Bearer token."
}
{
"error": "empty_token",
"message": "Bearer token is empty."
}
{
"error": "invalid_token",
"message": "The provided Bearer token is invalid or expired."
}
{
"error": "token_expired",
"message": "The API token has expired."
}
Auch token_revoked, token_not_found, wrong_token_kind, invalid_token_type, token_mismatch, invalid_expiry und missing_cid sind mögliche 401-Codes. Ein Token-Wechsel ist nur bei einem ungültigen oder ersetzten String erforderlich; ein deaktivierter Token kann aktiviert und ein abgelaufener v5-Token verlängert werden. Siehe Token-Lebenszyklus↗.
403 Forbidden
Bei fehlendem Scope nennt missing_required_scope die benötigte Berechtigung. Weitere mögliche Ursachen sind ip_not_allowed (Client-IP nicht freigegeben), contract_inactive (Organisationsvertrag nicht aktiv) und trial_expired (Testzeitraum abgelaufen). Eine geschützte Ressource kann trotz passendem Scope ebenfalls 403 liefern.
json
{
"error": "missing_required_scope",
"message": "The endpoint \"/v3/customers\" requires the \"v3:customers:read\" scope.",
"required_scope": "v3:customers:read",
"provided_scope": "v3:customers:read = 0",
"hint": "Ask your organization admin to enable 'v3:customers:read'."
}
// DELETE `/v3/documents/<id>` auf ein system-geschütztes Dokument
// (z. B. vertraglich gebunden, automatisch erzeugt). Auch mit
// passendem Scope nicht löschbar — der Schutz ist eine Eigenschaft
// des Datensatzes, nicht der Berechtigung.
{
"error": "document_protected",
"message": "This document is protected and cannot be deleted."
}
404 Not Found
Zwei semantisch unterschiedliche Fälle — beide HTTP 404:
json
// URL-Parameter `<id>` passt nicht zum Integer-Regex (z. B. UUID
// oder Buchstaben übergeben). Die Route existiert für diesen Pfad
// nicht — nicht der DATENSATZ fehlt, der PFAD selbst.
{
"error": "endpoint_not_found",
"message": "This API endpoint does not exist."
}
// ID ist syntaktisch gültig, aber kein Datensatz mit dieser ID
// existiert in deiner Organisation — oder er ist bereits
// soft-deleted (`del=1`). Wiederholte DELETE-Aufrufe geben
// denselben Fehler.
//
// Bei customer-/product-scoped Routen kommt `not_found` AUCH,
// wenn der Datensatz zwar existiert, aber zu einem anderen
// Parent-`<kid>` gehört als der im URL-Pfad — ein bewusstes
// Design, damit Cross-Tenant- bzw. Cross-Customer-Discovery
// über 403-vs-404-Differenzierung nicht möglich ist.
{
"error": "not_found",
"message": "No customer record exists for ID 12345."
}
// POST auf eine Annotation-Sub-Ressource (`/v3/<section>/<id>/comments`,
// vergleichbar für andere parent-scoped Annotations), wenn das
// Parent-Record `<section>/<id>` nicht existiert oder soft-deleted ist.
// Unterscheidet sich von `not_found` dadurch, dass die *Annotation*-Route
// erreichbar war, aber der Parent fehlt.
{
"error": "record_not_found",
"message": "Parent record does not exist for this annotation."
}
// Sub-Ressource im Pfad existiert in v3 gar nicht — z. B. ein Typo
// wie `/v3/goals/20/wurstbrot`. Die `message` enthält die zulässigen
// Sub-Ressourcen (`comments`, `followers`, `documents`, `tags`, `links`)
// als Hilfe.
{
"error": "unknown_resource",
"message": "Unknown sub-resource \"wurstbrot\". Allowed: comments, followers, documents, tags, links."
}
// Sub-Ressource existiert in v3 grundsätzlich, ist aber für DIESE
// Parent-Section nicht direkt verfügbar — z. B. `documents` an `goals`
// oder `finances`, weil die zugrundeliegende DB-Tabelle keinen direkten
// Documents-FK hat. Die `message` enthält den dokumentierten Workaround
// (typischerweise `/links` mit `foreign_section`/`foreign_id`).
//
// Unterscheidet sich von `unknown_resource` dadurch, dass die Sub-Ressource
// woanders sehr wohl direkt funktioniert (z. B. /v3/contracts/<id>/documents).
{
"error": "unsupported_sub_resource",
"message": "Sub-resource \"documents\" is not supported under /v3/goals. Use POST /v3/goals/20/links with {\"foreign_section\":\"documents\",\"foreign_id\":<documentId>} to attach a document."
}
405 Method Not Allowed
Eine bekannte URL wurde mit der falschen HTTP-Methode aufgerufen — z. B. PUT /v3/customers/12345 (es gibt nur GET/PATCH/DELETE) oder GET /v3/customers/12345/merge (merge existiert nur als POST).
json
// Die Route existiert für andere Methoden, aber nicht für diese.
// Welche Methoden zulässig sind, listet die [Endpoints-Übersicht](/rest/v3/endpoints).
{
"error": "method_not_allowed",
"message": "The HTTP method PUT is not supported on /v3/customers/[id]. Consult the API documentation for allowed methods on this resource."
}
409 Conflict
Race-Condition bei konkurrenten Updates oder Duplikate auf Annotation-Sub-Ressourcen (Links, Followers, Tags) — kein Retry sinnvoll, weil die Ziel-Beziehung bereits existiert.
json
// Soft-Delete UPDATE hat 0 Rows betroffen, obwohl der Read direkt
// davor 1 Row fand. Retry ist sicher, weil keine Daten verändert
// wurden.
{
"error": "delete_failed",
"message": "The record could not be deleted, possibly due to a concurrent update."
}
// POST `/v3/<section>/<id>/links` mit einem (`foreign_section`,
// `foreign_id`)-Paar, das auf diesen Parent-Datensatz bereits
// verlinkt ist. Kein Retry — das Link-Objekt existiert schon.
{
"error": "relation_exists",
"message": "This link already exists."
}
// POST `/v3/<section>/<id>/followers` mit einem User, der diesen
// Datensatz bereits followt. Kein Retry.
{
"error": "already_following",
"message": "User is already following this record."
}
// POST `/v3/<section>/<id>/tags` mit einem Tag, der diesem Datensatz
// bereits zugewiesen ist. Kein Retry.
{
"error": "tag_already_assigned",
"message": "Tag is already assigned to this record."
}
413 Content Too Large
Dokument-Uploads sind auf 25 MiB decodierten Dateiinhalt begrenzt (file_too_large). Für Upload-Requests gilt zusätzlich eine 40-MiB-Grenze für den JSON-Body (payload_too_large), da Base64 den Inhalt vergrößert. Teile Dateien nicht in beliebige JSON-Felder auf, um die Grenze zu umgehen.
422 Unprocessable Content
Beim Erstellen von Kunden können invalid_object oder no_valid_fields auftreten, wenn ein einzelnes Item nicht angelegt werden kann. Bei Kunden-Batches beachte zusätzlich die inserted-/failed-Zusammenfassung: vollständiger Erfolg liefert 200, teilweiser Erfolg 207; bei vollständigem Fehlschlag richtet sich der Fehlerstatus nach der Ursache. Wiederhole nur die fehlgeschlagenen Items. Eine vorgelagerte Feldvalidierung mit 400 schreibt kein Item.
429 Too Many Requests
Rate-Limit überschritten: gemeinsames Budget aller REST-Tokens einer Organisation oder Schutz gegen fehlgeschlagene Authentifizierung pro IP — siehe Rate Limits in der Overview↗. Der Response setzt immer den Retry-After-Header (Sekunden).
json
// Organisationsbudget ausgeschöpft. Fehlerdetails-`retry_after` und
// HTTP-Header `Retry-After` sind identisch.
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 12 seconds.",
"retry_after": 12
}
// Wiederholt fehlschlagende Auth-Versuche von derselben Client-IP.
// Gezählt werden fehlgeschlagene Authentifizierungsversuche.
// X-RateLimit-Layer ist authFail; Retry-After kann eine zusätzliche Sperrzeit enthalten.
{
"error": "too_many_auth_failures",
"message": "Rate limit exceeded. Try again in 60 seconds.",
"retry_after": 60
}
500 Internal Server Error
Unerwarteter Backend-Fehler. Body enthält keine internen Details (Stack-Traces, SQL-Errors) — die landen im Server-Log und sind über die x-request-id referenzierbar.
json
{
"error": "internal_error",
"message": "Unexpected server error in /v3/customers."
}
// Sehr selten: die DB-Spaltenliste (columnMap) konnte für die
// Allowlist-Prüfung nicht geladen werden (DB- oder Cache-Ausfall).
// Der Request wird **fail-closed** mit 500 abgelehnt — keine Daten
// wurden geschrieben. Identischer Request kann nach kurzer Pause
// wiederholt werden; bleibt der Fehler bestehen, Bug-Report an
// `support@i-planner.de` mit `x-request-id`.
{
"error": "validation_infrastructure_failure",
"message": "Body validation infrastructure failed; retry the request."
}
// POST auf eine documents-Annotation-Ressource (z. B.
// `/v3/customers/<kid>/documents`): das Dokument wurde angelegt,
// aber der zugehörige Section-Link konnte nicht erstellt werden.
// Die API rollt das Dokument zurück (soft-delete) und antwortet mit
// 500 — der Aufrufer kann den Request gefahrlos wiederholen, ohne
// Karteileichen zu hinterlassen.
{
"error": "link_failed",
"message": "Document was rolled back because the section link could not be created."
}
502 Bad Gateway
Der für den Aufruf benötigte Datei- oder Suchdienst ist vorübergehend nicht verfügbar, z. B. s3_upload_failed beim Dokument-Upload. Beachte die Request-ID und prüfe bei Schreibaufrufen zunächst, ob ein Datensatz bereits angelegt wurde, bevor du erneut sendest.
503 Service Unavailable
Die Limit-Prüfung ist nicht verfügbar oder das aktuelle Organisationslimit kann nicht geladen werden. Die API antwortet fail-closed, weil ohne funktionierendes Rate-Limiting kein ungedrosselter Traffic durchlaufen soll. Retry-After: 10 ist Standard, ein interner Circuit-Breaker probiert Redis regelmäßig.
json
{
"error": "service_unavailable",
"message": "Rate limiting service is temporarily unavailable. Please retry shortly.",
"retry_after": 10
}
Standard-Headers in Fehler-Responses
Die Verfügbarkeit der Header hängt davon ab, wie weit die Anfrage verarbeitet wurde:
x-request-id — Request-ID für Support-Anfragen.
X-Token-Expires-At / X-Token-Expires-In-Days — nach vollständig erfolgreicher Token-Prüfung, auch bei späteren Scope- oder Eingabefehlern. Bei 401, IP-Ablehnung oder einer früheren Drosselung fehlen sie. Expires-In-Days erscheint nur bei höchstens 30 vollen Tagen Restlaufzeit. Siehe Gültigkeit↗.
Retry-After — bei 429 und bei 503 aufgrund der Limit-Prüfung. Wartezeit in Sekunden.
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Layer — sobald das Organisationsbudget geprüft wurde, bzw. bei 429 des Authentifizierungsschutzes. Der Layer ist organization oder authFail; separate Tenant-Header gibt es nicht. Siehe Rate Limits↗.
Robuster Error-Handler
js
async function callApi(path, options = {}) {
const res = await fetch(`https://www.api.i-planner.app${path}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.IPLANNER_API_TOKEN}`,
...options.headers,
},
})
if (res.ok) return res.json()
const responseBody = await res.json().catch(() => ({}))
const body = responseBody.data ?? responseBody
const error = body.error ?? 'unknown'
switch (res.status) {
case 400:
throw new BadRequestError(error, body.message)
case 401:
// Token-Status und Gültigkeit prüfen; bei ersetztem String Integration aktualisieren
throw new AuthError(error, body.message)
case 403:
// Scope, IP-Freigabe und Organisationszugang prüfen; kein unveränderter Retry
throw new ScopeError(error, body.required_scope)
case 404:
// Unterscheidung: endpoint_not_found (URL falsch) vs. not_found (ID existiert nicht)
throw error === 'endpoint_not_found' ? new RouteError(path) : new NotFoundError(path)
case 409:
// delete_failed — Retry ist sicher, max. 2-3 Versuche
throw new ConflictError(error, body.message)
case 429:
// Retry-After-Header lesen, backoff respektieren
const retryAfter = Number(res.headers.get('Retry-After') ?? body.retry_after ?? 10)
throw new RateLimitError(retryAfter)
case 500:
case 503:
// Request-ID für Support; Schreibaufrufe vor Wiederholung auf Teilerfolg prüfen
throw new ServerError(error, res.headers.get('x-request-id'))
default:
throw new Error(`Unexpected HTTP ${res.status}: ${error}`)
}
}
import os
import requests
class IPlannerError(Exception):
def __init__(self, error, message=None):
self.error = error
self.message = message
super().__init__(message or error)
class BadRequestError(IPlannerError): pass
class AuthError(IPlannerError): pass
class ScopeError(IPlannerError):
def __init__(self, error, required_scope=None):
super().__init__(error)
self.required_scope = required_scope
class RouteError(IPlannerError): pass
class NotFoundError(IPlannerError): pass
class ConflictError(IPlannerError): pass
class RateLimitError(IPlannerError):
def __init__(self, retry_after):
super().__init__('rate_limit_exceeded')
self.retry_after = retry_after
class ServerError(IPlannerError):
def __init__(self, error, request_id=None):
super().__init__(error)
self.request_id = request_id
def call_api(path, method='GET', **kwargs):
res = requests.request(
method,
f'https://www.api.i-planner.app{path}',
headers={
'Authorization': f'Bearer {os.environ["IPLANNER_API_TOKEN"]}',
**kwargs.pop('headers', {}),
},
**kwargs,
)
if res.ok:
return res.json()
try:
body = res.json()
except ValueError:
body = {}
body = body.get('data') or body
error = body.get('error', 'unknown')
if res.status_code == 400:
raise BadRequestError(error, body.get('message'))
if res.status_code == 401:
# Token-Status und Gültigkeit prüfen; bei ersetztem String Integration aktualisieren
raise AuthError(error, body.get('message'))
if res.status_code == 403:
# Scope, IP-Freigabe und Organisationszugang prüfen; kein unveränderter Retry
raise ScopeError(error, body.get('required_scope'))
if res.status_code == 404:
# endpoint_not_found (URL falsch) vs. not_found (ID existiert nicht)
raise RouteError(path) if error == 'endpoint_not_found' else NotFoundError(path)
if res.status_code == 409:
# delete_failed — Retry ist sicher, max. 2-3 Versuche
raise ConflictError(error, body.get('message'))
if res.status_code == 429:
# Retry-After-Header lesen, backoff respektieren
retry_after = int(res.headers.get('Retry-After') or body.get('retry_after') or 10)
raise RateLimitError(retry_after)
if res.status_code in (500, 503):
# Request-ID für Support; Schreibaufrufe vor Wiederholung auf Teilerfolg prüfen
raise ServerError(error, res.headers.get('x-request-id'))
raise IPlannerError(error, f'Unexpected HTTP {res.status_code}: {error}')
<?php
class IPlannerError extends \Exception {
public string $errorCode;
public function __construct(string $errorCode, ?string $message = null) {
$this->errorCode = $errorCode;
parent::__construct($message ?? $errorCode);
}
}
class BadRequestError extends IPlannerError {}
class AuthError extends IPlannerError {}
class ScopeError extends IPlannerError {
public ?string $requiredScope;
public function __construct(string $errorCode, ?string $requiredScope = null) {
parent::__construct($errorCode);
$this->requiredScope = $requiredScope;
}
}
class RouteError extends IPlannerError {}
class NotFoundError extends IPlannerError {}
class ConflictError extends IPlannerError {}
class RateLimitError extends IPlannerError {
public int $retryAfter;
public function __construct(int $retryAfter) {
parent::__construct('rate_limit_exceeded');
$this->retryAfter = $retryAfter;
}
}
class ServerError extends IPlannerError {
public ?string $requestId;
public function __construct(string $errorCode, ?string $requestId = null) {
parent::__construct($errorCode);
$this->requestId = $requestId;
}
}
function callApi(string $path, string $method = 'GET', array $body = null): array {
$ch = curl_init('https://www.api.i-planner.app' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('IPLANNER_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$rawBody = substr($response, $headerSize);
curl_close($ch);
$responseBody = json_decode($rawBody, true) ?? [];
if ($status >= 200 && $status < 300) {
return $responseBody;
}
$body = $responseBody['data'] ?? $responseBody;
$error = $body['error'] ?? 'unknown';
// Header parsen
$headers = [];
foreach (explode("\r\n", $rawHeaders) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$headers[strtolower(trim($k))] = trim($v);
}
}
switch ($status) {
case 400: throw new BadRequestError($error, $body['message'] ?? null);
case 401: // Token-Status und Gültigkeit prüfen; bei ersetztem String Integration aktualisieren
throw new AuthError($error, $body['message'] ?? null);
case 403: // Scope, IP-Freigabe und Organisationszugang prüfen; kein unveränderter Retry
throw new ScopeError($error, $body['required_scope'] ?? null);
case 404: // endpoint_not_found (URL falsch) vs. not_found (ID existiert nicht)
throw $error === 'endpoint_not_found' ? new RouteError($path) : new NotFoundError($path);
case 409: // delete_failed — Retry ist sicher, max. 2-3 Versuche
throw new ConflictError($error, $body['message'] ?? null);
case 429: // Retry-After-Header lesen, backoff respektieren
$retryAfter = (int) ($headers['retry-after'] ?? $body['retry_after'] ?? 10);
throw new RateLimitError($retryAfter);
case 500:
case 503: // Request-ID für Support; Schreibaufrufe vor Wiederholung auf Teilerfolg prüfen
throw new ServerError($error, $headers['x-request-id'] ?? null);
default: throw new IPlannerError($error, "Unexpected HTTP $status: $error");
}
}
Dieses Pattern normalisiert beide Fehler-Envelope-Formen. Weitere HTTP-Statuscodes werden vom allgemeinen Fehlerzweig verarbeitet. Spezifische Exceptions pro HTTP-Status erlauben dem Caller, gezielt zu reagieren — Retry-Logik pro Exception-Klasse, kein generischer try/catch-Catch-all.