Why add recovery middleware to a Go http.Handler chain if net/http already recovers panics?
answer
- the built-in recover runs too late
- connection layer against request layer
- the client deserves a status code
- method, path and request id are in scope
- capture the stack while unwinding
basics
~20 sThe server's own recovery only saves the process: it drops the connection with no status and logs one context-free line. Middleware recovers inside the request's goroutine, so it can return a real 500, log with the method and path, count the panic, and keep the connection reusable.
solid answer
~50 s`net/http`'s built-in recover runs at the connection layer, after the request is already lost. It closes the connection with no status line and writes one plain-text line to `Server.ErrorLog` that knows the remote address and nothing else — not the route, not the request id, not the caller. Recovery middleware wraps `next.ServeHTTP` in a function with a deferred `recover()`, so it catches the panic in the goroutine that is still serving the request. From there it can write `http.StatusInternalServerError`, log the panic value together with the request fields it already has in scope, capture `debug.Stack()` while the frames are still meaningful, and increment a panic metric that your alerting can actually see. Because the handler then returns normally, the connection is not torn down and stays reusable. It also gives you one place to enforce policy — for example re-panicking on `http.ErrAbortHandler` instead of swallowing it.
code
go · 16 linesfunc Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler {
panic(rec)
}
log.Printf("panic %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
w.WriteHeader(http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
})
}go deeper
Know that the standard server closes the connection without a status, and that a small wrapper around next.ServeHTTP with a deferred recover is what produces a real 500.
Be ready to write the wrapper from memory and explain why the defer must live in the same function that calls next.ServeHTTP, and why the stack must be captured inside it.
Talk about what recovery does not fix: already-flushed responses, other goroutines, fatal runtime errors, and invariants left broken. Show how you route both recovered and unrecovered panics into one log stream with a metric behind it.
Set the contract: what a recovered panic costs, who is paged, and whether swallowing panics quietly is acceptable for handlers your team does not own.
## Two different recoveries, at two different layers There are two places a handler panic can be caught in a Go HTTP server, and they are not equivalent. **The connection layer (built in).** `http.Server` serves each connection in its own goroutine whose first deferred function calls `recover()`. By the time it fires, the stack has unwound past your handler and past every middleware. All it can do is log and close the socket. The client gets no status; the connection is destroyed rather than reused; the log line contains the remote address, the panic value and a stack, and nothing about *which request* was being served. **The handler layer (your middleware).** A wrapper of the form `func(http.Handler) http.Handler` that defers a `recover()` around the call to `next.ServeHTTP` catches the panic *inside* the request's own goroutine, with `w` and `r` still in scope and the handler's frames still on the stack. ## What the middleware buys you 1. **A real status code.** You can call `w.WriteHeader(http.StatusInternalServerError)` so the client, your load balancer and your access log all agree that this was a 5xx, instead of a mysterious connection reset that looks like a network problem. 2. **Request context in the log.** Method, path, matched route, request id, authenticated caller, elapsed time — all available as local variables, so the panic line joins the rest of your structured logs instead of arriving as raw text on stderr. 3. **A usable stack.** `runtime/debug.Stack()` called *inside* the deferred function returns the stack of the panicking goroutine as it stands during unwinding; call it later, from a helper invoked after the fact, and you get a much less useful trace. 4. **Metrics and alerting.** One counter increment, labelled by route, turns panics from an incident-time discovery into a graph you can alert on. 5. **A live connection.** Because your middleware absorbs the panic and the handler returns normally, `net/http` finishes the response and can keep the connection alive. The built-in path always closes it. 6. **One place for policy.** Sentinel values such as `http.ErrAbortHandler` need to be re-panicked rather than converted to a 500, and having a single recoverer means that rule is written once. ## The shape of the wrapper ```go func Recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { if rec == http.ErrAbortHandler { panic(rec) } log.Printf("panic %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack()) w.WriteHeader(http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } ``` The `defer` must be registered in the same function that calls `next.ServeHTTP`, because `recover()` only returns a non-nil value when it is called directly by a function deferred by the goroutine that is panicking. ## Its limits — say these before the interviewer does - **It cannot un-send bytes.** If the handler already wrote and flushed headers, the status is on the wire. Your recoverer can log and stop, but the client's response is already whatever was committed. - **It only covers one goroutine.** A panic in a goroutine the handler started with `go` has no recover on its stack; the runtime kills the entire process. - **It does not catch fatal runtime errors.** Concurrent map writes, the deadlock detector and out-of-memory are throws, not panics. - **It does not repair state.** A mutex locked without a deferred unlock is still locked; a partially applied mutation is still partial. Recovering keeps you serving, it does not make the process healthy. ## Wiring the leftovers into the same place Even with middleware, some panics still reach the connection layer — anything that panics outside your chain, and every `ErrAbortHandler` you deliberately re-panic. Point `Server.ErrorLog` at a logger you control so those lines land with everything else rather than on bare stderr; `slog.NewLogLogger` adapts a structured handler to the `*log.Logger` the field requires. ## The one-line answer The built-in recovery protects the *process*. Middleware protects the *request* — and gives you the status code, the context and the signal you need to find the bug at 3am.
- Why must debug.Stack() be called inside the deferred function rather than in a helper called afterwards?`runtime/debug.Stack()` returns the stack of the goroutine at the moment of the call. Inside the deferred function the panicking frames are still there, so you get the trace that names the failing line. Collect the bytes there and pass them on; if you only pass the recovered value and format the trace later, you record the logger's stack instead of the bug's.
- The handler already wrote a 200 and flushed before panicking. What can the middleware do?Very little about the response — the status and the flushed bytes are already on the wire and cannot be retracted. The middleware can still log, alert, and stop the unwinding, but the honest options are to accept a truncated 200 or to re-panic so the connection is torn down and the client sees a broken response rather than a plausible-looking short one.
- Does recovery middleware make it safe for handlers to panic instead of returning errors?No. It is a backstop for bugs, not a control-flow mechanism. Panicking through a handler skips any cleanup that is not deferred, abandons a request halfway, and hides the failure from ordinary error handling and testing. Expected failures should be returned as values and turned into a status deliberately; panics should stay reserved for genuine programming errors.
saying these in an interview costs you the question
- Says net/http sends a 500 by itself, so middleware is redundant
- Puts one recover in main and calls every handler covered
- Claims the middleware also protects goroutines the handler starts
- Logs the panic value without any stack trace
- Treats panicking as a normal way to return an HTTP error