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
Cinq principes structurent le package :
- Safe by default :
Error()retourne uniquement le message user-facing. - Pas de
Unwrap(): la chaîne d'introspection est intentionnellement rompue.errors.Is()ne traverse jamais la cause interne. Choix de sécurité. slog.LogValuer: les détails techniques sont exposés uniquement dans les logs structurés.- Builder pattern immutable :
WithMsg(),WrapCause()retournent toujours une nouvelle instance — les sentinelles ne sont jamais mutées. - Mapping HTTP centralisé : un seul point mappe les
ErrorKindvers les status HTTP.
Les sentinelles
var (
ErrNotFound = New("NOT_FOUND", KindNotFound, http.StatusNotFound, "not found")
ErrUnauthorized = New("UNAUTHORIZED", KindUnauthorized, http.StatusUnauthorized, "unauthorized")
ErrInvalidInput = New("INVALID_INPUT", KindInvalidInput, http.StatusUnprocessableEntity, "invalid input")
ErrInvalidState = New("INVALID_STATE", KindInvalidState, http.StatusConflict, "invalid state")
ErrInternal = New("INTERNAL", KindInternal, http.StatusInternalServerError, "internal error")
)
Le builder pattern immutable
func (e *AppErr) WithMsg(msg string) *AppErr {
return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}
func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}
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.
slog.LogValuer
func (e *AppErr) LogValue() slog.Value {
attrs := []slog.Attr{
slog.String("code", e.code),
slog.String("msg", e.msg),
}
if e.cause != nil {
attrs = append(attrs, slog.String("cause", e.cause.Error()))
}
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"
Les détails techniques — driver, requête, table — restent dans les logs. Jamais dans la réponse HTTP.
Mapping HTTP centralisé
func Error(c echo.Context, err error) error {
var appErr *safeerr.AppErr
if errors.As(err, &appErr) {
return c.JSON(appErr.Status(), 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. Si une erreur non typée remonte jusqu'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 safeerr.ErrNotFound.WrapCause(err, "order not found")
}
if order.ContractRef == "" {
return safeerr.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 (~130 lignes)
package safeerr
import "log/slog"
type ErrorKind int
const (
KindNotFound ErrorKind = iota
KindUnauthorized
KindForbidden
KindInvalidInput
KindInvalidState
KindConflict
KindInternal
)
type AppErr struct {
code string
kind ErrorKind
status int
msg string
cause error
}
func New(code string, kind ErrorKind, status int, msg string) *AppErr {
return &AppErr{code: code, kind: kind, status: status, msg: msg}
}
func (e *AppErr) Error() string { return e.msg }
func (e *AppErr) Code() string { return e.code }
func (e *AppErr) Kind() ErrorKind { return e.kind }
func (e *AppErr) Status() int { return e.status }
func (e *AppErr) Cause() error { return e.cause }
func (e *AppErr) WithMsg(msg string) *AppErr {
return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}
func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}
func (e *AppErr) WithCause(cause error) *AppErr {
return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: e.msg, cause: cause}
}
func (e *AppErr) LogValue() slog.Value {
attrs := []slog.Attr{
slog.String("code", e.code),
slog.String("msg", e.msg),
}
if e.cause != nil {
attrs = append(attrs, slog.String("cause", e.cause.Error()))
}
return slog.GroupValue(attrs...)
}
Les trade-offs honnêtes
-
Pas de stack traces.
AppErrne 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. -
WrapCausepeut leaker si mal utilisé.WrapCause(err, err.Error())rompt la sécurité — le message user-facing devient le message technique. La discipline reste nécessaire sur ce point précis. -
Le coût de conversion. Chaque erreur technique doit être convertie
en
AppErrà 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. -
errors.Is()ne fonctionne plus sur la cause. Si une couche haute veut testererrors.Is(err, sql.ErrNoRows), elle ne peut pas. Acceptable si la taxonomie des sentinelles est suffisamment expressive, problématique sinon.
Conclusion
La vraie valeur de ce package n'est pas dans les 130 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.