Fehlerbehandlung in Go: errors.Is, errors.As und Error Wrapping
1. Übersicht
In Go sind Fehler normale Werte. Funktionen geben Fehler an die Aufrufer zurück, und der Aufrufer entscheidet selbst, wie damit umzugehen ist. Wenn ein Fehler durch mehrere Schichten wie Repository, Service und Handler wandert, möchtest du normalerweise Kontext hinzufügen, aber dennoch den ursprünglichen Fehler erkennen können.
Dieser Artikel zeigt dir, wie du Fehler mit fmt.Errorf und %w wrappst, Fehler mit errors.Is überprüfst, Custom Errors mit errors.As extrahierst und mehrere Fehler mit errors.Join kombinierst. Am Ende folgen Tests und häufige Fallstricke. Alle Beispiele verwenden nur die Standard-Library und laufen mit Go 1.20 und später. Nur errors.AsType benötigt Go 1.26.
2. Sentinel Error und Grenzen des ==-Vergleichs
In Go ist error ein sehr kleines Interface:
type error interface {
Error() string
}
Der einfachste Weg, einen “benannten” Fehler zu definieren, ist die Erstellung eines Sentinel Error. Das ist eine Variable auf Package-Ebene, die der Aufrufer direkt vergleichen kann:
package main
import (
"errors"
"fmt"
)
var ErrOrderNotFound = errors.New("order not found")
func findOrder(id int) error {
if id != 42 {
return ErrOrderNotFound
}
return nil
}
func main() {
err := findOrder(7)
if err == ErrOrderNotFound {
fmt.Println("Bestellung nicht gefunden")
}
}
Bestellung nicht gefunden
errors.New erstellt einen neuen Pointer. Daher ist err == ErrOrderNotFound nur wahr, wenn err genau diese Variable ist. Dies funktioniert gut, bis du anfängst, Kontext zu Fehlern hinzuzufügen.
3. Fehler wrappen mit fmt.Errorf und %w
3.1. Unterschied zwischen %w und %v
Angenommen, der Service möchte die Bestellungs-ID zur Fehlermeldung hinzufügen:
func loadOrder(id int) error {
if err := findOrder(id); err != nil {
return fmt.Errorf("loadOrder id=%d: %w", id, err)
}
return nil
}
func main() {
err := loadOrder(7)
fmt.Println(err)
fmt.Println(err == ErrOrderNotFound)
fmt.Println(errors.Is(err, ErrOrderNotFound))
fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true
Erklärung Zeile für Zeile:
fmt.Errorf(... %w ...)erstellt einen neuen Fehler. Dieser Fehler behält einen Verweis auf den ursprünglichenerrdurch die MethodeUnwrap() error.err == ErrOrderNotFoundgibtfalsezurück, daerrjetzt ein anderer Wert ist, genannt ein „gewrappter” Fehler.errors.Istraversiert die Wrapper-Kette und findetErrOrderNotFounddarin.errors.Unwrapentfernt genau eine Ebene und gibt den ursprünglichen Fehler zurück.
Wenn du %w durch %v ersetzt, ist die gedruckte Meldung immer noch gleich. Aber errors.Is gibt false zurück, da %v nur den Text kopiert und nicht den ursprünglichen Fehler behält.
3.2. Mehrschichtiges Error Wrapping
Jede Schicht kann eine weitere Ebene wrappen. errors.Is kann den ursprünglichen Fehler immer noch finden, egal wie tief er liegt:
func handleGetOrder(id int) error {
if err := loadOrder(id); err != nil {
return fmt.Errorf("GET /orders/%d: %w", id, err)
}
return nil
}
// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true
Die Fehlermeldung liest sich jetzt wie ein komprimierter Stack Trace: die äußerste Schicht liegt auf der linken Seite, die Grundursache auf der rechten Seite.
4. errors.Is: Überprüfung „ist dieser Fehler X”
errors.Is(err, target) gibt true zurück, wenn ein beliebiger Fehler in der Kette eine der beiden Bedingungen erfüllt:
- Gleich
targetbeim==-Vergleich, oder - Hat eine Methode
Is(error) boolund diese Methode gibttruefürtargetzurück.
Die zweite Bedingung ermöglicht es einem Error-Typ, zu deklarieren, dass er „äquivalent” zu einem Sentinel ist. Zum Beispiel hast du einen allgemeinen ErrNotFound-Fehler und einen spezifischeren Error-Typ für Kunden:
var ErrNotFound = errors.New("not found")
type CustomerNotFoundError struct {
CustomerID string
}
func (e CustomerNotFoundError) Error() string {
return "customer " + e.CustomerID + " not found"
}
func (e CustomerNotFoundError) Is(target error) bool {
return target == ErrNotFound
}
func main() {
err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
fmt.Println(errors.Is(err, ErrNotFound))
}
true
Der HTTP-Handler braucht nur errors.Is(err, ErrNotFound) zu überprüfen, um 404 zurückzugeben. Er braucht nicht zu wissen, ob der Fehler von einer Bestellung, einem Kunden oder einem Produkt stammt.
Hinweis: Rufe
Unwrapnicht in der MethodeIsauf, daerrors.Isbereits die Kette traversiert. Die MethodeIssollte nur auf die aktuelle Error-Schicht selbst antworten.
5. errors.As: Extrahieren eines Fehlers mit spezifischem Typ
Sentinel sagt dir nur, was der Fehler ist. Wenn du zusätzliche Daten wie einen Fehlercode oder eine ID benötigst, verwendest du einen Custom-Error-Typ und extrahierst ihn mit errors.As.
5.1. Custom Error-Typ mit Unwrap
var ErrInsufficientFunds = errors.New("insufficient funds")
type PaymentError struct {
OrderID int
Code string
Err error
}
func (e *PaymentError) Error() string {
return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}
func (e *PaymentError) Unwrap() error { return e.Err }
type PaymentService struct{ balance int }
func (s *PaymentService) Charge(orderID, amount int) error {
if amount > s.balance {
return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
}
s.balance -= amount
return nil
}
func checkout(s *PaymentService, orderID, amount int) error {
if err := s.Charge(orderID, amount); err != nil {
return fmt.Errorf("checkout: %w", err)
}
return nil
}
Der Aufrufer behandelt es wie folgt:
func main() {
svc := &PaymentService{balance: 100}
err := checkout(svc, 42, 500)
var pe *PaymentError
if errors.As(err, &pe) {
fmt.Println("code:", pe.Code, "order:", pe.OrderID)
}
fmt.Println(errors.Is(err, ErrInsufficientFunds))
fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds
Wichtige Punkte:
errors.Astraversiert die Wrapper-Kette. Wenn es auf den ersten Fehler trifft, der in den Typ*PaymentErrorpasst, weist es diesen Fehlerpezu und gibttruezurück.- Da
PaymentErrorUnwrap()hat, kannerrors.Is(err, ErrInsufficientFunds)immer noch in das FeldErreindringen. - Das zweite Argument muss ein Zeiger auf die Zielvariable sein. Hier ist es
&pe, das den Typ**PaymentErrorhat.
5.2. Pointer-Receiver-Fallstricke
Die Methode Error() ist auf *PaymentError deklariert, also implementiert nur *PaymentError das Interface error. So zu schreiben ist falsch:
var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error
go vet fängt dies zur Build-Zeit auf. Führe go vet ./... in deinem CI aus. Die einfache Regel: Der Typ der Zielvariable muss genau dem Typ entsprechen, den du zurückgibst. Wenn du &PaymentError{...} zurückgibst, muss die Zielvariable *PaymentError sein.
5.3. errors.AsType (Go 1.26+)
Ab Go 1.26 bietet die Standard-Library eine kompaktere generische Variante. Du brauchst die Variable nicht vorab zu deklarieren:
if pe, ok := errors.AsType[*PaymentError](err); ok {
fmt.Println("code:", pe.Code)
}
Dies ist typsicher zur Compile-Zeit und kann nicht wie in Abschnitt 5.2 zu einem Panic führen. Wenn dein Projekt Go 1.26 nutzt, solltest du es verwenden.
6. Mehrere Fehler mit errors.Join kombinieren
Ab Go 1.20 kann ein Fehler mehrere Sub-Fehler wrappen. Ein typischer Fall ist die Datenvalidierung, wenn du alle Fehler auf einmal berichten möchtest, anstatt beim ersten Fehler zu stoppen:
var (
ErrEmptyName = errors.New("name is empty")
ErrInvalidEmail = errors.New("email is invalid")
)
type Customer struct {
Name string
Email string
}
func validateCustomer(c Customer) error {
var errs []error
if c.Name == "" {
errs = append(errs, ErrEmptyName)
}
if !strings.Contains(c.Email, "@") {
errs = append(errs, ErrInvalidEmail)
}
return errors.Join(errs...)
}
func main() {
err := validateCustomer(Customer{Name: "", Email: "abc"})
fmt.Println(err)
fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true
Vergiss nicht, "strings" zu den Imports hinzuzufügen. Das Ergebnis zeigt drei Dinge:
errors.Joinverbindet Nachrichten mit Zeilenumbrüchen.errors.Isunderrors.Askönnen jeden Sub-Fehler überprüfen.errors.Joingibtnilzurück, wenn alle Argumentenilsind oder die Liste leer ist. Daher musst du nicht extraif len(errs) == 0schreiben.
fmt.Errorf kann auch mehrere %w in derselben Format-Zeichenfolge akzeptieren, z.B. fmt.Errorf("sync order: %w; %w", errDB, errCache). Der resultierende Fehler wird beide Fehler wrappen.
7. Tests für Error-Verhalten schreiben
Beim Testen solltest du die Identität des Fehlers mit errors.Is oder errors.As überprüfen. Vergleiche keine Fehlermeldungs-Strings. Hier ist ein table-driven Test für checkout in der Datei checkout_test.go:
package main
import (
"errors"
"testing"
)
func TestCheckout(t *testing.T) {
tests := []struct {
name string
balance int
amount int
wantErr error
wantCode string
}{
{name: "genug Geld", balance: 1000, amount: 500},
{name: "nicht genug Geld", balance: 100, amount: 500,
wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)
if tc.wantErr == nil {
if err != nil {
t.Fatalf("Fehler nicht erwartet, erhalten: %v", err)
}
return
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
}
var pe *PaymentError
if !errors.As(err, &pe) {
t.Fatalf("Fehler enthält kein *PaymentError: %v", err)
}
if pe.Code != tc.wantCode {
t.Errorf("code = %q, möchte %q", pe.Code, tc.wantCode)
}
})
}
}
Test ausführen:
go test -v -run TestCheckout ./...
=== RUN TestCheckout
=== RUN TestCheckout/genug_Geld
=== RUN TestCheckout/nicht_genug_Geld
--- PASS: TestCheckout (0.00s)
--- PASS: TestCheckout/genug_Geld (0.00s)
--- PASS: TestCheckout/nicht_genug_Geld (0.00s)
PASS
Dieser Test läuft immer noch korrekt, wenn später jemand die Fehlermeldung ändert oder eine Wrapper-Ebene hinzufügt. Deshalb solltest du die Identität überprüfen, nicht die Zeichenfolge.
8. Häufige Fallstricke und wann man NICHT wrappen sollte
8.1. String-Vergleich err.Error()
if strings.Contains(err.Error(), "not found") { ... } // fragil
Nur eine Änderung der Nachricht oder ein anderer Fehler, der zufällig den Satz „not found” enthält, und die Logik ist kaputt. Verwende stattdessen Sentinels mit errors.Is.
8.2. Typed nil
func (s *PaymentService) Refund(orderID int) error {
var pe *PaymentError // nil
// ... kein Fehler ...
return pe // NICHT nil!
}
Das Interface error enthält jetzt den Typ *PaymentError mit einem nil-Wert. Da das Interface einen Typ hat, gibt err != nil immer noch true zurück. Wenn es keinen Fehler gibt, schreibe immer explizit return nil.
8.3. Wrap macht Implementierungsdetails zur öffentlichen API
Wenn du sql.ErrNoRows mit %w wrappst, verpflichtest du dich implizit, dass Aufrufer errors.Is(err, sql.ErrNoRows) überprüfen können. Dieser Fehler wird Teil deiner öffentlichen API. Wenn du später zu MongoDB wechselst, bricht der Code des Aufrufers.
An Package-Grenzen hast du zwei bessere Optionen:
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // übersetze zu Domain-Fehler
}
return fmt.Errorf("find order %d: %v", id, err) // nur Nachricht, verstecke ursprünglichen Fehler
Hinweis:
%wist eine API-Entscheidung, nicht nur eine Formatierungsentscheidung. Wrap nur, wenn Aufrufer den ursprünglichen Fehler wirklich überprüfen sollen.
8.4. Gleichzeitig loggen und zurückgeben
if err != nil {
log.Printf("charge failed: %v", err)
return fmt.Errorf("charge: %w", err)
}
Wenn du das in jeder Schicht machst, erscheint ein Fehler mehrfach in den Logs. Die allgemeine Regel ist entweder behandeln (log) oder zurückgeben, nicht beides. Logging sollte die äußerste Schicht übernehmen, z.B. HTTP-Handler oder main.
8.5. Wiederholter Kontext, mangelnde Information
Nachrichten wie failed to load order: failed to query: failed to ... sind lang und wenig informativ. Schreibe den Operationsnamen und wichtige Daten auf, wie loadOrder id=7: .... Lass „failed” weg, denn es ist bereits ein Fehler.
8.6. Wann man errors.As NICHT verwenden sollte
Wenn du nur wissen brauchst, welchen Fehlertyp es gibt, ist errors.Is mit einem Sentinel ausreichend und einfacher. Erstelle nur einen Custom-Error-Typ, wenn Aufrufer wirklich Begleitdaten benötigen, wie Fehlercode, falsches Feld oder Wiederholungszeit.
9. Fazit
Du hast die Go-Fehlerbehandlungs-Werkzeuge durchgegangen:
fmt.Errorfmit%wfügt Kontext hinzu, bewahrt aber den ursprünglichen Fehler.%vbehält nur den Text.errors.Isüberprüft, ob ein Fehler einen Sentinel erfüllt, traversiert die gesamte Wrapper-Kette. Ein Error-Typ kann die MethodeIsdefinieren, um seine Äquivalenz zu deklarieren.errors.As(odererrors.AsTypeab Go 1.26) extrahiert einen Fehler eines spezifischen Typs mit seinen Daten. Der Typ der Zielvariable muss genau dem zurückgegebenen Typ entsprechen.errors.Joinund mehrere%wermöglichen es einem Fehler, mehrere Sub-Fehler zu wrappen.
Letzten Endes: Sehe Wrapping als Teil des API-Designs. Wrap mit Absicht, übersetze Infrastruktur-Fehler in Domain-Fehler an Package-Grenzen, und schreibe Tests auf Basis der Error-Identität, nicht der Meldungszeichenfolge.