Die Form jedes Fehlers

Ein Körper, ein Aufbau, ein geschlossener Katalog von Kennungen — und warum in `message` nie eine rohe Datenbankmeldung steht.

Für Entwickler

JEDE Fehlerantwort der API hat denselben Aufbau, unabhängig von Route und Status: `error` (die maschinenlesbare Kennung), `message` (ein Satz auf Deutsch), `docs` (die Adresse der Seite zu genau dieser Kennung) und `requestId`. Je nach Fall kommen Felder dazu: `issues` bei Eingabefehlern, `module` und `op` bei 403, `feature` und `requiredPlan` bei 402, `retryAfterSeconds` bei 429.

VERZWEIGEN SIE AUF `error`, NIE AUF `message`. Der Katalog der Kennungen ist geschlossen und endlich; eine Kennung behält ihre Bedeutung. Der Wortlaut von `message` ist für Menschen und darf sich jederzeit ändern — auch in einer Fassung, die sonst nichts ändert.

IN `message` STEHT NIE EINE ROHE FEHLERMELDUNG. Keine Prisma-Tabellennamen, keine Anbietermeldung, kein Stapelverlauf. Was schiefging, steht im Serverprotokoll; verbunden sind beide über `requestId`. Nennen Sie diese Kennung im Supportfall — damit finden wir den Vorgang, ohne dass Sie Daten schicken müssen.

`issues` IST DIE LISTE DER FUNDSTELLEN einer Schemaprüfung, jede mit `path`, `code` und `message`. Der Pfad ist vollständig und zeigt auch in verschachtelte Rümpfe: `ingredients[2].quantity`, `days[0].closeTime`. Ein unbekanntes Feld erscheint als `unrecognized_keys` — die Schemata sind durchgehend streng, unbekannte Felder werden ABGELEHNT statt stillschweigend abgestreift. Genau das ist der Unterschied zwischen einem Tippfehler, der eine Minute kostet, und einem, den man nach drei Monaten im Datenbestand findet.

DIE ADRESSE IN `docs` FÜHRT AUF DIE SEITE ZU DIESER KENNUNG. Sie ist Teil des Vertrags und nicht Zierde: Die Erklärung erscheint genau dann, wenn sie gebraucht wird. Alle Kennungen samt Status stehen im Verzeichnis unter `/dokumentation/api/fehler`.

Schritt für Schritt

  1. 400 — die Anfrage ist falsch gebaut

    `validation` (Schema), `read_only_field` (ein Feld, das der Server bestimmt), `nothing_to_write` (kein änderbares Feld im Rumpf). Wiederholen hilft nur mit geänderter Anfrage.

  2. 401 — der Aufrufer ist nicht erkannt

    `unauthorized`, `key_malformed`, `key_unknown`, `key_revoked`, `key_expired`, `key_in_query`. Der Schlüssel ist das Problem, nicht das Recht.

  3. 402 — der Plan des Betriebs deckt das nicht

    `plan_upgrade_required` oder `trial_expired`. Der Schlüssel bleibt gültig; nur dieser Datenbereich ist gesperrt. Der Körper nennt `feature`, `featureLabel`, `requiredPlan` und `resolution` — den Satz, den Sie dem Betrieb zeigen können.

  4. 403 — erkannt, aber nicht befugt

    `forbidden` (Modulrecht fehlt), `forbidden_action` (die getrennte Handlung fehlt), `key_owner_lost_access` (der Ersteller gehört nicht mehr zum Betrieb), `origin_not_allowed` (die Anfrage kam aus einem Browser).

  5. 404, 409, 412 — der Zustand passt nicht

    `not_found` (auch für eine fremde Kennung — nie 403, das verriete, dass es die Zeile gibt), `conflict` (Tisch belegt, Mailadresse doppelt), `precondition_failed` (Ihr `If-Match` passt nicht mehr), `idempotency_key_reuse`, `idempotency_in_progress`.

  6. 429 und 503 — es ist gerade zu viel

    `rate_limited` mit `Retry-After`, `rate_limit_unavailable` wenn die Bremse selbst nicht prüfbar ist. Beide sind wiederholbar — mit Wartezeit.

  7. 500 — bei uns ist etwas schiefgegangen

    `internal`. Wiederholen Sie den Aufruf; bleibt es dabei, nennen Sie die `requestId`. Ein schreibender Aufruf mit `Idempotency-Key` lässt sich dabei gefahrlos wiederholen.

Codebeispiele

