Skip to content

Commit

Permalink
feat: impl http graceful shutdown
Browse files Browse the repository at this point in the history
  • Loading branch information
josestg committed Jun 2, 2023
1 parent 2d303b9 commit b6fe746
Show file tree
Hide file tree
Showing 7 changed files with 476 additions and 1 deletion.
30 changes: 30 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Go

on:
push:
branches: [ "main", "develop" ]
pull_request:
branches: [ "main", "develop" ]

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.18.x', '1.19.x', '1.20.x' ]

steps:
- uses: actions/checkout@v3
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}

- name: Display Go version
run: go version

- name: Install tparse
run: go install github.com/mfridman/tparse@latest

- name: Run Test
run: go test -race -count=1 -timeout 30s -coverprofile=coverage.txt -covermode=atomic -json ./... | tparse -all -format markdown
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@

# Go workspace file
go.work

## Editor
.idea
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,54 @@
# httpgraceful
# httpgraceful

A wrapper for `http.Server` that enables graceful shutdown handling.

## Installation

```shell
go get github.com/pkg-id/httpgraceful
```

## Example

```go
package main

import (
"fmt"
"io"
"net/http"

"github.com/pkg-id/httpgraceful"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "Hello, World")
})

server := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", 8080),
Handler: mux,
}

gs := httpgraceful.WrapServer(server)
if err := gs.ListenAndServe(); err != nil {
panic(err)
}
}
```

When you wrap your server with `httpgraceful.WrapServer`, the `ListenAndServe` method will keep the server running until it receives either the `os.Interrupt` signal or the `syscall.SIGTERM` signal.
At that point, the graceful shutdown process will be initiated, waiting for all active connections to be closed. If there are still active connections after the wait timeout is exceeded, the server will be force closed.
The default wait timeout is 5 seconds.

However, you can customize the timeout and the signal as shown in the example below:

```go
gs := httpgraceful.WrapServer(
server,
httpgraceful.WithWaitTimeout(100*time.Millisecond),
httpgraceful.WithSignal(syscall.SIGTERM)
)
```
26 changes: 26 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package main

import (
"fmt"
"io"
"net/http"

"github.com/pkg-id/httpgraceful"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "Hello, World")
})

server := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", 8080),
Handler: mux,
}

gs := httpgraceful.WrapServer(server)
if err := gs.ListenAndServe(); err != nil {
panic(err)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/pkg-id/httpgraceful

go 1.20
137 changes: 137 additions & 0 deletions httpgraceful.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package httpgraceful

import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

// Server is a contract for server that can be started and shutdown gracefully.
type Server interface {
// ListenAndServe starts listening and serving the server.
// This method should block until shutdown signal received or failed to start.
ListenAndServe() error

// Shutdown gracefully shuts down the server, it will wait for all active connections to be closed.
Shutdown(ctx context.Context) error

// Close force closes the server.
// Close is called when Shutdown timeout exceeded.
Close() error
}

// gracefulServer is a wrapper of http.Server that can be shutdown gracefully.
type gracefulServer struct {
Server
signalListener chan os.Signal
waitTimeout time.Duration
shutdownDone chan struct{}
}

// Option is a function to configure gracefulServer.
type Option func(*gracefulServer)

func (f Option) apply(gs *gracefulServer) { f(gs) }

// WithSignals sets the signals that will be listened to initiate shutdown.
func WithSignals(signals ...os.Signal) Option {
return func(s *gracefulServer) {
signalListener := make(chan os.Signal, 1)
signal.Notify(signalListener, signals...)
s.signalListener = signalListener
}
}

// WithWaitTimeout sets the timeout for waiting active connections to be closed.
func WithWaitTimeout(timeout time.Duration) Option {
return func(s *gracefulServer) {
s.waitTimeout = timeout
}
}

// WrapServer wraps a Server with graceful shutdown capability.
// It will listen to SIGINT and SIGTERM signals to initiate shutdown and
// wait for all active connections to be closed. If still active connections
// after wait timeout exceeded, it will force close the server. The default
// wait timeout is 5 seconds.
func WrapServer(server Server, opts ...Option) Server {
gs := gracefulServer{
Server: server,
shutdownDone: make(chan struct{}),
}

for _, opt := range opts {
opt.apply(&gs)
}

if gs.signalListener == nil {
WithSignals(syscall.SIGTERM, syscall.SIGINT).apply(&gs)
}

if gs.waitTimeout <= 0 {
WithWaitTimeout(5 * time.Second).apply(&gs)
}

return &gs
}

// ListenAndServe starts listening and serving the server gracefully.
func (s *gracefulServer) ListenAndServe() error {
serverErr := make(chan error, 1)
shutdownCompleted := make(chan struct{})

// start the original server.
go func() {
err := s.Server.ListenAndServe()
// if shutdown succeeded, http.ErrServerClosed will be returned.
if errors.Is(err, http.ErrServerClosed) {
shutdownCompleted <- struct{}{}
} else {
// only send error if it's not http.ErrServerClosed.
serverErr <- err
}
}()

// block until signalListener received or mux failed to start.
select {
case sig := <-s.signalListener:
ctx, cancel := context.WithTimeout(context.Background(), s.waitTimeout)
defer cancel()

err := s.Server.Shutdown(ctx)
// only force shutdown if deadline exceeded.
if errors.Is(err, context.DeadlineExceeded) {
closeErr := s.Server.Close()
if closeErr != nil {
return fmt.Errorf("deadline exceeded, force shutdown failed: %w", closeErr)
}
// force shutdown succeeded.
return nil
}

// unexpected error.
if err != nil {
return fmt.Errorf("shutdown failed, signal: %s: %w", sig, err)
}

// make sure shutdown completed.
<-shutdownCompleted
return nil
case err := <-serverErr:
return fmt.Errorf("server failed to start: %w", err)
}
}

// SendSignal sends a signal to the server, if signal is one of registered signals,
// shutdown will be triggered.
// this useful for testing.
func (s *gracefulServer) SendSignal(sig os.Signal) {
if s.signalListener != nil {
s.signalListener <- sig
}
}
Loading

0 comments on commit b6fe746

Please sign in to comment.