How do I handle panics in a Go HTTP server with recovery middleware?
Question
In our HTTP API written in Go, an unexpected panic occurred due to a nil pointer dereference. The panic caused the whole process to shut down abruptly and dropped every HTTP request that was in flight at the time. How do I set up a central middleware that uses Go's `recover()` to catch panics, log them and return a graceful 500 to the client?
Answer
Short answer: net/http already recovers a panic in a handler, so one bad request does not kill the process. But no proper response reaches the client: the connection is dropped and the stack trace goes only to the server’s error log, which is stderr by default. A recovery middleware is what gives you a controlled 500, structured logs with a request id and a metric — and it must re-panic http.ErrAbortHandler.
Short answer
The real issue is this: the server’s own recover is a safety net, not error handling. A nil deref in a handler doesn’t take thousands of healthy requests down with it, but the client whose request panicked sees a broken connection instead of an answer, and the only trace is a raw stack on stderr. If your whole process did die, look for a panic outside that safety net: a goroutine started with go, where a panic nobody recovers terminates the program. I cover the details of building the service with the standard library in writing HTTP services in Go.
Why
-
The built-in recover gives you no response and no signal. For a panicking handler the server recovers, logs a stack trace to its error log, and either closes the network connection or sends an HTTP/2
RST_STREAM. The client gets no500, the log line has no request id, and nothing increments a metric. -
recoveronly catches its own goroutine. The server’s recover covers the goroutine that runs the handler. If you spawngo func(){...}()inside a handler, a panic there reaches neither the server’s recover nor your middleware’s — and it terminates the whole process, which is the case that drops every in-flight request. -
A silently swallowed panic is as dangerous as an unrecovered one. Without a metric, the same bug stays hidden for days.
What to do
-
Wrap each request in
defer recover(). Inside the middleware set updefer func(){ if r := recover(); r != nil { /* log + write 500 */ } }(). -
Put this middleware at the outermost edge of the chain. Recovery must wrap the panics of every handler and middleware that comes after it.
-
Log the stack + request id, but don’t leak internals. Log the stack trace with
debug.Stack()and the request’s id; return a generic500to the client. -
Re-panic
http.ErrAbortHandler. If the recovered value ishttp.ErrAbortHandler, panic with it again instead of writing a500. It is the sentinel handlers use to abort a response on purpose, and re-panicking keeps the server’s behavior for it: the client sees an interrupted response and no stack trace is logged. -
Make the panic visible — emit a metric. Increment a counter on every recover.
-
Give every goroutine you spawn its own recover. Otherwise the process still dies.
Bottom line: I’d set up a per-request recover middleware that returns a controlled 500, logs with the request id, counts the panic and re-panics http.ErrAbortHandler — plus a per-goroutine recover. Two caveats: recover is for unexpected bugs (nil deref), not a substitute for returning errors — return expected errors as error. And keep a supervisor that restarts the process anyway.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.