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 :
- Safe by default :
Error()retourne uniquement le message user-facing. Mêmehttp.Error(w, err.Error(), 500)accidentel ne leake rien. Unwrap()expose la cause : la cause technique est accessible via la chaîneerrors.Is/Aspour le logging et le debugging, mais jamais viaError().slog.LogValuer: les détails techniques (code, cause) sont exposés uniquement dans les logs structurés.- 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.
Errorne 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 viaError(), 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 viaError(). 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.