From 54c4ccbe05f8d5506d0951be1e4b8ff6f3c5a115 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 5 Jul 2026 19:27:34 -0400 Subject: [PATCH] Ping database in health check --- internal/core/endpoints_shared_test.go | 23 +++++++++++++++++++++++ internal/web/env.go | 20 +++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/core/endpoints_shared_test.go b/internal/core/endpoints_shared_test.go index 3c375b1..735094d 100644 --- a/internal/core/endpoints_shared_test.go +++ b/internal/core/endpoints_shared_test.go @@ -28,6 +28,29 @@ func TestHealthzEndpoint(t *testing.T) { } } +// TestHealthzReportsDBDown: /healthz is a readiness probe — when the database is +// unreachable it must fail (503) rather than report healthy. (A cookieless +// request touches no session DB rows, so the ping is what fails.) +func TestHealthzReportsDBDown(t *testing.T) { + t.Parallel() + st, _, ts, h := splitServer(t) + + // Healthy: 200. + resp := do(t, ts, h.app, "/healthz") + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthy /healthz = %d, want 200", resp.StatusCode) + } + + // Take the database down; the readiness ping must now fail closed. + st.Close() + down := do(t, ts, h.app, "/healthz") + down.Body.Close() + if down.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("db-down /healthz = %d, want 503", down.StatusCode) + } +} + // #2 /static/* — serves an embedded asset. func TestStaticAssetEndpoint(t *testing.T) { t.Parallel() diff --git a/internal/web/env.go b/internal/web/env.go index 9778e88..8e616a4 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -16,6 +16,7 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -304,13 +305,26 @@ func limitBody(next http.Handler) http.Handler { // SharedRoutes registers endpoints every surface needs (health, static assets). func (e *Env) SharedRoutes(r chi.Router) { - r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok")) - }) + r.Get("/healthz", e.healthz) staticSub, _ := fs.Sub(staticFS, "static") r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) } +// healthz is a readiness probe: it pings the database (briefly) so a broken pool +// or unreachable DB fails the check — letting a load balancer route away or an +// orchestrator restart the instance — rather than reporting healthy while every +// request 500s. +func (e *Env) healthz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := e.Store.Pool().Ping(ctx); err != nil { + LogError(r, "healthz: db ping failed", err) + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("ok")) +} + // HostWithoutPort strips a trailing :port from a request Host, if present. func HostWithoutPort(host string) string { if h, _, err := net.SplitHostPort(host); err == nil {