Concevoir un package d'erreurs sécurisé en Go : safe by default

Les erreurs en Go, c'est simple. Trop simple, parfois. Un fmt.Errorf("pq: no rows in result set") qui remonte jusqu'au handler HTTP, et voilà des détails SQL exposés dans une réponse 500. Pas besoin d'une faille exotique — juste un err.Error() au mauvais endroit.

Ce problème est systémique. Il ne vient pas d'un manque de discipline, mais d'une absence de frontière dans le type system. Rien dans l'interface error ne distingue ce qui est safe à exposer de ce qui ne l'est pas.

Le problème concret

Voici la fuite classique. Un handler qui sert directement err.Error() dans sa réponse JSON, et une couche repo qui wrappe les erreurs avec du contexte technique :

// handler HTTP
func (h *Handler) GetOrder(c echo.Context) error {
    order, err := h.service.FindByID(ctx, id)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": err.Error(), // ← FUITE
        })
    }
    return c.JSON(http.StatusOK, order)
}

// couche repo
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
    var o Order
    err := r.db.GetContext(ctx, &o, "SELECT * FROM orders WHERE id = $1", id)
    if err != nil {
        return nil, fmt.Errorf("orders.FindByID: %w", err) // wrapping standard
    }
    return &o, nil
}

Réponse reçue par le client :

{"error": "orders.FindByID: pq: no rows in result set"}

Le nom de la table, le driver PostgreSQL, et la nature de l'erreur SQL sont exposés publiquement. Rien de catastrophique isolément — mais c'est exactement le type d'information qu'un attaquant utilise pour affiner une injection ou cibler une surface d'attaque.

Ce que les solutions existantes apportent (et leurs limites)

Solution Approche Limite
cockroachdb/errors Package exhaustif, stack traces, wrapping riche ~15k lignes, 8 sous-packages. Disproportionné pour un microservice métier.
Pattern upspin (Rob Pike) Type Error avec Kind, Op, Err Pré-Go 1.13, pas de slog, et errors.Is() traverse toute la chaîne.
Pattern hashicorp Interface UserError avec GetMessage() Repose sur la discipline développeur, pas sur le type system.

La vraie question : peut-on concevoir un type où err.Error() retourne toujours quelque chose de safe, sans convention à respecter ?

Le design de safeerr

Quatre principes structurent le package :

  1. Safe by default : Error() retourne uniquement le message user-facing. Même http.Error(w, err.Error(), 500) accidentel ne leake rien.
  2. Unwrap() expose la cause : la cause technique est accessible via la chaîne errors.Is/As pour le logging et le debugging, mais jamais via Error().
  3. slog.LogValuer : les détails techniques (code, cause) sont exposés uniquement dans les logs structurés.
  4. Builder pattern immutable : WithMsg(), WithCause(), WrapCause() retournent toujours une nouvelle instance — les sentinelles ne sont jamais mutées.

Les sentinelles

var (
    ErrNotFound     = safeerr.New("NOT_FOUND",     "not found")
    ErrUnauthorized = safeerr.New("UNAUTHORIZED",  "unauthorized")
    ErrInvalidInput = safeerr.New("INVALID_INPUT", "invalid input")
    ErrInvalidState = safeerr.New("INVALID_STATE", "invalid state")
    ErrInternal     = safeerr.New("INTERNAL",      "internal error")
)

La signature de New se limite à deux champs : le code machine et le message user-facing. Le mapping vers les status HTTP est délégué à la couche handler — la séparation est explicite.

Le builder pattern immutable

// Message user-facing personnalisé
err := ErrNotFound.WithMsg("user 42 not found")

// Message formaté
err = ErrNotFound.WithMsgf("user %d not found", id)

// Cause technique pour les logs uniquement
err = ErrNotFound.WithCause(dbErr)

// Cause formatée
err = ErrNotFound.WithCausef("query %s: %w", table, dbErr)

// Les deux — le chaînage est immutable, chaque appel retourne une nouvelle instance
err = ErrNotFound.WithMsg("user 42 not found").WithCause(dbErr)

Chaque appel crée une nouvelle instance. Les sentinelles déclarées en var ne sont jamais modifiées — pas de risque de mutation partagée entre goroutines.

errors.Is par code

func (e *Error) Is(target error) bool {
    if t, ok := target.(*Error); ok {
        return e.code == t.code
    }
    return false
}

// En usage
err := ErrNotFound.WithMsg("user 42 not found")
errors.Is(err, ErrNotFound) // true — même code, peu importe le message ou la cause

La comparaison se fait par code, pas par pointeur. Deux instances dérivées du même sentinel matchent toujours, quelle que soit la personnalisation appliquée.

slog.LogValuer

func (e *Error) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("code", e.code),
        slog.String("msg", e.msg),
    }
    if e.cause != nil {
        attrs = append(attrs, slog.Any("cause", e.cause))
    }
    return slog.GroupValue(attrs...)
}

Log résultant :

level=ERROR msg="order processing failed" err.code=NOT_FOUND err.msg="order not found" err.cause="pq: no rows in result set"

slog.Any au lieu de slog.String : si la cause implémente elle-même slog.LogValuer, elle se structure récursivement dans les logs. Les détails techniques ne traversent jamais vers Error().

Wrap pour les boundaries de service

// Ajoute du contexte à la chaîne de cause sans changer le sentinel
return safeerr.Wrap(repoErr, "UserService.Get")

// Équivalent formaté
return safeerr.Wrapf(repoErr, "UserService.Get user=%s", userID)

