Skip to content

proposal: net/http: context based graceful server shutdown #52805

Closed
@marwan-at-work

Description

@marwan-at-work

Proposal

I propose adding two new methods to the net/http *Server struct: ListenAndServeContext and ListenAndServeTLSContext that will handle gracefully shutting down the server upon a context cancellation signal.

To accompany these two methods, we need a graceful shutdown timeout which can either live as a field in the Server struct, or an argument to the new methods.

The signature can either be:

// ListenAndServeContext is like ListenAndServer but will call s.Shutdown() when the given
// context sends a cancellation signal. If the ShutdownTimeout field is set, it will be used
// as timeout duration for the Shutdown method.
func (s *Server) ListenAndServeContext(ctx context.Context) error
func (s *Server) ListenAndServeTLSContext(ctx context.Context, certFile, keyFile string) error

type Server struct {
  ...
  ShutdownTimeout time.Duration
  ...
}

OR

func (s *Server) ListenAndServeContext(ctx context.Context, timeout time.Duration) error
func (s *Server) ListenAndServeTLSContext(ctx context.Context, timeout time.Duration, certFile, keyFile string) error

It might also be worth noting that there are two more methods (Serve, and ServeTLS) that can arguably take a context as well. I rarely ever encounter explicit net.Listeners being passed in when starting an http server so I'll leave that up for discussion.

The examples below will assume option 2 above as it makes it more explicit for the user to pass a Shutdown timeout instead of it being tucked away as a struct field.

Code Examples

The code from the caller's perspective will look something like this:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

server := &http.Server{
  Addr: addr,
  Handler: handler,
}

if err := server.ListenAndServeContext(ctx, time.Second); err != nil {
  log.Fatal(err)
}

Implementation

The underlying implementation would abstract the subtle details to handle graceful shutdowns:

func (s *Server) ListenAndServe(ctx context.Context, shutdownTimeout time.Duration) error {
  return s.ListenAndServeTLS(ctx, shutdownTimeout, "", "")
}

func (s *Server) ListenAndServeTLS(ctx context.Context, shutdownTimeout time.Time, certFile, keyFile string) error {
	serverErr := make(chan error, 1)
	go func() {
		// Capture ListenAndServe errors such as "port already in use".
		// However, when a server is gracefully shutdown, it is safe to ignore errors
		// returned from this method (given the select logic below), because
		// Shutdown causes ListenAndServe to always return http.ErrServerClosed.
		if certFile != "" && keyFile != "" {
			serverErr <- s.ListenAndServeTLS(certFile, keyFile)
		} else {
			serverErr <- s.ListenAndServe()
		}
	}()
	var err error
	select {
	case <-ctx.Done():
		ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
		defer cancel()
		err = s.Shutdown(ctx)
	case err = <-serverErr:
	}
	return err
}

Notably, the implementation above would return a nil error when a server is gracefully and successfully shutdown without hitting the timeout. We could also explicitly return the ctx.Error() or a new error variable if we never want to return a nil error. But I think a nil error is a fine signal for saying a server was successfully terminated.

Finally, there's a question of what a 0 duration would mean:

  1. Either it means no graceful shutdown since the context being passed to s.Shutdown will immediately be cancelled.
  2. Or it means wait forever such as the equivalent of s.Shutdown(context.Background().

The first option seems more rational though the second might be nice for convenience but it would have to be explicitly documented.

Rationale

Many people today run Go servers while not implementing graceful shutdown in the first place (for example calling the global http.ListenAndServe function) or implementing it but missing edge cases such as the following:

  1. Misusing shutdown.
go func() {
  if err := srv.ListenAndServer(); err != nil {
    log.Fatal(err) // will immediately log http.ErrServerClosed and exit.
  }
}()
  1. Ignoring legitimate ListenAndServe errors:
go func() { srv.ListenAndServe() }()

And probably other subtleties. I have myself made those mistakes many items and ended up abstracting the logic into its own context: https://github.com/marwan-at-work/serverctx/blob/main/serverctx.go

Looking at private code within my company I also noticed each team does a flavor of the above which seems like a good case for code re-use.

Furthermore, looking at the standard library we already have similar patterns such as exec.Command and exec.CommandContext which could make this a natural fit for Go developers already familiar with these APIs.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions