Dev Skills

Middleware and graceful shutdown

Wrap every request with logging, panic recovery, and auth. Then stop the server without dropping anyone.

Topic 48 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Write a middleware that wraps an http.Handler for 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.

middleware.go
type Middleware func(http.Handler) http.Handler
func 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

logger.go
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(),
)
})
}
To log the status code you need a small wrapper around ResponseWriter that records what WriteHeader was called with. ResponseWriter does not expose it.

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.

recover.go
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)
})
}
Watch out: This recovers panics in the handler’s own goroutine only. A panic inside a goroutine you started yourself is not caught by any parent recover. It kills the whole process. If a goroutine you launch can panic, it needs its own 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.MustCompile on 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.

shutdown.go
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 dropped
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case <-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
}
The signal channel MUST be buffered. signal.Notify never blocks when it sends, so if nobody is receiving yet on an unbuffered channel, the signal is dropped with no error.
The short version
  • 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.
Remember: A handler that wraps another handler is how you extend Go’s HTTP stack, and for an API it covers logging, auth, limits and recovery.
Build this before moving on
Add Logger, Recover, and RequestID middleware to yesterday’s API, plus graceful shutdown. Add a route that panics on purpose. Check you get a 500 and a stack trace in the log, and that the server is still running afterwards.
Coding challenge · checked by tests
Write the middleware 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.

Then try these in your own editor
  1. 1Write a Logger middleware that also records the status code — you will need a small ResponseWriter wrapper.
  2. 2Add a route that panics. Confirm the recover middleware returns JSON and the server stays up.
  3. 3Now panic inside a goroutine you start in that handler, and watch the whole process die.
  4. 4Send SIGTERM during a slow request and confirm the request finishes before the process exits.
Check yourselfTake the Web services test