Un middleware Go est une fonction qui prend un http.Handler et en renvoie un nouveau : func(http.Handler) http.Handler. Le handler renvoyé fait son travail avant ou après l'appel à celui qu'il enveloppe. Cette signature constitue tout le pattern. Vous n'avez pas besoin de framework pour l'utiliser, et depuis Go 1.22 vous n'avez pas non plus besoin d'un routeur tiers, car http.ServeMux sait faire correspondre les méthodes et les wildcards de chemin (notes de version de Go 1.22).
En écrire un ne prend que quelques lignes. En production, les bonnes pratiques portent sur les détails autour : l'ordre de la pile, un wrapper de ResponseWriter qui ne casse pas le streaming, une récupération des panics qui signale correctement les échecs et des valeurs de context qui ne peuvent pas entrer en collision. Ce guide construit la pile de middlewares d'une API de commandes et de paiements, une couche à la fois. Chaque extrait compile avec Go 1.27, et les tests passent avec go test -race.
En bref
- Écrivez vos middlewares sous la forme
func(http.Handler) http.Handleret enchaînez-les avec un helper de dix lignes. Les middlewares globaux enveloppent le mux. Les middlewares par route enveloppent le handler que vous enregistrez. - L'ordre compte. Placez l'ID de requête et le logging à l'extérieur de la récupération des panics, afin que la ligne de log enregistre le 500 écrit par la récupération et porte l'ID de requête.
- Un wrapper de
ResponseWritera besoin d'une méthodeUnwrap() http.ResponseWriter. Sans elle,http.ResponseControllerne peut ni faire de flush ni définir de deadlines à travers votre middleware. - La récupération doit relancer la panic
http.ErrAbortHandler, et elle doit interrompre la réponse au lieu d'écrire un 500 dès que le handler a déjà envoyé un statut. - Définissez
ReadHeaderTimeoutsur le serveur, donnez à chaque route une deadline de context et plafonnez la taille des corps avechttp.MaxBytesReader. - Le middleware d'authentification place le principal dans le context. Il renvoie 401 quand l'appelant est inconnu et 403 quand l'appelant n'a pas la permission.
- Basez les limites de débit sur une IP ajoutée par votre propre proxy, et non sur la valeur la plus à gauche de
X-Forwarded-For.
À quoi ressemble un middleware HTTP en Go ?
Un middleware reçoit le handler suivant et renvoie un handler qui l'appelle. Nommer le type rend les chaînes plus lisibles :
example.gogotype Middleware func(http.Handler) http.Handler // Chain wraps h so that the first middleware in the list runs first. func Chain(h http.Handler, mws ...Middleware) http.Handler { for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) } return h }
Chain applique la liste à l'envers, si bien que la première entrée se retrouve la plus à l'extérieur et voit la requête en premier.
Dans chaque middleware, http.HandlerFunc transforme une closure en handler. Cette conversion fonctionne parce que HandlerFunc est un type fonction doté d'une méthode ServeHTTP, ce que le guide du mot-clé func en Go détaille avec les closures. Le type Middleware lui-même se résume à un http.Handler en entrée et en sortie, et le guide du mot-clé interface en Go explique pourquoi une interface à une seule méthode comme http.Handler se compose aussi bien.
Middleware global ou par route avec http.ServeMux
ServeMux n'a pas de méthode Use. Enveloppez le mux entier pour les middlewares dont chaque requête a besoin, et enveloppez les handlers individuels pour le reste :
example.gogofunc newServer(logger *slog.Logger, tokens TokenVerifier, limiter *RateLimiter) (*http.Server, error) { auth := requireAuth(tokens) mux := http.NewServeMux() mux.HandleFunc("GET /orders", listOrders) mux.Handle("GET /orders/{id}", auth(http.HandlerFunc(getOrder))) mux.Handle("POST /payments", Chain(http.HandlerFunc(createPayment), auth, requireScope("payments:write"), withDeadline(5*time.Second), )) csrf := http.NewCrossOriginProtection() if err := csrf.AddTrustedOrigin("https://admin.example.com"); err != nil { return nil, err } handler := Chain(mux, requestID, logRequests(logger), recoverPanics(logger), csrf.Handler, limiter.Middleware, limitBody(1<<20), ) return &http.Server{ Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, nil }
La liste publique des commandes se passe d'authentification, la lecture d'une commande exige un token valide, et la création d'un paiement exige en plus un scope et dispose d'un budget de cinq secondes. Comme la chaîne par route fait partie de l'enregistrement de la route, un seul écran de code suffit pour savoir qui peut appeler quoi.
Gardez ce câblage à un seul endroit, par exemple cmd/orders/server.go ou un package internal/httpapi. Structure d'un projet Go explique où placer ce package dans une arborescence plus large.
Dans quel ordre exécuter les middlewares Go ?
Le premier middleware de la chaîne enveloppe tout ce qui le suit. Il voit la requête en premier et la réponse en dernier. Une couche externe ne peut donc qu'observer ce que font les couches internes, et une couche interne ne voit jamais ce qui se passe à l'extérieur. Voici un bon ordre de production pour une API HTTP :
| # | Middleware | Pourquoi il se trouve ici |
|---|---|---|
| 1 | ID de requête | Chaque couche suivante, y compris la ligne de log et le rapport de panic, peut lire l'ID. |
| 2 | Log d'accès | Il enveloppe la récupération, il enregistre donc le statut écrit par celle-ci. |
| 3 | Récupération des panics | Elle intercepte les panics de toutes les couches internes et du handler, et les transforme en 500. |
| 4 | Protection cross-origin | Elle rejette les écritures cross-site des navigateurs avant qu'elles ne coûtent le moindre travail. |
| 5 | Limite de débit par IP client | Elle écarte le trafic abusif avant que l'authentification ne paie une vérification de token. |
| 6 | Limite de taille du corps | Elle plafonne chaque corps de requête avant qu'un handler ne le lise. |
| 7 | Authentification et scopes, par route | Seules les routes qui ont besoin d'un appelant paient sa vérification. |
| 8 | Deadline, par route | Chaque route reçoit un budget de temps adapté à son travail. |
Beaucoup de guides placent la récupération en premier pour qu'elle intercepte tout. Voici les logs d'un même handler qui panique, exécuté avec les deux ordres :
example.texttext# requestID → logRequests → recoverPanics level=ERROR msg="handler panic" panic="assignment to entry in nil map" request_id=U5UTAL72SXLQQQPQYWOHHEJVUO level=INFO msg=request method=GET path=/orders/ord_9 status=500 bytes=22 request_id=U5UTAL72SXLQQQPQYWOHHEJVUO # recoverPanics → requestID → logRequests level=INFO msg=request method=GET path=/orders/ord_9 status=200 bytes=0 request_id=NBJGZZOPM4YXB2FT3PS66BJE7K level=ERROR msg="handler panic" panic="assignment to entry in nil map"
Dans les deux cas, le client reçoit un 500. Avec la récupération tout à l'extérieur, le log d'accès indique 200, et la ligne de panic n'a aucun ID de requête pour la rattacher à la requête. Le seul code que la récupération interne ne protège pas est celui des middlewares d'ID de requête et de logging, qui sont petits. Le serveur net/http récupère aussi toute panic qui s'échappe de votre handler et la journalise, donc rien ne fait planter le processus (documentation de net/http Handler).
Envelopper http.ResponseWriter sans casser Flush
Le logging a besoin du code de statut et du nombre d'octets, et http.ResponseWriter n'expose ni l'un ni l'autre. La solution habituelle est un wrapper qui les enregistre :
example.gogotype statusWriter struct { http.ResponseWriter status int bytes int } func (w *statusWriter) WriteHeader(code int) { if w.status == 0 { w.status = code } w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Write(b []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } // Unwrap lets http.ResponseController reach Flush, Hijack and deadlines. func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
Embarquer http.ResponseWriter ne promeut que les trois méthodes de cette interface, une règle que le guide du mot-clé struct en Go traite dans la partie sur l'embedding. Le vrai writer sous-jacent implémente aussi http.Flusher et http.Hijacker, mais pas votre wrapper. Un handler de server-sent events qui fait w.(http.Flusher) cesse donc de streamer dès que le logging l'enveloppe.
Ajouter Unwrap règle ce problème. Depuis Go 1.20, http.ResponseController l'appelle pour retrouver le writer d'origine. Les handlers appellent alors http.NewResponseController(w).Flush() au lieu de faire une assertion de type. Sans Unwrap, cet appel renvoie une erreur correspondant à http.ErrNotSupported, et le test présenté plus loin dans ce guide échoue.
La même méthode permet aussi aux deadlines d'écriture par requête de fonctionner. Un export CSV peut dépasser le WriteTimeout de 30 secondes du serveur, dont la documentation dit qu'il ne « permet pas aux handlers de prendre des décisions requête par requête » (http.Server). ResponseController, lui, le permet :
example.gogofunc exportOrders(w http.ResponseWriter, r *http.Request) { rc := http.NewResponseController(w) if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Minute)); err != nil { slog.WarnContext(r.Context(), "cannot extend write deadline", "error", err) } w.Header().Set("Content-Type", "text/csv") // stream rows from the database, flushing every few hundred rows }
Comment récupérer les panics dans un middleware Go ?
Un middleware de récupération diffère un appel à recover, journalise la stack et écrit un 500. Une version de production a besoin de deux détails supplémentaires :
example.gogofunc recoverPanics(logger *slog.Logger) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sw := &statusWriter{ResponseWriter: w} defer func() { v := recover() if v == nil { return } if v == http.ErrAbortHandler { panic(v) } logger.ErrorContext(r.Context(), "handler panic", "panic", v, "stack", string(debug.Stack())) if sw.status != 0 { // The status line is already on the wire. Abort the // connection so the client doesn't take a cut-off // body for a complete 200. panic(http.ErrAbortHandler) } http.Error(w, "internal server error", http.StatusInternalServerError) }() next.ServeHTTP(sw, r) }) } }
Le premier détail est http.ErrAbortHandler. C'est une valeur sentinelle avec laquelle le code panique volontairement pour interrompre une réponse, et httputil.ReverseProxy l'utilise quand le backend échoue en pleine copie. La documentation précise que « paniquer avec ErrAbortHandler supprime également l'écriture d'une stack trace dans le log d'erreurs du serveur » (net/http). Si votre middleware l'avale, une interruption de proxy banale se transforme en log d'erreur avec stack trace, suivi d'une tentative d'écrire un 500 dans une réponse déjà à moitié envoyée.
Le second détail est la vérification sw.status != 0. Une fois qu'un handler a écrit un statut, appeler http.Error ne peut plus le modifier. net/http journalise "superfluous response.WriteHeader call" et ajoute le texte de l'erreur à la suite du corps déjà en cours d'envoi. Un tableau JSON qui s'arrête au milieu et se termine par internal server error arrive quand même comme un 200. Relancer la panic avec ErrAbortHandler pousse le serveur à fermer la connexion ou à réinitialiser le stream HTTP/2, et le client voit alors une requête en échec au lieu d'un succès corrompu.
Comment transmettre les ID de requête et d'autres valeurs via le context ?
context.WithValue transporte les données propres à une requête, des middlewares jusqu'aux handlers. La documentation indique que la clé « ne doit pas être de type string ni d'aucun autre type prédéfini, afin d'éviter les collisions entre les packages qui utilisent context » (context). Un type struct vide non exporté ne peut entrer en collision avec rien, car aucun autre package ne peut le nommer :
example.gogotype requestIDKey struct{} func requestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := rand.Text() w.Header().Set("X-Request-ID", id) ctx := context.WithValue(r.Context(), requestIDKey{}, id) next.ServeHTTP(w, r.WithContext(ctx)) }) } func RequestIDFrom(ctx context.Context) (string, bool) { id, ok := ctx.Value(requestIDKey{}).(string) return id, ok }
crypto/rand.Text, ajoutée dans Go 1.24, renvoie une chaîne aléatoire en base32 de 26 caractères, ce qui suffit largement pour un ID. Le même pattern transporte l'utilisateur authentifié plus loin dans ce guide. Réservez les valeurs de context aux données qui appartiennent à la requête, comme les ID et l'appelant. La documentation de context met en garde contre l'usage des valeurs pour « passer des paramètres optionnels à des fonctions ». Un handle de base de données ou un client de feature flags a sa place dans un champ de struct de votre handler.
Logs structurés avec slog
L'ID de requête n'est utile que si chaque ligne de log émise pendant la requête le porte. Plutôt que de le passer à chaque appel, enveloppez le slog.Handler pour qu'il lise l'ID dans le context que reçoivent InfoContext et les fonctions du même type :
example.gogotype requestIDHandler struct { slog.Handler } func (h requestIDHandler) Handle(ctx context.Context, rec slog.Record) error { if id, ok := RequestIDFrom(ctx); ok { rec.AddAttrs(slog.String("request_id", id)) } return h.Handler.Handle(ctx, rec) } func (h requestIDHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return requestIDHandler{h.Handler.WithAttrs(attrs)} } func (h requestIDHandler) WithGroup(name string) slog.Handler { return requestIDHandler{h.Handler.WithGroup(name)} }
Construisez le logger avec slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). Les deux méthodes With sont importantes. L'embedding promeut le WithAttrs du handler interne, qui renvoie le handler interne sans votre wrapper. Le premier logger.With("service", "orders") ferait alors disparaître les ID de requête de toutes les lignes suivantes.
Le middleware de log d'accès utilise le statusWriter vu plus haut et journalise dans un defer, ce qui lui permet d'écrire une ligne même quand une panic le traverse :
example.gogofunc logRequests(logger *slog.Logger) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w} defer func() { status := sw.status if status == 0 { status = http.StatusOK } logger.InfoContext(r.Context(), "request", "method", r.Method, "path", r.URL.Path, "status", status, "bytes", sw.bytes, "duration", time.Since(start), ) }() next.ServeHTTP(sw, r) }) } }
Un handler qui n'écrit rien reçoit un 200 de la part de net/http, c'est pourquoi un statut nul est journalisé comme 200.
Timeouts et limites de taille du corps dans un middleware Go
Les timeouts du serveur protègent les connexions, et les middlewares protègent les requêtes individuelles.
Sur le serveur, définissez toujours ReadHeaderTimeout. Laissé à zéro, il se rabat sur ReadTimeout, et quand celui-ci vaut aussi zéro, il n'y a plus aucune limite. Un client peut alors ouvrir des connexions et envoyer ses en-têtes un octet à la fois aussi longtemps qu'il le souhaite. gosec signale une valeur manquante sous le code G112, une attaque Slowloris potentielle (règles gosec). ReadTimeout, WriteTimeout et IdleTimeout plafonnent le reste de la durée de vie de la connexion, comme dans la fonction newServer plus haut.
Pour le budget propre à une requête, attachez une deadline à son context :
example.gogofunc withDeadline(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)) }) } }
Cela ne fonctionne que si le handler passe r.Context() à la base de données, aux clients HTTP et à tout ce qui peut bloquer. La deadline annule le travail, et le handler décide de ce qu'il écrit.
http.TimeoutHandler est l'autre option de la bibliothèque standard. Il écrit un 503 quand le handler dépasse le délai et fait échouer les écritures ultérieures du handler avec http.ErrHandlerTimeout. Il met aussi toute la réponse en mémoire tampon et, d'après sa documentation, « ne prend pas en charge les interfaces Hijacker ou Flusher » (net/http). La goroutine du handler continue de s'exécuter jusqu'à ce qu'il retourne, le handler doit donc toujours respecter son context. Utilisez TimeoutHandler pour de petits endpoints JSON où un 503 garanti vaut bien le coût de la mise en tampon. Utilisez la deadline de context pour tout ce qui fait du streaming.
Les limites de taille du corps protègent contre un client qui enverrait un gigaoctet à un endpoint qui attend un petit objet JSON :
example.gogofunc limitBody(n int64) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, n) next.ServeHTTP(w, r) }) } }
MaxBytesReader ne rejette rien d'emblée. Une lecture au-delà de la limite renvoie une *http.MaxBytesError (Go 1.19), et le handler la transforme en 413 :
example.gogofunc createPayment(w http.ResponseWriter, r *http.Request) { var req CreatePayment if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if tooBig, ok := errors.AsType[*http.MaxBytesError](err); ok { msg := fmt.Sprintf("body larger than %d bytes", tooBig.Limit) http.Error(w, msg, http.StatusRequestEntityTooLarge) return } http.Error(w, "invalid JSON", http.StatusBadRequest) return } p, _ := PrincipalFrom(r.Context()) slog.InfoContext(r.Context(), "payment accepted", "user_id", p.UserID, "order_id", req.OrderID) w.WriteHeader(http.StatusAccepted) }
errors.AsType est arrivée avec Go 1.26. Sur les versions plus anciennes, déclarez var tooBig *http.MaxBytesError et appelez errors.As(err, &tooBig). Les erreurs Go courantes à éviter explique pourquoi les erreurs doivent être comparées par type plutôt que par message.
Comment doit fonctionner un middleware d'authentification ?
Un middleware d'authentification vérifie l'appelant, stocke son identité dans le context et transmet la requête. Il ne fait jamais confiance à une identité fournie directement par le client :
example.gogotype Principal struct { UserID string Scopes []string } type TokenVerifier interface { Verify(ctx context.Context, token string) (Principal, error) } type principalKey struct{} func PrincipalFrom(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(principalKey{}).(Principal) return p, ok } func requireAuth(verifier TokenVerifier) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || token == "" { w.Header().Set("WWW-Authenticate", "Bearer") http.Error(w, "missing bearer token", http.StatusUnauthorized) return } p, err := verifier.Verify(r.Context(), token) if err != nil { w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) http.Error(w, "invalid token", http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), principalKey{}, p) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func requireScope(scope string) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, ok := PrincipalFrom(r.Context()) if !ok { // A wiring bug, not a client error: requireScope ran without requireAuth. http.Error(w, "internal server error", http.StatusInternalServerError) return } if !slices.Contains(p.Scopes, scope) { http.Error(w, "missing scope "+scope, http.StatusForbidden) return } next.ServeHTTP(w, r) }) } }
Ce code fait apparaître trois règles. Un 401 signifie « je ne sais pas qui vous êtes » et s'accompagne d'un en-tête WWW-Authenticate. Un 403 signifie « je sais qui vous êtes et la réponse est non ». Chaque rejet se termine par return, car un middleware qui écrit une erreur puis appelle quand même next exécute le handler de paiement pour un appelant non authentifié.
TokenVerifier est une petite interface définie là où elle est utilisée, ce qui permet à un test de passer un fake basé sur une map pendant que la production passe une implémentation JWT ou adossée à un session store. Des en-têtes comme X-User-ID provenant d'une API gateway ne sont sûrs que si la gateway est le seul chemin vers votre service. Si le service est joignable directement, n'importe qui peut envoyer cet en-tête.
Comment limiter le débit des requêtes dans un middleware Go ?
golang.org/x/time/rate fournit un token bucket. rate.NewLimiter(10, 20) autorise 10 événements par seconde avec des rafales allant jusqu'à 20, et Allow indique si l'événement courant rentre dans la limite (x/time/rate). Un limiteur par client conserve un bucket par clé et oublie les clients inactifs :
example.gogotype client struct { limiter *rate.Limiter lastSeen time.Time } type RateLimiter struct { mu sync.Mutex clients map[string]*client limit rate.Limit burst int } func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter { return &RateLimiter{clients: make(map[string]*client), limit: limit, burst: burst} } func (rl *RateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() c, ok := rl.clients[key] if !ok { c = &client{limiter: rate.NewLimiter(rl.limit, rl.burst)} rl.clients[key] = c } c.lastSeen = time.Now() return c.limiter.Allow() } // Cleanup drops clients idle for longer than maxIdle until ctx is done. func (rl *RateLimiter) Cleanup(ctx context.Context, maxIdle time.Duration) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: rl.mu.Lock() for key, c := range rl.clients { if time.Since(c.lastSeen) > maxIdle { delete(rl.clients, key) } } rl.mu.Unlock() } } } func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { ip = r.RemoteAddr } if !rl.allow(ip) { w.Header().Set("Retry-After", "1") http.Error(w, "too many requests", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) }
NewRateLimiter doit créer la map, faute de quoi le premier appel à allow paniquerait sur une map nil. Sans Cleanup, la map gagne une entrée pour chaque adresse qui s'est un jour connectée et ne rétrécit jamais. Lancez-la une fois avec go limiter.Cleanup(ctx, 10*time.Minute) et annulez ctx à l'arrêt.
Le choix de la clé est plus délicat. Derrière un load balancer, r.RemoteAddr est l'adresse du load balancer, et tous les clients partagent alors un seul bucket. La solution évidente consiste à prendre la première adresse de X-Forwarded-For, mais c'est justement celle que contrôle le client. MDN indique que si le serveur est joignable directement depuis Internet, « aucune partie de la liste d'IP de X-Forwarded-For ne peut être considérée comme fiable ou sûre pour un usage lié à la sécurité » (MDN). Prenez l'adresse ajoutée par votre propre proxy, en comptant depuis la droite selon le nombre de proxies de confiance placés devant vous. Assurez-vous que le service ne peut pas être atteint en les contournant.
Ce limiteur vit aussi dans un seul processus. Avec quatre réplicas, un client obtient quatre fois la limite. Pour une limite globale stricte, appliquez-la au niveau de la gateway ou dans un stockage partagé comme Redis.
Comment protéger les handlers Go contre le CSRF ?
Go 1.25 a ajouté http.CrossOriginProtection. Il rejette les requêtes cross-origin non sûres des navigateurs en vérifiant l'en-tête Sec-Fetch-Site, ou, si cet en-tête est absent, en comparant l'hôte de l'en-tête Origin avec Host. GET, HEAD et OPTIONS passent toujours. Il ne nécessite aucun token, il fonctionne donc sans modifier les templates. Il est important pour toute route qui authentifie par cookies. Les navigateurs n'attachent jamais d'eux-mêmes un bearer token, donc une API reposant uniquement sur des tokens n'est pas exposée au CSRF.
La fonction newServer plus haut montre toute la configuration : http.NewCrossOriginProtection(), AddTrustedOrigin pour un front-end d'administration séparé, et csrf.Handler dans la chaîne globale. Les requêtes sans aucun de ces deux en-têtes sont traitées comme du trafic same-origin ou non navigateur et sont autorisées, ce qui correspond à ce que vous voulez pour les appels de serveur à serveur.
Ce n'est pas du CORS. Les en-têtes CORS déterminent quelles autres origines peuvent lire vos réponses, et CrossOriginProtection n'en définit aucun. Si une single-page app hébergée sur un autre domaine appelle votre API, il vous faut toujours un petit middleware CORS avec une liste explicite d'origines autorisées. Il se place près du haut de la chaîne, car les navigateurs envoient les requêtes de preflight OPTIONS sans identifiants. Ajoutez aussi la même origine avec AddTrustedOrigin. Un front-end sur app.example.com qui appelle api.example.com envoie Sec-Fetch-Site: same-site, et CrossOriginProtection rejette ses écritures même quand CORS les autorise.
Tester un middleware Go avec httptest
Testez chaque middleware isolément avec httptest, en utilisant un handler minuscule comme next pour enregistrer s'il a été exécuté. staticTokens est un fake de TokenVerifier basé sur une map :
example.gogofunc TestRequireAuthRejectsMissingToken(t *testing.T) { called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) h := requireAuth(staticTokens{})(next) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/payments", nil)) res := rec.Result() if res.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", res.StatusCode) } if got := res.Header.Get("WWW-Authenticate"); got != "Bearer" { t.Errorf("WWW-Authenticate = %q, want Bearer", got) } if called { t.Error("next handler ran for an unauthenticated request") } }
Faites vos assertions sur rec.Result(), pas sur rec.Header(). Header() renvoie la map vivante que les handlers modifient, et la documentation vous oriente vers Result « pour tester les en-têtes qui ont été écrits une fois le handler terminé » (httptest). Result().Header est un instantané pris lors de la première écriture, ce qui correspond à ce que reçoit un vrai client. Ce middleware contient un bug que seul Result révèle :
example.gogo// serverTiming sets a header after the handler has written, which is a bug. func serverTiming(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) w.Header().Set("Server-Timing", fmt.Sprintf("app;dur=%d", time.Since(start).Milliseconds())) }) }
rec.Header().Get("Server-Timing") renvoie une valeur, donc un test qui s'appuie dessus passe. rec.Result().Header.Get("Server-Timing") renvoie une chaîne vide, car le handler avait déjà écrit la réponse et un vrai client ne verrait jamais cet en-tête.
Trois autres tests couvrent les corrections de cette pile. Pour la méthode Unwrap, exécutez à travers logRequests un handler qui appelle http.NewResponseController(w).Flush() et vérifiez rec.Flushed. Pour les deux cas de récupération, enveloppez un handler qui panique avec http.ErrAbortHandler, puis un autre qui écrit un corps partiel avant de paniquer, appelez ensuite recover dans le test et vérifiez que la valeur est http.ErrAbortHandler. Pour le wrapper slog, appelez .With(...) sur le logger et confirmez que l'ID de requête apparaît toujours. Quand vous écrivez ces tests, supprimez la ligne que chacun protège et vérifiez que le test échoue. S'il passe encore, il ne teste pas cette correction.
Erreurs courantes avec les middlewares Go
| Erreur | Ce qui se passe | Correction |
|---|---|---|
| Récupération à l'extérieur du logging | Les panics sont journalisées en 200 sans ID de requête | Ordre : ID de requête, logging, récupération |
Wrapper sans Unwrap | Flush et SetWriteDeadline échouent avec ErrNotSupported | Ajouter Unwrap() http.ResponseWriter |
Avaler http.ErrAbortHandler | Les interruptions de proxy deviennent des stack traces et des 500 cassés | Relancer la panic |
| Écrire un 500 après l'envoi du statut | Un corps tronqué arrive comme un 200 | Interrompre avec http.ErrAbortHandler |
| Clés de context de type string | Collisions entre packages, staticcheck SA1029 | Clé struct non exportée et accesseur typé |
Définir des en-têtes après next.ServeHTTP | L'en-tête est ignoré sans avertissement | Définir les en-têtes avant d'appeler next |
Pas de return après une réponse d'erreur | Le handler protégé s'exécute quand même | Retourner juste après avoir écrit l'erreur |
Pas de ReadHeaderTimeout | Des clients lents gardent les connexions ouvertes, gosec G112 | Le définir sur chaque http.Server |
X-Forwarded-For le plus à gauche comme clé de limitation | Les clients choisissent leur propre bucket | Utiliser l'adresse ajoutée par votre proxy |
| Map du limiteur sans nettoyage | La mémoire croît avec chaque adresse client | Évincer les entrées inactives sur un ticker |
Le principe derrière l'ordre
La règle d'ordonnancement s'applique bien au-delà de cette pile. Une couche ne peut observer que ce qui se passe à l'intérieur d'elle, alors placez les couches qui enregistrent les résultats à l'extérieur de celles qui les modifient. Dans un serveur HTTP, les logs d'accès, les métriques et les spans de trace se placent à l'extérieur de la récupération, de l'authentification et des limites, afin d'enregistrer ce que le client a réellement reçu. En gRPC, un intercepteur de logging se place à l'extérieur de l'intercepteur qui convertit les panics en codes de statut. Un wrapper de métriques autour d'une transaction de base de données doit envelopper la boucle de retry, sinon il comptera un appel là où la base de données en a vu trois.
La place de LevelUpGo
LevelUpGo enseigne ces patterns avec des exercices qui exécutent du vrai code Go dans le navigateur. Dans HTTP & Networking, vous écrivez des middlewares de logging, de chaînage et d'authentification avec net/http. Real-World Patterns couvre le pattern middleware, la récupération des panics et les chaînes. Logging with slog approfondit le logging de production qui se trouve derrière votre middleware de log d'accès.
Questions fréquentes
Qu'est-ce qu'un middleware en Go ?
Un middleware en Go est une fonction de signature func(http.Handler) http.Handler. Il enveloppe un handler pour exécuter du code avant ou après lui, pour le logging, l'authentification, la récupération des panics ou les limites. Comme il prend et renvoie l'interface standard http.Handler, un middleware issu de n'importe quel package se compose avec n'importe quel routeur qui accepte http.Handler, y compris http.ServeMux.
Faut-il un framework ou un routeur comme chi pour écrire des middlewares en Go ?
Non. Depuis Go 1.22, http.ServeMux prend en charge la correspondance sur la méthode et les wildcards de chemin comme /orders/{id}, et un middleware n'est qu'une fonction qui enveloppe un handler. Un helper Chain de dix lignes couvre les piles globales et par route. Des routeurs comme chi ajoutent des facilités comme les groupes de routes, et leurs middlewares utilisent la même signature, vous pouvez donc changer plus tard sans les réécrire.
Dans quel ordre les middlewares Go doivent-ils s'exécuter ?
Exécutez d'abord l'ID de requête, puis le log d'accès, puis la récupération des panics, puis les rejets peu coûteux comme les vérifications cross-origin, les limites de débit et les limites de taille du corps. Placez l'authentification et les deadlines par route en dernier. Le logging doit se trouver à l'extérieur de la récupération, sinon les panics sont journalisées avec un statut 200 et sans ID de requête.
Comment transmettre des valeurs d'un middleware aux handlers en Go ?
Utilisez context.WithValue avec un type de clé non exporté comme type principalKey struct{}, et fournissez aux handlers un accesseur typé comme PrincipalFrom(ctx) (Principal, bool). N'utilisez jamais une string comme clé, car deux packages qui choisissent la même chaîne écrasent mutuellement leurs valeurs.
Comment tester un middleware HTTP en Go ?
Enveloppez un handler bouchon avec le middleware, appelez ServeHTTP avec un httptest.NewRecorder() et un httptest.NewRequest, puis faites vos assertions sur rec.Result(). Vérifiez le statut, les en-têtes que le client recevrait et si le bouchon a été exécuté. Pour un middleware de récupération, appelez recover dans le test et comparez la valeur à http.ErrAbortHandler.
Sources
- Documentation du package net/http (Handler, ErrAbortHandler, ResponseController, TimeoutHandler, MaxBytesReader, CrossOriginProtection, Server)
- net/http/httptest : ResponseRecorder
- context : WithValue
- Documentation du package log/slog
- golang.org/x/time/rate
- Notes de version de Go 1.19 (MaxBytesError)
- Notes de version de Go 1.20 (ResponseController)
- Notes de version de Go 1.21 (log/slog)
- Notes de version de Go 1.22 (motifs de ServeMux)
- Notes de version de Go 1.24 (crypto/rand.Text)
- Notes de version de Go 1.25 (CrossOriginProtection)
- Notes de version de Go 1.26 (errors.AsType)
- MDN : X-Forwarded-For
- Règles gosec : G112, ReadHeaderTimeout non configuré
