Volver al blog

Buenas prácticas de middleware en Go (Golang) para producción

Buenas prácticas de middleware en Go: el orden que evita panics registrados como 200, wrappers de ResponseWriter con Unwrap, request IDs, timeouts y auth.

Buenas prácticas de middleware en Go (Golang) para producción

Un middleware en Go es una función que recibe un http.Handler y devuelve uno nuevo: func(http.Handler) http.Handler. El handler devuelto hace su trabajo antes o después de llamar al que envuelve. Esa firma es todo el patrón. No necesitas un framework para usarlo, y desde Go 1.22 tampoco necesitas un router de terceros, porque http.ServeMux hace matching de métodos y de comodines en la ruta (notas de la versión Go 1.22).

Escribir uno lleva pocas líneas. En producción, las buenas prácticas tienen que ver con los detalles que lo rodean: el orden de la pila, un wrapper de ResponseWriter que no rompa el streaming, una recuperación de panics que informe bien de los fallos y valores de contexto que no puedan colisionar. Esta guía construye, capa a capa, la pila de middleware de una API de pedidos y pagos. Todos los fragmentos compilan con Go 1.27, y los tests pasan con go test -race.

Resumen rápido

  • Escribe el middleware como func(http.Handler) http.Handler y encadénalo con un helper de diez líneas. El middleware global envuelve el mux. El middleware por ruta envuelve el handler que registras.
  • El orden importa. Pon el request ID y el logging por fuera de la recuperación de panics, para que la línea de log registre el 500 que escribe la recuperación y lleve el request ID.
  • Un wrapper de ResponseWriter necesita un método Unwrap() http.ResponseWriter. Sin él, http.ResponseController no puede hacer flush ni fijar deadlines a través de tu middleware.
  • La recuperación debe relanzar el panic de http.ErrAbortHandler, y debe abortar en lugar de escribir un 500 cuando el handler ya ha enviado un status.
  • Configura ReadHeaderTimeout en el servidor, dale a cada ruta un deadline en el contexto y limita los bodies con http.MaxBytesReader.
  • El middleware de autenticación guarda el principal en el contexto. Devuelve 401 cuando no sabe quién llama y 403 cuando quien llama no tiene permiso.
  • Usa como clave del rate limit una IP que haya añadido tu propio proxy, no el valor más a la izquierda de X-Forwarded-For.

¿Cómo es un middleware HTTP en Go?

Un middleware recibe el siguiente handler y devuelve un handler que lo llama. Ponerle nombre al tipo hace que las cadenas se lean mejor:

example.gogo
type 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 aplica la lista al revés, así que la primera entrada queda en la capa más externa y ve la petición antes que nadie.

Dentro de cada middleware, http.HandlerFunc convierte un closure en un handler. Funciona porque HandlerFunc es un tipo función con un método ServeHTTP, algo que la guía de la palabra clave func en Go explica junto con los closures. El tipo Middleware en sí no es más que http.Handler de entrada y de salida, y la guía de la palabra clave interface en Go explica por qué una interfaz de un solo método como http.Handler se compone tan bien.

Middleware global frente a middleware por ruta en http.ServeMux

ServeMux no tiene método Use. Envuelve el mux completo con el middleware que necesita cada petición, y envuelve handlers individuales con el resto:

example.gogo
func 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 lista pública de pedidos no pasa por la autenticación, consultar un pedido requiere un token válido y crear un pago requiere además un scope y recibe un presupuesto de cinco segundos. Como la cadena por ruta forma parte del registro de la ruta, puedes leer quién puede llamar a qué en una sola pantalla de código.

Mantén este cableado en un único sitio, como cmd/orders/server.go o un paquete internal/httpapi. Estructura de proyectos en Go explica dónde encaja ese paquete en una estructura más grande.

¿En qué orden debe ejecutarse el middleware en Go?

El primer middleware de la cadena envuelve todo lo que viene después. Ve la petición el primero y la respuesta el último. Eso significa que una capa externa solo puede observar lo que hacen las capas internas, y que una capa interna nunca ve lo que ocurre fuera de ella. Un buen orden de producción para una API HTTP es este:

