DEV Community

Avisto
Avisto

Posted on • Originally published at Medium on

Gérez vos erreurs du métier aux codes HTTP

LLa gestion des erreurs en Go, c’est souvent la dernière chose à laquelle on pense au démarrage d’un projet et la première qui pose problème quand le code grossit. Un http.Error ici, un if err != nil { return } là, et quelques messages dispersés dans les handlers. Ça fonctionne. Jusqu'au jour où il faut ajouter un nouveau cas d'erreur et qu'on réalise qu'il faut parcourir toute la codebase pour s'assurer de n'en avoir oublié aucun.

Go met pourtant à disposition tous les outils nécessaires pour centraliser cette logique proprement. Cet article s’adresse aux développeurs qui connaissent les bases du langage et veulent structurer la gestion d’erreurs de leur API de façon maintenable. On va construire une architecture en trois couches : métier, API, HTTP. Dans laquelle chaque erreur est typée, chaque décision HTTP est prise en un seul endroit, et aucun détail interne n’est exposé au client.

le problème

Voici un handler tiré d’un projet interne :

func (c *CapteurController) Delete(w http.ResponseWriter, r *http.Request) {
 pathId := r.PathValue("id")
 var id pgtype.UUID
 err := id.Scan(pathId)
 if err != nil {
  w.WriteHeader(http.StatusInternalServerError)
  return
 }
 err = c.DB.Query.DeleteCapteur(r.Context(), id)
 if err != nil {
  w.WriteHeader(http.StatusInternalServerError)
  return
 }
 w.WriteHeader(http.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

Ce handler est parfaitement lisible. Mais il cache quelques problèmes.

Si le paramètre id n'est pas un UUID valide, le client reçoit une 500 alors qu’une 400 serait plus approprié car c'est le client qui a envoyé une mauvaise requête.

Si le capteur n’existe pas, il reçoit aussi une 500 au lieu d’une 404.

Et dans les deux cas, le corps de la réponse est vide : aucun message pour expliquer ce qui a raté.

Ce pattern se retrouve en vingt exemplaires dans la codebase. Chaque handler prend ses propres décisions, souvent par défaut. Ajouter une règle transverse, par exemple retourner un 404 sur toutes les ressources introuvables, oblige à parcourir tous les handlers un par un.

L’objectif est de concentrer toute la gestion des erreurs HTTP en un seul endroit , de façon que les handlers se contentent de propager leurs erreurs sans savoir ce qu’il en advient.

Centraliser la gestion d’erreurs

Etape 1 : Déclaration des erreurs sentinelles

La première étape, c’est de définir des erreurs nommées qui servent de référence partagée entre les couches. On appelle ça des erreurs sentinelles.

Exemple :

// Erreurs de la couche controller/api
var (
    ErrInternalServerError = errors.New("internal server error")
    ErrInvalidBody = errors.New("invalid request body")
    ErrMissingCookie = errors.New("missing session cookie")
    ErrMissingParameter = errors.New("missing parameter")
    ErrInvalidRouteParam = errors.New("invalid route parameter")
    ErrInvalidQueryParam = errors.New("invalid query parameter")
    ErrBodyMismatch = errors.New("body mismatch")
    ErrInvalidPassword = errors.New("invalid password")
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden = errors.New("forbidden")
)

// Erreurs de la couche DB
var (
    ErrNotFound = errors.New("not found")
    ErrUnique = errors.New("unique constraint violation")
    ErrForeignKey = errors.New("foreign key constraint violation")
    ErrNotNull = errors.New("not null constraint violation")
    ErrInvalidInput = errors.New("invalid input")
    ErrTxClosed = errors.New("transaction closed")
    ErrUnknown = errors.New("unknown database error")
)
Enter fullscreen mode Exit fullscreen mode

Ces valeurs se comparent avec errors.Is(err, ErrNotFound). Elles forment le vocabulaire partagé entre les couches, sans rien révéler des détails d'implémentation.

Pourquoi définir ses erreurs avec errors.New et pas juste une chaine de caractère ? errors.New retourne un pointeur vers une structure interne. Deux appels avec le même message produisent deux valeurs distinctes :

a := errors.New("not found")
b := errors.New("not found")
fmt.Println(a == b) // false
fmt.Println(errors.Is(a, b)) // false
Enter fullscreen mode Exit fullscreen mode

L’identité d’une sentinelle est celle de son adresse mémoire, pas son message. C’est ce qui les rend sûres : var ErrNotFound = errors.New("not found") déclare une identité unique partagée dans tout le programme. Deux packages peuvent avoir une erreur "not found" sans que les errors.Is s'emmêlent.

Etape 2 : Enrichir les erreurs avec du contexte

Les sentinelles ont un défaut : elles ne portent pas de contexte. ErrInvalidBody seul ne dit pas quel champ pose problème.

La solution naïve serait de créer une nouvelle erreur avec fmt.Errorf("question title cannot be empty"). Mais alors errors.Is(err, ErrInvalidBody) retournerait false, l’identité de l’erreur a été perdue, et le mapping HTTP ne fonctionnerait plus.

Mais il est possible d’implémenter la méthode Unwrap() error ou Unwrap() []error sur un type personnalisé afin d'“envelopper” une erreur sentinelle. Comme on le détaillera dans la suite, cela permet à Go de construire une chaîne d'erreurs qu'il pourra ensuite parcourir pour l'identifier.

type BadRequestError struct {
    msg string
}
func (e *badRequestError) Error() string { return e.msg }
func (e *badRequestError) Unwrap() error { return ErrInvalidBody }
Enter fullscreen mode Exit fullscreen mode

Ce type fait trois choses à la fois :

  • Error() retourne le message personnalisé ("question title cannot be empty")
  • Unwrap() expose l'erreur sentinelle sous-jacente (ErrInvalidBody)
  • errors.Is(err, ErrInvalidBody) retourne true grâce à la traversée automatique de la chaîne

En pratique cette logique s’applique sans même connaître le cadre dans lequel cette erreur sera consommée.

func (p QuestionProperties) validate() error {
    if strings.TrimSpace(p.Title) == "" {
        return api.NewBadRequestError("question title cannot be empty")
    }
    if len(p.AnswerList) < 2 {
        return api.NewBadRequestError("question must have at least 2 answers")
    }
    if validCount != 1 {
        return api.NewBadRequestError("question must have exactly one correct answer")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

La couche validation ne sait pas ce que le handler fera de l’erreur. Elle retourne juste des erreurs typées métier. C’est le principe de responsabilité unique appliqué à la gestion d’erreur.

Etape 3 : Parcourir la chaîne d’erreur

Dans la partie précédente nous avions mentionné : erreur “enveloppée” et chaîne d’erreur. En go les erreurs forment un arbre dont la racine est l’erreur que l’on manipule. Cette arbre est parcouru par go à l’aide d’appel successifs à la méthode Unwrap.

Dans les fonctions de mapping d’erreurs nous allons retrouver deux méthodes qui parcourent cet arbre errors.Is et errors.As.

errors.Is(err, target) parcourt récursivement la chaîne d'erreurs via Unwrap jusqu'à trouver une valeur égale à target. C'est la fonction de test d'identité : elle répond à "est-ce que cette erreur est, ou contient, telle sentinelle ?"

err := NewBadRequestError("titre manquant") // Unwrap() → ErrInvalidBody
errors.Is(err, ErrInvalidBody) // true — trouvé après un niveau d'Unwrap
errors.Is(err, ErrNotFound) // false — pas dans la chaîne
Enter fullscreen mode Exit fullscreen mode

errors.As(err, &target) fait la même traversée, mais avec une assertion de type : elle cherche le premier maillon qui peut être assigné à target. Utile quand on veut extraire des données depuis une erreur concrète, pas juste tester son identité.

var target *badRequestError
if errors.As(err, &target) {
    fmt.Println(target.msg) // accès direct au champ du type concret
}
Enter fullscreen mode Exit fullscreen mode

fmt.Errorf("contexte : %w", err) est un raccourci pour créer une chaîne sans définir de type custom. Il génère une erreur dont Unwrap() retourne err. Parfait pour ajouter du contexte dans les logs, sans avoir besoin d'inspecter le type en aval.

// propager avec contexte — visible dans les logs, pas dans la réponse HTTP
return fmt.Errorf("updateQuiz: %w", database.ErrNotFound)
Enter fullscreen mode Exit fullscreen mode

errors.As(err, &target) est particulièrement utile à la frontière d'une couche externe, comme une API ou une base de données, là où l'on souhaite inspecter le détail d'une erreur afin de la traduire en réponse métier :

func mapPgErr(err error) error {
    if err == nil {
        return nil
    }
    log.Printf("[ERROR] Database : %+v", err.Error())
    if errors.Is(err, pgx.ErrNoRows) {
        return ErrNotFound
    }
    if errors.Is(err, pgx.ErrTxClosed) {
        return ErrTxClosed
    }
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        switch pgErr.Code {
        case "23505": // UNIQUE violation
            return ErrUnique
        case "23503": // FOREIGN KEY violation
            return ErrForeignKey
        case "23502": // NOT NULL violation
            return ErrNotNull
        case "23514", "22P02", "22001": // CHECK, invalid text, truncation
            return ErrInvalidInput
        }
    }
    return fmt.Errorf("%w: %v", ErrUnknown, err)
}
Enter fullscreen mode Exit fullscreen mode

errors.As(err, &pgErr) extrait l'erreur concrète de pgx pour lire le code PostgreSQL. Les couches supérieures n'ont jamais entendu parler de pgx. Elles reçoivent ErrNotFound ou ErrUnique, c'est tout.

Etape 4 : Mapper les erreurs vers leurs réponses HTTP

Toutes les erreurs convergent vers une unique fonction mapError :

func mapError(err error) (int, string) {
    switch {
    case errors.Is(err, database.ErrNotFound):
        return http.StatusNotFound, "resource not found"
    case errors.Is(err, ErrUnauthorized),
        errors.Is(err, ErrMissingCookie):
        return http.StatusUnauthorized, ErrUnauthorized.Error()
    case errors.Is(err, ErrInvalidPassword):
        return http.StatusUnauthorized, "invalid credentials"
    case errors.Is(err, ErrForbidden):
        return http.StatusForbidden, ErrForbidden.Error()
    case errors.Is(err, ErrBodyMismatch):
        return http.StatusUnprocessableEntity, ErrBodyMismatch.Error()
    case errors.Is(err, database.ErrUnique):
        return http.StatusConflict, "resource already exists"
    case errors.Is(err, database.ErrInvalidInput),
        errors.Is(err, ErrInvalidBody),
        errors.Is(err, ErrInvalidQueryParam),
        errors.Is(err, ErrInvalidRouteParam),
        errors.Is(err, ErrMissingParameter):
        return http.StatusBadRequest, err.Error()
    default:
        return http.StatusInternalServerError, ErrInternalServerError.Error()
    }
}
Enter fullscreen mode Exit fullscreen mode

Deux choses méritent l’attention ici :

  • Pour les erreurs 400, on retourne err.Error() directement. C'est là que Unwrap joue son rôle complet : errors.Is reconnaît ErrInvalidBody via la chaîne, et err.Error() retourne le message du type concret. Le client reçoit "question title cannot be empty", pas "invalid request body".
  • Pour ErrInvalidPassword en revanche, on ne retourne pas err.Error(). On retourne "invalid credentials". La raison : retourner "invalid password" confirmerait implicitement que le compte existe mais que le mot de passe est faux, une information qu'un attaquant peut exploiter pour de l'énumération de comptes. Le message interne reste dans les logs => le client reçoit quelque chose de neutre.

Etape 5 : Comment utiliser cette gestion d’erreur ?

type errorResponse struct {
    Error string `json:"error"`
}
func HandleError(w http.ResponseWriter, err error) {
    if err == nil {
        return
    }
    log.Printf("[ERROR] API: %+v\n", err)
    status, message := mapError(err)
    WriteJSON(w, status, errorResponse{
        Error: message,
    })
}
Enter fullscreen mode Exit fullscreen mode

Chaque handler appelle simplement HandleError et retourne :

// api/user/controller.go
var CreateUser = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    var dto CreateUserDTO
    if err := api.ReadBody(r, &dto); err != nil {
        api.HandleError(w, err)
        return
    }
    user, err := database.RegisterUser(r.Context(), database.Credentials{
        Name: dto.Name,
        Password: dto.Password,
    })
    if err != nil {
        api.HandleError(w, err)
        return
    }
    var out UserOutDTO
    out.FromDBUser(user)
    api.WriteJSON(w, http.StatusCreated, out)
})
Enter fullscreen mode Exit fullscreen mode

Le handler ne sait pas si l’erreur est un conflit unique (409), une validation (400), ou une erreur interne (500). Il délègue entièrement cette décision à HandleError.

Ce découplage a une conséquence concrète sur la maintenabilité. Ajouter un ErrRateLimited qui retourne un 429 ? On déclare la sentinelle, on ajoute un cas dans mapError, et c'est réglé pour l'ensemble du codebase. Zéro modification dans les handlers existants.

Conclusion

L’architecture présentée repose sur quatre principes qui se renforcent mutuellement.

Les sentinelles définissent un vocabulaire d’erreurs stable, comparable par identité de pointeur. Elles constituent le contrat entre les couches.

Les types custom avec Unwrap permettent d'enrichir ce vocabulaire avec du contexte sans briser la comparaison. C'est la clé pour avoir à la fois des messages utiles pour l'utilisateur et un routage HTTP fiable.

Le mappeur centralisé isole la politique HTTP du reste du code. Tout ce savoir est concentré dans mapError, ce qui le rend facile à auditer et à modifier.

La discipline sur err.Error() ferme le système : on ne surface ce message que pour des erreurs dont la chaîne a été explicitement rédigée pour un utilisateur final. Tout ce qui vient d'un système externe est absorbé par une couche de traduction avant d'atteindre la réponse.

Pour aller plus loin

Top comments (0)