Middleware Go en production : best practices, ordre et pièges réels

Le service tournait depuis six mois sans broncher. Puis un matin : 2 % des requêtes renvoient un 500, sans log, sans trace, sans panique remontée. Le genre de bug qui ne se reproduit pas en local. Après une demi-journée, la cause : un panic dans un handler, attrapé par un middleware recover… placé à l'intérieur du middleware de logging. Le recover avalait la panique après que le logger ait déjà écrit son entrée — mais avant que le request ID soit posé. Résultat : un 500 fantôme, invisible dans les dashboards.

Un middleware Go, c'est dix lignes triviales. Un enchaînement de middlewares en production, c'est là que se cachent les vrais problèmes : l'ordre, la récupération de panique, les timeouts, le wrapping de ResponseWriter, la propagation de contexte. Voici ce que 14 ans à faire tourner des API m'ont appris sur les middlewares qui tiennent — et ceux qui tombent.

Un middleware n'est qu'une fonction qui en emballe une autre

Pas de magie, pas de framework requis. Un middleware en Go est une fonction qui prend un http.Handler et en retourne un autre. Le pattern canonique, celui que tout le reste suit :

type Middleware func(http.Handler) http.Handler

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "dur", time.Since(start),
        )
    })
}

Tout ce qui est avant next.ServeHTTP s'exécute à l'aller (sur la requête). Tout ce qui est après s'exécute au retour (sur la réponse). C'est le modèle de l'oignon : la requête traverse chaque couche vers le handler, la réponse remonte en sens inverse.

La requête descend à travers chaque middleware jusqu'au handler, la réponse remonte en sens inverse requête réponse Recover (panic) Request ID + Tracing Logger Timeout (context) Auth + Rate limit Handler
Le modèle de l'oignon : l'ordre de déclaration = l'ordre de traversée.

Pour enchaîner sans imbriquer manuellement (A(B(C(handler))) devient vite illisible), un petit helper suffit :

func Chain(h http.Handler, mw ...Middleware) http.Handler {
    // on applique en sens inverse pour que mw[0] soit la couche la plus externe
    for i := len(mw) - 1; i >= 0; i-- {
        h = mw[i](h)
    }
    return h
}

// usage : Recover est le plus externe, il enveloppe tout le reste
handler := Chain(mux,
    Recover,
    RequestID,
    Logger,
    Timeout(5*time.Second),
    Auth,
)

Note bien le sens de la boucle : on applique mw de la fin vers le début pour que mw[0] reste la couche la plus externe. Si tu te trompes de sens, tout l'ordre s'inverse — et c'est exactement le premier piège.

L'ordre n'est pas cosmétique, il est fonctionnel

La question la plus posée sur les middlewares Go en production : dans quel ordre les déclarer ? La réponse n'est pas une convention de style, c'est une question de correction. Trois règles non négociables :

1. Recover doit être la couche la plus externe. S'il est à l'intérieur du logger, une panique dans le logger lui-même n'est pas attrapée. S'il est à l'intérieur du request ID, la réponse 500 d'urgence n'aura pas l'identifiant de corrélation. Recover enveloppe tout.

2. Le request ID avant le logger. Sinon ta ligne de log n'a pas l'ID à corréler avec le reste de la trace. Ça paraît évident écrit comme ça — pourtant c'est l'inversion la plus fréquente que je vois en revue.

3. L'auth et le rate-limit au plus près du handler, mais après l'observabilité. Tu veux logguer et tracer même les requêtes rejetées (un pic de 401 ou de 429 est un signal). Si l'auth est au-dessus du logger, tes refus sont invisibles.

// ❌ MAUVAIS : recover à l'intérieur, refus invisibles, ID manquant dans le 500
handler := Chain(mux, Logger, Auth, Recover, RequestID)

// ✅ BON : recover dehors, ID puis log, auth près du handler
handler := Chain(mux, Recover, RequestID, Logger, Timeout(5*time.Second), Auth, RateLimit)

Le principe pour t'en souvenir : du plus défensif au plus métier. Ce qui protège le serveur (recover) en haut, ce qui identifie et observe (ID, log, trace) ensuite, ce qui filtre (timeout, auth, rate-limit) juste avant le métier.

Recover : le middleware qui évite que tout le process tombe

En Go, une panique non récupérée dans une goroutine de handler ne crashe pas seulement la requête — selon la version et le runtime HTTP, elle peut faire tomber tout le serveur. Le serveur net/http standard récupère par défaut les paniques par requête, mais il les transforme en connexion coupée silencieuse, sans log applicatif exploitable. Un Recover explicite est donc indispensable :

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered",
                    "err", err,
                    "path", r.URL.Path,
                    "stack", string(debug.Stack()),
                )
                w.WriteHeader(http.StatusInternalServerError)
                _, _ = w.Write([]byte(`{"error":"internal"}`))
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Deux pièges réels ici. Premier piège : ne jamais re-paniquer dans le defer, sinon tu te retrouves avec une panique non récupérée pendant la récupération. Deuxième piège, plus subtil : si un middleware en aval a déjà écrit un status code avant la panique, ton w.WriteHeader(500) émettra un warning superfluous response.WriteHeader call et sera ignoré. Le client reçoit une réponse tronquée. C'est précisément pour gérer ce cas qu'il faut savoir si la réponse a déjà commencé — ce qui nous amène au wrapping.

