handlers.go

v0.6.0
Doc Versions Source
1
// Package healthhttp exposes /healthz and /readyz handlers backed by a
2
// steward Manager.
3
//
4
// livez always returns 200 — a process that can answer is alive by
5
// definition; coupling liveness to dependencies gets pods killed during
6
// transient outages. readyz runs Manager.HealthCheck under a configurable
7
// timeout and returns 503 with a JSON body on failure.
8
package healthhttp
9
10
import (
11
	"context"
12
	"encoding/json"
13
	"log/slog"
14
	"net/http"
15
	"time"
16
17
	"go.bigb.es/auxilia/steward"
18
)
19
20
// defaultTimeout caps the readiness check when no [WithTimeout] is given.
21
const defaultTimeout = 5 * time.Second
22
23
// config holds the resolved Handlers options.
24
type config struct {
25
	timeout   time.Duration
26
	logger    *slog.Logger
27
	respondFn func(err error) (status int, body any)
28
}
29
30
// Option configures the handlers returned by [Handlers].
31
type Option func(*config)
32
33
// WithTimeout overrides the default 5s readiness timeout.
34
func WithTimeout(d time.Duration) Option {
35
	return func(c *config) { c.timeout = d }
36
}
37
38
// WithLogger sets the logger used to report readiness failures. nil means
39
// slog.Default(). Failures are logged at Warn so they appear in normal
40
// production output without spamming Info.
41
func WithLogger(l *slog.Logger) Option {
42
	return func(c *config) { c.logger = l }
43
}
44
45
// WithReadyResponse customises the readiness response shape. The function
46
// receives the error returned by Manager.HealthCheck (nil on success) and
47
// returns the HTTP status and body to send.
48
//
49
// On success the default fn returns (200, {"status":"ok"}). On failure it
50
// returns (503, {"status":"unready","error":"<message>"}). The body is
51
// JSON-encoded by the handler unless it is a string, in which case it is
52
// written as text/plain.
53
func WithReadyResponse(fn func(err error) (status int, body any)) Option {
54
	return func(c *config) { c.respondFn = fn }
55
}
56
57
// defaultReadyResponse implements the JSON shape documented in the
58
// package overview.
59
func defaultReadyResponse(err error) (int, any) {
60
	if err == nil {
61
		return http.StatusOK, map[string]string{"status": "ok"}
62
	}
63
	return http.StatusServiceUnavailable, map[string]string{
64
		"status": "unready",
65
		"error":  err.Error(),
66
	}
67
}
68
69
// Handlers returns the liveness and readiness HTTP handlers.
70
//
71
// livez always returns 200 with body "ok".
72
//
73
// readyz wraps the request context with the configured timeout (default
74
// 5s) and calls mgr.HealthCheck. On success it returns 200 with
75
// {"status":"ok"}. On failure it returns 503 with
76
// {"status":"unready","error":"<message>"} and logs at Warn. Use
77
// [WithReadyResponse] to change the body shape.
78
//
79
// Handlers panics if mgr is nil.
80
func Handlers(mgr *steward.Manager, opts ...Option) (livez, readyz http.Handler) {
81
	if mgr == nil {
82
		panic("healthhttp: nil *steward.Manager")
83
	}
84
85
	cfg := &config{
86
		timeout:   defaultTimeout,
87
		respondFn: defaultReadyResponse,
88
	}
89
	for _, o := range opts {
90
		o(cfg)
91
	}
92
	if cfg.logger == nil {
93
		cfg.logger = slog.Default()
94
	}
95
96
	livez = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
97
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
98
		w.WriteHeader(http.StatusOK)
99
		_, _ = w.Write([]byte("ok"))
100
	})
101
102
	readyz = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103
		ctx, cancel := context.WithTimeout(r.Context(), cfg.timeout)
104
		defer cancel()
105
106
		err := mgr.HealthCheck(ctx)
107
		if err != nil {
108
			cfg.logger.Warn("readiness check failed", "err", err)
109
		}
110
111
		status, body := cfg.respondFn(err)
112
		writeBody(w, status, body)
113
	})
114
115
	return livez, readyz
116
}
117
118
// writeBody writes the response body, choosing JSON or plain text based
119
// on the body's runtime type.
120
func writeBody(w http.ResponseWriter, status int, body any) {
121
	switch v := body.(type) {
122
	case string:
123
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
124
		w.WriteHeader(status)
125
		_, _ = w.Write([]byte(v))
126
	case []byte:
127
		w.Header().Set("Content-Type", "application/octet-stream")
128
		w.WriteHeader(status)
129
		_, _ = w.Write(v)
130
	default:
131
		w.Header().Set("Content-Type", "application/json")
132
		w.WriteHeader(status)
133
		_ = json.NewEncoder(w).Encode(v)
134
	}
135
}
136

Source Files