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) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:34:05 -07:00
parent aa608b7efd
commit 20dc7ae0d6
2 changed files with 145 additions and 0 deletions

48
health/health.go Normal file
View File

@@ -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)
}

97
health/health_test.go Normal file
View File

@@ -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
}