Wrapper les erreurs en Go : où, et surtout pourquoi

En review sur un mailer de confirmation de commande, je viens d'expliquer avec assurance que la bonne pratique, c'est de wrapper l'erreur une seule fois, à la frontière du package, pas à chaque étape interne. Les fonctions qui construisent le message restent muettes, seule la fonction publique qui envoie l'email ajoute le contexte utile. J'ai même cité Dave Cheney à l'appui. Sauf qu'en creusant pour écrire cet article, je découvre que Dave Cheney lui-même a changé d'avis entre temps, et que la vraie règle n'est pas où on wrappe, mais ce qu'on choisit d'exposer.

Le conseil que je viens de donner

Le code en question ressemblait à ça : deux fonctions internes qui construisent le contenu d'un email, et deux fonctions publiques qui les appellent avant d'envoyer :

func buildOrderMessage(order Order) (Message, error) {
    tpl, err := loadTemplate("order-confirm")
    if err != nil {
        return Message{}, err // pas de wrap, propage tel quel
    }
    return tpl.Render(order)
}

func mailOrder(order Order) error {
    msg, err := buildOrderMessage(order)
    if err != nil {
        return fmt.Errorf("failed to build message for order %d: %w", order.ID, err)
    }
    return sender.Send(msg)
}

L'idée : wrapper à chaque étage produit un message bruyant du genre failed to send: failed to build message: failed to load template: open failed. Personne ne lit ces couches intermédiaires en prod, seuls le message final et la cause racine comptent. Wrapper une fois, au bon endroit, garde le message lisible et ajoute le contexte qui a vraiment de la valeur, ici l'ID de commande. C'est un argument correct. Il n'est juste pas complet.

Sauf que Dave Cheney a changé d'avis

En 2016, Dave Cheney publie Don't just check errors, handle them gracefully et popularise pkg/errors. Sa recommandation à l'époque : wrapper à chaque couche avec errors.Wrap, justement pour construire une pile de contexte lisible sans stack trace classique :

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed")
    }
    // ...
}

func ReadConfig() ([]byte, error) {
    config, err := ReadFile(path)
    return config, errors.Wrap(err, "could not read config")
}

Le message final devient could not read config: open failed: open /path: no such file or directory. À l'époque, l'argument tient : sans ce wrap à chaque niveau, un échec dans authenticate() remonte jusqu'en haut de la pile en affichant juste "no such file or directory", sans dire où ni pourquoi.

Puis en 2021, en cherchant un repreneur pour pkg/errors, Cheney écrit qu'il ne wrappe plus du tout ses erreurs. Sa raison : le logging structuré (des champs slog plutôt qu'une string concaténée) transporte mieux ce contexte de debug que des couches de Wrap imbriquées. Le créateur du pattern a fini par le trouver redondant avec l'outillage moderne.

Ce qui compte ici n'est pas de choisir un camp entre "toujours wrapper" et "jamais wrapper". C'est de comprendre pourquoi les deux positions sont défendables selon ce qu'on essaie de résoudre : un message d'erreur lisible en prod, ou un contrat d'API entre packages.

La vraie question : qu'est-ce que %w engage

Depuis Go 1.13, %w dans fmt.Errorf fait plus que décorer un message. Il rend l'erreur interne inspectable via errors.Is et errors.As depuis n'importe quel appelant. Ça veut dire que %w n'est pas un choix de style, c'est un engagement d'API :

// %w : j'expose l'erreur interne, l'appelant peut la matcher
func LookupUser(id string) (*User, error) {
    row, err := db.QueryRow(...)
    if err != nil {
        return nil, fmt.Errorf("lookup user %s: %w", id, err)
    }
    // ...
}

// L'appelant peut maintenant faire ça, et compter dessus dans le temps
if errors.Is(err, sql.ErrNoRows) {
    return http.StatusNotFound
}

