From 20dc7ae0d69e150e5f9a66a628ecda21e73e45dc Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 16:34:05 -0700 Subject: [PATCH] Add health package: REST and gRPC health checks - Handler(db) returns http.HandlerFunc: 200 ok / 503 unhealthy - RegisterGRPC registers grpc.health.v1.Health on a gRPC server - 4 tests: healthy, unhealthy (closed db), content type, gRPC registration Co-Authored-By: Claude Opus 4.6 (1M context) --- health/health.go | 48 +++++++++++++++++++++ health/health_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 health/health.go create mode 100644 health/health_test.go diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..d727fab --- /dev/null +++ b/health/health.go @@ -0,0 +1,48 @@ +// Package health provides standard health check implementations for +// Metacircular services, supporting both REST and gRPC. +package health + +import ( + "database/sql" + "encoding/json" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" +) + +// Handler returns an http.HandlerFunc that checks database connectivity. +// It returns 200 {"status":"ok"} if the database is reachable, or +// 503 {"status":"unhealthy","error":"..."} if the ping fails. +// +// Mount it on whatever path the service uses (typically /healthz or +// /v1/health). +func Handler(database *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := database.Ping(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "unhealthy", + "error": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) + } +} + +// RegisterGRPC registers the standard gRPC health checking service +// (grpc.health.v1.Health) on the given gRPC server. The health server +// is set to SERVING status immediately. +func RegisterGRPC(srv *grpc.Server) { + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) +} diff --git a/health/health_test.go b/health/health_test.go new file mode 100644 index 0000000..31fc325 --- /dev/null +++ b/health/health_test.go @@ -0,0 +1,97 @@ +package health + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "google.golang.org/grpc" + + "git.wntrmute.dev/kyle/mcdsl/db" +) + +func TestHandlerHealthy(t *testing.T) { + database := openTestDB(t) + + handler := Handler(database) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("status = %q, want %q", body["status"], "ok") + } +} + +func TestHandlerUnhealthy(t *testing.T) { + database := openTestDB(t) + // Close the database to simulate unhealthy state. + _ = database.Close() + + handler := Handler(database) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "unhealthy" { + t.Fatalf("status = %q, want %q", body["status"], "unhealthy") + } + if body["error"] == "" { + t.Fatal("expected error message") + } +} + +func TestHandlerContentType(t *testing.T) { + database := openTestDB(t) + + handler := Handler(database) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + handler.ServeHTTP(rec, req) + + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want %q", ct, "application/json") + } +} + +func TestRegisterGRPC(t *testing.T) { + srv := grpc.NewServer() + // Should not panic. + RegisterGRPC(srv) + + info := srv.GetServiceInfo() + if _, ok := info["grpc.health.v1.Health"]; !ok { + t.Fatal("health service not registered") + } +} + +func openTestDB(t *testing.T) *sql.DB { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + database, err := db.Open(path) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +}