Architecture de gestion des erreurs en Go : limites et modèles

Gérer les erreurs au bon niveau.

Sommaire

La gestion des erreurs en Go est un sujet sur lequel il est facile de se plaindre. Chaque développeur Go a écrit ce code des centaines de fois :

if err != nil {
	return err
}

Ce n’est pas là la partie la plus intéressante. La partie intéressante est la signification de 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 et ce qui doit être exposé à l’appelant — c’est là que réside la question d’architecture.

Go traite les erreurs comme des valeurs. Cela rend les échecs explicites. Cela signifie également 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 les détails de la base de données, les journaux dupliquent la même erreur cinq fois, les tentatives de répétition (retries) se produisent pour les mauvaises raisons, et les appelants inspectent du texte au lieu du comportement.

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

Cet article n’est pas une introduction débutante à if err != nil.

C’est un guide pratique sur l’architecture de la gestion des erreurs en Go : enveloppement, sentinelles, types d’erreur personnalisés, errors.Is, errors.As, limites d’erreur, mappage d’API, journalisation, tentatives de répétition, sécurité et modèles de production.

La version légèrement dogmatique : 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 des 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 du contexte.

L’objectif n’est pas moins de gestion d’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 devrait 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 devrait 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 d’origine 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 au monitoring
  • Ignorer l’annulation

Cette décision devrait 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’erreur en Go moderne

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

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 sentinelles.

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 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) {
	// handle not found
}

Utilisez cela pour les erreurs sentinelles 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 cela lorsque l’erreur contient des données structurées.

errors.Join

Utilisez errors.Join lorsque plusieurs erreurs se sont produites et qu’elles doivent toutes être conservé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 sentinelles

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 sentinelles sont utiles lorsque l’appelant n’a besoin de savoir que 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) {
	// return 404
}

Quand utiliser les erreurs sentinelles

Utilisez les erreurs sentinelles lorsque :

  • La condition est stable.
  • L’appelant doit prendre une décision en fonction de celle-ci.
  • Aucune donnée structurée supplémentaire n’est nécessaire.
  • L’erreur appartient à votre paquet ou à votre 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 sentinelles

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 prennent pas de décision en fonction de celles-ci, ils peuvent n’être que des messages.

Soyez également prudent quant à l’exportation de trop de sentinelles. Les erreurs sentinelles exportées font partie de l’API de votre paquet.

Types d’erreur personnalisés

Un type d’erreur personnalisé est utile lorsque l’erreur contient 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)
}

Ceci est meilleur que l’analyse d’une chaîne d’erreur.

Quand utiliser les types d’erreur personnalisés

Utilisez les types d’erreur 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 le nom du champ
  • Erreur de limitation de débit avec le temps de réessaie
  • Erreur HTTP avec le code d’état
  • Erreur d’analyse avec la ligne et la colonne
  • Erreur de domaine avec l’ID de la ressource

Quand ne pas utiliser les types d’erreur 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 : lecture de la configuration
  • la cause de bas niveau : permission refusée, fichier introuvable, etc.

Les deux sont disponibles via la chaîne d’erreurs, ce qui rend l’enveloppement avec %w digne d’être fait de manière cohérente.

Envelopper avec un contexte utile

Un bon enveloppement indique 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 mais pratiquement ennuyeux.

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

Mais dans une API de paquet publique, l’enveloppement peut exposer les détails d’implémentation comme faisant partie de votre contrat.

Par exemple, si votre paquet enveloppe sql.ErrNoRows, les appelants peuvent commencer à dépendre de cela :

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

Si vous pouvez changer le stockage plus tard, privilégiez une sentinelle de domaine :

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

Puis retournez celle-ci depuis la limite du paquet.

Limites d’erreur

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 d’API

L’architecture d’erreur 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 dans une forme que la couche suivante peut exploiter.

Limite du référentiel

Le référentiel communique avec le 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 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 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 les détails bruts de la base de données ou des erreurs internes. C’est là que beaucoup d’applications Go se trompent — elles exposent soit trop de détails internes, soit elles réduisent toutes les erreurs à 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

