218 lines
5.6 KiB
Go
218 lines
5.6 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.threesix.ai/jordan/sp4-fresh/pkg/httperror"
|
|
"git.threesix.ai/jordan/sp4-fresh/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
|
|
}
|
|
}
|