Antwort 400 — validation mit Fundstellen
JSON
{  "error": "validation",  "message": "Die Anfrage ist nicht gueltig.",  "issues": [    {      "path": "partySize",      "code": "too_small",      "message": "Mindestens 1 Person."    },    {      "path": "guestname",      "code": "unrecognized_keys",      "message": "Unrecognized key(s) in object: 'guestname'"    }  ],  "docs": "https://tactictable.com/dokumentation/api/fehler/validation",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Das zweite Issue ist der Regelfall im Alltag: `guestname` statt `guestName`. Ohne strenge Schemata wäre das ein stilles 200 mit fehlendem Namen — mit ihnen ein 400 in der ersten Minute der Einbindung.
Antwort 400 — read_only_field
JSON
{  "error": "read_only_field",  "message": "Die Anfrage setzt Felder, die der Server bestimmt.",  "fields": ["startsAt", "durationMin"],  "issues": [    {      "path": "startsAt",      "code": "read_only_field",      "message": "Abgeleitet. Geschrieben wird mit `date` und `time`."    },    {      "path": "durationMin",      "code": "read_only_field",      "message": "Die Dauer bestimmt der Betrieb (Zeitfenster bzw. Standarddauer). Schicken Sie `endTime`, wenn eine Sloteinstellung greift."    }  ],  "docs": "https://tactictable.com/dokumentation/api/fehler/read_only_field",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Ein BEKANNTES, aber nicht schreibbares Feld ergibt `read_only_field` und nicht „unbekanntes Feld" — sonst sucht man einen Tippfehler, den es nicht gibt. Die Meldung sagt jedes Mal, welches Feld man stattdessen nimmt.
Antwort 402 — der Plan deckt das Modul nicht
JSON
{  "error": "plan_upgrade_required",  "message": "Der gebuchte Plan dieses Betriebs enthaelt „Warenwirtschaft“ nicht. Enthalten ist es ab dem Plan „Professional“. Der Schluessel selbst bleibt gueltig; nur dieser Datenbereich ist gesperrt.",  "feature": "warenwirtschaft",  "featureLabel": "Warenwirtschaft",  "requiredPlan": "professional",  "requiredPlanLabel": "Professional",  "currentPlan": "starter",  "resolution": "Der Inhaber schaltet den Plan im Dashboard unter Abrechnung hoch.",  "docs": "https://tactictable.com/dokumentation/api/fehler/plan_upgrade_required",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Zeigen Sie `resolution` unverändert in Ihrer Oberfläche an. Der Satz ist so geschrieben, dass der Betriebsinhaber weiss, was ER tun muss — Ihr Support bekommt sonst den Anruf.
curl — Kopfzeilen und Körper zusammen ansehen
curl
curl -sS -i https://tactictable.com/api/v1/guests \
  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM"
`-i` zeigt die Kopfzeilen mit. Bei 429 steht dort `Retry-After`, bei 403 nichts Zusätzliches — die Auskunft steht dann im Körper.
TypeScript — einmal auswerten, überall verwenden
TypeScript
type Fehlerkennung =    | 'validation'    | 'read_only_field'    | 'nothing_to_write'    | 'unauthorized'    | 'forbidden'    | 'forbidden_action'    | 'not_found'    | 'conflict'    | 'precondition_failed'    | 'rate_limited'    | 'plan_upgrade_required'    | 'trial_expired'    | 'internal' interface Fundstelle {    path: string    code: string    message: string} export function istWiederholbar(kennung: string): boolean {    // 429 und 503 gehen nach Wartezeit erneut; 500 einmal. Alles andere    // aendert sich nicht dadurch, dass man dieselbe Anfrage noch einmal    // schickt — ein blinder Wiederholversuch auf 400 ist eine Endlosschleife.    return kennung === 'rate_limited' || kennung === 'rate_limit_unavailable' || kennung === 'internal'} export function feldFehler(issues: Fundstelle[] | undefined): Record<string, string> {    const aus: Record<string, string> = {}    for (const i of issues ?? []) aus[i.path] = i.message    return aus} export function istKennung(wert: unknown): wert is Fehlerkennung {    return typeof wert === 'string'}
Die Union ist eine Bequemlichkeit für den eigenen Code, KEINE vollständige Prüfung: der Katalog kann wachsen (additiv, siehe „Versionierung"). Behandeln Sie deshalb eine unbekannte Kennung wie einen allgemeinen Fehler und nicht wie einen Programmfehler.
Python — Fehler auswerten statt raten
Python
import requests WIEDERHOLBAR = {"rate_limited", "rate_limit_unavailable", "internal"}  def auswerten(antwort: requests.Response) -> dict:    """Gibt den Rumpf zurueck oder wirft mit einer brauchbaren Meldung."""    try:        rumpf = antwort.json()    except ValueError:        raise RuntimeError("Antwort war kein JSON (HTTP {})".format(antwort.status_code))     if antwort.status_code < 400:        return rumpf     kennung = rumpf.get("error", "unbekannt")    zeilen = [rumpf.get("message", "")]    for fund in rumpf.get("issues", []):        zeilen.append("  {}: {}".format(fund["path"], fund["message"]))    zeilen.append("  siehe " + rumpf.get("docs", ""))    zeilen.append("  requestId " + rumpf.get("requestId", ""))     fehler = RuntimeError("\n".join(zeilen))    fehler.kennung = kennung    fehler.wiederholbar = kennung in WIEDERHOLBAR    raise fehler
Die `requestId` gehört in JEDE Protokollzeile Ihres Systems. Sie ist der einzige Faden, an dem sich ein Vorgang später auf beiden Seiten wiederfinden lässt.