Zurück zum Blog

Go (Golang) Middleware: Best Practices für die Produktion

Best Practices für Go-Middleware: die Reihenfolge gegen Panics mit Status 200 im Log, ResponseWriter-Wrapper mit Unwrap, Request-IDs, Timeouts und Auth.

Go (Golang) Middleware: Best Practices für die Produktion

Go-Middleware ist eine Funktion, die einen http.Handler entgegennimmt und einen neuen zurückgibt: func(http.Handler) http.Handler. Der zurückgegebene Handler erledigt seine Arbeit vor oder nach dem Aufruf des Handlers, den er wrappt. Mehr steckt nicht hinter dem Muster. Du brauchst dafür kein Framework, und seit Go 1.22 auch keinen Router von Drittanbietern mehr, weil http.ServeMux Methoden und Wildcards im Pfad matcht (Go 1.22 Release Notes).

Eine Middleware ist in ein paar Zeilen geschrieben. In der Produktion geht es bei den Best Practices um die Details drumherum: die Reihenfolge im Stack, einen ResponseWriter-Wrapper, der Streaming nicht kaputt macht, eine Panic-Recovery, die Fehler korrekt meldet, und Context-Werte, die nicht kollidieren können. Dieser Guide baut den Middleware-Stack für eine API für Bestellungen und Zahlungen Schicht für Schicht auf. Jedes Snippet kompiliert mit Go 1.27, und die Tests laufen mit go test -race durch.

Kurzfassung

  • Schreib Middleware als func(http.Handler) http.Handler und verkette sie mit einem Helper von zehn Zeilen. Globale Middleware wrappt den Mux. Middleware pro Route wrappt den Handler, den du registrierst.
  • Die Reihenfolge zählt. Setz Request-ID und Logging außerhalb der Panic-Recovery, damit die Log-Zeile den 500er festhält, den die Recovery schreibt, und die Request-ID enthält.
  • Ein ResponseWriter-Wrapper braucht eine Methode Unwrap() http.ResponseWriter. Ohne sie kann http.ResponseController durch deine Middleware hindurch weder flushen noch Deadlines setzen.
  • Die Recovery muss bei http.ErrAbortHandler erneut eine Panic auslösen, und sie muss abbrechen, statt einen 500er zu schreiben, wenn der Handler schon einen Status gesendet hat.
  • Setz ReadHeaderTimeout auf dem Server, gib jeder Route eine Context-Deadline und begrenze Bodys mit http.MaxBytesReader.
  • Auth-Middleware legt den Principal in den Context. Sie gibt 401 zurück, wenn der Aufrufer unbekannt ist, und 403, wenn ihm die Berechtigung fehlt.
  • Verwende als Schlüssel für Rate Limits eine IP, die dein eigener Proxy hinzugefügt hat, nicht den Wert ganz links in X-Forwarded-For.

Wie sieht HTTP-Middleware in Go aus?

Eine Middleware bekommt den nächsten Handler und gibt einen Handler zurück, der ihn aufruft. Mit einem benannten Typ lassen sich Ketten leichter lesen:

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 wendet die Liste rückwärts an. Der erste Eintrag landet also ganz außen und sieht den Request als Erster.

Innerhalb jeder Middleware macht http.HandlerFunc aus einer Closure einen Handler. Diese Umwandlung funktioniert, weil HandlerFunc ein Funktionstyp mit einer Methode ServeHTTP ist. Das behandelt das Go-Keyword func zusammen mit Closures. Der Typ Middleware selbst ist nichts weiter als http.Handler rein und raus, und das Go-Keyword interface erklärt, warum sich ein Interface mit einer einzigen Methode wie http.Handler so gut kombinieren lässt.

Globale Middleware vs. Middleware pro Route auf http.ServeMux

ServeMux hat keine Methode Use. Wrapp den ganzen Mux für Middleware, die jeder Request braucht, und einzelne Handler für den Rest:

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
}

Die öffentliche Bestellliste kommt ohne Authentifizierung aus, für eine einzelne Bestellung braucht es ein gültiges Token, und das Anlegen einer Zahlung verlangt zusätzlich einen Scope und bekommt ein Budget von fünf Sekunden. Weil die Kette pro Route Teil der Routenregistrierung ist, steht auf einer Bildschirmseite, wer was aufrufen darf.

