tree-e2e-1770173774/pkg/app/health.go
jordan 50e98ffcc2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-04 02:56:15 +00:00

218 lines
5.6 KiB
Go

package app
import (
"context"
"net/http"
"sync"
"time"
"git.threesix.ai/jordan/tree-e2e-1770173774/pkg/httperror"
"git.threesix.ai/jordan/tree-e2e-1770173774/pkg/httpresponse"
)
// 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 := app.NewHealthHandler(app.HealthConfig{
// Service: "my-service",
// Timeout: 5 * time.Second,
// Checks: map[string]app.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 {
httpresponse.OK(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(),
}
httpresponse.JSON(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", app.NewLivenessHandler("my-service"))
func NewLivenessHandler(service string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(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", app.NewReadinessHandler(app.HealthConfig{
// Service: "my-service",
// Checks: map[string]app.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]app.HealthChecker{
// "postgres": app.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]app.HealthChecker{
// "external-api": app.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 resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return httperror.Internalf("unhealthy: status %d", resp.StatusCode)
}
return nil
}
}