service.go

v0.6.0
Doc Versions Source
1
// Package httpsvc exposes an HTTP server as a steward-managed service.
2
//
3
// Init builds the underlying *http.Server, Start binds the listener and
4
// runs Serve in a goroutine, Stop drains via Shutdown with a configurable
5
// timeout. A listener failure observed after Start is reported via the
6
// steward HealthChecker interface.
7
package httpsvc
8
9
import (
10
	"context"
11
	"errors"
12
	"log/slog"
13
	"net"
14
	"net/http"
15
	"sync"
16
	"sync/atomic"
17
	"time"
18
19
	"go.bigb.es/auxilia/culpa"
20
)
21
22
// defaultShutdownTimeout caps Service.Stop when ShutdownTimeout is zero.
23
const defaultShutdownTimeout = 30 * time.Second
24
25
// Service is a steward-managed HTTP server.
26
//
27
// Configure the public fields before adding the service to the steward
28
// Manager. Leave private fields zero. Init builds the underlying
29
// *http.Server; Start runs the listener in a goroutine; Stop drains via
30
// Shutdown with ShutdownTimeout.
31
type Service struct {
32
	// Addr is the listen address (e.g. ":8080" or "127.0.0.1:0"). Required.
33
	Addr string
34
35
	// Handler is the root http.Handler. Required.
36
	Handler http.Handler
37
38
	// ReadTimeout, WriteTimeout, and IdleTimeout map onto the matching
39
	// http.Server fields. Zero values fall through to net/http defaults.
40
	ReadTimeout  time.Duration
41
	WriteTimeout time.Duration
42
	IdleTimeout  time.Duration
43
44
	// ShutdownTimeout caps Stop's drain window. Zero means 30 seconds.
45
	ShutdownTimeout time.Duration
46
47
	// Logger is optional. When nil, slog.Default() is used.
48
	Logger *slog.Logger
49
50
	// BaseContext is optional; it is passed through to
51
	// http.Server.BaseContext. The default is the standard library default.
52
	BaseContext func(net.Listener) context.Context
53
54
	srv      *http.Server
55
	listener net.Listener
56
	serveErr atomic.Pointer[error]
57
	stopOnce sync.Once
58
	stopErr  error
59
	started  atomic.Bool
60
	doneCh   chan struct{}
61
}
62
63
// Init validates the configuration and constructs the underlying
64
// *http.Server. It is safe to inspect Addr and Handler after Init returns.
65
func (s *Service) Init(_ context.Context) error {
66
	if s.Addr == "" {
67
		return culpa.WithCode(culpa.New("httpsvc: Addr is required"), "HTTPSVC_INVALID_CONFIG")
68
	}
69
	if s.Handler == nil {
70
		return culpa.WithCode(culpa.New("httpsvc: Handler is required"), "HTTPSVC_INVALID_CONFIG")
71
	}
72
73
	s.srv = &http.Server{
74
		Addr:         s.Addr,
75
		Handler:      s.Handler,
76
		ReadTimeout:  s.ReadTimeout,
77
		WriteTimeout: s.WriteTimeout,
78
		IdleTimeout:  s.IdleTimeout,
79
		BaseContext:  s.BaseContext,
80
	}
81
	s.doneCh = make(chan struct{})
82
	return nil
83
}
84
85
// Start binds the listener (so bind errors surface synchronously) and
86
// runs Serve in a goroutine. Returns once the listener is bound.
87
func (s *Service) Start(_ context.Context) error {
88
	ln, err := net.Listen("tcp", s.Addr)
89
	if err != nil {
90
		return culpa.Wrap(err, "httpsvc: bind listener")
91
	}
92
	s.listener = ln
93
	s.started.Store(true)
94
95
	go s.serve()
96
	return nil
97
}
98
99
// serve runs the HTTP server until it stops, recording any non-graceful
100
// error for HealthCheck and logging it.
101
func (s *Service) serve() {
102
	defer close(s.doneCh)
103
104
	err := s.srv.Serve(s.listener)
105
	if err == nil || errors.Is(err, http.ErrServerClosed) {
106
		return
107
	}
108
109
	wrapped := culpa.Wrap(err, "http.Server.Serve")
110
	s.serveErr.Store(&wrapped)
111
	s.logger().Error("http server stopped unexpectedly", "err", wrapped)
112
}
113
114
// Stop drains in-flight requests via http.Server.Shutdown, capped by
115
// ShutdownTimeout (default 30s). Idempotent: a second call is a no-op.
116
func (s *Service) Stop(ctx context.Context) error {
117
	s.stopOnce.Do(func() {
118
		if s.srv == nil {
119
			return
120
		}
121
		timeout := s.ShutdownTimeout
122
		if timeout <= 0 {
123
			timeout = defaultShutdownTimeout
124
		}
125
		shutdownCtx, cancel := context.WithTimeout(ctx, timeout)
126
		defer cancel()
127
128
		if err := s.srv.Shutdown(shutdownCtx); err != nil {
129
			s.stopErr = culpa.Wrap(err, "httpsvc: shutdown")
130
			// Shutdown does not interrupt active connections on timeout;
131
			// force them closed so the serve goroutine exits and Stop
132
			// returns promptly. We intentionally drop Close's error: it
133
			// would only ever wrap "use of closed network connection"
134
			// from the listener Shutdown already closed.
135
			_ = s.srv.Close()
136
		}
137
138
		// Wait for the serve goroutine to exit so HealthCheck reflects
139
		// any final error and so the listener fd is released before we
140
		// return.
141
		if s.doneCh != nil {
142
			<-s.doneCh
143
		}
144
	})
145
	return s.stopErr
146
}
147
148
// HealthCheck returns nil while the listener is running, or the wrapped
149
// listener error after the goroutine exits unexpectedly.
150
func (s *Service) HealthCheck(_ context.Context) error {
151
	if errPtr := s.serveErr.Load(); errPtr != nil {
152
		return *errPtr
153
	}
154
	return nil
155
}
156
157
// Addr returns the actual listen address. Most useful when configured
158
// with a wildcard port like "127.0.0.1:0" — the OS-assigned port is only
159
// known after Start.
160
func (s *Service) ListenAddr() net.Addr {
161
	if s.listener == nil {
162
		return nil
163
	}
164
	return s.listener.Addr()
165
}
166
167
func (s *Service) logger() *slog.Logger {
168
	if s.Logger != nil {
169
		return s.Logger
170
	}
171
	return slog.Default()
172
}
173

Source Files