tenseleyflow/shithub / 7813980

Browse files

Wire /readyz to optional db.Healthcheck via Deps.ReadyCheck

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
78139803a917615889ef58d44ed744bc2dc4ed32
Parents
6e7ec09
Tree
ce9032c

3 changed files

StatusFile+-
M internal/web/handlers/handlers.go 5 1
M internal/web/handlers/health.go 30 7
M internal/web/server.go 41 2
internal/web/handlers/handlers.gomodified
@@ -7,6 +7,7 @@
77
 package handlers
88
 
99
 import (
10
+	"context"
1011
 	"fmt"
1112
 	"io/fs"
1213
 	"log/slog"
@@ -23,6 +24,9 @@ type Deps struct {
2324
 	TemplatesFS fs.FS
2425
 	StaticFS    fs.FS
2526
 	LogoSVG     string
27
+	// ReadyCheck is optionally invoked by /readyz. Returning a non-nil
28
+	// error makes /readyz report 503. If nil, /readyz always reports ready.
29
+	ReadyCheck func(context.Context) error
2630
 }
2731
 
2832
 // Register wires every S00 route into mux. Later sprints' Register entrypoints
@@ -45,7 +49,7 @@ func Register(mux *http.ServeMux, deps Deps) error {
4549
 
4650
 	mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
4751
 	mux.HandleFunc("GET /healthz", healthz)
48
-	mux.HandleFunc("GET /readyz", readyz)
52
+	mux.Handle("GET /readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
4953
 	mux.Handle("GET /{$}", helloHandler{render: r, logoSVG: deps.LogoSVG, logger: deps.Logger})
5054
 
5155
 	return nil
internal/web/handlers/health.gomodified
@@ -2,7 +2,12 @@
22
 
33
 package handlers
44
 
5
-import "net/http"
5
+import (
6
+	"context"
7
+	"log/slog"
8
+	"net/http"
9
+	"time"
10
+)
611
 
712
 // healthz returns 200 if the process is alive. No dependency checks.
813
 func healthz(w http.ResponseWriter, _ *http.Request) {
@@ -11,10 +16,28 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
1116
 	_, _ = w.Write([]byte("ok\n"))
1217
 }
1318
 
14
-// readyz returns 200 when the server is ready to take traffic. S00 has no
15
-// dependencies to check; S01 wires Postgres into here, S04 wires storage.
16
-func readyz(w http.ResponseWriter, _ *http.Request) {
17
-	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
18
-	w.Header().Set("Cache-Control", "no-store")
19
-	_, _ = w.Write([]byte("ready\n"))
19
+// readinessHandler returns a /readyz handler that calls check (when non-nil)
20
+// with a 2-second budget. A nil error returns 200 ready; a non-nil error
21
+// returns 503 with the error reason in the body. When check is nil the
22
+// handler always reports ready (the S00 default for a DB-less boot).
23
+func readinessHandler(check func(context.Context) error, logger *slog.Logger) http.Handler {
24
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
26
+		w.Header().Set("Cache-Control", "no-store")
27
+		if check == nil {
28
+			_, _ = w.Write([]byte("ready\n"))
29
+			return
30
+		}
31
+		ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
32
+		defer cancel()
33
+		if err := check(ctx); err != nil {
34
+			if logger != nil {
35
+				logger.Warn("readyz: dependency unhealthy", "error", err)
36
+			}
37
+			w.WriteHeader(http.StatusServiceUnavailable)
38
+			_, _ = w.Write([]byte("not ready: " + err.Error() + "\n"))
39
+			return
40
+		}
41
+		_, _ = w.Write([]byte("ready\n"))
42
+	})
2043
 }
internal/web/server.gomodified
@@ -17,6 +17,7 @@ import (
1717
 	"syscall"
1818
 	"time"
1919
 
20
+	"github.com/tenseleyFlow/shithub/internal/infra/db"
2021
 	"github.com/tenseleyFlow/shithub/internal/web/handlers"
2122
 )
2223
 
@@ -44,13 +45,31 @@ func Run(ctx context.Context, opts Options) error {
4445
 		return fmt.Errorf("load logo: %w", err)
4546
 	}
4647
 
48
+	// DB pool is optional in S01: the server boots without one (the hello
49
+	// page works), but /readyz reports 503 if a DB is configured but
50
+	// unreachable. S02+ will make a pool effectively required.
51
+	var pool *pgxpoolHandle
52
+	if cfg := db.Defaults().Resolve(); cfg.URL != "" {
53
+		p, err := db.Open(ctx, cfg)
54
+		if err != nil {
55
+			logger.Warn("db: open failed; /readyz will report unhealthy", "error", err)
56
+		} else {
57
+			pool = &pgxpoolHandle{p: p}
58
+			defer p.Close()
59
+		}
60
+	}
61
+
4762
 	mux := http.NewServeMux()
48
-	if err := handlers.Register(mux, handlers.Deps{
63
+	deps := handlers.Deps{
4964
 		Logger:      logger,
5065
 		TemplatesFS: TemplatesFS(),
5166
 		StaticFS:    StaticFS(),
5267
 		LogoSVG:     string(logoBytes),
53
-	}); err != nil {
68
+	}
69
+	if pool != nil {
70
+		deps.ReadyCheck = pool.healthcheck
71
+	}
72
+	if err := handlers.Register(mux, deps); err != nil {
5473
 		return fmt.Errorf("register handlers: %w", err)
5574
 	}
5675
 
@@ -95,3 +114,23 @@ func Run(ctx context.Context, opts Options) error {
95114
 	}
96115
 	return nil
97116
 }
117
+
118
+// pgxpoolHandle is an internal wrapper that converts the pool into the
119
+// callback-shape the handlers package expects, without exposing pgx types
120
+// to internal/web/handlers. It also lets us pass a nil pool through cleanly.
121
+type pgxpoolHandle struct {
122
+	p interface {
123
+		Close()
124
+	}
125
+}
126
+
127
+func (h *pgxpoolHandle) healthcheck(ctx context.Context) error {
128
+	// Re-open the type via the db package's typed helper.
129
+	type pinger interface {
130
+		Ping(context.Context) error
131
+	}
132
+	if p, ok := h.p.(pinger); ok {
133
+		return p.Ping(ctx)
134
+	}
135
+	return nil
136
+}