Halte dieses Setup an einer Stelle zusammen, etwa in cmd/orders/server.go oder in einem Package internal/httpapi. Go-Projektstruktur zeigt, wo dieses Package in einem größeren Layout hingehört.

In welcher Reihenfolge sollte Go-Middleware laufen?

Die erste Middleware in der Kette wrappt alles, was nach ihr kommt. Sie sieht den Request als Erste und die Response als Letzte. Eine äußere Schicht kann also nur beobachten, was innere Schichten tun, und eine innere Schicht sieht nie, was außerhalb von ihr passiert. Eine gute Reihenfolge für eine HTTP-API in der Produktion sieht so aus:

#MiddlewareWarum sie hier steht
1Request-IDJede spätere Schicht kann die ID lesen, auch die Log-Zeile und der Panic-Report.
2Access-LoggingSie wrappt die Recovery und hält deshalb den Status fest, den die Recovery geschrieben hat.
3Panic-RecoverySie fängt Panics aus jeder inneren Schicht und aus dem Handler und macht daraus einen 500er.
4Cross-Origin-SchutzSie lehnt Cross-Site-Schreibzugriffe aus dem Browser ab, bevor sie Arbeit verursachen.
5Rate Limit pro Client-IPSie wirft missbräuchlichen Traffic raus, bevor die Auth für einen Token-Lookup bezahlt.
6Body-LimitSie begrenzt jeden Request-Body, bevor ein Handler ihn liest.
7Auth und Scopes, pro RouteNur Routen, die einen Aufrufer brauchen, bezahlen für seine Prüfung.
8Deadline, pro RouteJede Route bekommt ein Zeitbudget, das zu ihrer Arbeit passt.

Viele Guides setzen die Recovery an die erste Stelle, damit sie alles abfängt. So sehen die Logs aus, wenn derselbe Handler mit Panic durch beide Reihenfolgen läuft:

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"

In beiden Fällen bekommt der Client einen 500er. Steht die Recovery ganz außen, meldet das Access-Log 200, und die Panic-Zeile hat keine Request-ID, über die du sie dem Request zuordnen könntest. Ungeschützt bleiben bei der inneren Recovery nur die Middlewares für Request-ID und Logging, und die sind klein. Außerdem fängt der net/http-Server jede Panic ab, die aus deinem Handler entkommt, und loggt sie, der Prozess stürzt also nicht ab (net/http Handler-Doku).

http.ResponseWriter wrappen, ohne Flush kaputt zu machen

Fürs Logging brauchst du den Statuscode und die Anzahl der Bytes, und http.ResponseWriter gibt keins von beidem preis. Die übliche Lösung ist ein Wrapper, der beides mitschreibt:

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
}

Das Einbetten von http.ResponseWriter übernimmt nur die drei Methoden dieses Interfaces. Diese Regel behandelt das Go-Keyword struct im Abschnitt über Embedding. Der echte Writer darunter implementiert auch http.Flusher und http.Hijacker, dein Wrapper aber nicht. Ein Handler für Server-Sent Events, der w.(http.Flusher) macht, hört also auf zu streamen, sobald das Logging ihn wrappt.

Mit einer Methode Unwrap ist das behoben. Seit Go 1.20 ruft http.ResponseController sie auf, um den ursprünglichen Writer zu finden. Handler rufen dann http.NewResponseController(w).Flush() auf, statt eine Type Assertion zu machen. Ohne Unwrap gibt dieser Aufruf einen Fehler zurück, der http.ErrNotSupported entspricht, und der Test weiter unten in diesem Guide schlägt fehl.

Dieselbe Methode sorgt dafür, dass Write-Deadlines pro Request funktionieren. Ein CSV-Export kann länger dauern als das WriteTimeout des Servers von 30 Sekunden, das laut Doku Handlern nicht erlaubt, „Entscheidungen pro Request zu treffen“ (http.Server). ResponseController erlaubt es:

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
}

Wie fängt man Panics in Go-Middleware ab?

Eine Recovery-Middleware ruft recover per defer auf, loggt den Stack und schreibt einen 500er. Eine Version für die Produktion braucht zwei weitere Details:

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)
		})
	}
}