#MiddlewarePor qué va aquí
1Request IDTodas las capas posteriores, incluidas la línea de log y el informe del panic, pueden leer el ID.
2Log de accesosEnvuelve la recuperación, así que registra el status que escribió la recuperación.
3Recuperación de panicsCaptura los panics de todas las capas internas y del handler, y los convierte en un 500.
4Protección cross-originRechaza las escrituras cross-site del navegador antes de que cuesten trabajo.
5Rate limit por IP de clienteDescarta el tráfico abusivo antes de que la autenticación pague una consulta de token.
6Límite de bodyLimita el body de cada petición antes de que ningún handler lo lea.
7Autenticación y scopes, por rutaSolo las rutas que necesitan saber quién llama pagan por verificarlo.
8Deadline, por rutaCada ruta recibe un presupuesto de tiempo acorde con su trabajo.

Muchas guías ponen la recuperación la primera para que lo capture todo. Estos son los logs de un mismo handler que provoca un panic, ejecutado con los dos órdenes:

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"

En los dos casos el cliente recibe un 500. Con la recuperación en la capa más externa, el log de accesos dice 200, y la línea del panic no tiene request ID con el que relacionarla con la petición. El único código que la recuperación interna no protege es el middleware de request ID y el de logging, que son pequeños. El servidor de net/http también recupera cualquier panic que se escape de tu handler y lo registra, así que nada tumba el proceso (documentación de net/http Handler).

Envolver http.ResponseWriter sin romper Flush

El logging necesita el código de status y el número de bytes, y http.ResponseWriter no expone ninguno de los dos. La solución habitual es un wrapper que los registre:

example.gogo
type 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
}

Incrustar http.ResponseWriter solo promueve los tres métodos de esa interfaz, una regla que la guía de la palabra clave struct en Go explica en el apartado de incrustación. El writer real que hay debajo también implementa http.Flusher y http.Hijacker, pero tu wrapper no, así que un handler de server-sent events que hace w.(http.Flusher) deja de hacer streaming en cuanto el logging lo envuelve.

Añadir Unwrap lo arregla. Desde Go 1.20, http.ResponseController lo llama para encontrar el writer original. Los handlers llaman entonces a http.NewResponseController(w).Flush() en lugar de hacer una aserción de tipo. Sin Unwrap, esa llamada devuelve un error que coincide con http.ErrNotSupported, y el test que aparece más adelante en esta guía falla.

Gracias al mismo método siguen funcionando los deadlines de escritura por petición. Una exportación a CSV puede durar más que el WriteTimeout de 30 segundos del servidor, que según la documentación no «permite a los handlers tomar decisiones petición a petición» (http.Server). ResponseController sí lo permite:

example.gogo
func 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
}

¿Cómo se recupera un middleware en Go de un panic?

Un middleware de recuperación difiere un recover, registra el stack y escribe un 500. Una versión para producción necesita dos detalles más:

example.gogo
func 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)
		})
	}
}

El primer detalle es http.ErrAbortHandler. Es un valor centinela con el que el código provoca un panic a propósito para abortar una respuesta, y httputil.ReverseProxy lo usa cuando el backend falla a mitad de la copia. La documentación dice que «provocar un panic con ErrAbortHandler también evita que se registre un stack trace en el log de errores del servidor» (net/http). Si tu middleware se lo traga, un aborto rutinario del proxy se convierte en un log de error con stack trace y en un intento de escribir un 500 en una respuesta que ya está enviada a medias.

El segundo detalle es la comprobación sw.status != 0. Una vez que un handler ha escrito un status, llamar a http.Error no puede cambiarlo. net/http registra «superfluous response.WriteHeader call» y añade el texto del error a cualquier body que ya se estuviera enviando. Un array JSON que se corta a la mitad y termina en internal server error sigue llegando como un 200. Relanzar el panic con ErrAbortHandler hace que el servidor cierre la conexión o resetee el stream de HTTP/2, así que el cliente ve una petición fallida en lugar de un éxito corrupto.

¿Cómo se pasan los request IDs y otros valores a través del contexto?

context.WithValue lleva datos con ámbito de petición desde el middleware hasta los handlers. La documentación dice que la clave «no debe ser de tipo string ni de ningún otro tipo predefinido, para evitar colisiones entre paquetes que usan el contexto» (context). Un tipo struct vacío y no exportado no puede colisionar con nada, porque ningún otro paquete puede nombrarlo:

example.gogo
type 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, añadido en Go 1.24, devuelve una cadena aleatoria en base32 de 26 caracteres, más que suficiente para un ID. El mismo patrón lleva al usuario autenticado más adelante en esta guía. Limita los valores del contexto a datos que pertenecen a la petición, como los IDs y quién llama. La documentación de context desaconseja usar valores para «pasar parámetros opcionales a funciones». Un handle de base de datos o un cliente de feature flags va en un campo del struct de tu handler.

