Architecture de la gestion des erreurs en Go : limites et schémas

Gérer les erreurs à la bonne frontière.

Sommaire

La gestion des erreurs en Go est souvent critiquée. Tout développeur Go a écrit ce code des centaines de fois :

if err != nil {
	return err
}

Ce n’est pas là que réside l’intérêt. L’aspect intéressant, c’est ce que signifie l’erreur, 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 (logged) et ce qui doit être exposé à l’appelant — c’est là que se pose la 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 divulguent des détails de la base de données, les journaux dupliquent la même échec cinq fois, les nouvelles tentatives (retries) se produisent pour les mauvaises raisons, et les appelants inspectent le texte au lieu du comportement.

Architecture de la gestion des erreurs en Go : les erreurs circulant entre les couches

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), erreurs sentinelle, types d’erreurs personnalisés, errors.Is, errors.As, limites d’erreurs (error boundaries), cartographie des API, journalisation (logging), nouvelles tentatives, sécurité et modèles de production.

La version légèrement opinionnée : ne cherchez pas à faire disparaître les erreurs Go. Donnez-leur un sens à la bonne limite (boundary).

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 en Go semble si directe.

Les fonctions renvoient explicitement des erreurs :

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 ni exceptions ni déballage 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 d’une conception. Si chaque paquet renvoie 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 des chaînes d’erreurs confuses. Si aucune couche n’enveloppe les erreurs, les échecs perdent leur contexte.

L’objectif n’est pas moins de gestion des erreurs, mais un meilleur sens 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 un 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 celle-ci est importante.

Exemple :

return fmt.Errorf("load user %s: %w", id, err)

Le %w enveloppe l’erreur d’origine pour que les appelants puissent l’inspecter avec errors.Is ou errors.As. L’enveloppement a un nouveau piège en Go 1.27 : la vérification par vet derrière go test émet maintenant un avertissement sur fmt.Errorf("...: %w", p) lorsque p est un pointeur vers un type qui lui-même implémente error, car l’indirection supplémentaire brise les assertions de type et la correspondance de errors.Is. Le Guide de mise à niveau Go 1.27 couvre cela ainsi que les autres constats de vet qui apparaissent après une montée de version de la chaîne d’outils.

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
  • Retenter l’opération
  • Journaliser au niveau avertissement
  • Afficher un message sûr pour l’utilisateur
  • Abandonner la transaction
  • Envoyer l’erreur à la supervision
  • Ignorer l’annulation

Cette décision doit généralement être basée sur l’identité ou le type d’erreur, et non sur la correspondance de chaînes.

Les principaux outils d’erreurs dans Go moderne

Le Go moderne vous offre un petit mais puissant ensemble d’outils.

errors.New

Utilisez errors.New pour créer une valeur d’erreur simple :

var ErrNotFound = errors.New("not found")

Cela 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 d’origine pour 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) {
	// handle not found
}

Utilisez ceci 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) {
	// use validationErr.Field or validationErr.Reason
}

Utilisez ceci lorsque l’erreur transporte des données structurées.

errors.Join

Utilisez errors.Join lorsque plusieurs erreurs se sont produites et 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 précaution. 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 n’a besoin que 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
}

Puis un service ou un gestionnaire peut vérifier :

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

Quand utiliser les erreurs sentinelle

Utilisez des erreurs sentinelle lorsque :

  • La condition est stable.
  • L’appelant doit en faire une condition de branchement.
  • Aucune donnée structurée supplémentaire n’est nécessaire.
  • L’erreur appartient à votre paquet ou à votre domaine.

Bonnes 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 branchement sur ceux-ci, ils ne sont peut-ê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 meilleur que d’analyser une chaîne d’erreur.

Quand utiliser des types d’erreurs personnalisés

Utilisez des 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 traiter plusieurs valeurs différemment.

Exemples :

  • Erreur de validation avec le nom du champ
  • Erreur de limite de débit avec le temps de nouvelle tentative
  • Erreur HTTP avec code de statut
  • Erreur d’analyse avec ligne et colonne
  • Erreur de domaine avec l’ID de la ressource

Quand ne pas utiliser des 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 des erreurs (Wrapping)

L’enveloppement ajoute du contexte à une erreur tout en préservant l’erreur d’origine.

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 à la fois :

  • l’opération de haut niveau : lire la configuration
  • la cause de bas niveau : permission refusée, fichier introuvable, etc.

Les deux sont disponibles à travers la chaîne d’erreurs, ce qui rend l’enveloppement avec %w utile à 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 commun à 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 ennuyeux.