Das erste Detail ist http.ErrAbortHandler. Das ist ein Sentinel-Wert, mit dem Code absichtlich eine Panic auslöst, um eine Response abzubrechen, und httputil.ReverseProxy verwendet ihn, wenn das Backend mitten im Kopieren ausfällt. Laut Doku unterdrückt eine Panic mit ErrAbortHandler außerdem, dass ein Stack-Trace ins Error-Log des Servers geschrieben wird (net/http). Verschluckt deine Middleware ihn, schreibt sie bei einem ganz normalen Abbruch im Proxy ein Error-Log mit Stack-Trace und versucht, einen 500er in eine Response zu schreiben, die schon halb gesendet ist.

Das zweite Detail ist die Prüfung sw.status != 0. Sobald ein Handler einen Status geschrieben hat, kann http.Error ihn nicht mehr ändern. net/http loggt „superfluous response.WriteHeader call“ und hängt den Fehlertext an den Body an, der gerade gestreamt wurde. Ein JSON-Array, das mittendrin abbricht und mit internal server error endet, kommt trotzdem als 200 an. Löst du stattdessen erneut eine Panic mit ErrAbortHandler aus, schließt der Server die Verbindung oder setzt den HTTP/2-Stream zurück. Der Client sieht dann einen fehlgeschlagenen Request statt eines kaputten Erfolgs.

Wie reicht man Request-IDs und andere Werte über den Context weiter?

context.WithValue transportiert Request-bezogene Daten von der Middleware zu den Handlern. Laut Doku sollte der Schlüssel nicht vom Typ string oder einem anderen eingebauten Typ sein, um Kollisionen zwischen Packages zu vermeiden, die den Context verwenden (context). Ein nicht exportierter leerer Struct-Typ kann mit nichts kollidieren, weil kein anderes Package ihn benennen kann:

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, eingeführt in Go 1.24, gibt einen zufälligen Base32-String mit 26 Zeichen zurück, und das reicht für eine ID locker. Dasselbe Muster transportiert weiter unten in diesem Guide den authentifizierten User. Beschränke Context-Werte auf Daten, die zum Request gehören, etwa IDs und den Aufrufer. Die Context-Doku rät davon ab, Werte zu verwenden, um „optionale Parameter an Funktionen zu übergeben“. Ein Datenbank-Handle oder ein Client für Feature Flags gehört in ein Struct-Feld deines Handlers.

Strukturiertes Logging mit slog

Die Request-ID nützt nur etwas, wenn jede Log-Zeile während des Requests sie enthält. Statt sie an jeden Aufruf zu übergeben, wrappst du den slog.Handler, sodass er die ID aus dem Context liest, den InfoContext und die verwandten Methoden bekommen:

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)}
}

Bau den Logger mit slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). Die beiden With-Methoden sind wichtig. Durch das Einbetten wird WithAttrs des inneren Handlers übernommen, und das gibt den inneren Handler ohne deinen Wrapper zurück. Schon das erste logger.With("service", "orders") würde dann die Request-IDs aus jeder folgenden Zeile entfernen.

Die Access-Log-Middleware verwendet den statusWriter von oben und loggt in einem defer. So schreibt sie auch dann eine Zeile, wenn eine Panic durch sie hindurchläuft:

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)
		})
	}
}

Ein Handler, der nichts schreibt, bekommt von net/http eine 200. Deshalb wird ein Status von null als 200 geloggt.

Timeouts und Body-Limits in Go-Middleware

Die Timeouts des Servers schützen Verbindungen, Middleware schützt einzelne Requests.

Setz auf dem Server immer ReadHeaderTimeout. Steht der Wert auf null, fällt er auf ReadTimeout zurück, und wenn auch das null ist, gibt es überhaupt kein Limit. Ein Client kann dann Verbindungen öffnen und Header Byte für Byte schicken, so lange er will. gosec meldet einen fehlenden Wert als G112, einen möglichen Slowloris-Angriff (gosec-Regeln). ReadTimeout, WriteTimeout und IdleTimeout begrenzen den Rest der Lebensdauer einer Verbindung, wie in der Funktion newServer oben.

Für das eigene Budget eines Requests hängst du eine Deadline an seinen Context:

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))
		})
	}
}

Das funktioniert nur, wenn der Handler r.Context() an die Datenbank, an HTTP-Clients und an alles andere weitergibt, was blockieren kann. Die Deadline bricht die Arbeit ab, und der Handler entscheidet, was er schreibt.

