2026-05-12 21:51:39 +08:00

135 lines
3.4 KiB
Go

// Package healthhttp exposes process-local HTTP health endpoints for services
// whose primary protocol is not HTTP.
package healthhttp
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"hyapp/pkg/grpchealth"
)
const (
statusOK = "ok"
statusFail = "fail"
)
// LiveChecker allows services with richer runtime liveness checks, such as
// room-service, to reuse their existing process probes.
type LiveChecker interface {
Live() []grpchealth.Check
}
// Response is intentionally close to gateway health responses so CLB probes and
// deploy scripts can consume one stable shape across all services.
type Response struct {
Status string `json:"status"`
Service string `json:"service"`
NodeID string `json:"node_id"`
Checks []grpchealth.Check `json:"checks"`
}
// Server owns the separate HTTP listener used by CLB health checks for gRPC
// processes. It does not replace gRPC health; it makes health visible to CLB.
type Server struct {
nodeID string
checker grpchealth.Checker
server *http.Server
listener net.Listener
once sync.Once
}
// New creates a health HTTP server. Empty addr disables the HTTP health port so
// unit tests and special one-off tools can keep a single listener.
func New(addr string, nodeID string, checker grpchealth.Checker) (*Server, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil, nil
}
if checker == nil {
return nil, errors.New("health checker is required")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{
nodeID: strings.TrimSpace(nodeID),
checker: checker,
listener: listener,
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz/live", s.live)
mux.HandleFunc("/healthz/ready", s.ready)
s.server = &http.Server{
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
}
return s, nil
}
// Run serves health endpoints until Close is called or the listener fails.
func (s *Server) Run() error {
if s == nil {
return nil
}
err := s.server.Serve(s.listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Close stops the health endpoint. Readiness should already be failing before
// this is called because the owning service marks its checker draining first.
func (s *Server) Close(ctx context.Context) error {
if s == nil {
return nil
}
var err error
s.once.Do(func() {
err = s.server.Shutdown(ctx)
})
return err
}
func (s *Server) live(writer http.ResponseWriter, request *http.Request) {
checks := []grpchealth.Check{grpchealth.OK("process")}
if live, ok := s.checker.(LiveChecker); ok {
// Services with process-local invariants should expose them on live, but
// live still avoids slow external dependency probes.
checks = live.Live()
}
s.write(writer, checks)
}
func (s *Server) ready(writer http.ResponseWriter, request *http.Request) {
s.write(writer, s.checker.Ready(request.Context()))
}
func (s *Server) write(writer http.ResponseWriter, checks []grpchealth.Check) {
status := statusFail
code := http.StatusServiceUnavailable
if grpchealth.Healthy(checks) {
status = statusOK
code = http.StatusOK
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(code)
_ = json.NewEncoder(writer).Encode(Response{
Status: status,
Service: s.checker.ServiceName(),
NodeID: s.nodeID,
Checks: checks,
})
}