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éesql.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, porteOp+Path+Err: indispensable pour afficher un message utile*url.Error— struct, porteMethod+URL+Errpour 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.