Le piège classique : LookupUser tourne sur database/sql aujourd'hui. Le jour où on passe sur un ORM, ou qu'on ajoute un cache devant la requête, sql.ErrNoRows ne remonte plus. Le check errors.Is de l'appelant casse silencieusement, sans erreur de compilation, juste un 404 qui devient un 500 en prod. Le %w avait transformé un détail d'implémentation (on utilise database/sql) en promesse publique.

D'où la règle simple qui ressort de la doc Go 1.13 : %v par défaut, parce qu'il préserve l'abstraction sans rien exposer. %w seulement quand on décide consciemment que l'appelant doit pouvoir inspecter l'erreur interne, et dans ce cas autant définir sa propre sentinel plutôt que de fuiter celle d'une dépendance :

var ErrUserNotFound = errors.New("user not found")

func LookupUser(id string) (*User, error) {
    row, err := db.QueryRow(...)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("lookup user %s: %w", id, ErrUserNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("lookup user %s: %v", id, err) // détail interne, pas exposé
    }
    // ...
}

L'appelant matche désormais ErrUserNotFound, une sentinel que le package contrôle. Si database/sql disparaît demain, le contrat tient.

La règle publique/privée qui tient debout

Reste la question du "où". Sur ce point, la règle la plus utile que j'ai trouvée en creusant vient du blog d'Efe Karakus : wrapper seulement aux frontières publiques, laisser les fonctions privées propager tel quel. L'exemple qu'il donne est parlant : un chemin de fichier répété trois fois dans le message d'erreur, parce que chaque étage privé rajoutait sa propre couche de contexte redondant.

// ❌ Chaque niveau wrappe, même les fonctions privées
// "failed to read config from /etc/x: failed to read file from /etc/x: failed to open /etc/x: ..."

// ✅ Seules les fonctions publiques wrappent
func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err // privé, propage tel quel
    }
    return io.ReadAll(f)
}

func Read(path string) ([]byte, error) { // publique, wrappe une fois
    data, err := readFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config from %s: %w", path, err)
    }
    return data, nil
}

C'est exactement ce que faisait le mailer de confirmation de commande, et c'est pour ça que le conseil initial n'était pas faux. Il était juste incomplet : il répondait à "où", pas à "%w ou %v".

Ce que je change dans le mailer

Version révisée, en appliquant les deux critères ensemble : où wrapper (la frontière publique), et quoi exposer (seulement si l'appelant doit réagir) :

func buildOrderMessage(order Order) (Message, error) {
    tpl, err := loadTemplate("order-confirm")
    if err != nil {
        return Message{}, err // privé, propage tel quel
    }
    return tpl.Render(order)
}

func mailOrder(order Order) error {
    msg, err := buildOrderMessage(order)
    if err != nil {
        // %v : le caller n'a rien à matcher sur une erreur de template,
        // juste besoin de savoir quelle commande a échoué
        return fmt.Errorf("failed to build message for order %d: %v", order.ID, err)
    }
    if err := sender.Send(msg); err != nil {
        if errors.Is(err, smtp.ErrRateLimited) {
            // ici %w : l'appelant DOIT pouvoir distinguer "réessayer plus tard"
            // d'une erreur d'envoi définitive
            return fmt.Errorf("send order %d: %w", order.ID, smtp.ErrRateLimited)
        }
        return fmt.Errorf("send order %d: %v", order.ID, err)
    }
    return nil
}

La différence tient en une ligne : le rate limit SMTP est wrappé avec %w parce que l'appelant a un vrai comportement différent à adopter (retry avec backoff). Tout le reste passe en %v, contexte utile pour un humain qui lit les logs, sans promettre à personne de continuer à exposer *template.Error dans dix versions.

Conclusion

Le vrai piège n'est pas de choisir le mauvais camp entre "wrapper partout" et "wrapper une fois". C'est de traiter %w comme un simple embellissement de message alors que c'est une clause de contrat. Une fois qu'on se pose la question "est-ce que j'engage mon package à toujours retourner cette erreur précise", la position dans le code devient secondaire. Et accessoirement, ça m'apprend à vérifier une citation avant de la balancer en review, même quand elle vient d'un nom aussi solide que Dave Cheney.

Commentaires (0)