package main import ( "errors" "log" "net/http" ) // Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder // Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), // was genau passiert ist (Code) und was der Nutzer lesen soll (Msg). // // Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein // Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können, // und der HTTP-Status ergibt sich allein aus Kind. type Kind uint8 const ( KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen KindInvalid // Eingabe passt nicht KindUnauth // nicht angemeldet KindForbidden // angemeldet, aber nicht erlaubt KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein) KindConflict // Name vergeben, Doppel-Post KindTooLarge // Upload über dem Limit KindRateLimited // zu viele Anfragen KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode ) // Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist // bewusst kein HTTP-Status: die Zuordnung passiert erst im Adapter, damit ein // künftiger CLI- oder App-Client dieselben Fehler auf eigene Weise abbilden // kann (Exit-Code, Dialog, Retry). type Error struct { Kind Kind Code string // stabil und maschinenlesbar, Schema "." Msg string // deutsche Nutzermeldung Field string // optional: betroffenes Formularfeld Fields map[string]string // optional: mehrere Feldfehler auf einmal cause error // nur fürs Log, nie für den Nutzer } func (e *Error) Error() string { return e.Code + ": " + e.Msg } // Unwrap gibt die eingepackte Ursache frei, damit errors.Is/As über den // Taxonomie-Fehler hinweg bis zum ursprünglichen Fehler (etwa sql.ErrNoRows) // durchsuchen kann. func (e *Error) Unwrap() error { return e.cause } func Invalid(code, msg string) *Error { return &Error{Kind: KindInvalid, Code: code, Msg: msg} } func Unauth(code, msg string) *Error { return &Error{Kind: KindUnauth, Code: code, Msg: msg} } func Forbidden(code, msg string) *Error { return &Error{Kind: KindForbidden, Code: code, Msg: msg} } func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} } func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} } func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} } func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} } func MethodNotAllowed(code, msg string) *Error { return &Error{Kind: KindMethodNotAllowed, Code: code, Msg: msg} } // Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen // DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log // sieht ihn immer. Deshalb hat Internal auch keine freie Msg -- eine // nutzerseitige Unterscheidung zwischen "Datenbankfehler" und "Speichern // fehlgeschlagen" ist bedeutungslos. func Internal(cause error) *Error { return &Error{ Kind: KindInternal, Code: "internal", Msg: "Es ist ein Fehler aufgetreten.", cause: cause, } } // At markiert das Formularfeld, an dem der Fehler hängt. Verkettbar, damit die // Aufrufstelle einzeilig bleibt: return Invalid(...).At("content") // // Achtung: At und WithCause verändern den Empfänger. Auf einen frisch // konstruierten Fehler anwenden, NICHT auf ein geteiltes Sentinel wie // errEntryNotFound -- das würde den globalen Wert für alle Requests umschreiben. func (e *Error) At(field string) *Error { e.Field = field return e } // WithCause hängt eine technische Ursache an einen fachlichen Fehler. Nützlich, // wenn ein DB-Fehler fachlich eindeutig ist (UNIQUE-Verletzung -> Conflict), // die Ursache aber trotzdem ins Log soll. func (e *Error) WithCause(cause error) *Error { e.cause = cause return e } // asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit // müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus // Bibliotheken oder noch nicht umgestellten Stellen kommen. func asError(err error) *Error { var e *Error if errors.As(err, &e) { return e } return Internal(err) } // statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert. func statusOf(k Kind) int { switch k { case KindInvalid: return http.StatusBadRequest case KindUnauth: return http.StatusUnauthorized case KindForbidden: return http.StatusForbidden case KindNotFound: return http.StatusNotFound case KindConflict: return http.StatusConflict case KindTooLarge: return http.StatusRequestEntityTooLarge case KindRateLimited: return http.StatusTooManyRequests case KindMethodNotAllowed: return http.StatusMethodNotAllowed default: return http.StatusInternalServerError } } // logInternal protokolliert nur echte Serverfehler -- Validierungs- und // Rechtefehler sind normaler Betrieb und würden das Log zumüllen. func logInternal(e *Error) { if e.Kind != KindInternal { return } // Die Ursache ist das Einzige, was hier wirklich interessiert; fehlt sie, // bleibt wenigstens die Meldung. if e.cause != nil { log.Printf("internal: %v", e.cause) } else { log.Printf("internal: %s", e.Msg) } } // apiErrorBody ist das Drahtformat des JSON-Adapters. Eigener Typ statt einer // Map, damit die Feldnamen an genau einer Stelle stehen -- sie sind Teil des // API-Vertrags, sobald ein externer Client existiert. type apiErrorBody struct { Code string `json:"code"` Message string `json:"message"` Field string `json:"field,omitempty"` Fields map[string]string `json:"fields,omitempty"` } // writeAPIError ist der einzige Ausgang für Fehler im JSON-Adapter. Hier -- an // der Adaptergrenze -- wird auch geloggt, und zwar nur einmal: die // Service-Schicht loggt nicht, sonst steht derselbe Fehler mehrfach im Log. func writeAPIError(w http.ResponseWriter, err error) { e := asError(err) logInternal(e) writeJSON(w, statusOf(e.Kind), map[string]any{ "error": apiErrorBody{ Code: e.Code, Message: e.Msg, Field: e.Field, Fields: e.Fields, }, }) }