Enveloppez là où le contexte change le sens. Si vous ne pouvez pas expliquer en une phrase quelle opération a échoué, vous enveloppez probablement trop agressivement ou pas assez.

Quand envelopper et quand ne pas envelopper

C’est l’une des décisions d’architecture les plus importantes.

Envelopper lorsque l’on traverse 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 dépôt fait maintenant partie d’une opération de service, et ce contexte ajouté est utile lorsque les opérateurs retracent un échec à travers 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 par le fait qu’une erreur existe.

Ne pas envelopper si vous traduisez

Parfois, vous devez traduire une erreur en une autre erreur de domaine.

Exemple :

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Cela cache intentionnellement le détail de la base de données et expose une condition de domaine.

Vous pouvez toujours préserver la cause si c’est utile, mais faites-le intentionnellement.

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 bien à l’intérieur de votre application.

Mais dans une API publique de paquet, 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) {
	// caller now knows you use database/sql
}

Si vous pouvez changer le stockage plus tard, préférez une sentinelle de domaine :

var ErrUserNotFound = errors.New("user not found")

Puis renvoyez celle-ci depuis la limite du paquet.

Limites d’erreurs (Error boundaries)

La façon la plus utile de penser à la gestion des erreurs en Go est à travers les limites.

Une limite est un endroit où une erreur change de sens ou d’audience.

Les limites courantes incluent :

  • de la base de données au dépôt
  • du dépôt au service
  • du service au gestionnaire HTTP
  • du service à la commande CLI
  • de l’erreur interne au message destiné à l’utilisateur
  • de l’échec transitoire à la décision de nouvelle tentative
  • de l’échec de l’opération à l’événement journalisé
  • de l’erreur de domaine à la réponse d’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 sur laquelle la couche suivante peut agir.

Limite du dépôt

Le dépôt 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 dépôt cache sql.ErrNoRows et expose ErrUserNotFound — une limite propre qui signifie que le service n’a pas besoin de savoir comment le stockage représente “introuvable”.

Limite du service

Le service possède le sens métier.

Il devrait généralement ajouter le contexte d’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 l’endroit approprié pour les erreurs de niveau métier — créées directement de la logique de domaine plutôt que traduites de conditions d’infrastructure.

Limite du gestionnaire HTTP

Le gestionnaire HTTP traduit les erreurs de l’application en réponses HTTP.

C’est une limite où les détails internes doivent devenir des réponses sûres pour l’utilisateur.

Les frameworks web diffèrent dans la façon dont ils structurent cette traduction. Echo achemine les erreurs du gestionnaire à travers un seul HTTPErrorHandler, tandis que les gestionnaires Gin rendent les erreurs directement sur le contexte de la requête — les compromis entre ces deux styles sont comparés dans Frameworks Web Go en 2026 : net/http, chi, Gin, Echo, Fiber comparés.

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)
	}
}

Cartographie des erreurs :

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 sur la sémantique HTTP plutôt que d’exposer les détails bruts de la base de données ou des erreurs internes. C’est là que de nombreuses applications Go se trompent — elles exposent soit trop de détails internes, soit réduisent toutes les erreurs à HTTP 500. Pour une image complète des modèles de gestionnaires et des middleware dans les API Go, Construire des API REST en 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 qui exécute 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))
	}
}

Mapper les erreurs connues aux 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 afficher plus de détails qu’une API publique, mais elle devrait toujours éviter de divulguer des secrets.

Modèle de type d’erreur d’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 souhaitez des erreurs d’API structurées avec des codes stables.

Utilisez-le à la limite de l’API. Ne forcez pas chaque paquet interne à renvoyer 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")

Cartographie du transport :

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Ne laissez pas votre couche de domaine renvoyer des codes de statut HTTP :

return &APIError{Status: http.StatusConflict}

Cela couple la logique métier au HTTP et empêche votre couche de service de fonctionner proprement à travers HTTP, CLI, workers, tests et futurs adaptateurs gRPC. La cartographie du transport appartient à la limite de transport, et non 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, Structure de Projet Go : Pratiques et Modèles couvre les conventions internal/, pkg/ et adaptateur qui gardent ces couches proprement séparées.

Erreurs pouvant faire l’objet d’une nouvelle tentative

Certaines erreurs devraient déclencher une nouvelle tentative. 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 nouvelle tentative :

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

C’est beaucoup meilleur 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 une bonne utilisation de errors.As car l’appelant a besoin d’informations structurées — noms de champs et messages de validation — et non 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)
}

