feat(health): initial stable release v0.9.0

HTTP health check handler with parallel goroutine-per-check execution, 5 s request-derived timeout, and two-level criticality (LevelCritical → 503, LevelDegraded → 200).

What's included:
- `Checkable` interface (HealthCheck / Name / Priority) and `Level` type with LevelCritical and LevelDegraded constants
- `NewHandler(logger, checks...)` returning http.Handler; runs all checks concurrently via buffered channel, returns JSON with per-component status and latency
- `ComponentStatus` and `Response` types for the JSON response body

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-18 14:06:17 -06:00
commit e1b6b7ddd7
14 changed files with 685 additions and 0 deletions

132
health.go Normal file
View File

@@ -0,0 +1,132 @@
package health
import (
"context"
"encoding/json"
"net/http"
"time"
)
// Level represents the criticality of a component to the overall application health.
type Level int
const (
// LevelCritical indicates a component essential for the application.
// If any critical component is DOWN, the overall status is DOWN (503).
LevelCritical Level = iota
// LevelDegraded indicates a component that is important but not essential.
// If a degraded component is DOWN, the overall status is DEGRADED (200).
LevelDegraded
)
// Checkable is the interface that infrastructure components implement.
type Checkable interface {
HealthCheck(ctx context.Context) error
Name() string
Priority() Level
}
// Logger is the minimal interface health needs — satisfied by logz.Logger via duck typing.
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, err error, args ...any)
WithContext(ctx context.Context) Logger
}
// ComponentStatus represents the health state of an individual component.
type ComponentStatus struct {
Status string `json:"status"`
Latency string `json:"latency,omitempty"`
Error string `json:"error,omitempty"`
}
// Response is the JSON body returned by the health handler.
type Response struct {
Status string `json:"status"`
Components map[string]ComponentStatus `json:"components"`
}
type handler struct {
logger Logger
checks []Checkable
}
// NewHandler returns an http.Handler for the health endpoint.
// Runs all checks concurrently with a 5-second timeout.
// Returns 200 (UP/DEGRADED) or 503 (DOWN).
func NewHandler(logger Logger, checks ...Checkable) http.Handler {
return &handler{logger: logger, checks: checks}
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
logger := h.logger.WithContext(r.Context())
logger.Debug("health: running checks")
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
type result struct {
name string
status ComponentStatus
priority Level
}
resChan := make(chan result, len(h.checks))
for _, check := range h.checks {
go func(chk Checkable) {
start := time.Now()
err := chk.HealthCheck(ctx)
latency := time.Since(start).String()
status := "UP"
errMsg := ""
if err != nil {
errMsg = err.Error()
if chk.Priority() == LevelDegraded {
status = "DEGRADED"
} else {
status = "DOWN"
}
}
resChan <- result{
name: chk.Name(),
priority: chk.Priority(),
status: ComponentStatus{
Status: status,
Latency: latency,
Error: errMsg,
},
}
}(check)
}
overallStatus := "UP"
httpStatus := http.StatusOK
components := make(map[string]ComponentStatus)
for range h.checks {
res := <-resChan
components[res.name] = res.status
switch res.status.Status {
case "DOWN":
overallStatus = "DOWN"
httpStatus = http.StatusServiceUnavailable
case "DEGRADED":
if overallStatus == "UP" {
overallStatus = "DEGRADED"
}
}
}
resp := Response{Status: overallStatus, Components: components}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
if err := json.NewEncoder(w).Encode(resp); err != nil {
logger.Error("health: failed to encode response", err)
}
}