Architecture de la gestion des erreurs en Go : limites et modèles
Gérer les erreurs au bon niveau.
La gestion des erreurs en Go est souvent critiquée. Chaque développeur Go a écrit ce code des centaines de fois :
if err != nil {
return err
}
Ce n’est pas là la partie intéressante. L’intéressant, c’est ce que l’erreur signifie, où elle doit être gérée, où elle doit être enveloppée (wrapped), où elle doit être traduite, où elle doit être journalisée (loguée), et quoi exposer à l’appelant — c’est une question d’architecture.
Go traite les erreurs comme des valeurs. Cela rend les échecs explicites. Cela signifie aussi que votre base de code a besoin d’une conception claire de la gestion des erreurs. Sans cela, les erreurs deviennent des chaînes de caractères aléatoires, les gestionnaires HTTP fuient des détails de base de données, les journaux dupliquent la même erreur cinq fois, les tentatives de réessaie (retries) se produisent pour les mauvaises raisons, et les appelants inspectent le texte au lieu du comportement.

Cet article n’est pas une introduction pour débutants à if err != nil.
C’est un guide pratique sur l’architecture de la gestion des erreurs en Go : enveloppement (wrapping), sentinels, types d’erreurs personnalisés, errors.Is, errors.As, limites d’erreurs (error boundaries), mappage API, journalisation, tentatives de réessaie, sécurité et modèles de production.
La version légèrement avisée : ne cherchez pas à faire disparaître les erreurs Go. Rendez-les significatives à la bonne limite.
Ce que sont les erreurs Go
En Go, une erreur est simplement une valeur implémentant cette interface :
type error interface {
Error() string
}
Cette petite interface est la raison pour laquelle la gestion des erreurs Go semble si directe.
Les fonctions retournent les erreurs explicitement :
func LoadUser(id string) (*User, error) {
// ...
}
Les appelants décident quoi faire :
user, err := LoadUser(id)
if err != nil {
return nil, err
}
Il n’y a pas d’exceptions et pas de déroulement de pile caché. L’échec fait partie de la signature de la fonction.
C’est bien, mais cela signifie aussi que les erreurs ont besoin de conception. Si chaque paquet retourne des messages arbitraires, les appelants ne peuvent pas prendre de décisions fiables. Si chaque couche enveloppe chaque erreur sans discipline, les opérateurs obtiennent des messages bruyants et les développeurs obtiennent des chaînes confuses. Si aucune couche n’enveloppe les erreurs, les échecs perdent leur contexte.
L’objectif n’est pas moins de gestion d’erreurs, mais une meilleure signification des erreurs.
Les trois rôles d’une erreur
Une erreur utile a généralement un ou plusieurs rôles.
Rôle 1 : Expliquer ce qui a échoué
Pour les humains, l’erreur doit expliquer quelle opération a échoué.
Exemple :
return fmt.Errorf("load user %s: %w", id, err)
Cela donne du contexte. Cela indique que l’échec s’est produit lors du chargement d’un utilisateur.
Rôle 2 : Préserver la cause
Pour le code, l’erreur doit préserver la cause sous-jacente lorsque cette cause est importante.
Exemple :
return fmt.Errorf("load user %s: %w", id, err)
Le %w enveloppe l’erreur originale afin que les appelants puissent l’inspecter avec errors.Is ou errors.As.
Rôle 3 : Permettre à une limite de prendre une décision
À une certaine limite, le programme doit décider quoi faire.
Exemples :
- Retourner HTTP 404
- Retourner HTTP 409
- Réessayer l’opération
- Journaliser au niveau avertissement
- Afficher un message sûr pour l’utilisateur
- Abandonner la transaction
- Envoyer l’erreur à la surveillance
- Ignorer l’annulation
Cette décision devrait généralement être basée sur l’identité ou le type de l’erreur, et non sur la correspondance de chaînes.
Les principaux outils d’erreur dans le Go moderne
Le Go moderne vous offre un ensemble petit mais puissant d’outils.
errors.New
Utilisez errors.New pour créer une valeur d’erreur simple :
var ErrNotFound = errors.New("not found")
Ceci est utile pour les erreurs sentinelle.
fmt.Errorf avec %w
Utilisez fmt.Errorf avec %w pour envelopper une erreur :
return fmt.Errorf("query user: %w", err)
L’enveloppement ajoute du contexte tout en préservant l’erreur originale pour l’inspection.
errors.Is
Utilisez errors.Is pour vérifier si une erreur correspond à une cible spécifique quelque part dans sa chaîne :
if errors.Is(err, ErrNotFound) {
// gérer non trouvé
}
Utilisez cela pour les erreurs sentinelle et les conditions connues.
errors.As
Utilisez errors.As pour extraire un type d’erreur spécifique d’une chaîne :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// utiliser validationErr.Field ou validationErr.Reason
}
Utilisez cela lorsque l’erreur transporte des données structurées.
errors.Join
Utilisez errors.Join lorsque plusieurs erreurs se sont produites et qu’elles doivent toutes être préservées :
return errors.Join(closeErr, flushErr)
Les erreurs jointes peuvent toujours être inspectées avec errors.Is et errors.As.
Utilisez cela avec prudence. Une erreur jointe signifie que plusieurs échecs font partie d’un seul résultat.
Erreurs sentinelle
Une erreur sentinelle est une valeur d’erreur au niveau du paquet qui représente une condition connue.
Exemple :
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Les erreurs sentinelle sont utiles lorsque l’appelant a seulement besoin de savoir quelle catégorie d’échec s’est produite.
Exemple :
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.queryUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
Ensuite, un service ou un gestionnaire peut vérifier :
if errors.Is(err, ErrUserNotFound) {
// retourner 404
}
Quand utiliser les erreurs sentinelle
Utilisez les erreurs sentinelle lorsque :
- La condition est stable.
- L’appelant doit faire une branche dessus.
- Aucune donnée structurée supplémentaire n’est nécessaire.
- L’erreur appartient à votre paquet ou domaine.
Bons exemples :
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Quand ne pas utiliser les erreurs sentinelle
Ne créez pas de sentinelles pour chaque échec possible.
Mauvais :
var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")
Si les appelants ne font pas de branches sur celles-ci, elles peuvent n’être que des messages.
Soyez également prudent quant à l’exportation de trop de sentinelles. Les erreurs sentinelle exportées deviennent partie de l’API de votre paquet.
Types d’erreurs personnalisés
Un type d’erreur personnalisé est utile lorsque l’erreur transporte des informations structurées.
Exemple :
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Appelant :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
C’est mieux que d’analyser une chaîne d’erreur.
Quand utiliser les types d’erreurs personnalisés
Utilisez les types d’erreurs personnalisés lorsque :
- Les appelants ont besoin de données structurées.
- L’erreur a des champs significatifs.
- Le type fait partie du contrat de votre paquet.
- L’appelant peut avoir besoin de gérer plusieurs valeurs différemment.
Exemples :
- Erreur de validation avec nom de champ
- Erreur de limite de taux avec temps de réessaie
- Erreur HTTP avec code de statut
- Erreur d’analyse avec ligne et colonne
- Erreur de domaine avec ID de ressource
Quand ne pas utiliser les types d’erreurs personnalisés
Ne créez pas de types personnalisés juste pour éviter errors.New.
Ceci est inutile :
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
S’il n’y a pas de données utiles, une sentinelle est souvent suffisante.
Enveloppement d’erreurs (Error wrapping)
L’enveloppement ajoute du contexte à une erreur tout en préservant l’erreur originale.
Exemple :
func LoadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := parseConfig(data); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
Si os.ReadFile échoue, l’appelant obtient les deux :
- l’opération de haut niveau : lire la configuration
- la cause de bas niveau : permission refusée, fichier non trouvé, etc.
Les deux sont disponibles via la chaîne d’erreurs, ce qui rend l’enveloppement avec %w pertinent à faire de manière cohérente.
Envelopper avec un contexte utile
Un bon enveloppement dit quelle opération a échoué :
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Un mauvais enveloppement ajoute du bruit :
return fmt.Errorf("error: %w", err)
Cela ne dit rien à l’appelant.
Évitez également de répéter le même nom à chaque couche :
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Ce type de chaîne est techniquement correct et pratiquement agaçant.
Enveloppez là où le contexte change de sens. Si vous ne pouvez pas expliquer en une phrase quelle opération a échoué, vous enveloppez probablement de manière trop agressive ou pas assez.
Quand envelopper et quand ne pas envelopper
C’est l’une des décisions architecturales les plus importantes.
Envelopper lors du franchissement d’une limite significative
Enveloppez lorsque l’erreur passe d’une opération à une opération de niveau supérieur.
Exemple :
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
L’erreur du référentiel fait maintenant partie d’une opération de service, et ce contexte ajouté est utile lorsque les opérateurs retracent une erreur via les journaux.
Ne pas envelopper juste pour dire “échec”
Mauvais :
if err != nil {
return fmt.Errorf("failed: %w", err)
}
Le mot “failed” (échec) est généralement implicite du fait qu’une erreur existe.
Ne pas envelopper si vous traduisez
Parfois, vous devriez traduire une erreur en une autre erreur de domaine.
Exemple :
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Cela masque intentionnellement le détail de la base de données et expose une condition de domaine.
Vous pouvez toujours préserver la cause si utile, mais faites-le délibérément.
Ne pas exposer accidentellement les détails d’implémentation
Si vous enveloppez une erreur de bas niveau avec %w, les appelants peuvent l’inspecter.
C’est généralement bon à l’intérieur de votre application.
Mais dans une API de paquet public, l’enveloppement peut exposer des détails d’implémentation comme partie de votre contrat.
Par exemple, si votre paquet enveloppe sql.ErrNoRows, les appelants peuvent commencer à en dépendre :
if errors.Is(err, sql.ErrNoRows) {
// l'appelant sait maintenant que vous utilisez database/sql
}
Si vous pouvez changer le stockage plus tard, préférez une sentinelle de domaine :
var ErrUserNotFound = errors.New("user not found")
Ensuite, retournez-la depuis la limite du paquet.
Limites d’erreurs (Error boundaries)
La manière la plus utile de penser à la gestion des erreurs Go est à travers les limites.
Une limite est un endroit où une erreur change de sens ou de public.
Les limites courantes incluent :
- base de données vers référentiel
- référentiel vers service
- service vers gestionnaire HTTP
- service vers commande CLI
- erreur interne vers message visible par l’utilisateur
- échec transitoire vers décision de réessaie
- échec d’opération vers événement de journalisation
- erreur de domaine vers réponse API
L’architecture des erreurs est principalement une conception de limites. Chaque limite est un point de décision où les erreurs gagnent du contexte, perdent des détails d’implémentation, ou sont traduites en une forme que la couche suivante peut traiter.
Limite du référentiel
Le référentiel parle au stockage.
Il devrait généralement traduire les erreurs spécifiques à la base de données en erreurs de domaine.
Exemple :
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Le référentiel masque sql.ErrNoRows et expose ErrUserNotFound — une limite propre qui signifie que le service n’a pas besoin de savoir comment le stockage représente “non trouvé”.
Limite du service
Le service possède la signification métier.
Il devrait généralement ajouter le contexte de l’opération et préserver les erreurs de domaine.
Exemple :
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Cela préserve la condition de domaine tout en ajoutant du contexte pour les erreurs inattendues.
Pour des règles métier plus complexes, le service peut créer des erreurs de domaine directement :
var ErrAccountDisabled = errors.New("account disabled")
func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
// ...
return session, nil
}
Le service est le bon endroit pour les erreurs de niveau métier — créées directement à partir de la logique de domaine plutôt que traduites à partir de conditions d’infrastructure.
Limite du gestionnaire HTTP
Le gestionnaire HTTP traduit les erreurs d’application en réponses HTTP.
C’est une limite où les détails internes doivent devenir des réponses sûres pour l’utilisateur.
Exemple :
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
writeHTTPError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Mappage d’erreur :
func writeHTTPError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrAccountDisabled):
http.Error(w, "account disabled", http.StatusForbidden)
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Le gestionnaire mappe les erreurs de domaine vers les sémantiques HTTP plutôt que d’exposer des détails bruts de base de données ou d’erreur interne. C’est là que beaucoup d’applications Go se trompent — elles exposent trop de détails internes ou font s’effondrer toutes les erreurs en HTTP 500. Pour une vue complète des modèles de gestionnaires et de middleware dans les API Go, Building REST APIs in Go couvre l’authentification, le routage et la gestion des erreurs à travers la bibliothèque standard, Gin, Echo et Fiber.
Limite CLI
Une CLI a une limite différente d’une API HTTP.
Dans une CLI, l’erreur doit être utile à la personne exécutant la commande.
Exemple :
func RunImport(ctx context.Context, args []string) error {
if len(args) == 0 {
return ErrMissingInputFile
}
if err := importFile(ctx, args[0]); err != nil {
return fmt.Errorf("import %s: %w", args[0], err)
}
return nil
}
À la limite de la commande :
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Mappez les erreurs connues vers les codes de sortie :
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
Une CLI peut souvent montrer plus de détails qu’une API publique, mais elle devrait toujours éviter de fuiter des secrets.
Modèle de type d’erreur API
Pour les API HTTP, un petit type d’erreur au niveau de l’application peut être utile.
Exemple :
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}
func (e *APIError) Unwrap() error {
return e.Err
}
Constructeur :
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Utilisation :
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Gestionnaire :
func writeAPIError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, map[string]string{
"code": apiErr.Code,
"message": apiErr.Message,
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
Ce modèle est utile lorsque vous voulez des erreurs API structurées avec des codes stables.
Utilisez-le à la limite API. Ne forcez pas chaque paquet interne à retourner des erreurs spécifiques à l’API.
Erreurs de domaine vs erreurs de transport
Gardez les erreurs de domaine séparées des erreurs de transport.
Erreur de domaine :
var ErrInsufficientBalance = errors.New("insufficient balance")
Mappage de transport :
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
Ne faites pas retourner des codes de statut HTTP à votre couche de domaine :
return &APIError{Status: http.StatusConflict}
Cela couple la logique métier à HTTP et empêche votre couche de service de fonctionner proprement à travers HTTP, CLI, workers, tests et futurs adaptateurs gRPC. Le mappage de transport appartient à la limite de transport, pas dans le code de domaine. Pour des conseils sur où définir les erreurs de domaine, les sentinelles et les adaptateurs de transport dans votre structure de projet, Go Project Structure: Practices & Patterns couvre les conventions internal/, pkg/ et d’adaptateurs qui maintiennent ces couches proprement séparées.
Erreurs réessuyables (Retryable errors)
Certaines erreurs devraient déclencher un réessaie. D’autres non.
Ne décidez pas cela en faisant correspondre des chaînes.
Utilisez une interface marqueur ou une fonction explicite.
Exemple :
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Assistant :
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Utilisation :
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Boucle de réessaie :
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// réessayer avec backoff
}
return err
}
C’est beaucoup mieux que de vérifier si la chaîne d’erreur contient “timeout” — la correspondance de chaînes échoue silencieusement lorsque les messages changent et crée un couplage invisible entre le producteur et le consommateur.
Erreurs de validation
Les erreurs de validation ont souvent besoin de données structurées.
Exemple :
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Utilisation :
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Gestionnaire :
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
C’est un bon usage de errors.As car l’appelant a besoin d’informations structurées — noms de champs et messages de validation — et non pas juste une chaîne d’erreur opaque.
Erreurs multiples
Parfois, plusieurs choses échouent.
Exemples :
- fermeture de plusieurs ressources
- validation de nombreux champs
- arrêt de plusieurs workers
- exécution de vérifications indépendantes
- vidage et fermeture de la sortie
Utilisez errors.Join lorsque toutes les erreurs doivent être préservées.
Exemple :
func CloseAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Appelant :
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
Les deux errors.Is et errors.As peuvent inspecter les erreurs jointes, ce qui signifie que les valeurs d’erreur jointes restent entièrement compatibles avec les modèles de vérification d’erreurs standards.
Quand ne pas utiliser errors.Join
N’utilisez pas errors.Join lorsqu’il y a une erreur principale et un contexte de journalisation.
Ne l’utilisez pas pour éviter de décider quelle erreur est importante.
Ne retournez pas d’énormes erreurs jointes aux utilisateurs.
Les erreurs jointes sont utiles, mais elles peuvent devenir bruyantes rapidement.
Panic n’est pas une gestion d’erreurs
Dans le code d’application normal, n’utilisez pas panic pour les erreurs attendues.
Mauvais :
if err != nil {
panic(err)
}
Utilisez panic pour les erreurs de programmeur ou les situations vraiment irrécupérables.
Exemples :
- violation d’invariant interne impossible
- initialisation de paquet invalide
- échec d’assistant de test avec
t.Fatalou panic dans des cas limités - erreur de configuration de démarrage irrécupérable, selon le style
Ne paniquez pas parce qu’une requête de base de données a échoué ou qu’un utilisateur a soumis une entrée invalide.
Ce sont des erreurs normales.
Journalisation des erreurs (Logging errors)
Une erreur courante en Go est de journaliser la même erreur à chaque couche.
Mauvais :
func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if err != nil {
log.Printf("query failed: %v", err)
return nil, err
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
log.Printf("service failed: %v", err)
return nil, err
}
return user, nil
}
Cela crée des journaux dupliqués pour un seul échec.
Mieux :
- envelopper les erreurs à mesure qu’elles montent
- journaliser une seule fois à la limite où l’erreur est gérée
- inclure un contexte structuré dans le journal
Exemple :
func (s *Server) handleError(r *http.Request, err error) {
s.logger.ErrorContext(
r.Context(),
"request failed",
"method", r.Method,
"path", r.URL.Path,
"err", err,
)
}
Cela donne un seul événement de journal avec la chaîne d’erreurs complète. Pour une configuration de journalisation structurée prête pour la production, Structured Logging in Go with slog couvre les enregistrements log/slog, les gestionnaires JSON, la corrélation de contexte et la censure — tous s’associant naturellement avec la journalisation des erreurs au niveau des limites.
Quand journaliser à l’intérieur des couches inférieures
Journalisez à l’intérieur des couches inférieures uniquement lorsque la couche gère réellement l’erreur ou ajoute un contexte opérationnel important qui ne sera pas visible ailleurs.
Par exemple, une boucle de réessaie peut journaliser chaque tentative de réessaie au niveau debug ou avertissement.
Mais un référentiel ne devrait pas journaliser chaque erreur de requête si le gestionnaire journalisera l’échec final de la requête.
Erreurs visibles par l’utilisateur vs erreurs opérateur
Ne montrez pas les erreurs internes directement aux utilisateurs.
Erreur interne :
query user by id: dial tcp 10.0.4.12:5432: connection refused
Message visible par l’utilisateur :
internal server error
Journal opérateur :
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
Ce sont des publics différents, et une bonne architecture d’erreurs les garde séparés :
- erreur diagnostique interne
- réponse sûre pour l’utilisateur
- code d’erreur API stable
- contexte de journal opérateur
Forcer une chaîne d’erreur à servir tous ces publics produit soit un risque d’exposition, soit un cauchemar de débogage. Concevez votre architecture d’erreurs autour de valeurs distinctes pour des consommateurs distincts.
Gestion sécurisée des erreurs
Les erreurs peuvent fuiter des informations sensibles.
Évitez d’exposer :
- chaînes de connexion de base de données
- requêtes SQL avec secrets
- noms d’hôtes internes
- chemins de fichiers
- jetons d’accès
- clés API
- traces de pile
- données clientes privées
- détails des politiques d’autorisation
Cela est particulièrement important dans les API HTTP.
Mauvais :
http.Error(w, err.Error(), http.StatusInternalServerError)
Bon :
http.Error(w, "internal server error", http.StatusInternalServerError)
Journalisez l’erreur interne de manière sécurisée pour les opérateurs. Retournez un message sûr à l’utilisateur.
Codes d’erreur
Pour les API publiques, des codes d’erreur stables sont souvent meilleurs que de se fier uniquement aux messages.
Exemple de réponse :
{
"code": "user_not_found",
"message": "user not found"
}
Le message peut changer. Le code devrait être stable.
Utilisez les codes d’erreur pour :
- comportement client
- documentation
- SDKs
- localisation
- diagnostics de support
Ne faites pas analyser des messages d’erreur en anglais aux clients.
Une conception d’erreurs en couches pratique
Voici un modèle propre pour de nombreux services backend Go.
Couche référentiel
- Parle à la base de données ou au stockage externe.
- Convertit les erreurs “non trouvé” spécifiques au stockage en erreurs de domaine.
- Enveloppe les erreurs de stockage inattendues avec le contexte de l’opération.
- Ne retourne pas d’erreurs HTTP.
- Ne journalise généralement pas.
Exemple :
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Couche service
- Possède les règles métier.
- Crée des erreurs de domaine.
- Préserve les erreurs de domaine connues.
- Enveloppe les erreurs de bas niveau inattendues.
- Ne retourne pas de codes de statut HTTP.
- Ne journalise généralement pas.
Exemple :
if user.Disabled {
return nil, ErrAccountDisabled
}
Couche transport
- Mappe les erreurs de domaine vers les réponses HTTP, gRPC ou CLI.
- Journalise les erreurs non gérées ou inattendues.
- Cache les détails internes aux utilisateurs.
- Définit les codes de statut et les codes d’erreur API.
Exemple :
switch {
case errors.Is(err, ErrUserNotFound):
writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
Cette séparation maintient la gestion des erreurs compréhensible et permet à chaque couche d’évoluer indépendamment — vous pouvez changer la technologie de stockage sans toucher à la logique de service ou au mappage de transport. La conception en couches fonctionne mieux lorsque les dépendances sont injectées plutôt que codées en dur ; Dependency Injection in Go: Patterns & Best Practices couvre les modèles de constructeur et d’interface qui rendent chaque limite facile à tester en isolation.
Exemple complet
Voici un petit exemple de bout en bout.
Erreurs de domaine :
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Référentiel :
package users
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name, disabled
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Disabled,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Service :
package users
import (
"context"
"errors"
"fmt"
)
type Service struct {
repo *Repository
}
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get profile for user %s: %w", id, err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
return &Profile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
Gestionnaire HTTP :
package httpapi
import (
"context"
"errors"
"net/http"
"example.com/app/users"
)
type Handler struct {
users *users.Service
}
func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
if err != nil {
h.writeError(w, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, users.ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{
"code": "user_not_found",
"message": "user not found",
})
case errors.Is(err, users.ErrAccountDisabled):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "account_disabled",
"message": "account is disabled",
})
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusGatewayTimeout, map[string]string{
"code": "request_timeout",
"message": "request timed out",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
}
Cette structure vous donne :
- erreurs de domaine
- traduction de stockage
- contexte de service
- mappage HTTP sûr
- chaînes d’erreurs inspectables
- pas de correspondance de chaînes
- pas de fuite de transport dans le code de domaine
C’est le genre d’architecture d’erreurs qui évolue — assez simple pour qu’un nouveau contributeur puisse comprendre, mais assez structurée pour que la logique de domaine ne fuite jamais dans les réponses de transport.
Test du comportement des erreurs
Le comportement des erreurs devrait être testé aussi rigoureusement que le chemin heureux, car les décisions aux limites — mappage de sentinelle, extraction de type, codes HTTP — sont souvent là où les bugs cachés se cachent le plus longtemps. Pour un guide complet sur la structure de test Go, le mocking et les modèles de couverture, voir Go Unit Testing: Structure & Best Practices.
Tester le mappage de sentinelle
func TestGetByIDNotFound(t *testing.T) {
repo := newTestRepository(t)
_, err := repo.GetByID(t.Context(), "missing")
if !errors.Is(err, users.ErrUserNotFound) {
t.Fatalf("got %v, want ErrUserNotFound", err)
}
}
Tester l’extraction d’erreur personnalisée
func TestValidationError(t *testing.T) {
err := ValidateCreateUser(CreateUserRequest{})
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("got %T, want ValidationError", err)
}
if len(validationErr.Fields) == 0 {
t.Fatal("expected validation fields")
}
}
Tester le mappage HTTP
func TestWriteErrorNotFound(t *testing.T) {
rec := httptest.NewRecorder()
writeHTTPError(rec, users.ErrUserNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
Les tests devraient prouver que les erreurs connues produisent le bon comportement à chaque limite, afin que la refactorisation des couches de stockage ou de transport ne puisse pas changer silencieusement le contrat d’échec.
Anti-modèles courants
Anti-modèle 1 : Correspondance de chaînes
Mauvais :
if strings.Contains(err.Error(), "not found") {
// ...
}
Utilisez errors.Is ou errors.As à la place — les deux gèrent automatiquement les chaînes d’erreurs enveloppées et ne cassent pas lorsque les messages sont reformattés ou localisés.
Anti-modèle 2 : Perte de la cause
Mauvais :
return errors.New("query failed")
Mieux :
return fmt.Errorf("query user: %w", err)
Anti-modèle 3 : Enveloppement sans sens
Mauvais :
return fmt.Errorf("error happened: %w", err)
Enveloppez avec un contexte d’opération qui explique ce qui était tenté, comme "create invoice %s: %w" plutôt qu’un préfixe vague qui n’ajoute aucune valeur diagnostique.
Anti-modèle 4 : Journalisation à chaque couche
Mauvais :
log.Println(err)
return err
à chaque niveau. Journalisez une seule fois là où l’erreur est enfin gérée, pas à chaque couche intermédiaire qui la transmet simplement vers le haut.
Anti-modèle 5 : Retourner des erreurs HTTP depuis le code de domaine
Mauvais :
return &APIError{Status: http.StatusNotFound}
depuis un service de domaine. Mappez les erreurs de domaine vers les codes de statut HTTP et les corps de réponse à la limite du gestionnaire, en gardant votre couche de service indépendante des préoccupations de transport.
Anti-modèle 6 : Exposer les erreurs internes aux utilisateurs
Mauvais :
http.Error(w, err.Error(), http.StatusInternalServerError)
Retournez des messages génériques sûrs aux utilisateurs et journalisez l’erreur interne complète avec un contexte structuré pour les opérateurs. N’exposez jamais les chaînes de connexion de base de données, les chemins de fichiers ou les traces de pile brutes dans les réponses API.
Anti-modèle 7 : Trop de sentinelles exportées
Les erreurs exportées font partie de votre API de paquet, et les ajouter vous engage à les maintenir. N’exportez pas chaque condition interne à moins que les appelants externes aient vraiment besoin de faire une branche dessus — préférez garder les sentinelles non exportées tant qu’il n’y a pas un besoin clair.
Anti-modèle 8 : Utiliser panic pour les échecs attendus
Mauvais :
panic(err)
pour les échecs de runtime normaux. Réservez panic pour les conditions vraiment irrécupérables ou les erreurs de programmeur, pas pour les enregistrements manquants ou les entrées utilisateur invalides — retournez toujours des erreurs dans ces cas.
Anti-modèle 9 : Ignorer les erreurs de contexte
Mauvais :
return fmt.Errorf("request failed")
lorsque la cause réelle était context.Canceled. Préservez les erreurs de contexte afin que les appelants puissent distinguer entre un échec d’opération réel et une requête annulée ou expirée, et répondre de manière appropriée à chacun. Pour un traitement approfondi de la façon dont l’annulation de contexte et la propagation des délais fonctionnent à travers les couches de service, voir Go context.Context Done Right.
Liste de vérification de révision d’erreurs
Utilisez cette liste de vérification dans la révision de code.
Création d’erreur
- Est-ce une condition connue ?
- Devrait-ce être une sentinelle ?
- A-t-il besoin de données structurées ?
- Devrait-ce être un type personnalisé ?
- Le message d’erreur est-il clair ?
Enveloppement d’erreur
- L’enveloppement ajoute-t-il un contexte d’opération utile ?
%wpréserve-t-il la cause là où c’est nécessaire ?- Le code expose-t-il accidentellement des détails d’implémentation ?
- La chaîne est-elle trop bruyante ?
Traduction d’erreur
- Une erreur de bas niveau est-elle traduite à la bonne limite ?
- Le comportement spécifique à la base de données est-il caché au code de service ?
- Les erreurs de domaine sont-elles indépendantes des préoccupations HTTP ou CLI ?
Gestion d’erreur
- L’appelant fait-il une branche avec
errors.Isouerrors.As? - L’annulation de contexte et les délais sont-ils gérés correctement ?
- Les erreurs réessuyables sont-elles identifiées explicitement ?
- Les erreurs de validation sont-elles structurées ?
Journalisation
- L’erreur est-elle journalisée une seule fois, à la limite de gestion ?
- Les journaux sont-ils structurés ?
- Les détails sensibles sont-ils exclus des réponses utilisateur ?
- Y a-t-il suffisamment de contexte pour les opérateurs ?
Test
- Les cas d’erreur connus sont-ils testés ?
- Les mappages HTTP ou CLI sont-ils testés ?
- Les détails de validation sont-ils testés ?
- Les décisions de réessaie sont-elles testées ?
Mes règles avisées
Règle 1 : Les erreurs doivent traverser les limites avec du sens
Ne faites pas simplement passer les erreurs. Décidez ce qu’elles signifient à chaque couche.
Règle 2 : Envelopper pour le contexte, pas pour la décoration
Si l’enveloppement n’ajoute pas d’informations utiles sur l’opération qui a échoué, n’enveloppez pas. Une couche supplémentaire de contexte sans sens rend la chaîne d’erreurs plus difficile à lire et n’ajoute aucune valeur diagnostique.
Règle 3 : Traduire les erreurs d’implémentation en erreurs de domaine
Ne laissez pas sql.ErrNoRows faire partie de votre logique métier. Traduisez les erreurs d’implémentation en erreurs de domaine à la limite de stockage, afin que le reste de l’application n’ait jamais besoin de savoir quelle base de données ou ORM se trouve en dessous.
Règle 4 : Ne pas analyser les chaînes d’erreur
Si le code doit faire une branche sur le type d’échec, utilisez des sentinelles, des types personnalisés, errors.Is ou errors.As. L’inspection de chaînes crée un couplage invisible qui casse silencieusement lorsque les messages d’erreur changent.
Règle 5 : Journaliser une seule fois
Enveloppez à mesure que les erreurs montent. Journalisez là où l’erreur est enfin gérée.
Règle 6 : Garder les messages utilisateur sûrs
Les erreurs diagnostiques internes sont pour les journaux. Les messages visibles par l’utilisateur sont pour les utilisateurs.
Règle 7 : Garder les erreurs de transport à la limite de transport
Les codes de statut HTTP appartiennent aux gestionnaires ou aux adaptateurs API, pas aux services de domaine. Le code de domaine devrait être réutilisable à travers les transports — aujourd’hui HTTP, demain CLI, gRPC ou un worker piloté par événements.
Pensées finales
La gestion des erreurs Go n’est pas une question d’écrire if err != nil pour toujours — c’est une question de rendre l’échec explicite et compréhensible à chaque limite.
La mécanique est simple :
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
L’architecture est la partie plus difficile :
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
C’est la gestion des erreurs Go bien faite — pas astucieuse, pas magique, mais assez claire pour que le prochain développeur, opérateur, client API et vous du futur puissiez comprendre ce qui a échoué et ce qui devrait se passer ensuite. Pour une vue plus large des modèles de production Go à travers l’intégration, le test et l’accès aux données, voir App Architecture in Production.