Let's Go Middleware › Panic recovery
Previous · Contents · Next
Chapter 6.4.

Panic recovery

Earlier in the book, I mentioned that Go’s HTTP server handles requests concurrently, with each HTTP request handled in its own separate goroutine. This is important for understanding how runtime panics affect your web application.

Normally, in most Go programs, when there is a runtime panic it will result in the application being terminated.

However, Go’s HTTP server automatically recovers any panics in the goroutines it creates. It assumes that the effect of any panic is isolated to the goroutine serving the active HTTP request, and recovers it so that the panic will not terminate your web application.

More specifically, if there is a panic in your middleware or handler code, the following things will happen:

  1. Normal execution of the code in your middleware or handlers will immediately stop.
  2. Any deferred functions for the current goroutine will be run in reverse (last-in, first-out) order.
  3. The panic will then be recovered by Go’s HTTP server, which will close the underlying HTTP connection.
  4. An error message and stack trace will be output to the server error log (note: we’ll talk about the server error log in more detail later in the book).
  5. No other HTTP requests will be affected by the panic.

So, if a panic does happen in our handlers or middleware, what will the user see?

Let’s take a look and introduce a deliberate panic into our home handler.

File: cmd/web/handlers.go
package main

...

func (app *application) home(w http.ResponseWriter, r *http.Request) {
    panic("oops! something went wrong") // Deliberate panic for testing

    snippets, err := app.snippets.Latest()
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    data := app.newTemplateData(r)
    data.Snippets = snippets

    app.render(w, r, http.StatusOK, "home.tmpl", data)
}

...

Restart your application…

$ go run ./cmd/web
time=2024-03-18T11:29:23.000+00:00 level=INFO msg="starting server" addr=:4000

… and make an HTTP request for the home page from a second terminal window:

$ curl -i http://localhost:4000
curl: (52) Empty reply from server

Unfortunately, all we get is an empty response due to Go closing the underlying HTTP connection following the panic.

This isn’t a great experience for the user. It would be more appropriate and meaningful to send them a proper HTTP response with a 500 Internal Server Error status instead.

A neat way of doing this is to create some additional middleware that recovers the panic ourselves and calls our app.serverError() helper method. To do this, we can leverage the fact that deferred functions in the current goroutine are always called following a panic.

Open up your middleware.go file and add the following code:

File: cmd/web/middleware.go
package main

import (
    "fmt" // New import
    "net/http"
)

...

func (app *application) recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Create a deferred function (which will always be run in the event
        // of a panic).
        defer func() {
            // Use the built-in recover() function to check if a panic occurred.
            // If a panic did happen, recover() will return the panic value. If
            // a panic didn't happen, it will return nil.
            pv := recover()
            
            // If a panic did happen...
            if pv != nil {
                // Set a "Connection: close" header on the response.
                w.Header().Set("Connection", "close")
                // Call the app.serverError helper method to return a 500
                // Internal Server response.
                app.serverError(w, r, fmt.Errorf("%v", pv))
            }
        }()

        next.ServeHTTP(w, r)
    })
}

There are two details about this which need explaining:

Let’s now put this to use in the routes.go file, so that it is the first thing in our chain to be executed (so that it covers panics in all subsequent middleware and handlers).

File: cmd/web/routes.go
package main

import "net/http"

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    fileServer := http.FileServer(http.Dir("./ui/static/"))
    mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))

    mux.HandleFunc("GET /{$}", app.home)
    mux.HandleFunc("GET /snippet/view/{id}", app.snippetView)
    mux.HandleFunc("GET /snippet/create", app.snippetCreate)
    mux.HandleFunc("POST /snippet/create", app.snippetCreatePost)

    // Wrap the existing chain with the recoverPanic middleware.
    return app.recoverPanic(app.logRequest(commonHeaders(mux)))
}

If you restart the application and make a request for the home page now, you should see a nicely formed 500 Internal Server Error response following the panic, including the Connection: close header that we talked about.

$ go run ./cmd/web
time=2024-03-18T11:29:23.000+00:00 level=INFO msg="starting server" addr=:4000
$ curl -i http://localhost:4000
HTTP/1.1 500 Internal Server Error
Connection: close
Content-Security-Policy: default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com
Content-Type: text/plain; charset=utf-8
Referrer-Policy: origin-when-cross-origin
Server: Go
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-Xss-Protection: 0
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 22

Internal Server Error

Before we continue, head back to your home handler and remove the deliberate panic from the code.

File: cmd/web/handlers.go
package main

...

func (app *application) home(w http.ResponseWriter, r *http.Request) {
    snippets, err := app.snippets.Latest()
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    data := app.newTemplateData(r)
    data.Snippets = snippets

    app.render(w, r, http.StatusOK, "home.tmpl", data)
}

...

Additional information

Panic recovery in background goroutines

It’s important to realize that our middleware will only recover panics that happen in the same goroutine that executed the recoverPanic() middleware.

If, for example, you have a handler which spins up another goroutine (e.g. to do some background processing), then any panics that happen in the second goroutine will not be recovered — not by the recoverPanic() middleware… and not by the panic recovery built into the Go HTTP server. They will cause your application to exit and bring down the server.

So, if you are spinning up additional goroutines from within your web application and there is any chance of a panic, you must make sure that you recover any panics from within those too. For example:

func (app *application) myHandler(w http.ResponseWriter, r *http.Request) {
    ...

    // Spin up a new goroutine to do some background processing.
    go func() {
        defer func() {
            pv := recover()
            if pv != nil {
                app.logger.Error(fmt.Errorf("%v", pv))
            }
        }()

        doSomeBackgroundProcessing()
    }()

    w.Write([]byte("OK"))
}