Erreurs Go : sentinel errors, error structs ou fmt.Errorf — comment choisir

Code review sur ClaudeGate. Le handler HTTP qui reçoit les jobs d'exécution retournait un 500 quand la file d'attente était pleine. Un collègue l'a remarqué en lisant les logs : "c'est pas une erreur interne, c'est de la saturation — ça devrait être un 503."

Il avait raison. Le problème venait d'une ligne innocente : toutes les erreurs d'Enqueue traitées de la même façon, sans distinguer leur nature. C'est le moment où on se pose la vraie question : comment structurer ses erreurs pour que le caller puisse réagir correctement ?

Le bug concret — 500 au lieu de 503

Le code original dans le handler HTTP ressemblait à ça :

// ❌ Avant — toute erreur d'enqueue retourne 500
if err := h.queue.Enqueue(j.ID); err != nil {
    writeError(w, http.StatusInternalServerError, "failed to enqueue job")
    return
}

"Queue pleine" et "erreur interne" ne sont pas la même chose. Un serveur avec une file pleine fonctionne correctement — il est temporairement saturé. Un client qui reçoit 500 pense que le serveur est cassé et ne réessaiera probablement pas. Un client qui reçoit 503 Service Unavailable sait qu'il peut réessayer dans quelques secondes.

Pour distinguer les deux cas, il faut que l'erreur soit identifiable. C'est là qu'interviennent les sentinel errors.

La solution — sentinel error + errors.Is

Une sentinel error est une valeur d'erreur déclarée au niveau du package, que le caller peut comparer directement. C'est exactement ce dont on a besoin ici :

// Dans queue.go
var ErrQueueFull = errors.New("queue full")

func (q *Queue) Enqueue(jobID string) error {
    select {
    case q.jobs <- jobID:
        return nil
    default:
        return fmt.Errorf("%w: job %s", ErrQueueFull, jobID)
    }
}
// Dans handler.go
if err := h.queue.Enqueue(j.ID); err != nil {
    if errors.Is(err, queue.ErrQueueFull) {
        writeError(w, http.StatusServiceUnavailable, "server busy, retry later")
    } else {
        writeError(w, http.StatusInternalServerError, "failed to enqueue job")
    }
    return
}

Le %w dans fmt.Errorf est important : il wrape l'erreur originale dans le message enrichi ("queue full: job abc-123"), et errors.Is sait traverser ce wrapping pour retrouver ErrQueueFull. Sans %w, on perdrait la capacité de comparaison.

Les 3 patterns d'erreurs Go

Ce cas ClaudeGate illustre bien pourquoi il y a plusieurs façons de gérer les erreurs en Go. Voici les trois patterns, quand les utiliser, et pourquoi.

Pattern 1 : fmt.Errorf — le cas général

Quand le caller ne distingue pas les causes d'échec — il propage l'erreur ou logue et continue — fmt.Errorf suffit largement :

// ✅ Le caller ne fait rien de spécial selon le type d'erreur
func processItem(ctx context.Context, item Item) error {
    result, err := fetchData(ctx, item.ID)
    if err != nil {
        return fmt.Errorf("failed to fetch data for item %s: %w", item.ID, err)
    }

    if err := store.Save(ctx, result); err != nil {
        return fmt.Errorf("failed to save result for item %s: %w", item.ID, err)
    }

    return nil
}

Le %w reste utile même ici : il permet aux couches supérieures d'unwrapper l'erreur si elles en ont besoin, sans qu'on soit obligé de prévoir ça à l'avance.

Pattern 2 : Sentinel error — le caller distingue plusieurs cas

Quand le caller doit prendre des décisions différentes selon la raison de l'échec, il faut une valeur comparable. C'est le rôle des sentinel errors :

// Déclaration au niveau du package
var (
    ErrQueueFull   = errors.New("queue full")
    ErrJobNotFound = errors.New("job not found")
    ErrJobCanceled = errors.New("job canceled")
)

// Retour avec contexte enrichi
func (q *Queue) GetJob(id string) (*Job, error) {
    job, ok := q.jobs[id]
    if !ok {
        return nil, fmt.Errorf("%w: %s", ErrJobNotFound, id)
    }
    return job, nil
}

// Caller qui distingue les cas
job, err := q.GetJob(jobID)
if err != nil {
    switch {
    case errors.Is(err, ErrJobNotFound):
        http.NotFound(w, r)
    case errors.Is(err, ErrJobCanceled):
        writeError(w, http.StatusGone, "job was canceled")
    default:
        writeError(w, http.StatusInternalServerError, "unexpected error")
    }
    return
}

La stdlib est pleine de sentinels : io.EOF, sql.ErrNoRows, context.Canceled, context.DeadlineExceeded. Ce sont des valeurs connues que tous les callers peuvent comparer directement.

Pattern 3 : Error struct — l'erreur transporte des données

Parfois le caller a besoin de plus qu'un signal booléen — il a besoin de données structurées contenues dans l'erreur. Une error struct implémentant l'interface error permet ça :

// Définition de la struct
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field %s: %s", e.Field, e.Message)
}

// Retour
func validatePayload(p Payload) error {
    if p.Name == "" {
        return &ValidationError{Field: "name", Message: "required"}
    }
    return nil
}

// Caller qui extrait les données
if err := validatePayload(p); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        respondWithFieldError(w, ve.Field, ve.Message)
        return
    }
    writeError(w, http.StatusInternalServerError, "validation failed")
    return
}

C'est errors.As et non errors.Is pour les structs : on extrait le type concret plutôt que de comparer une valeur. La stdlib fait pareil avec *os.PathError (porte Op + Path + Err) ou *url.Error (porte Method + URL + Err).

errors.Is vs errors.As — la différence

Les deux traversent la chaîne de wrapping créée par %w, mais ils font des choses différentes :

// errors.Is — compare des valeurs (pour les sentinel errors)
// Renvoie true si l'une des erreurs dans la chaîne == ErrQueueFull
errors.Is(err, ErrQueueFull)

// errors.As — extrait un type (pour les error structs)
// Parcourt la chaîne, assigne le premier *ValidationError trouvé dans &ve
var ve *ValidationError
errors.As(err, &ve)

Le piège classique avec les error structs :

// ❌ Type assertion directe — ne traverse pas le wrapping %w
if _, ok := err.(*ValidationError); ok {
    // Raté si l'erreur a été wrappée avec fmt.Errorf("...: %w", ve)
}

// ✅ errors.As — traverse correctement
var ve *ValidationError
if errors.As(err, &ve) {
    // Fonctionne même si wrappée
}

La table de décision

Situation Pattern
Le caller propage ou logue, sans distinguer les cas fmt.Errorf("contexte: %w", err)
Le caller distingue plusieurs causes d'échec var ErrXxx = errors.New(...) + errors.Is
L'erreur doit transporter des données structurées Struct qui implémente error + errors.As

Ce qu'il ne faut pas faire

Trois anti-patterns qui reviennent régulièrement dans les code reviews :

// ❌ Comparer des strings — fragile et non maintenable
if err.Error() == "queue full" {
    // Casse dès qu'on change le message d'erreur
}

// ❌ Type assertion directe — rate les erreurs wrappées
if _, ok := err.(*ValidationError); ok {
    // Ne fonctionne pas si l'erreur a été wrappée
}

// ❌ Sentinel error quand les données comptent
var ErrNotFound = errors.New("not found")
// Quel ID ? Quel type de ressource ? Le caller ne sait pas.
// Mieux : une struct qui porte ces informations

Le dernier point est subtil. sql.ErrNoRows fonctionne comme sentinel parce que le contexte (quelle requête, quel ID) est connu du caller. Mais si l'erreur doit transporter ce contexte elle-même, une struct est plus adaptée.

Les exemples de la stdlib à retenir

La stdlib Go est un bon guide de ce qui fonctionne en pratique sur le long terme :

  • io.EOF — sentinel, 0 donnée additionnelle, le caller sait juste que la lecture est terminée
  • sql.ErrNoRows — sentinel, le caller distingue "pas de résultat" de "erreur de connexion"
  • context.Canceled / context.DeadlineExceeded — deux sentinels distincts, le caller peut réagir différemment
  • *os.PathError — struct, porte Op + Path + Err : indispensable pour afficher un message utile
  • *url.Error — struct, porte Method + URL + Err pour le débogage des appels HTTP

Conclusion

La règle est simple : choisir le pattern le plus simple qui répond au besoin réel du caller. Si le caller n'a pas besoin de distinguer les cas, fmt.Errorf suffit. Si le caller doit prendre des décisions différentes, une sentinel error est la bonne réponse. Si l'erreur doit transporter des données structurées, une error struct s'impose.

Dans le cas ClaudeGate, la fix était mineure — une sentinel error et deux lignes dans le handler. Mais la différence pour les clients de l'API était significative : un 503 avec un message "retry later" leur donne une information actionnable. Un 500 générique ne dit rien d'utile.

Le système d'erreurs Go est délibérément minimal. C'est à nous de choisir la bonne granularité selon ce que le caller a réellement besoin de savoir — pas selon ce qui semble plus "propre" à l'écriture.

Commentaires (0)