Skip to content

Commit 0923ac6

Browse files
authored
feat: add server with hooks and healthz (#109)
1 parent 720877d commit 0923ac6

5 files changed

Lines changed: 990 additions & 0 deletions

File tree

http/server/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package server provides a generic HTTP server.
2+
package server

http/server/healthz.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package server
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
lctx "github.com/hamba/logger/v2/ctx"
10+
"github.com/hamba/pkg/v2/http/healthz"
11+
"github.com/hamba/pkg/v2/http/middleware"
12+
)
13+
14+
// MustAddHealthzChecks adds health checks to both readyz and livez, panicking if there is an error.
15+
func (s *GenericServer[T]) MustAddHealthzChecks(checks ...healthz.HealthChecker) {
16+
if err := s.AddHealthzChecks(checks...); err != nil {
17+
panic(err)
18+
}
19+
}
20+
21+
// AddHealthzChecks adds health checks to both readyz and livez.
22+
func (s *GenericServer[T]) AddHealthzChecks(checks ...healthz.HealthChecker) error {
23+
if err := s.AddReadyzChecks(checks...); err != nil {
24+
return err
25+
}
26+
return s.AddLivezChecks(checks...)
27+
}
28+
29+
// MustAddReadyzChecks adds health checks to readyz, panicking if there is an error.
30+
func (s *GenericServer[T]) MustAddReadyzChecks(checks ...healthz.HealthChecker) {
31+
if err := s.AddReadyzChecks(checks...); err != nil {
32+
panic(err)
33+
}
34+
}
35+
36+
// AddReadyzChecks adds health checks to readyz.
37+
func (s *GenericServer[T]) AddReadyzChecks(checks ...healthz.HealthChecker) error {
38+
s.readyzMu.Lock()
39+
defer s.readyzMu.Unlock()
40+
if s.readyzInstalled {
41+
return errors.New("could not add checks as readyz has already been installed")
42+
}
43+
s.readyzChecks = append(s.readyzChecks, checks...)
44+
return nil
45+
}
46+
47+
// MustAddLivezChecks adds health checks to livez, panicking if there is an error.
48+
func (s *GenericServer[T]) MustAddLivezChecks(checks ...healthz.HealthChecker) {
49+
if err := s.AddLivezChecks(checks...); err != nil {
50+
panic(err)
51+
}
52+
}
53+
54+
// AddLivezChecks adds health checks to livez.
55+
func (s *GenericServer[T]) AddLivezChecks(checks ...healthz.HealthChecker) error {
56+
s.livezMu.Lock()
57+
defer s.livezMu.Unlock()
58+
if s.livezInstalled {
59+
return errors.New("could not add checks as livez has already been installed")
60+
}
61+
s.livezChecks = append(s.livezChecks, checks...)
62+
return nil
63+
}
64+
65+
func (s *GenericServer[T]) installChecks(h http.Handler, shutdownCh chan struct{}) http.Handler {
66+
mux := http.NewServeMux()
67+
mux.Handle("/", h)
68+
s.installLivezChecks(mux)
69+
70+
// When shutdown is started, the readyz check should start failing.
71+
if err := s.AddReadyzChecks(shutdownCheck{ch: shutdownCh}); err != nil {
72+
s.Log.Error("Could not install readyz shutdown check", lctx.Err(err))
73+
}
74+
s.installReadyzChecks(mux)
75+
76+
return mux
77+
}
78+
79+
func (s *GenericServer[T]) installReadyzChecks(mux *http.ServeMux) {
80+
s.readyzMu.Lock()
81+
defer s.readyzMu.Unlock()
82+
s.readyzInstalled = true
83+
s.installCheckers(mux, "/readyz", s.readyzChecks)
84+
}
85+
86+
func (s *GenericServer[T]) installLivezChecks(mux *http.ServeMux) {
87+
s.livezMu.Lock()
88+
defer s.livezMu.Unlock()
89+
s.livezInstalled = true
90+
s.installCheckers(mux, "/livez", s.livezChecks)
91+
}
92+
93+
func (s *GenericServer[T]) installCheckers(mux *http.ServeMux, path string, checks []healthz.HealthChecker) {
94+
if len(checks) == 0 {
95+
checks = []healthz.HealthChecker{healthz.PingHealth}
96+
}
97+
98+
s.Log.Info("Installing health checkers",
99+
lctx.Str("path", path),
100+
lctx.Str("checks", strings.Join(checkNames(checks), ",")),
101+
)
102+
103+
name := strings.TrimPrefix(path, "/")
104+
h := healthz.Handler(name, func(output string) {
105+
s.Log.Info(fmt.Sprintf("%s check failed\n%s", name, output))
106+
}, checks...)
107+
mux.Handle(path, middleware.WithStats(name, s.Stats, h))
108+
}
109+
110+
func checkNames(checks []healthz.HealthChecker) []string {
111+
names := make([]string, len(checks))
112+
for i, check := range checks {
113+
names[i] = check.Name()
114+
}
115+
return names
116+
}
117+
118+
type shutdownCheck struct {
119+
ch <-chan struct{}
120+
}
121+
122+
func (s shutdownCheck) Name() string { return "shutdown" }
123+
124+
func (s shutdownCheck) Check(*http.Request) error {
125+
select {
126+
case <-s.ch:
127+
return errors.New("server is shutting down")
128+
default:
129+
return nil
130+
}
131+
}

