Skip to content

Expert · Building a REST API with a database · topic 36 of 46 · 35 min

Middleware: logging, recovery and request IDs

Explanation

Middleware is a function that wraps a handler to add behaviour before and after it: logging every request, recovering from panics, adding a request ID, checking auth. In Go its shape is func(next http.Handler) http.Handler. You chain middleware by wrapping one around another; the outermost runs first.

Why middleware?

Some things must happen for every request: log the method, path, status and time taken; catch panics so the client gets a clean 500; attach a request ID so logs from one request can be found together; check a login token. Copying that code into 40 handlers is error-prone. Middleware writes it once and wraps all handlers.

Think of it like this: Middleware is like airport security layers. Your bag (the request) passes the ticket check, then the scanner, then the gate. Each layer can stop you or let you through, and on the way back (the response) the layers can note what happened. The handler is the plane.

The shape

go
func middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before: runs on the way in
		next.ServeHTTP(w, r)
		// after: runs on the way out
	})
}
It takes the next handler and returns a new handler: a closure around next.

Logging with the status code

http.ResponseWriter does not tell you which status the handler wrote. A small wrapper struct embeds the real writer and remembers the code when WriteHeader is called.

go
type statusRecorder struct {
	http.ResponseWriter
	status int
}

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

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", rec.status,
			"duration_ms", time.Since(start).Milliseconds(),
		)
	})
}
Embedding http.ResponseWriter gives statusRecorder all its methods; we only override WriteHeader.

Recovering from panics

go
func recoverPanics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if v := recover(); v != nil {
				slog.Error("panic in handler", "panic", v, "path", r.URL.Path,
					"stack", string(debug.Stack()))
				writeError(w, http.StatusInternalServerError, "internal error")
			}
		}()
		next.ServeHTTP(w, r)
	})
}
net/http would recover anyway, but only by closing the connection. This sends a proper JSON 500 and logs the stack (runtime/debug.Stack).

Request IDs through the context

go
type ctxKey string

const requestIDKey ctxKey = "request_id"

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = newID() // e.g. a random hex string or a UUID
		}
		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 {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}
A request ID is a perfect use of context.WithValue: request-scoped data that crosses API boundaries. Use an unexported key type to avoid clashes.

Chaining

go
api := &API{store: store}
var handler http.Handler = api.routes()
handler = logRequests(handler)
handler = requestID(handler)
handler = recoverPanics(handler) // outermost: runs first, catches panics from everything inside

srv := &http.Server{Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second}
The order matters: the last wrapper applied is the first to see the request.
  • Put recovery outermost so it also catches panics in other middleware.
  • Auth middleware returns early (401) without calling next when the token is invalid.
  • Router libraries such as chi provide ready middleware (logger, recoverer, request ID, timeouts) with the same func(http.Handler) http.Handler shape, so you can mix them with your own.
  • For CORS (letting a browser app on another domain call your API), set the Access-Control-Allow-* headers in a middleware, or use a well-known library; allow only the origins you trust.
Careful: Common mistake: forgetting to call next.ServeHTTP. The request then stops in the middleware and the client gets an empty 200. Another one: writing the response in middleware AND letting the handler write too.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Middleware: logging, recovery and request IDs · 1/7

Middleware

Middleware adds behaviour to every request: logging, panic recovery, request IDs, auth.

Prefer a video by a teacher? Find free videos on “golang http middleware logging recovery chain tutorial” on YouTube ↗

In real life: Tracing a failed food order across logs

A customer complains that order 5531 failed. Support pastes the X-Request-ID shown in the app's error screen into the log search. Because every log line for that request carries the same request_id, the developer sees the whole story in seconds: the request arrived, the restaurant service timed out after 3 s, and the API returned 504.

go
func handler(w http.ResponseWriter, r *http.Request) {
	log := slog.With("request_id", RequestIDFrom(r.Context()))
	log.Info("placing order", "order_id", 5531)
	// ... call restaurant service with r.Context() ...
	log.Warn("restaurant service timeout", "after_ms", 3000)
	writeError(w, http.StatusGatewayTimeout, "restaurant did not respond, please retry")
}

// {"level":"INFO","msg":"placing order","request_id":"7f3a9c","order_id":5531}
// {"level":"WARN","msg":"restaurant service timeout","request_id":"7f3a9c","after_ms":3000}
// {"level":"INFO","msg":"request","method":"POST","path":"/v1/orders","status":504,"duration_ms":3004}
slog.With attaches the request ID once; every following log line includes it.

Practice (0/4 done)

Task 1 · Question

What is the usual type of Go HTTP middleware?

Task 2 · Question

handler = A(B(C(mux))). Which middleware sees the request first?

Task 3 · Question

An auth middleware finds a valid token but the client gets an empty 200 response. Most likely bug?

Task 4 · Practice

Write a requireAPIKey middleware that reads the X-API-Key header and compares it with a key loaded from an environment variable. Return a 401 JSON error if it is missing or wrong, otherwise call next. Test both cases with httptest.

Quick quiz

Question 1

1.Why wrap http.ResponseWriter in a logging middleware?

Question 2

2.Where should panic-recovery middleware go in the chain?

Question 3

3.What is a good use of context.WithValue in middleware?

Question 4

4.Why compare secrets with crypto/subtle.ConstantTimeCompare?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Why wrap http.ResponseWriter in a logging middleware?

Answer: To remember the status code the handler wrote

ResponseWriter has no method to read back the status.

Where should panic-recovery middleware go in the chain?

Answer: Outermost

Outermost catches panics from all inner middleware and handlers.

What is a good use of context.WithValue in middleware?

Answer: Carrying a request ID for this request

Request-scoped data like IDs or the authenticated user.

Why compare secrets with crypto/subtle.ConstantTimeCompare?

Answer: Its timing does not reveal how many characters matched

A normal == can leak information through response timing.

Where should the error-handling middleware be added?Related

Answer: After all routes, at the end

Errors from routes travel down the chain, so the error handler must come after them.

From Middleware: checks before your handler

What does net/http do if one of your handlers panics?Related

Answer: It recovers, logs the panic and closes that connection

The HTTP server recovers handler panics so one request cannot kill the server.

From panic and recover: only for the truly unexpected

What does auth middleware usually do when the session is valid?Related

Answer: Sets req.userId and lets the handler run

Middleware attaches the user id from the session to the request, then calls next() so the route handler runs.

From Protecting pages and API routes

What happens if a middleware does not call next?Related

Answer: The request stops there (short-circuit) and later middleware and endpoints do not run

Short-circuiting is used for things like returning cached responses.

From Configuration, secrets and the middleware pipeline

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 2 more topics in “Building a REST API with a database” before its exam: Databases with database/sql, Configuration, project structure and graceful shutdown.
  2. Module 8 exam: Building a REST API with a database · 14 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, CLI tools and Go in cloud and DevOps. 46 topics left in the course before the final exam.

← Previous: REST API design and JSON handlers