Middleware and graceful shutdown
Wrap every request with logging, panic recovery, and auth. Then stop the server without dropping anyone.
- Write a middleware that wraps an
http.Handlerfor logging or panic recovery. - Explain when to panic and when to return an error.
- Build a server that shuts down gracefully on SIGINT without dropping in-flight requests.
What middleware is, and what graceful shutdown means
Middleware is code that runs around every request rather than inside one handler. Logging, catching panics, checking a token, limiting how often somebody can call you: none of that belongs to a single route, so it wraps all of them.
In Go there is no middleware system to learn. A middleware is a function that takes a handler and returns a handler. You compose them by nesting one inside another.
Graceful shutdown is what happens when your server is told to stop. The default is to die instantly and drop every request that was in progress. Graceful shutdown means: stop accepting new connections, let the ones already running finish, then exit. Every deployment restarts your service, so this code runs on every release.
Middleware is a function that wraps a handler
A small Chain helper saves you from writing Recover(RequestID(Logger(mux))) by hand. The order matters: the outermost wrapper runs first on the way in and last on the way out, so Recover goes first to catch panics from everything inside it.
type Middleware func(http.Handler) http.Handlerfunc Chain(h http.Handler, ms ...Middleware) http.Handler { for i := len(ms) - 1; i >= 0; i-- { // apply in reverse so the first listed runs first h = ms[i](h) } return h}handler := Chain(mux, Recover, RequestID, Logger)Logging
func Logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) // everything before this runs on the way in, slog.Info("request", // everything after, on the way out "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds(), ) })}Panic recovery
net/http already recovers a panic in a handler, but only to print the stack to the server's error log and close the connection. The client gets no response at all, and the trace is not in your structured logs. Recover it yourself, log it with slog, and return a clean 500.
func Recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { slog.Error("panic", "err", rec, "stack", string(debug.Stack())) http.Error(w, "internal server error", 500) } }() next.ServeHTTP(w, r) })}defer recover().Panic versus error, settled
- Panic means "this program’s assumptions are broken". A nil dependency at startup, an impossible switch branch, a
regexp.MustCompileon a literal. - Everything a user can cause is an error. A bad request, a missing row, a network failure, a validation failure. Never panic on those.
Graceful shutdown
Deploy tools stop a process with SIGTERM, then kill it with SIGKILL if it is still running after a grace period (30 seconds by default in Kubernetes). Your drain timeout must fit inside that window, which is why the example uses 20 seconds.
errCh := make(chan error, 1)go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err }}()quit := make(chan os.Signal, 1) // buffered, or the signal is droppedsignal.Notify(quit, os.Interrupt, syscall.SIGTERM)select {case err := <-errCh: return errcase <-quit: slog.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() return srv.Shutdown(ctx) // stop accepting, drain what is in flight}- Middleware is
func(http.Handler) http.Handler. Compose by nesting; no framework required. - Recover panics at the edge, log the stack, return a clean 500. Panic is for broken assumptions only.
- A panic inside a goroutine you started is not recovered by the parent. It kills the process.
srv.Shutdown(ctx)drains in-flight requests. Buffer the signal channel.
Safe(next http.Handler) http.Handler. It adds the header X-Content-Type-Options: nosniff to every response, and if next panics it recovers and replies 500 with the body internal server error. A handler that does not panic must keep its own status and body. Set the header before calling next: once a handler has written, headers can no longer change. The hidden tests use httptest.NewRecorder.Tab indents. Press Esc, then Tab, to leave the editor.
- 1Write a Logger middleware that also records the status code — you will need a small
ResponseWriterwrapper. - 2Add a route that panics. Confirm the recover middleware returns JSON and the server stays up.
- 3Now panic inside a goroutine you start in that handler, and watch the whole process die.
- 4Send SIGTERM during a slow request and confirm the request finishes before the process exits.