À la fois errors.Is et errors.As peuvent inspecter les erreurs jointes, ce qui signifie que les valeurs d’erreurs jointes restent entièrement compatibles avec les modèles standards de vérification des erreurs.

Quand ne pas utiliser errors.Join

N’utilisez pas errors.Join lorsqu’il y a une erreur primaire et du contexte de journalisation.

Ne l’utilisez pas pour éviter de décider quelle erreur compte.

Ne renvoyez pas d’énormes erreurs jointes aux utilisateurs.

Les erreurs jointes sont utiles, mais elles peuvent devenir bruyantes rapidement.

Panic n’est pas la gestion des 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 des situations véritablement irrécupérables.

Exemples :

  • violation impossible d’un invariant interne
  • initialisation de paquet invalide
  • échec d’un assistant de test avec t.Fatal ou 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

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 événement de journal avec la chaîne d’erreurs complète. Pour une configuration de journalisation structurée prête pour la production, Journalisation Structurée en Go avec slog couvre les enregistrements log/slog, les gestionnaires JSON, la corrélation du contexte et la révélation — tous ceux-ci s’accordent naturellement avec la journalisation des erreurs au niveau des limites.

Quand journaliser dans les couches inférieures

Journalisez dans les 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 nouvelle tentative peut journaliser chaque tentative au niveau de débogage ou d’avertissement.

Mais un dépôt ne devrait pas journaliser chaque erreur de requête si le gestionnaire journalisera l’échec final de la requête.

Erreurs destinées à l’utilisateur vs erreurs destinées à l’opérateur

N’affichez pas les erreurs internes directement aux utilisateurs.

Erreur interne :

query user by id: dial tcp 10.0.4.12:5432: connection refused

Message destiné à l’utilisateur :

internal server error

Journal de l’opérateur :

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

Ce sont des audiences différentes, et une bonne architecture d’erreurs les garde séparées :

  • erreur de diagnostic interne
  • réponse sûre pour l’utilisateur
  • code d’erreur d’API stable
  • contexte de journal pour l’opérateur

Forcer une chaîne d’erreur unique à servir toutes ces audiences 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 divulguer des informations sensibles.

Évitez d’exposer :

  • chaînes de connexion à la base de données
  • requêtes SQL avec des secrets
  • noms d’hôtes internes
  • chemins de fichiers
  • jetons d’accès
  • clés API
  • traces de pile
  • données privées des clients
  • détails de la politique d’autorisation

Cela importe particulièrement dans les API HTTP.

Mauvais :

http.Error(w, err.Error(), http.StatusInternalServerError)

Bien :

http.Error(w, "internal server error", http.StatusInternalServerError)

Journalisez l’erreur interne de manière sécurisée pour les opérateurs. Renvoyez un message sûr à l’utilisateur.

Codes d’erreur

Pour les API publiques, des codes d’erreur stables sont souvent meilleurs que de s’appuyer uniquement sur des messages.

Exemple de réponse :

{
  "code": "user_not_found",
  "message": "user not found"
}

Le message peut changer. Le code devrait être stable.

Utilisez des codes d’erreur pour :

  • comportement du client
  • documentation
  • SDKs
  • localisation
  • diagnostics de support

Ne laissez pas les clients analyser des messages d’erreur en anglais.

Conception d’erreurs en couches pratique

Voici un modèle propre pour de nombreux services backend Go.

Couche dépôt

  • Parle à la base de données ou au stockage externe.
  • Convertit les erreurs “introuvable” spécifiques au stockage en erreurs de domaine.
  • Enveloppe les erreurs de stockage inattendues avec le contexte d’opération.
  • Ne renvoie 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 renvoie 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 d’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 à la cartographie du transport. La conception en couches fonctionne le mieux lorsque les dépendances sont injectées plutôt qu’harcodées ; Injection de Dépendances en Go : Modèles et Meilleures Pratiques couvre les modèles de constructeur et d’interface qui rendent chaque limite facile à tester de manière isolée.

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")
)

Dépôt :

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 du stockage
  • contexte du service
  • cartographie HTTP sûre
  • chaînes d’erreurs inspectables
  • pas de correspondance de chaînes
  • pas de fuite de transport dans le code de domaine

C’est le type d’architecture d’erreurs qui se scale — assez simple pour qu’un nouveau contributeur puisse comprendre, mais assez structuré pour que la logique métier 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 cas nominal, car les décisions aux limites — mapping sentinelle, extraction de type, codes HTTP — sont souvent là que les bugs cachent le plus longtemps. Pour un guide complet de la structure de test Go, du mocking et des modèles de couverture, voir Unit Testing en Go : Structure et Meilleures Pratiques.