Logging estructurado con slog

El request ID solo sirve si todas las líneas de log de la petición lo llevan. En lugar de pasarlo a cada llamada, envuelve el slog.Handler para que lea el ID del contexto que reciben InfoContext y compañía:

example.gogo
type 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)}
}

Construye el logger con slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). No te saltes los dos métodos With. La incrustación promueve el WithAttrs del handler interno, que devuelve el handler interno sin tu wrapper. El primer logger.With("service", "orders") eliminaría entonces los request IDs de todas las líneas posteriores.

El middleware de log de accesos usa el statusWriter de antes y escribe el log en un defer, así que sigue escribiendo una línea cuando un panic pasa a través de él:

example.gogo
func 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 que no escribe nada recibe un 200 de net/http, y por eso un status a cero se registra como 200.

Timeouts y límites de body en el middleware de Go

Los timeouts del servidor protegen las conexiones, y el middleware protege cada petición.

En el servidor, configura siempre ReadHeaderTimeout. Si se deja a cero, recurre a ReadTimeout, y si ese también es cero no hay ningún límite. Un cliente puede entonces abrir conexiones y enviar las cabeceras byte a byte durante todo el tiempo que quiera. gosec informa de la falta de este valor como G112, un posible ataque Slowloris (reglas de gosec). ReadTimeout, WriteTimeout e IdleTimeout limitan el resto de la vida de la conexión, como en la función newServer de arriba.

Para el presupuesto propio de una petición, añade un deadline a su contexto:

example.gogo
func 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))
		})
	}
}

Esto solo funciona si el handler pasa r.Context() a la base de datos, a los clientes HTTP y a cualquier otra cosa que pueda bloquearse. El deadline cancela el trabajo, y el handler decide qué escribir.

http.TimeoutHandler es la otra opción de la biblioteca estándar. Escribe un 503 cuando el handler se pasa de tiempo y hace que las escrituras posteriores del handler fallen con http.ErrHandlerTimeout. También guarda en buffer toda la respuesta y, según su documentación, «no admite las interfaces Hijacker ni Flusher» (net/http). La goroutine del handler sigue ejecutándose hasta que retorna, así que el handler tiene que respetar su contexto de todos modos. Usa TimeoutHandler para endpoints JSON pequeños en los que un 503 garantizado compensa el buffering. Usa el deadline en el contexto para todo lo que haga streaming.

Los límites de body protegen frente a un cliente que envía un gigabyte a un endpoint que espera un objeto JSON pequeño:

example.gogo
func 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 no rechaza nada de antemano. Una lectura que supera el límite devuelve un *http.MaxBytesError (Go 1.19), y el handler lo convierte en un 413:

example.gogo
func 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 llegó en Go 1.26. En versiones anteriores, declara var tooBig *http.MaxBytesError y llama a errors.As(err, &tooBig). Errores comunes en Go que debes evitar explica por qué los errores deben compararse por tipo y no por mensaje.

¿Cómo debería funcionar el middleware de autenticación?

El middleware de autenticación verifica quién llama, guarda su identidad en el contexto y pasa la petición al siguiente. Nunca confía en una identidad que el cliente haya enviado directamente:

example.gogo
type 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)
		})
	}
}

En ese código se ven tres reglas. Un 401 significa «no sé quién eres» y lleva una cabecera WWW-Authenticate. Un 403 significa «sé quién eres y la respuesta es no». Cada rechazo termina en return, porque un middleware que escribe un error y después llama a next de todos modos ejecuta el handler de pagos para alguien que no se ha autenticado.

TokenVerifier es una interfaz pequeña definida donde se usa, así que un test puede pasar un fake basado en un map mientras que producción pasa una implementación con JWT o con un almacén de sesiones. Las cabeceras como X-User-ID que llegan de un API gateway solo son seguras cuando el gateway es la única vía de acceso a tu servicio. Si se puede llegar al servicio directamente, cualquiera puede enviar esa cabecera.

¿Cómo se aplica rate limiting en un middleware de Go?

golang.org/x/time/rate proporciona un token bucket. rate.NewLimiter(10, 20) permite 10 eventos por segundo con ráfagas de hasta 20, y Allow indica si el evento actual cabe (x/time/rate). Un limitador por cliente mantiene un bucket por clave y se olvida de los clientes inactivos:

