Em Go, um middleware é uma função que recebe um http.Handler e devolve um novo: func(http.Handler) http.Handler. O handler devolvido faz o seu trabalho antes ou depois de chamar aquele que embrulha. Essa assinatura é o padrão inteiro. Não precisas de um framework para isto e, desde o Go 1.22, também não precisas de um router externo, porque o http.ServeMux já faz a correspondência de métodos e de wildcards no caminho (notas de lançamento do Go 1.22).
Escrever um leva poucas linhas. Em produção, as boas práticas estão nos detalhes à volta: a ordem da pilha, um wrapper de ResponseWriter que não parte o streaming, uma recuperação de panics que reporta as falhas corretamente e valores no context que não podem colidir. Este guia constrói a pilha de middleware de uma API de encomendas e pagamentos, uma camada de cada vez. Todos os excertos compilam no Go 1.27, e os testes passam com go test -race.
Resumo rápido
- Escreve o middleware como
func(http.Handler) http.Handlere encadeia-o com uma função auxiliar de dez linhas. O middleware global embrulha o mux. O middleware por rota embrulha o handler que registas. - A ordem importa. Põe o ID de pedido e o logging por fora da recuperação de panics, para que a linha de log registe o 500 que a recuperação escreve e leve o ID de pedido.
- Um wrapper de
ResponseWriterprecisa de um métodoUnwrap() http.ResponseWriter. Sem ele, ohttp.ResponseControllernão consegue fazer flush nem definir deadlines através do teu middleware. - A recuperação tem de voltar a lançar o panic de
http.ErrAbortHandlere tem de abortar em vez de escrever um 500 quando o handler já enviou um status. - Define
ReadHeaderTimeoutno servidor, dá a cada rota uma deadline no context e limita os bodies comhttp.MaxBytesReader. - O middleware de autenticação põe o principal no context. Devolve 401 quando não sabe quem é o cliente e 403 quando o cliente não tem permissão.
- Baseia o rate limiting num IP acrescentado pelo teu próprio proxy, não no valor mais à esquerda do
X-Forwarded-For.
Como é o middleware HTTP em Go?
Um middleware recebe o handler seguinte e devolve um handler que o chama. Dar um nome ao tipo torna as cadeias mais fáceis de ler:
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 }
O Chain aplica a lista de trás para a frente, por isso a primeira entrada fica na camada mais exterior e vê o pedido primeiro.
Dentro de cada middleware, o http.HandlerFunc transforma uma closure num handler. Essa conversão funciona porque o HandlerFunc é um tipo função com um método ServeHTTP, o que o guia da palavra-chave func em Go explica junto com as closures. O tipo Middleware em si é apenas http.Handler à entrada e à saída, e o guia da palavra-chave interface em Go explica porque é que uma interface de um só método como o http.Handler se compõe tão bem.
Middleware global vs middleware por rota no http.ServeMux
O ServeMux não tem um método Use. Embrulha o mux inteiro com o middleware de que todos os pedidos precisam, e embrulha handlers individuais com o resto:
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 }
A lista pública de encomendas dispensa autenticação, obter uma encomenda exige um token válido, e criar um pagamento exige também um scope e tem um orçamento de cinco segundos. Como a cadeia por rota faz parte do registo da rota, consegues ver quem pode chamar o quê num único ecrã de código.
Mantém esta ligação num só sítio, como cmd/orders/server.go ou um pacote internal/httpapi. A estrutura de projetos Go explica onde esse pacote deve ficar numa organização maior.
Por que ordem deve correr o middleware em Go?
O primeiro middleware da cadeia embrulha tudo o que vem depois dele. Vê o pedido em primeiro lugar e a resposta em último. Por isso, uma camada exterior só pode observar o que as camadas interiores fazem, e uma camada interior nunca vê o que acontece fora dela. Uma boa ordem de produção para uma API HTTP é esta:
| # | Middleware | Porque fica aqui |
|---|---|---|
| 1 | ID de pedido | Todas as camadas seguintes, incluindo a linha de log e o relatório do panic, conseguem ler o ID. |
| 2 | Access logging | Embrulha a recuperação, por isso regista o status que a recuperação escreveu. |
| 3 | Recuperação de panics | Apanha os panics de todas as camadas interiores e do handler e transforma-os num 500. |
| 4 | Proteção cross-origin | Rejeita escritas cross-site feitas por navegadores antes de custarem qualquer trabalho. |
| 5 | Rate limit por IP do cliente | Corta o tráfego abusivo antes de a autenticação pagar uma consulta ao token. |
| 6 | Limite de body | Limita o body de todos os pedidos antes de qualquer handler o ler. |
| 7 | Autenticação e scopes, por rota | Só as rotas que precisam de saber quem chama pagam por essa verificação. |
| 8 | Deadline, por rota | Cada rota recebe um orçamento de tempo à medida do seu trabalho. |
Muitos guias põem a recuperação em primeiro lugar para que ela apanhe tudo. Estes são os logs do mesmo handler com panic a passar pelas duas ordens:
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"
Nos dois casos, o cliente recebe um 500. Com a recuperação na camada mais exterior, o access log diz 200, e a linha do panic não tem um ID de pedido que a ligue ao pedido. O único código que a recuperação interior não protege é o middleware de ID de pedido e o de logging, que são pequenos. O servidor net/http também recupera qualquer panic que escape ao teu handler e regista-o, por isso nada deita o processo abaixo (documentação do Handler no net/http).
Embrulhar o http.ResponseWriter sem partir o Flush
O logging precisa do código de status e do número de bytes, e o http.ResponseWriter não expõe nenhum dos dois. A solução habitual é um wrapper que os regista:
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 }
Incorporar o http.ResponseWriter promove apenas os três métodos dessa interface, uma regra que o guia da palavra-chave struct em Go explica na parte da incorporação. O writer real por baixo também implementa http.Flusher e http.Hijacker, mas o teu wrapper não. Por isso, um handler de server-sent events que faça w.(http.Flusher) deixa de fazer streaming assim que o logging o embrulha.
Acrescentar o Unwrap resolve isto. Desde o Go 1.20, o http.ResponseController chama-o para encontrar o writer original. Os handlers passam então a chamar http.NewResponseController(w).Flush() em vez de fazerem uma type assertion. Sem o Unwrap, essa chamada devolve um erro que corresponde a http.ErrNotSupported, e o teste mais à frente neste guia falha.
O mesmo método mantém as deadlines de escrita por pedido a funcionar. Uma exportação CSV pode durar mais do que o WriteTimeout de 30 segundos do servidor, que, segundo a documentação, não "let Handlers make decisions on a per-request basis" (http.Server). O ResponseController permite-o:
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 }
Como se recupera de panics no middleware em Go?
Um middleware de recuperação adia um recover, regista a stack e escreve um 500. Uma versão de produção precisa de mais dois detalhes:
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) }) } }
O primeiro detalhe é o http.ErrAbortHandler. É um valor sentinela com que o código lança um panic de propósito para abortar uma resposta, e o httputil.ReverseProxy usa-o quando o backend falha a meio da cópia. A documentação diz que "panicking with ErrAbortHandler also suppresses logging of a stack trace to the server's error log" (net/http). Se o teu middleware o engolir, um aborto rotineiro do proxy transforma-se num log de erro com stack trace e numa tentativa de escrever um 500 numa resposta que já vai a meio.
O segundo detalhe é a verificação sw.status != 0. Depois de um handler escrever um status, chamar http.Error já não o consegue mudar. O net/http regista "superfluous response.WriteHeader call" e acrescenta o texto do erro a qualquer body que já estivesse a ser enviado. Um array JSON que para a meio e termina em internal server error chega mesmo assim como um 200. Voltar a lançar o panic com ErrAbortHandler faz com que o servidor feche a ligação ou faça reset ao stream HTTP/2, por isso o cliente vê um pedido falhado em vez de um sucesso corrompido.
Como se passam IDs de pedido e outros valores pelo context?
O context.WithValue transporta dados de âmbito do pedido do middleware para os handlers. A documentação diz que a chave "should not be of type string or any other built-in type to avoid collisions between packages using context" (context). Um tipo struct vazio e não exportado não pode colidir com nada, porque nenhum outro pacote o consegue nomear:
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 }
O crypto/rand.Text, acrescentado no Go 1.24, devolve uma string aleatória em base32 com 26 caracteres, o que chega bem para um ID. O mesmo padrão transporta o utilizador autenticado mais à frente neste guia. Guarda no context apenas dados que pertencem ao pedido, como IDs e quem faz a chamada. A documentação do context desaconselha usar valores para "passing optional parameters to functions". Um handle de base de dados ou um cliente de feature flags pertence a um campo da struct do teu handler.
Logging estruturado com slog
O ID de pedido só é útil se todas as linhas de log do pedido o levarem. Em vez de o passares a cada chamada, embrulha o slog.Handler para que ele leia o ID do context que o InfoContext e companhia recebem:
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)} }
Cria o logger com slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). Não saltes os dois métodos With. A incorporação promove o WithAttrs do handler interior, que devolve o handler interior sem o teu wrapper. O primeiro logger.With("service", "orders") deixaria então de pôr os IDs de pedido em todas as linhas seguintes.
O middleware de access log usa o statusWriter de há pouco e escreve o log num defer, por isso continua a escrever uma linha quando um panic passa por ele:
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) }) } }
Um handler que não escreve nada recebe um 200 do net/http, e é por isso que um status a zero fica registado como 200.
Timeouts e limites de body no middleware em Go
Os timeouts do servidor protegem as ligações, e o middleware protege cada pedido.
No servidor, define sempre o ReadHeaderTimeout. Se ficar a zero, passa a usar o ReadTimeout, e quando esse também é zero não há limite nenhum. Um cliente pode então abrir ligações e enviar os headers um byte de cada vez durante o tempo que quiser. O gosec reporta a falta deste valor como G112, um potencial ataque Slowloris (regras do gosec). O ReadTimeout, o WriteTimeout e o IdleTimeout limitam o resto da vida da ligação, como na função newServer acima.
Para o orçamento de cada pedido, associa uma deadline ao seu 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)) }) } }
Isto só funciona se o handler passar o r.Context() à base de dados, aos clientes HTTP e a tudo o resto que possa bloquear. A deadline cancela o trabalho, e é o handler que decide o que escrever.
O http.TimeoutHandler é a outra opção da biblioteca padrão. Escreve um 503 quando o handler excede o tempo e faz com que as escritas seguintes do handler falhem com http.ErrHandlerTimeout. Também guarda a resposta inteira em buffer e, segundo a documentação, "does not support the Hijacker or Flusher interfaces" (net/http). A goroutine do handler continua a correr até ele retornar, por isso o handler continua a ter de respeitar o seu context. Usa o TimeoutHandler em endpoints JSON pequenos, onde um 503 garantido compensa o buffer. Usa a deadline no context em tudo o que faça streaming.
Os limites de body protegem contra um cliente que envia um gigabyte para um endpoint que espera um pequeno objeto 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) }) } }
O MaxBytesReader não rejeita nada à partida. Uma leitura para lá do limite devolve um *http.MaxBytesError (Go 1.19), e o handler transforma-o num 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) }
O errors.AsType chegou no Go 1.26. Em versões anteriores, declara var tooBig *http.MaxBytesError e chama errors.As(err, &tooBig). Os erros comuns a evitar em Go explicam porque é que os erros devem ser comparados pelo tipo e não pela mensagem.
Como deve funcionar o middleware de autenticação?
O middleware de autenticação verifica quem faz a chamada, guarda essa identidade no context e passa o pedido adiante. Nunca confia numa identidade fornecida diretamente pelo cliente:
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) }) } }
Há três regras visíveis neste código. Um 401 significa «não sei quem és» e leva um header WWW-Authenticate. Um 403 significa «sei quem és e a resposta é não». Todas as rejeições terminam em return, porque um middleware que escreve um erro e depois chama o next na mesma executa o handler de pagamentos para alguém não autenticado.
O TokenVerifier é uma interface pequena definida onde é usada, por isso um teste pode passar uma implementação falsa baseada num map, enquanto a produção passa uma implementação com JWT ou com um session store. Headers como X-User-ID vindos de um API gateway só são seguros quando o gateway é o único caminho até ao teu serviço. Se o serviço puder ser alcançado diretamente, qualquer pessoa pode enviar esse header.
Como se faz rate limiting no middleware em Go?
O golang.org/x/time/rate fornece um token bucket. rate.NewLimiter(10, 20) permite 10 eventos por segundo com picos até 20, e o Allow indica se o evento atual cabe (x/time/rate). Um limiter por cliente mantém um bucket por chave e esquece os clientes inativos:
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) }) }
O NewRateLimiter tem de criar o map, porque caso contrário o primeiro allow provocaria um panic num map nil. Sem o Cleanup, o map ganha uma entrada por cada endereço que alguma vez se liga e nunca encolhe. Lança-o uma vez com go limiter.Cleanup(ctx, 10*time.Minute) e cancela o ctx no encerramento.
Escolher a chave é mais difícil. Atrás de um load balancer, o r.RemoteAddr é o endereço do balancer, por isso todos os clientes partilham um único bucket. A solução óbvia é o primeiro endereço do X-Forwarded-For, mas é o cliente que controla esse. A MDN afirma que, se o servidor puder ser alcançado diretamente a partir da internet, "no part of the X-Forwarded-For IP list can be considered trustworthy or safe for security-related uses" (MDN). Usa o endereço que o teu próprio proxy acrescentou, contando a partir da direita pelo número de proxies de confiança à tua frente. Garante que o serviço não pode ser alcançado contornando-os.
Este limiter também vive num único processo. Com quatro réplicas, um cliente recebe quatro vezes o limite. Para um limite global rígido, aplica-o no gateway ou num armazenamento partilhado como o Redis.
Como se protegem os handlers Go contra CSRF?
O Go 1.25 acrescentou o http.CrossOriginProtection. Rejeita pedidos cross-origin não seguros feitos por navegadores, verificando o header Sec-Fetch-Site ou, quando esse header não existe, comparando o host do header Origin com o Host. GET, HEAD e OPTIONS passam sempre. Não precisa de tokens, por isso funciona sem mexer nos templates. Faz falta em qualquer rota autenticada por cookies. Os navegadores nunca juntam um bearer token por iniciativa própria, por isso uma API que usa apenas tokens não está exposta a CSRF.
O newServer acima mostra a configuração completa: http.NewCrossOriginProtection(), AddTrustedOrigin para um front end de administração separado e csrf.Handler na cadeia global. Os pedidos sem nenhum dos dois headers são tratados como tráfego da mesma origem ou que não vem de um navegador, e são permitidos, que é o que queres para chamadas entre servidores.
Isto não é CORS. Os headers CORS decidem que outras origens podem ler as tuas respostas, e o CrossOriginProtection não define nenhum. Se uma single-page app noutro domínio chamar a tua API, continuas a precisar de um pequeno middleware CORS com uma lista explícita de origens permitidas. Esse middleware deve ficar perto do topo da cadeia, porque os navegadores enviam os pedidos OPTIONS de preflight sem credenciais. Acrescenta também a mesma origem com AddTrustedOrigin. Um front end em app.example.com que chama api.example.com envia Sec-Fetch-Site: same-site, e o CrossOriginProtection rejeita as suas escritas mesmo quando o CORS as permite.
Testar middleware em Go com httptest
Testa cada middleware isoladamente com o httptest, usando um handler minúsculo como next para registar se foi executado. O staticTokens é uma implementação falsa de TokenVerifier baseada num 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") } }
Faz as asserções sobre rec.Result(), não sobre rec.Header(). O Header() devolve o map vivo que os handlers alteram, e a documentação aponta para o Result "to test the headers that were written after a handler completes" (httptest). O Result().Header é uma cópia feita no momento da primeira escrita, que é o que um cliente real recebe. Este middleware tem um bug que só o Result expõe:
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") devolve um valor, por isso um teste baseado nele passa. rec.Result().Header.Get("Server-Timing") devolve uma string vazia, porque o handler já tinha escrito a resposta e um cliente real nunca veria o header.
Mais três testes cobrem as correções desta pilha. Para o método Unwrap, executa através do logRequests um handler que chama http.NewResponseController(w).Flush() e verifica o rec.Flushed. Para os dois casos de recuperação, embrulha um handler que lança um panic com http.ErrAbortHandler e outro que escreve um body parcial antes do panic, faz recover no teste e verifica que o valor é http.ErrAbortHandler. Para o wrapper do slog, chama .With(...) no logger e confirma que o ID de pedido continua a aparecer. Quando escreveres estes testes, apaga a linha que cada um protege e verifica que o teste falha. Se continuar a passar, não está a testar essa correção.
Erros comuns no middleware em Go
| Erro | O que corre mal | Correção |
|---|---|---|
| Recuperação por fora do logging | Os panics ficam registados como 200 e sem ID de pedido | Ordem: ID de pedido, logging, recuperação |
Wrapper sem Unwrap | Flush e SetWriteDeadline falham com ErrNotSupported | Acrescentar Unwrap() http.ResponseWriter |
Engolir o http.ErrAbortHandler | Os abortos do proxy tornam-se stack traces e 500 partidos | Voltar a lançar o panic |
| Escrever um 500 depois de o status ter sido enviado | Um body truncado chega como um 200 | Abortar com http.ErrAbortHandler |
| Chaves de context do tipo string | Colisões entre pacotes, staticcheck SA1029 | Chave struct não exportada e um acessor tipado |
Definir headers depois de next.ServeHTTP | O header é descartado sem aviso | Definir os headers antes de chamar o next |
Nenhum return depois de uma resposta de erro | O handler protegido corre na mesma | Retornar logo depois de escrever o erro |
Nenhum ReadHeaderTimeout | Clientes lentos mantêm ligações abertas, gosec G112 | Defini-lo em todos os http.Server |
X-Forwarded-For mais à esquerda como chave do rate limit | Os clientes escolhem o seu próprio bucket | Usar o endereço que o teu proxy acrescentou |
| Map do rate limiter sem limpeza | A memória cresce com cada endereço de cliente | Remover as entradas inativas com um ticker |
O princípio por trás da ordem
A regra da ordem vale muito para além desta pilha. Uma camada só consegue observar o que acontece dentro dela, por isso põe as camadas que registam resultados por fora das camadas que os alteram. Num servidor HTTP, os access logs, as métricas e os spans de tracing ficam por fora da recuperação, da autenticação e dos limites, para registarem o que o cliente recebeu de facto. Em gRPC, um interceptor de logging deve ficar por fora do interceptor que converte panics em códigos de status. Um wrapper de métricas à volta de uma transação de base de dados deve embrulhar o ciclo de retry, senão conta uma chamada quando a base de dados viu três.
Onde entra o LevelUpGo
O LevelUpGo ensina estes padrões com exercícios que executam código Go real no navegador. Em HTTP & Networking, escreves middleware de logging, de encadeamento e de autenticação sobre o net/http. O Real-World Patterns cobre o padrão middleware, a recuperação de panics e as cadeias. O Logging with slog aprofunda o logging de produção que está por trás do teu middleware de access log.
Perguntas frequentes
O que é middleware em Go?
Em Go, um middleware é uma função com a assinatura func(http.Handler) http.Handler. Embrulha um handler para executar código antes ou depois dele, para logging, autenticação, recuperação ou limites. Como recebe e devolve a interface padrão http.Handler, o middleware de qualquer pacote compõe-se com qualquer router que aceite http.Handler, incluindo o http.ServeMux.
Preciso de um framework ou de um router como o chi para ter middleware em Go?
Não. Desde o Go 1.22, o http.ServeMux suporta correspondência de métodos e wildcards no caminho como /orders/{id}, e um middleware é apenas uma função que embrulha um handler. Uma função auxiliar Chain de dez linhas cobre pilhas globais e por rota. Routers como o chi acrescentam conveniências como grupos de rotas, e o middleware deles usa a mesma assinatura, por isso podes mudar mais tarde sem o reescrever.
Por que ordem deve correr o middleware em Go?
Primeiro o ID de pedido, depois o access logging, depois a recuperação de panics e a seguir as rejeições baratas, como as verificações cross-origin, os rate limits e os limites de body. Põe a autenticação e as deadlines por rota no fim. O logging tem de ficar por fora da recuperação, senão os panics ficam registados com status 200 e sem ID de pedido.
Como passo valores do middleware para os handlers em Go?
Usa context.WithValue com um tipo de chave não exportado, como type principalKey struct{}, e dá aos handlers um acessor tipado como PrincipalFrom(ctx) (Principal, bool). Nunca uses uma string como chave, porque dois pacotes que escolham a mesma string escrevem por cima dos valores um do outro.
Como se testa middleware HTTP em Go?
Embrulha um handler de teste com o middleware, chama o ServeHTTP com um httptest.NewRecorder() e um httptest.NewRequest e faz as asserções sobre rec.Result(). Verifica o status, os headers que o cliente receberia e se o handler de teste foi executado. Para o middleware de recuperação, faz recover no teste e compara o valor com http.ErrAbortHandler.
Fontes
- Documentação do pacote net/http (Handler, ErrAbortHandler, ResponseController, TimeoutHandler, MaxBytesReader, CrossOriginProtection, Server)
- net/http/httptest: ResponseRecorder
- context: WithValue
- Documentação do pacote log/slog
- golang.org/x/time/rate
- Notas de lançamento do Go 1.19 (MaxBytesError)
- Notas de lançamento do Go 1.20 (ResponseController)
- Notas de lançamento do Go 1.21 (log/slog)
- Notas de lançamento do Go 1.22 (padrões do ServeMux)
- Notas de lançamento do Go 1.24 (crypto/rand.Text)
- Notas de lançamento do Go 1.25 (CrossOriginProtection)
- Notas de lançamento do Go 1.26 (errors.AsType)
- MDN: X-Forwarded-For
- Regras do gosec: G112, ReadHeaderTimeout não configurado
