Skip to content
Muhammet Şafak
tr
Asked by: Oğuz Answered:

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

  1. 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 no 500, the log line has no request id, and nothing increments a metric.

  2. recover only catches its own goroutine. The server’s recover covers the goroutine that runs the handler. If you spawn go 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.

  3. A silently swallowed panic is as dangerous as an unrecovered one. Without a metric, the same bug stays hidden for days.

What to do

  1. Wrap each request in defer recover(). Inside the middleware set up defer func(){ if r := recover(); r != nil { /* log + write 500 */ } }().

  2. Put this middleware at the outermost edge of the chain. Recovery must wrap the panics of every handler and middleware that comes after it.

  3. Log the stack + request id, but don’t leak internals. Log the stack trace with debug.Stack() and the request’s id; return a generic 500 to the client.

  4. Re-panic http.ErrAbortHandler. If the recovered value is http.ErrAbortHandler, panic with it again instead of writing a 500. 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.

  5. Make the panic visible — emit a metric. Increment a counter on every recover.

  6. 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.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind