rdev/pkg/api/health.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

215 lines
5.5 KiB
Go

package api
import (
"context"
"net/http"
"sync"
"time"
)
// HealthChecker is a function that checks the health of a dependency.
// Returns nil if healthy, an error describing the issue otherwise.
type HealthChecker func(ctx context.Context) error
// HealthCheckResult represents the result of a single health check.
type HealthCheckResult struct {
Name string `json:"name"`
Status string `json:"status"` // "healthy" or "unhealthy"
Latency string `json:"latency,omitempty"`
Error string `json:"error,omitempty"`
}
// HealthResponse is the response structure for health endpoints.
type HealthResponse struct {
Status string `json:"status"` // "healthy" or "unhealthy"
Service string `json:"service"`
Checks []HealthCheckResult `json:"checks,omitempty"`
Duration string `json:"duration,omitempty"`
}
// HealthConfig configures health check behavior.
type HealthConfig struct {
// Service name for identification
Service string
// Timeout for individual health checks (default: 5s)
Timeout time.Duration
// Checks is a map of check names to checker functions
Checks map[string]HealthChecker
}
// NewHealthHandler creates an HTTP handler that runs health checks concurrently.
// Returns 200 if all checks pass, 503 if any check fails.
//
// Example:
//
// healthHandler := api.NewHealthHandler(api.HealthConfig{
// Service: "my-service",
// Timeout: 5 * time.Second,
// Checks: map[string]api.HealthChecker{
// "database": func(ctx context.Context) error {
// return db.PingContext(ctx)
// },
// "redis": func(ctx context.Context) error {
// return redis.Ping(ctx).Err()
// },
// },
// })
//
// r.Get("/health", healthHandler)
func NewHealthHandler(cfg HealthConfig) http.HandlerFunc {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// If no checks configured, return simple healthy response
if len(cfg.Checks) == 0 {
WriteSuccess(w, r, HealthResponse{
Status: "healthy",
Service: cfg.Service,
})
return
}
// Create context with timeout
ctx, cancel := context.WithTimeout(r.Context(), cfg.Timeout)
defer cancel()
// Run checks concurrently
results := make([]HealthCheckResult, 0, len(cfg.Checks))
var mu sync.Mutex
var wg sync.WaitGroup
for name, checker := range cfg.Checks {
wg.Add(1)
go func(name string, checker HealthChecker) {
defer wg.Done()
checkStart := time.Now()
err := checker(ctx)
latency := time.Since(checkStart)
result := HealthCheckResult{
Name: name,
Status: "healthy",
Latency: latency.Round(time.Millisecond).String(),
}
if err != nil {
result.Status = "unhealthy"
result.Error = err.Error()
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}(name, checker)
}
wg.Wait()
// Determine overall status
status := "healthy"
httpStatus := http.StatusOK
for _, result := range results {
if result.Status == "unhealthy" {
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
break
}
}
resp := HealthResponse{
Status: status,
Service: cfg.Service,
Checks: results,
Duration: time.Since(start).Round(time.Millisecond).String(),
}
WriteJSON(w, r, httpStatus, resp)
}
}
// NewLivenessHandler creates a simple liveness probe handler.
// Always returns 200 OK if the process is running.
// Use for Kubernetes liveness probes.
//
// Example:
//
// r.Get("/health/live", api.NewLivenessHandler("my-service"))
func NewLivenessHandler(service string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, r, map[string]string{
"status": "ok",
"service": service,
})
}
}
// NewReadinessHandler creates a readiness probe handler with dependency checks.
// Returns 200 if all checks pass, 503 if any check fails.
// Use for Kubernetes readiness probes.
//
// Example:
//
// r.Get("/health/ready", api.NewReadinessHandler(api.HealthConfig{
// Service: "my-service",
// Checks: map[string]api.HealthChecker{
// "database": dbHealthCheck,
// },
// }))
func NewReadinessHandler(cfg HealthConfig) http.HandlerFunc {
return NewHealthHandler(cfg)
}
// -----------------------------------------------------------------------------
// Common Health Checkers
// -----------------------------------------------------------------------------
// PingChecker creates a health checker from a Ping function.
// Many database clients have a Ping or PingContext method.
//
// Example:
//
// checks := map[string]api.HealthChecker{
// "postgres": api.PingChecker(db.PingContext),
// }
func PingChecker(pingFn func(context.Context) error) HealthChecker {
return pingFn
}
// HTTPChecker creates a health checker that makes an HTTP GET request.
// Returns error if status is not 2xx.
//
// Example:
//
// checks := map[string]api.HealthChecker{
// "external-api": api.HTTPChecker("https://api.example.com/health"),
// }
func HTTPChecker(url string) HealthChecker {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Internalf("unhealthy: status %d", resp.StatusCode)
}
return nil
}
}