example.gogo
type 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 tiene que crear el map, porque de lo contrario el primer allow provocaría un panic sobre un map nil. Sin Cleanup, el map gana una entrada por cada dirección que se conecta alguna vez y nunca se reduce. Arráncalo una sola vez con go limiter.Cleanup(ctx, 10*time.Minute) y cancela ctx al apagar el servidor.

Elegir la clave es más difícil. Detrás de un balanceador de carga, r.RemoteAddr es la dirección del balanceador, así que todos los clientes comparten un mismo bucket. Lo obvio sería usar la primera dirección de X-Forwarded-For, pero esa la controla el cliente. MDN afirma que, si se puede llegar al servidor directamente desde internet, «ninguna parte de la lista de IPs de X-Forwarded-For puede considerarse fiable ni segura para usos relacionados con la seguridad» (MDN). Toma la dirección que añadió tu propio proxy, contando desde la derecha tantas posiciones como proxies de confianza tengas delante. Asegúrate de que no se pueda llegar al servicio saltándose esos proxies.

Además, este limitador vive en un solo proceso. Con cuatro réplicas, un cliente obtiene cuatro veces el límite. Si necesitas un límite global estricto, aplícalo en el gateway o en un almacén compartido como Redis.

¿Cómo se protegen los handlers de Go frente a CSRF?

Go 1.25 añadió http.CrossOriginProtection. Rechaza las peticiones cross-origin no seguras de los navegadores comprobando la cabecera Sec-Fetch-Site, o comparando el host de la cabecera Origin con Host cuando falta esa cabecera. GET, HEAD y OPTIONS siempre pasan. No necesita tokens, así que funciona sin cambios en las plantillas. Lo necesitas en cualquier ruta que se autentique con cookies. Los navegadores nunca añaden un bearer token por su cuenta, así que una API que solo usa tokens no está expuesta a CSRF.

newServer, más arriba, muestra la configuración completa: http.NewCrossOriginProtection(), AddTrustedOrigin para un front end de administración separado y csrf.Handler en la cadena global. Las peticiones que no traen ninguna de las dos cabeceras se tratan como tráfico del mismo origen o de fuera del navegador y se permiten, que es lo que quieres para las llamadas entre servidores.

No es CORS. Las cabeceras CORS deciden qué otros orígenes pueden leer tus respuestas, y CrossOriginProtection no establece ninguna. Si una single-page app en otro dominio llama a tu API, sigues necesitando un pequeño middleware de CORS con una lista explícita de orígenes permitidos. Va cerca de la parte superior de la cadena, porque los navegadores envían las peticiones preflight OPTIONS sin credenciales. Añade también ese mismo origen con AddTrustedOrigin. Un front end en app.example.com que llama a api.example.com envía Sec-Fetch-Site: same-site, y CrossOriginProtection rechaza sus escrituras aunque CORS las permita.

Tests de middleware en Go con httptest

Testea cada middleware por separado con httptest, usando un handler minúsculo como next para registrar si se ha ejecutado. staticTokens es un fake de TokenVerifier basado en un map:

example.gogo
func 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")
	}
}

Haz las aserciones sobre rec.Result(), no sobre rec.Header(). Header() devuelve el map vivo que modifican los handlers, y la documentación te remite a Result «para testear las cabeceras que se escribieron después de que termine un handler» (httptest). Result().Header es una instantánea tomada en la primera escritura, que coincide con lo que recibe un cliente real. Este middleware tiene un bug que solo Result deja al descubierto:

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") devuelve un valor, así que un test sobre eso pasa. rec.Result().Header.Get("Server-Timing") devuelve una cadena vacía, porque el handler ya había escrito la respuesta y un cliente real nunca vería la cabecera.

Otros tres tests cubren las correcciones de esta pila. Para el método Unwrap, ejecuta a través de logRequests un handler que llame a http.NewResponseController(w).Flush() y comprueba rec.Flushed. Para los dos casos de recuperación, envuelve un handler que provoque un panic con http.ErrAbortHandler y otro que escriba un body parcial antes del panic, luego haz recover en el test y comprueba que el valor es http.ErrAbortHandler. Para el wrapper de slog, llama a .With(...) sobre el logger y confirma que el request ID sigue apareciendo. Cuando los escribas, borra la línea que protege cada test y comprueba que el test falla. Si sigue pasando, no está probando esa corrección.

Errores habituales con el middleware en Go