Wrapper http.ResponseWriter sans casser Flush ni Hijack

Pour logguer le status code ou la taille de la réponse, il faut intercepter les appels à WriteHeader et Write. La solution naïve : un wrapper qui mémorise le code.

type statusRecorder struct {
    http.ResponseWriter
    status  int
    written int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
    if r.status == 0 {
        r.status = http.StatusOK // Write implicite = 200
    }
    n, err := r.ResponseWriter.Write(b)
    r.written += n
    return n, err
}

Ça marche… jusqu'au jour où tu sers du Server-Sent Events ou du streaming, et que tout casse. Le piège classique du wrapping : en embarquant http.ResponseWriter dans un struct, tu perds les interfaces optionnelles que le writer original implémentait — http.Flusher, http.Hijacker, http.Pusher. Ton handler SSE fait un type-assert sur http.Flusher, échoue, et le streaming ne flushe plus jamais.

// ✅ Réexposer Flush pour ne pas casser le streaming derrière le wrapper
func (r *statusRecorder) Flush() {
    if f, ok := r.ResponseWriter.(http.Flusher); ok {
        f.Flush()
    }
}

// idem pour Hijack si tu sers du WebSocket derrière ce middleware
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    if h, ok := r.ResponseWriter.(http.Hijacker); ok {
        return h.Hijack()
    }
    return nil, nil, fmt.Errorf("hijack non supporté")
}

Depuis Go 1.20, http.ResponseController existe justement pour appeler Flush/SetWriteDeadline à travers des wrappers imbriqués sans avoir à réexposer chaque interface à la main. Si tu pars d'une base neuve, utilise-le. Si tu maintiens un wrapper existant, réexpose au minimum Flush — c'est le cas qui casse le plus souvent en prod.

Timeouts : couper la requête avant qu'elle ne déborde

Un handler qui appelle une base lente ou une API tierce sans timeout est une bombe à retardement : sous charge, les goroutines s'accumulent, la mémoire grimpe, et le service finit OOM-killed. Le bon réflexe : un middleware qui pose une deadline sur le context de la requête, propagée à tous les appels en aval.

func Timeout(d time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Attention : ce middleware ne tue pas le handler, il signale l'annulation via ctx.Done(). Encore faut-il que ton code en aval respecte le contexte — un db.QueryContext(ctx, ...), pas un db.Query(...). Un timeout sur un contexte que personne n'écoute ne sert à rien. C'est la même logique de discipline que pour éviter les fuites de goroutines : si rien n'écoute ctx.Done(), la goroutine reste bloquée.

Évite http.TimeoutHandler de la stdlib pour ce besoin précis : il écrit lui-même une réponse 503 quand le délai expire, ce qui entre en conflit avec ton wrapper de status et ton recover. Un timeout par contexte te laisse maître de la réponse.

Observabilité : request ID, logs structurés, et le piège du contexte

Le request ID, c'est le fil rouge qui relie une ligne de log, une trace distribuée et un ticket de support. Le middleware le génère (ou le récupère depuis l'en-tête entrant) et le pose dans le contexte :

type ctxKey string

const requestIDKey ctxKey = "request_id"

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Deux best practices que beaucoup ratent. Une : la clé de contexte doit être un type privé (type ctxKey string), jamais une string nue — sinon deux packages qui utilisent "request_id" s'écrasent mutuellement. C'est exactement l'esprit de la rigueur des types en Go : on rend l'erreur impossible à compiler. Deux : enrichis le logger avec l'ID une seule fois, via un slog.Logger placé dans le contexte, plutôt que de re-passer l'ID à chaque appel de log :

// dans le middleware Logger, après avoir l'ID :
logger := slog.With("request_id", RequestIDFrom(r.Context()))
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))

// partout en aval :
LoggerFrom(r.Context()).Info("user created", "user_id", u.ID)

Le rate-limiting suit la même mécanique de middleware ; si tu en construis un sérieux par IP avec golang.org/x/time/rate, j'en ai détaillé l'implémentation complète dans cet article dédié au token bucket.

Conclusion

Un middleware pris isolément est un exercice de cours. Une chaîne de middlewares en production est une question d'ordre et de contrats : qui enveloppe qui, qui voit la panique en premier, qui pose le contexte que les autres liront. Aucune de ces décisions n'est esthétique — chacune change le comportement du service sous charge ou en cas d'incident.

Le test qui ne ment pas : déclenche une panique dans un handler, en prod simulée, et regarde ce qui arrive dans tes logs. Si tu as un 500 propre, avec le request ID, la stack, et la ligne de log corrélée — ta chaîne est bonne. Si tu as un 500 fantôme sans trace, tu sais maintenant exactement quel ordre est à revoir.

Commentaires (0)