// 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, }) }