Go-API-Fehlerbehandlungsmuster: typisierte Fehler, konsistente HTTP-Statuszuordnung, Request-IDs und sichere Meldungen ohne interne Details zu leaken.

Wenn jeder Endpunkt Fehler anders meldet, verlieren Clients das Vertrauen in deine API. Eine Route gibt { "error": "not found" } zurück, eine andere { "message": "missing" }, und eine dritte sendet reinen Text. Selbst wenn die Bedeutung ähnlich ist, muss der Client-Code nun raten, was passiert ist.
Die Kosten zeigen sich schnell. Teams bauen fragile Parsing-Logik und fügen pro Endpunkt Sonderfälle hinzu. Retries werden riskant, weil der Client nicht unterscheiden kann, ob er es später noch einmal versuchen sollte oder ob die Eingabe falsch ist. Support-Tickets steigen, weil der Client nur eine vage Nachricht sieht und dein Team die Antwort nicht leicht mit einer Server-Logzeile abgleichen kann.
Ein typisches Szenario: Eine Mobile-App ruft während der Registrierung drei Endpunkte auf. Der erste gibt HTTP 400 mit einer feldbezogenen Fehlermap zurück, der zweite HTTP 500 mit einem Stacktrace-String, und der dritte HTTP 200 mit { "ok": false }. Das App-Team liefert drei verschiedene Error-Handler, und dein Backend-Team erhält trotzdem Meldungen wie „Signup schlägt manchmal fehl“ ohne klaren Startpunkt.
Das Ziel ist ein vorhersehbarer Vertrag. Clients sollten zuverlässig lesen können, was passiert ist: ob es an ihrer Eingabe lag, ob ein Retry sinnvoll ist und welche Request-ID sie an den Support weitergeben können.
Hinweis zum Umfang: Das richtet sich auf JSON-HTTP-APIs (nicht gRPC), aber dieselben Ideen gelten überall dort, wo du Fehler an andere Systeme zurückgibst.
Wähle einen klaren Fehlervertrag und sorge dafür, dass jeder Endpunkt ihn einhält. „Konsistent“ bedeutet dieselbe JSON-Form, dieselbe Semantik der Felder und dasselbe Verhalten, unabhängig davon, welcher Handler fehlschlägt. Sobald das steht, hören Clients auf zu raten und beginnen, Fehler richtig zu behandeln.
Ein nützlicher Vertrag hilft Clients zu entscheiden, wie sie weiter vorgehen. Für die meisten Apps sollte jede Fehlerantwort drei Fragen beantworten:
Eine praktische Regelmenge:
Entscheide vorher, was niemals in Antworten auftauchen darf. Häufige „Nie“-Punkte sind SQL-Fragmente, Stacktraces, interne Hostnamen, Secrets und rohe Fehlermeldungen von Abhängigkeiten.
Halte eine saubere Trennung: eine kurze, nutzerfreundliche Nachricht (sicher, höflich, handlungsorientiert) und interne Details (voller Fehler, Stack und Kontext) nur in Logs. Zum Beispiel ist „Konnte deine Änderungen nicht speichern. Bitte versuche es erneut.“ sicher. „pq: duplicate key value violates unique constraint users_email_key“ ist es nicht.
Wenn jeder Endpunkt demselben Vertrag folgt, kann der Client einen einzigen Error-Handler bauen und überall wiederverwenden.
Clients können Fehler nur sauber behandeln, wenn jeder Endpunkt dieselbe Form zurückgibt. Wähle einen JSON-Envelope und halte ihn stabil.
Ein praktisches Default ist ein error-Objekt plus eine top-level request_id:
{
\"error\": {
\"code\": \"VALIDATION_FAILED\",
\"message\": \"Some fields are invalid.\",
\"details\": {
\"fields\": {
\"email\": \"must be a valid email address\"
}
}
},
\"request_id\": \"req_01HV...\"
}
Der HTTP-Status gibt die grobe Kategorie an (400, 401, 409, 500). Der maschinenlesbare error.code gibt den konkreten Fall an, auf den der Client verzweigen kann. Diese Trennung ist wichtig, weil viele verschiedene Probleme denselben Status teilen. Eine Mobile-App kann für EMAIL_TAKEN eine andere UI zeigen als für WEAK_PASSWORD, auch wenn beides 400 ist.
Halte error.message sicher und menschenverständlich. Sie sollte dem Nutzer helfen, das Problem zu beheben, darf aber niemals Interna leaken (SQL, Stacktraces, Provider-Namen, Dateipfade).
Optionale Felder sind nützlich, wenn sie vorhersehbar bleiben:
details.fields als Map von Feld zu Nachricht.details.retry_after_seconds.details.docs_hint als Klartext (keine URL).Für Abwärtskompatibilität: Behandle error.code-Werte als Teil deines API-Vertrags. Füge neue Codes hinzu, ohne alte Bedeutungen zu verändern. Füge nur optionale Felder hinzu und erwarte, dass Clients unbekannte Felder ignorieren.
Fehlerbehandlung wird unübersichtlich, wenn jeder Handler seine eigene Art erfindet, Fehlschläge zu signalisieren. Eine kleine Menge typisierter Fehler behebt das: Handler geben bekannte Fehlertypen zurück, und eine einzige Response-Schicht macht daraus konsistente Antworten.
Ein praktisches Starter-Set deckt die meisten Endpunkte ab:
Der Schlüssel ist Stabilität auf oberster Ebene, selbst wenn die Root-Ursache sich ändert. Du kannst unterliegende Fehler (SQL, Netzwerk, JSON-Parsing) wrappen und trotzdem denselben öffentlichen Typ zurückgeben, den Middleware erkennen kann.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
Gib in deinem Handler NotFoundError{Resource: "user", ID: id, Err: err} zurück, anstatt sql.ErrNoRows direkt durchzureichen.
Zum Überprüfen von Fehlern verwende errors.As für benutzerdefinierte Typen und errors.Is für Sentinel-Errors. Sentinel-Errors (wie var ErrUnauthorized = errors.New("unauthorized")) funktionieren für einfache Fälle, aber benutzerdefinierte Typen gewinnen, wenn du sicheren Kontext brauchst (z. B. welche Ressource fehlt), ohne deinen öffentlichen Antwortvertrag zu ändern.
Sei streng bei dem, was du anhängst:
Err, Stack-Info, rohe SQL-Fehler, Tokens, Nutzerdaten.Diese Trennung ermöglicht es dir, Clients zu helfen, ohne Interna offenzulegen.
Wenn du typisierte Fehler hast, ist die nächste Aufgabe langweilig, aber wichtig: derselbe Fehler-Typ sollte immer denselben HTTP-Status erzeugen. Clients bauen Logik darauf auf.
Eine praktikable Zuordnung, die für die meisten APIs funktioniert:
| Fehler-Typ (Beispiel) | Status | Wann verwenden |
|---|---|---|
| BadRequest (fehlerhaftes JSON, fehlender Query-Param) | 400 | Die Anfrage ist auf Protokoll- oder Format-Ebene ungültig. |
| Unauthenticated (kein/ungültiger Token) | 401 | Der Client muss sich authentifizieren. |
| Forbidden (keine Berechtigung) | 403 | Auth ist gültig, aber Zugriff nicht erlaubt. |
| NotFound (Ressource existiert nicht) | 404 | Die angeforderte Ressource ist nicht vorhanden (oder du verbirgst die Existenz). |
| Conflict (Unique-Constraint, Versionskonflikt) | 409 | Die Anfrage ist wohlgeformt, kollidiert aber mit dem aktuellen Zustand. |
| ValidationFailed (Feldregeln) | 422 | Die Form ist ok, aber Geschäftsvalidierung schlägt fehl (E-Mail-Format, Mindestlänge). |
| RateLimited | 429 | Zu viele Anfragen in einem Zeitfenster. |
| Internal (unbekannter Fehler) | 500 | Bug oder unerwarteter Fehler. |
| Unavailable (Abhängigkeit down, Timeout, Wartung) | 503 | Temporäres serverseitiges Problem. |
Zwei Unterscheidungen, die viel Verwirrung verhindern:
Retry-Hinweise sind wichtig:
Eine Request-ID ist ein kurzer, eindeutiger Wert, der einen API-Aufruf von Anfang bis Ende identifiziert. Wenn Clients ihn in jeder Antwort sehen können, wird Support einfach: „Schick mir die Request-ID“ reicht oft, um die genaue Logzeile und den Fehler zu finden.
Diese Gewohnheit zahlt sich sowohl bei Erfolgs- als auch bei Fehlerantworten aus.
Verwende eine klare Regel: Wenn der Client eine Request-ID sendet, behalte sie. Wenn nicht, erzeuge eine neue.
X-Request-Id).Setze die Request-ID an drei Stellen:
request_id in deinem Standard-Schema)Bei Batch-Endpunkten oder Hintergrundjobs behalte eine Parent-Request-ID. Beispiel: Ein Client lädt 200 Zeilen hoch, 12 validieren nicht, und du enqueuest Arbeit. Gib eine request_id für den gesamten Aufruf zurück und füge jedem Job und jedem per-Item-Error eine parent_request_id hinzu. So kannst du „einen Upload“ nachverfolgen, auch wenn er in viele Tasks zerfällt.
Clients brauchen eine klare, stabile Fehlerantwort. Deine Logs brauchen die ungeschönte Wahrheit. Halte diese beiden Welten getrennt: gib dem Client eine sichere Nachricht und einen öffentlichen Fehlercode, während du die interne Ursache, Stack und Kontext auf dem Server loggst.
Logge für jede Fehlerantwort ein strukturiertes Event, durchsuchbar nach request_id.
Felder, die konsistent gehalten werden sollten:
Speichere interne Details nur in Server-Logs (oder einem internen Fehler-Store). Der Client sollte niemals rohe Datenbankfehler, Query-Text, Stacktraces oder Provider-Meldungen sehen. Wenn du mehrere Services betreibst, kann ein internes Feld wie source (api, db, auth, upstream) die Triage beschleunigen.
Beobachte laute Endpunkte und rate-limitete Fehler. Wenn ein Endpunkt tausende 429- oder 400-Antworten pro Minute produzieren kann, vermeide Log-Spam: sample wiederkehrende Events oder senke die Schwere für erwartete Fehler, während du sie trotzdem in Metriken zählst.
Metriken entdecken Probleme früher als Logs. Tracke Zählungen gruppiert nach HTTP-Status und Fehlercode und alarmiere bei plötzlichen Spitzen. Wenn RATE_LIMITED nach einem Deploy um das 10-fache ansteigt, siehst du das schnell, selbst wenn Logs gesamplet sind.
Der einfachste Weg, Fehler konsistent zu machen, ist aufzuhören, sie überall individuell zu behandeln, und sie durch eine kleine Pipeline zu leiten. Diese Pipeline entscheidet, was der Client sieht und was du in Logs behältst.
Starte mit einer kleinen Menge Fehlercodes, auf die sich Clients verlassen können (z. B.: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Wrapp diese in typisierte Fehler, die nur sichere, öffentliche Felder (code, sichere Nachricht, optionale Details wie welches Feld falsch ist) freigeben. Bewahre interne Ursachen privat.
Implementiere dann eine einzige Übersetzerfunktion, die jeden Fehler in (statusCode, responseBody) verwandelt. Hier werden typisierte Fehler auf HTTP-Statuscodes gemappt, und unbekannte Fehler werden zu einer sicheren 500-Antwort.
Füge anschließend Middleware hinzu, die:
request_id hatEin Panic darf niemals Stacktraces an den Client ausgeben. Gib eine normale 500-Antwort mit einer generischen Nachricht zurück und logge den vollen Panic mit derselben request_id.
Ändere schließlich deine Handler so, dass sie error zurückgeben statt die Response direkt zu schreiben. Ein Wrapper kann den Handler aufrufen, den Übersetzer ausführen und JSON im Standard-Format schreiben.
Eine kompakte Checkliste:
Golden-Tests sind wichtig, weil sie den Vertrag festschreiben. Wenn später jemand eine Nachricht oder einen Statuscode ändert, schlagen die Tests fehl, bevor Clients überrascht werden.
Stell dir einen Endpunkt vor: Eine Client-App legt einen Customer-Datensatz an.
POST /v1/customers mit JSON wie { "email": "[email protected]", "name": "Pat" }. Der Server gibt immer dieselbe Fehlerform zurück und immer eine request_id.
Die E-Mail fehlt oder ist fehlerhaft formatiert. Der Client kann das Feld hervorheben.
{
\"request_id\": \"req_01HV9N2K6Q7A3W1J9K8B\",
\"error\": {
\"code\": \"VALIDATION_FAILED\",
\"message\": \"Some fields need attention.\",
\"details\": {
\"fields\": {
\"email\": \"must be a valid email address\"
}
}
}
}
Die E-Mail existiert bereits. Der Client kann zum Anmelden raten oder eine andere E-Mail vorschlagen.
{
\"request_id\": \"req_01HV9N3C2D0F0M3Q7Z9R\",
\"error\": {
\"code\": \"ALREADY_EXISTS\",
\"message\": \"A customer with this email already exists.\"
}
}
Eine Abhängigkeit ist down. Der Client kann mit Backoff retryen und eine ruhige Nachricht anzeigen.
{
\"request_id\": \"req_01HV9N3X8P2J7T4N6C1D\",
\"error\": {
\"code\": \"TEMPORARILY_UNAVAILABLE\",
\"message\": \"We could not save your request right now. Please try again.\"
}
}
Mit einem Vertrag reagiert der Client konsistent:
details.fields markierenrequest_id als Support-ID anzeigenFür den Support ist dieselbe request_id der schnellste Weg zur echten Ursache in internen Logs, ohne Stacktraces oder Datenbankfehler offenzulegen.
Der schnellste Weg, API-Clients zu verärgern, ist sie raten zu lassen. Wenn ein Endpunkt { "error": "..." } zurückgibt und ein anderer { "message": "..." }, wird jeder Client zu einem Haufen Sonderfälle und Bugs bleiben wochenlang verborgen.
Einige Fehler treten immer wieder auf:
code, an dem Clients sich orientieren können.request_id nur bei Fehlern hinzufügen, sodass du einen User-Report nicht mit dem vorherigen erfolgreichen Aufruf korrelieren kannst.Interna zu leaken ist die leichteste Falle. Ein Handler gibt err.Error() zurück, weil es praktisch ist, und dann landet ein Constraint-Name oder eine Drittanbieter-Meldung in Produktions-Antworten. Halte die Client-Nachricht kurz und sicher und lege die ausführliche Ursache in die Logs.
Sich nur auf Text zu verlassen, ist ein schleichendes Problem. Wenn der Client englische Sätze wie „email already exists" parsen muss, kannst du die Formulierung nicht ändern, ohne Logik zu brechen. Stabile Fehlercodes erlauben dir, Nachrichten anzupassen, zu übersetzen und Verhalten konsistent zu halten.
Behandle Fehlercodes als Teil deines öffentlichen Vertrags. Musst du einen Code ändern, füge einen neuen hinzu und halte den alten vorerst weiter verfügbar, auch wenn beide denselben HTTP-Status haben.
Schließlich: Füge in jede Antwort dasselbe request_id-Feld ein, Erfolg wie Fehler. Wenn ein Nutzer sagt „es hat funktioniert, dann ging es kaputt“, spart diese eine ID oft eine Stunde Rätselraten.
Vor dem Release eine kurze Konsistenzprüfung:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Schreibe Tests, damit Handler nicht versehentlich unbekannte Codes zurückgeben.request_id zurück und logge sie für jede Anfrage, inkl. Panics und Timeouts.Danach spot-checke einige Endpunkte manuell. Erzeuge einen Validierungsfehler, einen Missing-Record-Fehler und einen unerwarteten Fehler. Wenn Antworten zwischen Endpunkten unterschiedlich aussehen (Felder wechseln, Statuscodes driften, Nachrichten Interna zeigen), behebe die gemeinsame Pipeline, bevor du neue Funktionen hinzufügst.
Eine praktische Regel: Wenn eine Nachricht einem Angreifer helfen würde oder einen normalen Nutzer verwirrt, gehört sie in die Logs, nicht in die Antwort.
Schreibe den Fehlervertrag auf, dem jeder Endpunkt folgen soll, auch wenn deine API schon live ist. Ein geteilter Vertrag (Status, stabiler Fehlercode, sichere Nachricht und request_id) ist der schnellste Weg, Fehler für Clients vorhersehbar zu machen.
Migriere dann schrittweise. Behalte bestehende Handler bei, leite aber ihre Fehler durch einen Mapper, der interne Fehler in deine öffentliche Antwortform überführt. Das erhöht die Konsistenz ohne riskanten Big-Rewrite und verhindert, dass neue Endpunkte neue Formate erfinden.
Führe einen kleinen Fehlercode-Katalog und behandle ihn wie Teil deiner API. Wenn jemand einen neuen Code einführen will, prüfe kurz: Ist er wirklich neu, ist die Benennung klar, und mappt er auf den richtigen HTTP-Status?
Füge einige Tests hinzu, die Drift erkennen:
request_id.error.code ist vorhanden und stammt aus dem Katalog.error.message bleibt sicher und enthält keine internen Details.Wenn du ein Go-Backend von Grund auf aufbaust, hilft es, den Vertrag früh zu fixieren. Zum Beispiel enthält Koder.ai (koder.ai) einen Planungsmodus, in dem du Konventionen wie ein Fehler-Schema und einen Code-Katalog vorab definieren kannst, um Handler beim Wachsen der API im Takt zu halten.
Verwende überall dieselbe JSON-Struktur für Fehlerantworten. Ein praktisches Default ist eine top-level request_id plus ein error-Objekt mit code, message und optionalen details, sodass Clients zuverlässig parsen und reagieren können.
Gib error.message als kurzen, für Nutzer sicheren Satz zurück und bewahre die eigentliche Ursache in den Server-Logs auf. Gib niemals rohe Datenbankfehler, Stacktraces, interne Hostnamen oder Nachrichten von Drittanbietern zurück – auch wenn das in der Entwicklung hilfreich wirkt.
Verwende einen stabilen error.code für maschinelle Logik und lasse den HTTP-Status die grobe Kategorie beschreiben. Clients sollten sich auf error.code (z. B. ALREADY_EXISTS) stützen und den Status als Richtwert (z. B. 409 für Konflikte) nutzen.
Verwende 400, wenn die Anfrage nicht zuverlässig geparst oder interpretiert werden kann (fehlerhaftes JSON, falsche Typen). Nutze 422, wenn die Anfrage wohlgeformt ist, aber Geschäftsregeln verletzt (ungültiges E-Mail-Format, zu kurzes Passwort).
Nutze 409, wenn die Eingabe gültig ist, aber nicht angewendet werden kann, weil sie mit dem aktuellen Zustand kollidiert (E-Mail bereits vergeben, Versionskonflikt). Verwende 422 für feldbezogene Validierung, bei der das Ändern des Felds das Problem löst, ohne dass sich der Serverzustand ändern muss.
Erzeuge eine kleine Menge typisierter Fehler (Validation, NotFound, Conflict, Unauthorized, Internal) und lasse Handler diese zurückgeben. Ein gemeinsamer Übersetzer mappt dann diese Typen auf Statuscodes und die standardisierte JSON-Antwortform.
Gib in jeder Antwort eine request_id zurück, Erfolg wie Fehler, und logge sie in jeder Server-Logzeile. Wenn ein Client ein Problem meldet, reicht diese ID oft, um den genauen Fehlerpfad in den Logs zu finden.
Gib 200 nur zurück, wenn die Operation erfolgreich war. Verwende 4xx/5xx für Fehler. Fehler hinter 200 zu verstecken zwingt Clients, Body-Felder zu parsen, und erzeugt inkonsistentes Verhalten zwischen Endpunkten.
Standardmäßig nicht retryen für 400, 401, 403, 404, 409 und 422, da ein Retry ohne Änderung in der Regel nicht hilft. Erlaube Retry für 503 und manchmal für 429 nach Wartezeit; wenn du Idempotenz-Keys unterstützt, werden Retries für POST bei transienten Fehlern sicherer.
Sichere den Vertrag mit einigen „golden“ Tests, die Status, error.code und Vorhandensein von request_id prüfen. Füge neue Fehlercodes hinzu, ohne alte Bedeutungen zu ändern, und füge nur optionale Felder hinzu, damit ältere Clients weiter funktionieren.