Wrap distingue deux cas : si err est déjà un *Error, il préserve le code et enrichit la cause. Si c'est une erreur stdlib, il délègue à fmt.Errorf. Un seul appel gère les deux.

Mapping HTTP centralisé

func Error(c echo.Context, err error) error {
    var appErr *safeerr.Error
    if errors.As(err, &appErr) {
        return c.JSON(httpStatus(appErr.Code()), map[string]string{
            "error": appErr.Error(),
        })
    }
    slog.Error("unhandled error reaching handler", "err", err)
    return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
}

Un seul endroit dans le codebase sait comment traduire une erreur en réponse HTTP. httpStatus() mappe les codes machine vers les status HTTP — séparé de la définition des erreurs elles-mêmes. Si une erreur non typée remonte ici, elle est loggée et retourne un message générique.

Avant / après

Avant — les erreurs techniques remontent brutes jusqu'au handler :

// service — erreur technique remontée brute
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("FindOrder: %w", err)
    }
    if order.ContractRef == "" {
        return errors.New("order has no contract reference")
    }
    return nil
}

Après — conversion explicite à la frontière service :

// service — conversion explicite à la frontière
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return ErrNotFound.WithMsg("order not found").WithCause(err)
    }
    if order.ContractRef == "" {
        return ErrInvalidState.WithMsg("order has no contract reference")
    }
    return nil
}

// handler
func (h *Handler) ProcessOrder(c echo.Context) error {
    if err := h.service.ProcessOrder(ctx, cmd); err != nil {
        return render.Error(c, err)
    }
    return c.JSON(http.StatusOK, nil)
}

Réponse HTTP : {"error": "order not found"} — la cause SQL reste dans les logs, jamais dans la réponse.

Le package complet (~70 lignes)

package safeerr

import (
    "errors"
    "fmt"
    "log/slog"
)

// Error() retourne uniquement le message user-facing — safe par défaut.
type Error struct {
    code  string
    msg   string
    cause error
}

func New(code, msg string) *Error {
    return &Error{code: code, msg: msg}
}

func (e *Error) Error() string { return e.msg }
func (e *Error) Code() string  { return e.code }
func (e *Error) Unwrap() error { return e.cause }

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    return ok && e.code == t.code
}

func (e *Error) LogValue() slog.Value {
    attrs := []slog.Attr{slog.String("code", e.code), slog.String("msg", e.msg)}
    if e.cause != nil {
        attrs = append(attrs, slog.Any("cause", e.cause))
    }
    return slog.GroupValue(attrs...)
}

func (e *Error) WithMsg(msg string) *Error {
    return &Error{code: e.code, msg: msg, cause: e.cause}
}

func (e *Error) WithMsgf(format string, args ...any) *Error {
    return e.WithMsg(fmt.Sprintf(format, args...))
}

func (e *Error) WithCause(cause error) *Error {
    return &Error{code: e.code, msg: e.msg, cause: cause}
}

func (e *Error) WithCausef(format string, args ...any) *Error {
    return e.WithCause(fmt.Errorf(format, args...))
}

func Wrap(err error, msg string) error {
    if err == nil {
        return nil
    }
    if e, ok := err.(*Error); ok {
        if inner := e.Unwrap(); inner != nil {
            return e.WithCause(fmt.Errorf("%s: %w", msg, inner))
        }
        return e.WithCause(errors.New(msg))
    }
    return fmt.Errorf("%s: %w", msg, err)
}

func Wrapf(err error, format string, args ...any) error {
    if err == nil {
        return nil
    }
    return Wrap(err, fmt.Sprintf(format, args...))
}

Les trade-offs honnêtes

  • Pas de stack traces. Error ne capture pas la goroutine stack. Les logs structurés avec contexte métier compensent pour la majorité des cas, mais pour du debugging post-mortem approfondi c'est une limite réelle.
  • Le leak est toujours possible si on ne fait pas attention. ErrNotFound.WithMsg(err.Error()) rompt la sécurité — le message technique devient user-facing. L'API empêche le leak accidentel via Error(), pas le leak volontaire par maladresse. La discipline reste nécessaire à ce point précis.
  • Le coût de conversion. Chaque erreur technique doit être convertie en *Error à la frontière service. C'est une friction intentionnelle — sur un projet avec une centaine de points d'erreur, c'est du code à écrire et maintenir.
  • Unwrap() expose la cause à toute la chaîne. errors.Is(err, sql.ErrNoRows) fonctionne depuis une couche haute — ce qui signifie qu'une couche handler peut coupler sur un détail d'implémentation repo si elle le veut. C'est un choix conscient : la cause est accessible pour le code (logs, tests), mais jamais via Error(). La taxonomie des sentinelles doit rester suffisamment expressive pour que ce couplage ne soit pas nécessaire.

Conclusion

La vraie valeur de ce package n'est pas dans les 70 lignes. C'est dans la frontière qu'il rend explicite entre ce qui est safe à exposer et ce qui ne l'est pas. Cette frontière existait déjà dans toute application correctement conçue — mais elle reposait sur la discipline, sur des conventions documentées, sur des reviews attentives.

Déplacer cette contrainte dans le type system a un coût : la conversion explicite à chaque boundary. En échange : un err.Error() accidentel dans une réponse HTTP ne fuite jamais rien, indépendamment de qui a écrit le code.

Pour une librairie interne partagée entre plusieurs microservices d'une même stack, c'est un investissement qui se rembourse rapidement.

Commentaires (0)