Un CLI a une limite différente d’une API HTTP.

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

Mapper les erreurs connues vers des 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
	}
}

Un CLI peut souvent montrer plus de détails qu’une API publique, mais il devrait toujours éviter de fuiter 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 à 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 d’état 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 l’endroit 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 adaptateur qui gardent ces couches proprement séparées.

Erreurs réessayables

Certaines erreurs devraient déclencher une tentative de réessaie. D’autres non.

Ne prenez pas cette décision 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) {
		// retry with 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 producteur et 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 pas juste d’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 conservé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 standard de vérification des erreurs.

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 la gestion d’erreur

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 programmation ou les situations vraiment irrécupérables.

Exemples :

  • violation d’invariant interne impossible
  • initialisation de paquet invalide
  • échec d’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 remontent
  • 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, Structured Logging in Go with slog couvre les enregistrements log/slog, les gestionnaires JSON, la corrélation de contexte et la rédaction — 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 va journaliser 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’erreur les garde séparés :

  • erreur de diagnostic interne
  • réponse sûre pour l’utilisateur
  • code d’erreur d’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’erreur 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 des secrets
  • noms d’hôte internes
  • chemins de fichiers
  • jetons d’accès
  • clés API
  • traces de pile
  • données clients privées
  • détails de la politique d’autorisation

Ceci 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 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 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’erreur en couches pratique

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

Couche référentiel

  • Communique avec la base de données ou le stockage externe.
  • Convertit les erreurs “introuvable” 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 d’état HTTP.
  • Ne journalise généralement pas.

Exemple :

if user.Disabled {
	return nil, ErrAccountDisabled
}

Couche transport

  • Mappe les erreurs de domaine vers des 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 d’état 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 garde 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 du service ou au mappage du 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 du stockage
  • contexte du 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 type d’architecture d’erreur qui évolue — assez simple pour qu’un nouveau contributeur puisse comprendre, mais assez structuré pour que la logique de domaine ne fuite jamais dans les réponses de transport.

Tester le comportement des erreurs

Le comportement des erreurs doit ê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 se cachent le plus longtemps. Pour un guide complet sur la structure des tests Go, le mocking et les modèles de couverture, voir Go Unit Testing: Structure & Best Practices.

Tester le mappage des sentinelles

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

Mauvais :

log.Println(err)
return err

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

Anti-modèle 5 : Retour d’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 d’état 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 : Exposition des 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 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 vraiment besoin de prendre une décision en fonction de celle-ci — privilégiez le maintien des 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 les échecs de runtime normaux. Réservez panic pour les conditions vraiment irrécupérables ou les erreurs de programmation, 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 véritable cause é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.

Liste de contrôle de révision d’erreur

Utilisez cette liste de contrôle lors des revues 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 ?
  • %w pré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 du service ?
  • Les erreurs de domaine sont-elles indépendantes des préoccupations HTTP ou CLI ?

Gestion d’erreur

  • L’appelant prend-il une décision avec errors.Is ou errors.As ?
  • L’annulation de contexte et les délais sont-ils gérés correctement ?
  • Les erreurs réessayables 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 assez de contexte pour les opérateurs ?

Tests

  • 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 dogmatiques

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

Ne faites pas simplement circuler 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 intégrante de votre logique métier. Traduisez les erreurs d’implémentation en erreurs de domaine à la limite du 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 prendre une décision en fonction du 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 remontent. 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 visibles par l’utilisateur sont pour les utilisateurs.

Règle 7 : Garder les erreurs de transport à la limite de transport

Les codes d’état HTTP appartiennent aux gestionnaires ou aux adaptateurs d’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 événementiel.

Pensées finales

La gestion des erreurs Go ne consiste pas à écrire if err != nil pour toujours — il s’agit 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-même du futur puissent 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, les tests et l’accès aux données, voir App Architecture in Production.

Sources

S'abonner

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