ErrorQué sale malSolución
Recuperación por fuera del loggingLos panics se registran como 200 y sin request IDOrden: request ID, logging, recuperación
Wrapper sin UnwrapFlush y SetWriteDeadline fallan con ErrNotSupportedAñade Unwrap() http.ResponseWriter
Tragarse http.ErrAbortHandlerLos abortos del proxy se convierten en stack traces y 500 rotosRelanza el panic
Escribir un 500 cuando el status ya se envióUn body truncado llega como un 200Aborta con http.ErrAbortHandler
Claves de contexto de tipo stringColisiones entre paquetes, staticcheck SA1029Clave struct no exportada y un accessor tipado
Fijar cabeceras después de next.ServeHTTPLa cabecera se descarta sin avisarFija las cabeceras antes de llamar a next
Sin return después de una respuesta de errorEl handler protegido se ejecuta igualmenteRetorna justo después de escribir el error
Sin ReadHeaderTimeoutLos clientes lentos mantienen las conexiones abiertas, gosec G112Configúralo en cada http.Server
El valor más a la izquierda de X-Forwarded-For como clave del rate limitLos clientes eligen su propio bucketUsa la dirección que añadió tu proxy
Map del rate limiter sin limpiezaLa memoria crece con cada dirección de clienteExpulsa las entradas inactivas con un ticker

El principio detrás del orden

La regla del orden sirve mucho más allá de esta pila. Una capa solo puede observar lo que ocurre dentro de ella, así que pon las capas que registran resultados por fuera de las que los cambian. En un servidor HTTP, los logs de accesos, las métricas y los spans de trazas van por fuera de la recuperación, la autenticación y los límites, para que registren lo que el cliente recibió de verdad. En gRPC, un interceptor de logging va por fuera del interceptor que convierte los panics en códigos de status. Un wrapper de métricas alrededor de una transacción de base de datos debe envolver el bucle de reintentos, o contará una llamada cuando la base de datos vio tres.

Dónde encaja LevelUpGo

LevelUpGo enseña estos patrones con ejercicios que ejecutan código Go real en el navegador. En HTTP & Networking escribes middleware de logging, de encadenamiento y de autenticación sobre net/http. Real-World Patterns cubre el patrón middleware, la recuperación de panics y las cadenas. Logging with slog profundiza en el logging de producción que hay detrás de tu middleware de log de accesos.

Preguntas frecuentes

¿Qué es un middleware en Go?

Un middleware en Go es una función con la firma func(http.Handler) http.Handler. Envuelve un handler para ejecutar código antes o después de él, para logging, autenticación, recuperación o límites. Como recibe y devuelve la interfaz estándar http.Handler, el middleware de cualquier paquete se compone con cualquier router que acepte http.Handler, incluido http.ServeMux.

¿Necesito un framework o un router como chi para usar middleware en Go?

No. Desde Go 1.22, http.ServeMux admite matching por método y comodines en la ruta como /orders/{id}, y un middleware no es más que una función que envuelve un handler. Un helper Chain de diez líneas cubre las pilas globales y por ruta. Los routers como chi añaden comodidades como los grupos de rutas, y su middleware usa la misma firma, así que puedes cambiar más adelante sin reescribirlo.

¿En qué orden debe ejecutarse el middleware en Go?

Ejecuta primero el request ID, luego el log de accesos, luego la recuperación de panics y después los rechazos baratos, como las comprobaciones cross-origin, los rate limits y los límites de body. Deja para el final la autenticación y los deadlines por ruta. El logging tiene que ir por fuera de la recuperación, o los panics se registran con status 200 y sin request ID.

¿Cómo paso valores de un middleware a los handlers en Go?

Usa context.WithValue con un tipo de clave no exportado como type principalKey struct{}, y dales a los handlers un accessor tipado como PrincipalFrom(ctx) (Principal, bool). Nunca uses un string como clave, porque dos paquetes que elijan el mismo string se sobrescriben los valores entre sí.

¿Cómo se testea un middleware HTTP en Go?

Envuelve un handler stub con el middleware, llama a ServeHTTP con un httptest.NewRecorder() y un httptest.NewRequest, y después haz las aserciones sobre rec.Result(). Comprueba el status, las cabeceras que recibiría el cliente y si el stub se ejecutó. Para el middleware de recuperación, haz recover en el test y compara el valor con http.ErrAbortHandler.

Fuentes

Escribe Go como un ingeniero sénior

Lecciones interactivas en tu navegador. Las primeras son gratis.

Prueba una lección gratisO crea una cuenta gratuita