207 lines
5.9 KiB
Go
207 lines
5.9 KiB
Go
// Package health provides transport-neutral liveness and readiness handlers.
|
|
package health
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
)
|
|
|
|
const (
|
|
StatusReady = "ready"
|
|
StatusNotReady = "not_ready"
|
|
)
|
|
|
|
// Probe is one dependency check used by readiness. Timeout is optional; a
|
|
// zero value lets the request context control the probe duration.
|
|
type Probe struct {
|
|
Name string
|
|
Check func(context.Context) error
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// CheckResult is the public result of one readiness probe.
|
|
type CheckResult struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
DurationMs float64 `json:"durationMs"`
|
|
}
|
|
|
|
// Result is returned by Checker.Check and the readiness endpoint.
|
|
type Result struct {
|
|
Status string `json:"status"`
|
|
Checks []CheckResult `json:"checks,omitempty"`
|
|
cause error
|
|
}
|
|
|
|
func (r Result) Ready() bool { return r.Status == StatusReady }
|
|
|
|
// Checker owns the probes registered by a service. It can be shared by all
|
|
// requests after startup; registration is safe before or during serving.
|
|
type Checker struct {
|
|
mu sync.RWMutex
|
|
probes []Probe
|
|
}
|
|
|
|
// New creates a checker and validates its initial probes.
|
|
func New(probes ...Probe) (*Checker, error) {
|
|
checker := &Checker{}
|
|
for _, probe := range probes {
|
|
if err := checker.Add(probe); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return checker, nil
|
|
}
|
|
|
|
// Add registers a named readiness probe. Names must be unique and checks must
|
|
// be non-nil so configuration errors fail during startup.
|
|
func (c *Checker) Add(probe Probe) error {
|
|
if c == nil {
|
|
return fmt.Errorf("health: nil checker")
|
|
}
|
|
probe.Name = strings.TrimSpace(probe.Name)
|
|
if probe.Name == "" {
|
|
return fmt.Errorf("health: probe name is required")
|
|
}
|
|
if probe.Check == nil {
|
|
return fmt.Errorf("health: probe %q has no check", probe.Name)
|
|
}
|
|
if probe.Timeout < 0 {
|
|
return fmt.Errorf("health: probe %q timeout must not be negative", probe.Name)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, current := range c.probes {
|
|
if current.Name == probe.Name {
|
|
return fmt.Errorf("health: duplicate probe %q", probe.Name)
|
|
}
|
|
}
|
|
c.probes = append(c.probes, probe)
|
|
return nil
|
|
}
|
|
|
|
// Check runs every registered probe in registration order. A failed probe is
|
|
// represented in the result; the other probes still run so operators see all
|
|
// unavailable dependencies in one response.
|
|
func (c *Checker) Check(ctx context.Context) Result {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
c.mu.RLock()
|
|
probes := append([]Probe(nil), c.probes...)
|
|
c.mu.RUnlock()
|
|
|
|
result := Result{Status: StatusReady, Checks: make([]CheckResult, 0, len(probes))}
|
|
for _, probe := range probes {
|
|
started := time.Now()
|
|
probeContext := ctx
|
|
cancel := func() {}
|
|
if probe.Timeout > 0 {
|
|
probeContext, cancel = context.WithTimeout(ctx, probe.Timeout)
|
|
}
|
|
err := probe.Check(probeContext)
|
|
cancel()
|
|
checkResult := CheckResult{
|
|
Name: probe.Name,
|
|
Status: StatusReady,
|
|
DurationMs: time.Since(started).Seconds() * 1000,
|
|
}
|
|
if err != nil {
|
|
result.Status = StatusNotReady
|
|
checkResult.Status = StatusNotReady
|
|
checkResult.Error = err.Error()
|
|
result.cause = errors.Join(result.cause, fmt.Errorf("%s: %w", probe.Name, err))
|
|
}
|
|
result.Checks = append(result.Checks, checkResult)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// LivenessHandler reports that the process is running. It does not execute
|
|
// dependency probes and is safe to use for orchestrator liveness checks.
|
|
func (c *Checker) LivenessHandler(options ...HandlerOption) httpx.Handler {
|
|
settings := handlerSettings(options)
|
|
return func(ctx *httpx.Context) error {
|
|
data := map[string]string{"status": "ok"}
|
|
if settings.raw {
|
|
return ctx.JSON(http.StatusOK, data)
|
|
}
|
|
return ctx.RespondStatus(http.StatusOK, data, nil)
|
|
}
|
|
}
|
|
|
|
// ReadinessHandler reports whether all registered dependencies are ready.
|
|
// Failure always uses HTTP 503. App response configuration is used by default;
|
|
// WithRawResponse returns just health data. Internal probe errors remain in the
|
|
// recorded failure for logs, not in the HTTP response.
|
|
func (c *Checker) ReadinessHandler(options ...HandlerOption) httpx.Handler {
|
|
settings := handlerSettings(options)
|
|
return func(ctx *httpx.Context) error {
|
|
var result Result
|
|
if settings.shutdown != nil && settings.shutdown.Err() != nil {
|
|
result = Result{Status: StatusNotReady, cause: context.Cause(settings.shutdown)}
|
|
} else {
|
|
result = c.Check(ctx.Context())
|
|
// A stop may arrive while the probes are running.
|
|
if settings.shutdown != nil && settings.shutdown.Err() != nil {
|
|
result.Status = StatusNotReady
|
|
result.cause = errors.Join(result.cause, context.Cause(settings.shutdown))
|
|
}
|
|
}
|
|
for i := range result.Checks {
|
|
result.Checks[i].Error = ""
|
|
}
|
|
status := http.StatusOK
|
|
var failure error
|
|
if !result.Ready() {
|
|
status = http.StatusServiceUnavailable
|
|
failure = apierrors.New(status, "NOT_READY", "service is not ready").WithCause(result.cause)
|
|
}
|
|
if settings.raw {
|
|
if failure != nil {
|
|
resolved := ctx.ResolveError(failure)
|
|
resolved.Status = status
|
|
ctx.RecordFailure(resolved)
|
|
}
|
|
return ctx.JSON(status, result)
|
|
}
|
|
return ctx.RespondStatus(status, result, failure)
|
|
}
|
|
}
|
|
|
|
type handlerConfig struct {
|
|
raw bool
|
|
shutdown context.Context
|
|
}
|
|
|
|
type HandlerOption func(*handlerConfig)
|
|
|
|
// WithRawResponse bypasses the App envelope for this probe's health data.
|
|
func WithRawResponse() HandlerOption { return func(c *handlerConfig) { c.raw = true } }
|
|
|
|
// WithShutdownContext marks readiness false as soon as service shutdown begins.
|
|
// It never changes liveness or closes any project resource.
|
|
func WithShutdownContext(ctx context.Context) HandlerOption {
|
|
return func(c *handlerConfig) { c.shutdown = ctx }
|
|
}
|
|
|
|
func handlerSettings(options []HandlerOption) handlerConfig {
|
|
var cfg handlerConfig
|
|
for _, option := range options {
|
|
if option != nil {
|
|
option(&cfg)
|
|
}
|
|
}
|
|
return cfg
|
|
}
|