http.TimeoutHandler ist die andere Option aus der Standardbibliothek. Er schreibt einen 503er, wenn der Handler zu lange braucht, und lässt spätere Schreibversuche des Handlers mit http.ErrHandlerTimeout scheitern. Außerdem puffert er die gesamte Response und unterstützt laut Doku die Interfaces Hijacker und Flusher nicht (net/http). Die Goroutine des Handlers läuft weiter, bis sie zurückkehrt, der Handler muss seinen Context also trotzdem beachten. Nimm TimeoutHandler für kleine JSON-Endpunkte, bei denen ein garantierter 503er das Puffern wert ist. Nimm die Context-Deadline für alles, was streamt.

Body-Limits schützen davor, dass ein Client ein Gigabyte an einen Endpunkt schickt, der ein kleines JSON-Objekt erwartet:

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 lehnt nichts im Voraus ab. Ein Lesezugriff über das Limit hinaus gibt einen *http.MaxBytesError zurück (Go 1.19), und der Handler macht daraus einen 413er:

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 kam mit Go 1.26. In älteren Versionen deklarierst du var tooBig *http.MaxBytesError und rufst errors.As(err, &tooBig) auf. Häufige Go-Fehler, die du vermeiden solltest erklärt, warum man Fehler über ihren Typ statt über ihre Meldung erkennen sollte.

Wie sollte Authentifizierungs-Middleware funktionieren?

Auth-Middleware prüft den Aufrufer, legt im Context ab, wer er ist, und reicht den Request weiter. Einer Identität, die der Client direkt mitgeschickt hat, vertraut sie nie:

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)
		})
	}
}

In diesem Code stecken drei Regeln. Ein 401 bedeutet „Ich weiß nicht, wer du bist“ und hat einen WWW-Authenticate-Header. Ein 403 bedeutet „Ich weiß, wer du bist, und die Antwort ist nein“. Jede Ablehnung endet mit return. Eine Middleware, die einen Fehler schreibt und danach trotzdem next aufruft, führt den Zahlungs-Handler für einen nicht authentifizierten Aufrufer aus.

TokenVerifier ist ein kleines Interface, das dort definiert ist, wo es verwendet wird. Ein Test kann also ein Fake auf Basis einer Map übergeben, während die Produktion eine Implementierung mit JWT oder Session-Store übergibt. Header wie X-User-ID von einem API-Gateway sind nur sicher, wenn das Gateway der einzige Weg zu deinem Service ist. Ist der Service direkt erreichbar, kann jeder diesen Header schicken.

Wie setzt man Rate Limiting in Go-Middleware um?

golang.org/x/time/rate stellt einen Token Bucket bereit. rate.NewLimiter(10, 20) erlaubt 10 Ereignisse pro Sekunde mit Bursts von bis zu 20, und Allow meldet, ob das aktuelle Ereignis noch hineinpasst (x/time/rate). Ein Limiter pro Client hält einen Bucket pro Schlüssel und vergisst inaktive Clients:

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 muss die Map anlegen, weil das erste allow sonst mit einer Panic auf einer nil-Map scheitern würde. Ohne Cleanup bekommt die Map für jede Adresse, die sich je verbindet, einen Eintrag und schrumpft nie. Starte es einmal mit go limiter.Cleanup(ctx, 10*time.Minute) und brich ctx beim Herunterfahren ab.

Schwieriger ist die Wahl des Schlüssels. Hinter einem Load Balancer ist r.RemoteAddr die Adresse des Balancers, und alle Clients teilen sich einen Bucket. Naheliegend wäre die erste Adresse in X-Forwarded-For, aber die bestimmt der Client. MDN stellt klar: Wenn der Server direkt aus dem Internet erreichbar ist, kann kein Teil der IP-Liste in X-Forwarded-For als vertrauenswürdig oder sicher für sicherheitsrelevante Zwecke gelten (MDN). Nimm die Adresse, die dein eigener Proxy angehängt hat, und zähl dafür von rechts so viele Einträge ab, wie vertrauenswürdige Proxys vor dir stehen. Stell sicher, dass der Service nicht an ihnen vorbei erreichbar ist.

Außerdem lebt dieser Limiter in einem einzigen Prozess. Bei vier Replicas bekommt ein Client das Vierfache des Limits. Für ein hartes globales Limit setzt du es am Gateway oder in einem gemeinsamen Store wie Redis durch.

Wie schützt man Go-Handler vor CSRF?

