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:
132
health.go
Normal file
132
health.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user