Tester le mapping 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’erreurs personnalisées

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 la cartographie 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, de sorte que la refactorisation des couches de stockage ou de transport ne puisse pas modifier 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 : Envelopper sans signification

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 : Journaliser à chaque couche

Mauvais :

log.Println(err)
return err

à chaque niveau. Journalisez une seule fois là où l’erreur est finalement gérée, et non à chaque couche intermédiaire qui la passe simplement vers le haut.

Anti-modèle 5 : Renvoyer 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, 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)

Renvoyez 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 à la base de données, les chemins de fichiers ou les traces de pile brutes dans les réponses d’API.

Anti-modèle 7 : Trop de sentinelles exportées

Les erreurs exportées font partie de l’API de votre paquet, et les ajouter vous engage à les maintenir. N’exportez pas chaque condition interne à moins que les appelants externes aient réellement besoin d’en faire une condition de branchement — préférez de garder les sentinelles non exportées jusqu’à ce qu’il y ait un besoin clair.

Anti-modèle 8 : Utiliser panic pour les échecs attendus

Mauvais :

panic(err)

pour des échecs de runtime normaux. Réservez panic pour des conditions véritablement irrécupérables ou des erreurs de programmeur, et non pour des enregistrements manquants ou des entrées utilisateur invalides — renvoyez toujours des erreurs dans ces cas.

Anti-modèle 9 : Ignorer les erreurs de contexte

Mauvais :

return fmt.Errorf("request failed")

lorsque la vraie cause était context.Canceled. Préservez les erreurs de contexte pour 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 à chacune. Pour un traitement exhaustif de la façon dont l’annulation du contexte et la propagation des délais fonctionnent à travers les couches de service, voir Go context.Context Bien Fait.

Liste de contrôle de revue des erreurs

Utilisez cette liste de contrôle lors de la revue de code.

Création d’erreurs

  • S’agit-il d’une condition connue ?
  • Devrait-elle être une sentinelle ?
  • A-t-elle besoin de données structurées ?
  • Devrait-elle être un type personnalisé ?
  • Le message d’erreur est-il clair ?

Enveloppement des erreurs

  • L’enveloppement ajoute-t-il un contexte d’opération utile ?
  • Le %w préserve-t-il la cause là où nécessaire ?
  • Le code expose-t-il accidentellement des détails d’implémentation ?
  • La chaîne est-elle trop bruyante ?

Traduction des erreurs

  • Une erreur de bas niveau est-elle traduite à la bonne limite ?
  • Le comportement spécifique à la base de données est-il caché du code de service ?
  • Les erreurs de domaine sont-elles indépendantes des préoccupations HTTP ou CLI ?

Gestion des erreurs

  • L’appelant fait-il du branchement avec errors.Is ou errors.As ?
  • L’annulation du contexte et les délais sont-ils gérés correctement ?
  • Les erreurs pouvant faire l’objet d’une nouvelle tentative 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’erreurs connus sont-ils testés ?
  • Les cartographies HTTP ou CLI sont-elles testées ?
  • Les détails de validation sont-ils testés ?
  • Les décisions de nouvelle tentative sont-elles testées ?

Mes règles opinionnées

Règle 1 : Les erreurs doivent traverser les limites avec un sens

Ne vous contentez pas de 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 devenir partie de votre logique métier. Traduisez les erreurs d’implémentation en erreurs de domaine à la limite de stockage, pour que le reste de l’application n’ait jamais besoin de savoir quelle base de données ou quel ORM est en dessous.

Règle 4 : Ne pas analyser les chaînes d’erreurs

Si le code a besoin de faire du branchement 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 finalement gérée.

Règle 6 : Garder les messages utilisateur sûrs

Les erreurs de diagnostic internes sont pour les journaux. Les messages destinés à 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 d’API, et non 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.

Réflexions finales

La gestion des erreurs en Go ne consiste pas à écrire if err != nil pour toujours — cela consiste à rendre l’échec explicite et compréhensible à chaque limite.

Les mécaniques sont simples :

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 ce que c’est la gestion des erreurs en Go bien faite — pas astucieux, pas magique, mais assez clair pour que le prochain développeur, opérateur, client d’API, et vous-même à l’avenir puissiez comprendre ce qui a échoué et ce qui devrait se passer ensuite. Pour une vue plus large des modèles Go de production à travers l’intégration, le test et l’accès aux données, voir Architecture d’Application en Production.

Sources

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.