Go 1.25 hat http.CrossOriginProtection eingeführt. Es lehnt nicht sichere Cross-Origin-Requests aus dem Browser ab. Dazu prüft es den Header Sec-Fetch-Site oder vergleicht, falls dieser fehlt, den Host aus dem Origin-Header mit Host. GET, HEAD und OPTIONS kommen immer durch. Es braucht keine Tokens und funktioniert deshalb ohne Änderungen an Templates. Relevant ist es für jede Route, die mit Cookies authentifiziert. Browser hängen ein Bearer-Token nie von selbst an, eine reine Token-API ist also nicht anfällig für CSRF.

newServer oben zeigt das komplette Setup: http.NewCrossOriginProtection(), AddTrustedOrigin für ein separates Admin-Frontend und csrf.Handler in der globalen Kette. Requests ohne beide Header gelten als Same-Origin- oder Nicht-Browser-Traffic und werden durchgelassen. Das willst du auch für Aufrufe von Server zu Server.

Das ist kein CORS. CORS-Header entscheiden, welche anderen Origins deine Responses lesen dürfen, und CrossOriginProtection setzt keinen davon. Wenn eine Single-Page-App auf einer anderen Domain deine API aufruft, brauchst du trotzdem eine kleine CORS-Middleware mit einer expliziten Allowlist für Origins. Sie gehört weit nach oben in die Kette, weil Browser Preflight-Requests mit OPTIONS ohne Credentials schicken. Trag denselben Origin außerdem mit AddTrustedOrigin ein. Ein Frontend auf app.example.com, das api.example.com aufruft, schickt Sec-Fetch-Site: same-site, und CrossOriginProtection lehnt seine Schreibzugriffe ab, selbst wenn CORS sie erlaubt.

Go-Middleware mit httptest testen

Teste jede Middleware einzeln mit httptest und verwende als next einen winzigen Handler, der festhält, ob er gelaufen ist. staticTokens ist ein TokenVerifier-Fake auf Basis einer 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")
	}
}

Prüfe gegen rec.Result(), nicht gegen rec.Header(). Header() gibt die lebende Map zurück, die Handler verändern, und die Doku verweist auf Result, um die Header zu testen, die geschrieben wurden, nachdem ein Handler fertig ist (httptest). Result().Header ist ein Snapshot vom Zeitpunkt des ersten Schreibens und entspricht damit dem, was ein echter Client bekommt. Diese Middleware hat einen Bug, den nur Result aufdeckt:

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") gibt einen Wert zurück, ein Test darauf läuft also durch. rec.Result().Header.Get("Server-Timing") gibt einen leeren String zurück, weil der Handler die Response schon geschrieben hatte und ein echter Client den Header nie sehen würde.

Drei weitere Tests decken die Fixes in diesem Stack ab. Für die Methode Unwrap lässt du einen Handler, der http.NewResponseController(w).Flush() aufruft, durch logRequests laufen und prüfst rec.Flushed. Für beide Recovery-Fälle wrappst du einen Handler, der mit http.ErrAbortHandler eine Panic auslöst, und einen, der vor der Panic einen Teil des Bodys schreibt. Dann rufst du im Test recover auf und prüfst, ob der Wert http.ErrAbortHandler ist. Für den slog-Wrapper rufst du .With(...) auf dem Logger auf und prüfst, ob die Request-ID weiterhin erscheint. Wenn du diese Tests schreibst, lösch jeweils die Zeile, die der Test absichert, und schau, ob er fehlschlägt. Läuft er trotzdem durch, testet er diesen Fix nicht.

Häufige Fehler bei Go-Middleware

FehlerWas schiefgehtLösung
Recovery außerhalb des LoggingsPanics werden als 200 ohne Request-ID geloggtReihenfolge: Request-ID, Logging, Recovery
Wrapper ohne UnwrapFlush und SetWriteDeadline scheitern mit ErrNotSupportedUnwrap() http.ResponseWriter ergänzen
http.ErrAbortHandler verschluckenAbbrüche im Proxy werden zu Stack-Traces und kaputten 500ernErneut eine Panic damit auslösen
500er schreiben, nachdem der Status gesendet wurdeEin abgeschnittener Body kommt als 200 anMit http.ErrAbortHandler abbrechen
Strings als Context-SchlüsselKollisionen zwischen Packages, staticcheck SA1029Nicht exportierter Struct-Schlüssel und ein typisierter Accessor
Header nach next.ServeHTTP setzenDer Header geht stillschweigend verlorenHeader vor dem Aufruf von next setzen
Kein return nach einer Fehler-ResponseDer geschützte Handler läuft trotzdemDirekt nach dem Schreiben des Fehlers zurückkehren
Kein ReadHeaderTimeoutLangsame Clients halten Verbindungen offen, gosec G112Auf jedem http.Server setzen
Wert ganz links in X-Forwarded-For als Rate-Limit-SchlüsselClients wählen ihren Bucket selbstDie Adresse verwenden, die dein Proxy angehängt hat
Map des Rate Limiters ohne CleanupDer Speicher wächst mit jeder Client-AdresseInaktive Einträge per Ticker entfernen

