Gestion des erreurs en Go : errors.Is, errors.As et wrapping
1. Aperçu
En Go, une erreur est une valeur ordinaire. La fonction retourne l’erreur à l’appelant, et l’appelant décide lui-même de la gérer. Quand une erreur traverse plusieurs couches comme repository, service, handler, on souhaite souvent ajouter du contexte tout en reconnaissant l’erreur initiale.
Cet article explique comment envelopper les erreurs avec fmt.Errorf en utilisant %w, vérifier les erreurs avec errors.Is, extraire les erreurs personnalisées avec errors.As et fusionner plusieurs erreurs avec errors.Join. Vous trouverez à la fin des tests et les pièges courants. Tous les exemples utilisent uniquement la stdlib et fonctionnent avec Go 1.20 ou plus. Seul errors.AsType nécessite Go 1.26.
2. Erreur sentinelle et limites de la comparaison ==
En Go, error est une très petite interface :
type error interface {
Error() string
}
La façon la plus simple de définir une erreur “nommée” est de créer une erreur sentinelle. C’est une variable au niveau du package que l’appelant peut comparer directement :
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("Commande non trouvée")
}
}
Commande non trouvée
errors.New crée un nouveau pointeur. Donc err == ErrOrderNotFound n’est vrai que si err est exactement cette variable. Cela fonctionne bien jusqu’à ce que vous commenciez à ajouter du contexte à l’erreur.
3. Envelopper les erreurs avec fmt.Errorf et %w
3.1. En quoi %w diffère de %v
Supposons que la couche service veuille ajouter l’ID de la commande au message d’erreur :
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
Expliquons chaque ligne :
fmt.Errorf(... %w ...)crée une nouvelle erreur. Cette erreur conserve une référence verserrd’origine via la méthodeUnwrap() error.err == ErrOrderNotFoundretournefalsecarerrest maintenant une valeur différente, appelée erreur enveloppée.errors.Isparcourt la chaîne d’enveloppe et trouveErrOrderNotFoundà l’intérieur.errors.Unwrapretire une seule couche et retourne l’erreur d’origine.
Si vous remplacez %w par %v, le message imprimé sera identique. Mais errors.Is retournera false, car %v copie seulement la chaîne de texte sans conserver l’erreur d’origine.
3.2. Chaîne d’enveloppe multi-couches
Chaque couche peut ajouter une enveloppe. errors.Is trouve toujours l’erreur d’origine, peu importe sa profondeur :
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
Le message d’erreur ressemble maintenant à une stack trace raccourcie : la couche la plus externe est à gauche, la cause racine à droite.
4. errors.Is : vérifier “cette erreur est-elle X”
errors.Is(err, target) retourne true si l’une des erreurs dans la chaîne satisfait l’une de ces deux conditions :
- Elle est égale à
targetlors de la comparaison==, ou - Elle a une méthode
Is(error) boolet cette méthode retournetruepourtarget.
La deuxième condition permet à un type d’erreur de déclarer qu’il est “équivalent” à une sentinelle. Par exemple, vous avez une erreur générique ErrNotFound et un type d’erreur plus détaillé pour les clients :
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
Le handler HTTP n’a besoin que de vérifier errors.Is(err, ErrNotFound) pour retourner 404. Il n’a pas besoin de savoir si l’erreur vient d’une commande, d’un client ou d’un produit.
Note : N’appelez pas
Unwrapà l’intérieur de la méthodeIs, carerrors.Isparcourt déjà la chaîne. La méthodeIsdoit seulement répondre pour la classe d’erreur actuelle.
5. errors.As : extraire une erreur de type spécifique
Une sentinelle vous dit seulement ce qu’est l’erreur. Quand vous avez besoin de données supplémentaires comme un code d’erreur ou un ID, vous utilisez un type d’erreur personnalisé et l’extrayez avec errors.As.
5.1. Type d’erreur personnalisé avec 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
}
L’appelant gère comme suit :
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
Points à retenir :
errors.Asparcourt la chaîne d’enveloppe. Quand il trouve la première erreur assignable au type*PaymentError, il l’assigne àpeet retournetrue.- Puisque
PaymentErroraUnwrap(),errors.Is(err, ErrInsufficientFunds)traverse toujours le champErr. - Le deuxième argument doit être un pointeur vers la variable cible. Ici c’est
&pe, qui a le type**PaymentError.
5.2. Piège avec les receiver pointeurs
La méthode Error() est déclarée sur *PaymentError, donc seul *PaymentError implémente error. Écrire ceci serait une erreur :
var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error
go vet détecte cette erreur lors de la compilation. Une règle facile à retenir : le type de la variable cible doit être exactement le même que le type que vous retournez. Vous retournez &PaymentError{...} donc la variable cible doit être *PaymentError.
5.3. errors.AsType (Go 1.26+)
À partir de Go 1.26, la stdlib a ajouté une version générique plus concise. Vous n’avez pas besoin de déclarer la variable d’abord :
if pe, ok := errors.AsType[*PaymentError](err); ok {
fmt.Println("code:", pe.Code)
}
C’est type-safe à la compilation, donc on ne peut pas avoir la panic comme à la section 5.2. Si votre projet est déjà en Go 1.26, préférez cette approche.
6. Fusionner plusieurs erreurs avec errors.Join
À partir de Go 1.20, une erreur peut envelopper plusieurs erreurs enfants. Le cas typique est la validation de données, quand on veut signaler toutes les erreurs à la fois plutôt que de s’arrêter à la première :
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
N’oubliez pas d’ajouter "strings" à l’import. Les résultats montrent trois choses :
errors.Joinconcatène les messages en les séparant par des sauts de ligne.errors.Iseterrors.Asvérifient chaque erreur enfant.errors.Joinretournenilsi tous les arguments sontnilou la liste est vide. Donc vous n’avez pas besoin d’écrireif len(errs) == 0.
fmt.Errorf peut aussi accepter plusieurs %w dans la même chaîne de format, par exemple fmt.Errorf("sync order: %w; %w", errDB, errCache). L’erreur créée enveloppera les deux erreurs.
7. Écrire des tests pour le comportement des erreurs
Lors du test, vous devriez vérifier l’identité de l’erreur avec errors.Is ou errors.As. Ne comparez pas les chaînes de message. Voici un test table-driven pour checkout, à placer dans 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: "suffisant", balance: 1000, amount: 500},
{name: "insuffisant", 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("pas d'erreur attendue, reçue : %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("l'erreur ne contient pas *PaymentError : %v", err)
}
if pe.Code != tc.wantCode {
t.Errorf("code = %q, attendu %q", pe.Code, tc.wantCode)
}
})
}
}
Lancer les tests :
go test -v -run TestCheckout ./...
=== RUN TestCheckout
=== RUN TestCheckout/suffisant
=== RUN TestCheckout/insuffisant
--- PASS: TestCheckout (0.00s)
--- PASS: TestCheckout/suffisant (0.00s)
--- PASS: TestCheckout/insuffisant (0.00s)
PASS
Ce test reste correct si quelqu’un modifie le message d’erreur ultérieurement ou ajoute une couche d’enveloppe supplémentaire. C’est pourquoi il faut vérifier l’identité plutôt que la chaîne.
8. Pièges courants et quand NE PAS envelopper
8.1. Comparer la chaîne err.Error()
if strings.Contains(err.Error(), "not found") { ... } // fragile
Un seul changement de message, ou une autre erreur qui contient accidentellement “not found”, et la logique se casse. Utilisez plutôt une sentinelle combinée avec errors.Is.
8.2. Interface nil typée
func (s *PaymentService) Refund(orderID int) error {
var pe *PaymentError // nil
// ... pas d'erreur ...
return pe // PAS nil !
}
L’interface error contient maintenant le type *PaymentError avec une valeur nil. Puisque l’interface a un type, err != nil retourne toujours true. Quand il n’y a pas d’erreur, écrivez toujours explicitement return nil.
8.3. Envelopper révèle les détails internes
Quand vous enveloppez sql.ErrNoRows avec %w, vous vous engagez implicitement à ce que l’appelant puisse vérifier errors.Is(err, sql.ErrNoRows). Cette erreur devient part de votre API publique. Si vous passez à MongoDB plus tard, le code qui vous appelle sera cassé.
À la limite du package, vous avez deux meilleures options :
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // traduire vers l'erreur du domaine
}
return fmt.Errorf("find order %d: %v", id, err) // garder seulement le message, cacher l'erreur d'origine
Note :
%west une décision API, pas juste une question de format. N’enveloppez que quand vous voulez vraiment que l’appelant vérifie l’erreur d’origine.
8.4. Logger ET retourner
if err != nil {
log.Printf("charge failed: %v", err)
return fmt.Errorf("charge: %w", err)
}
Si vous faites ça dans chaque couche, une erreur apparaîtra plusieurs fois dans les logs. La règle générale est soit gérer (logger), soit retourner, pas les deux. Le logging devrait être fait par la couche la plus externe, comme le handler HTTP ou la fonction main.
8.5. Contexte répétitif, informations manquantes
Des messages comme failed to load order: failed to query: failed to ... sont longs mais n’apportent rien. Notez le nom de l’opération et les données importantes comme loadOrder id=7: .... Omettez “failed” car c’est déjà une erreur, donc c’est évident que c’est un échec.
8.6. Quand NE PAS utiliser errors.As
Si vous avez juste besoin de connaître le type d’erreur, errors.Is avec une sentinelle suffit et est plus simple. Créez un type d’erreur personnalisé seulement quand l’appelant a vraiment besoin des données supplémentaires, comme un code d’erreur, un champ invalide ou un délai de retry.
9. Conclusion
Vous avez traversé l’ensemble des outils de gestion des erreurs de Go :
fmt.Errorfavec%wajoute du contexte tout en conservant l’erreur d’origine.%vgarde seulement le texte.errors.Isvérifie si une erreur correspond à une sentinelle, en traversant toute la chaîne d’enveloppe. Un type d’erreur peut définir la méthodeIspour déclarer son équivalence.errors.As(ouerrors.AsTypeà partir de Go 1.26) extrait une erreur de type spécifique avec ses données. Le type de la variable cible doit correspondre exactement au type retourné.errors.Joinet plusieurs%wpermettent à une erreur d’envelopper plusieurs erreurs enfants.
Enfin, considérez l’enveloppe comme une partie de la conception API. Enveloppez avec intention, traduisez les erreurs d’infrastructure vers des erreurs métier à la limite du package, et écrivez les tests basés sur l’identité de l’erreur plutôt que sur la chaîne de message.