http/server/hooks.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
9+
lctx "github.com/hamba/logger/v2/ctx"
10+
)
11+
12+
// PostStartHookFunc is a function called after server start.
13+
type PostStartHookFunc[T context.Context] func(T) error
14+
15+
// PreShutdownHookFunc is a function called before server shutdown.
16+
type PreShutdownHookFunc func() error
17+
18+
type postStartHookEntry[T context.Context] struct {
19+
fn PostStartHookFunc[T]
20+
doneCh chan struct{}
21+
}
22+
23+
// MustAddPostStartHook adds a post-start hook, panicking if there is an error.
24+
func (s *GenericServer[T]) MustAddPostStartHook(name string, fn PostStartHookFunc[T]) {
25+
if err := s.AddPostStartHook(name, fn); err != nil {
26+
panic(err)
27+
}
28+
}
29+
30+
// AddPostStartHook adds a post-start hook.
31+
func (s *GenericServer[T]) AddPostStartHook(name string, fn PostStartHookFunc[T]) error {
32+
if name == "" {
33+
return errors.New("name is required")
34+
}
35+
if fn == nil {
36+
return errors.New("fn is required")
37+
}
38+
39+
s.postStartHookMu.Lock()
40+
defer s.postStartHookMu.Unlock()
41+
42+
if s.postStartHooksCalled {
43+
return errors.New("hooks have already been called")
44+
}
45+
if _, exists := s.postStartHooks[name]; exists {
46+
return fmt.Errorf("hook %q as it is already registered", name)
47+
}
48+
49+
if s.postStartHooks == nil {
50+
s.postStartHooks = map[string]postStartHookEntry[T]{}
51+
}
52+
53+
doneCh := make(chan struct{})
54+
err := s.AddReadyzChecks(postStartHookHealth{
55+
name: "postStartHook:" + name,
56+
doneCh: doneCh,
57+
})
58+
if err != nil {
59+
return fmt.Errorf("adding readyz check: %w", err)
60+
}
61+
62+
s.postStartHooks[name] = postStartHookEntry[T]{
63+
fn: fn,
64+
doneCh: doneCh,
65+
}
66+
return nil
67+
}
68+
69+
// MustAddPreShutdownHook adds a pre-shutdown hook, panicking if there is an error.
70+
func (s *GenericServer[T]) MustAddPreShutdownHook(name string, fn PreShutdownHookFunc) {
71+
if err := s.AddPreShutdownHook(name, fn); err != nil {
72+
panic(err)
73+
}
74+
}
75+
76+
// AddPreShutdownHook adds a pre-shutdown hook.
77+
func (s *GenericServer[T]) AddPreShutdownHook(name string, fn PreShutdownHookFunc) error {
78+
if name == "" {
79+
return errors.New("name is required")
80+
}
81+
if fn == nil {
82+
return errors.New("fn is required")
83+
}
84+
85+
s.preShutdownHookMu.Lock()
86+
defer s.preShutdownHookMu.Unlock()
87+
88+
if s.preShutdownHooksCalled {
89+
return errors.New("hooks have already been called")
90+
}
91+
if _, exists := s.preShutdownHooks[name]; exists {
92+
return fmt.Errorf("hook %q as it is already registered", name)
93+
}
94+
95+
if s.preShutdownHooks == nil {
96+
s.preShutdownHooks = map[string]PreShutdownHookFunc{}
97+
}
98+
99+
s.preShutdownHooks[name] = fn
100+
return nil
101+
}
102+
103+
func (s *GenericServer[T]) runPostStartHooks(ctx T) {
104+
s.postStartHookMu.Lock()
105+
defer s.postStartHookMu.Unlock()
106+
107+
s.postStartHooksCalled = true
108+
109+
for name, entry := range s.postStartHooks {
110+
go s.runPostStartHook(ctx, name, entry)
111+
}
112+
}
113+
114+
func (s *GenericServer[T]) runPostStartHook(ctx T, name string, entry postStartHookEntry[T]) {
115+
defer func() {
116+
if v := recover(); v != nil {
117+
s.Log.Error("Panic while running post-start hook",
118+
lctx.Interface("error", v),
119+
lctx.Stack("stack"),
120+
)
121+
}
122+
}()
123+
124+
s.Log.Info("Running post-start hook", lctx.Str("hook", name))
125+
126+
if err := entry.fn(ctx); err != nil {
127+
s.Log.Error("Could not run post-start hook", lctx.Str("name", name), lctx.Err(err))
128+
}
129+
close(entry.doneCh)
130+
}
131+
132+
func (s *GenericServer[T]) hasPreShutdownHooks() bool {
133+
s.preShutdownHookMu.Lock()
134+
defer s.preShutdownHookMu.Unlock()
135+
136+
return len(s.preShutdownHooks) > 0
137+
}
138+
139+
func (s *GenericServer[T]) runPreShutdownHooks() error {
140+
s.preShutdownHookMu.Lock()
141+
defer s.preShutdownHookMu.Unlock()
142+
143+
s.preShutdownHooksCalled = true
144+
145+
var errs error
146+
for name, fn := range s.preShutdownHooks {
147+
if err := s.runPreShutdownHook(name, fn); err != nil {
148+
errs = errors.Join(errs, err)
149+
}
150+
}
151+
return errs
152+
}
153+
154+
func (s *GenericServer[T]) runPreShutdownHook(name string, fn PreShutdownHookFunc) error {
155+
defer func() {
156+
if v := recover(); v != nil {
157+
s.Log.Error("Panic while running pre-shutdown hook",
158+
lctx.Interface("error", v),
159+
lctx.Stack("stack"),
160+
)
161+
}
162+
}()
163+
164+
s.Log.Info("Running pre-shutdown hook", lctx.Str("hook", name))
165+
166+
if err := fn(); err != nil {
167+
return fmt.Errorf("running preshutdown hook %q: %w", name, err)
168+
}
169+
return nil
170+
}
171+
172+
type postStartHookHealth struct {
173+
name string
174+
doneCh chan struct{}
175+
}
176+
177+
func (h postStartHookHealth) Name() string {
178+
return h.name
179+
}
180+
181+
func (h postStartHookHealth) Check(*http.Request) error {
182+
select {
183+
case <-h.doneCh:
184+
return nil
185+
default:
186+
return errors.New("not finished")
187+
}
188+
}

0 commit comments

Comments
 (0)