Das Prinzip hinter der Reihenfolge

Die Regel zur Reihenfolge gilt weit über diesen Stack hinaus. Eine Schicht sieht nur, was in ihr passiert. Setz die Schichten, die Ergebnisse festhalten, deshalb außerhalb der Schichten, die sie verändern. In einem HTTP-Server liegen Access-Logs, Metriken und Trace-Spans außerhalb von Recovery, Auth und Limits, damit sie festhalten, was der Client tatsächlich bekommen hat. In gRPC gehört ein Logging-Interceptor außerhalb des Interceptors, der Panics in Statuscodes umwandelt. Ein Metrik-Wrapper um eine Datenbanktransaktion sollte die Retry-Schleife wrappen, sonst zählt er einen Aufruf, wo die Datenbank drei gesehen hat.

Wo LevelUpGo ins Spiel kommt

LevelUpGo bringt dir diese Muster mit Übungen bei, die echten Go-Code im Browser ausführen. In HTTP & Networking schreibst du Middleware für Logging, Verkettung und Authentifizierung auf Basis von net/http. Real-World Patterns behandelt das Middleware-Muster, Panic-Recovery und Ketten. Logging with slog geht tiefer in das Logging für die Produktion, das hinter deiner Access-Log-Middleware steckt.

Häufig gestellte Fragen

Was ist Middleware in Go?

Middleware ist in Go eine Funktion mit der Signatur func(http.Handler) http.Handler. Sie wrappt einen Handler, um Code davor oder danach auszuführen, etwa für Logging, Authentifizierung, Recovery oder Limits. Weil sie das Standard-Interface http.Handler entgegennimmt und zurückgibt, lässt sich Middleware aus jedem Package mit jedem Router kombinieren, der http.Handler akzeptiert, auch mit http.ServeMux.

Brauche ich für Middleware in Go ein Framework oder einen Router wie chi?

Nein. Seit Go 1.22 unterstützt http.ServeMux das Matchen von Methoden und Wildcards im Pfad wie /orders/{id}, und Middleware ist einfach eine Funktion, die einen Handler wrappt. Ein Chain-Helper mit zehn Zeilen deckt globale Stacks und Stacks pro Route ab. Router wie chi bieten Komfortfunktionen wie Routengruppen, und ihre Middleware verwendet dieselbe Signatur. Du kannst also später wechseln, ohne sie neu zu schreiben.

In welcher Reihenfolge sollte Go-Middleware laufen?

Zuerst die Request-ID, dann das Access-Logging, dann die Panic-Recovery und danach günstige Ablehnungen wie Cross-Origin-Prüfungen, Rate Limits und Body-Limits. Auth und Deadlines pro Route kommen zuletzt. Das Logging muss außerhalb der Recovery liegen, sonst werden Panics mit Status 200 und ohne Request-ID geloggt.

Wie übergebe ich in Go Werte von Middleware an Handler?

Verwende context.WithValue mit einem nicht exportierten Schlüsseltyp wie type principalKey struct{} und gib Handlern einen typisierten Accessor wie PrincipalFrom(ctx) (Principal, bool). Verwende nie einen String als Schlüssel, denn zwei Packages, die denselben String wählen, überschreiben gegenseitig ihre Werte.

Wie testet man HTTP-Middleware in Go?

Wrapp einen Stub-Handler mit der Middleware, ruf ServeHTTP mit einem httptest.NewRecorder() und einem httptest.NewRequest auf und prüfe dann gegen rec.Result(). Kontrolliere den Status, die Header, die der Client bekommen würde, und ob der Stub gelaufen ist. Bei Recovery-Middleware rufst du im Test recover auf und vergleichst den Wert mit http.ErrAbortHandler.

Quellen

Schreib Go wie ein Senior Engineer

Interaktive Lektionen im Browser. Die ersten sind kostenlos.

Kostenlose Lektion testenOder kostenloses Konto erstellen