picoclaw/pkg/health/server.go
dj-oyu 85e2702ccc fix: eliminate dual HTTP server bind on gateway port
channelManager and healthServer both created independent http.Server
instances on the same address, causing "address already in use" on
startup. Consolidate to a single listener owned by healthServer —
channelManager now registers webhook/health handlers directly onto
the healthServer mux instead of creating its own server.

Also raise healthServer timeouts from 5s to 30s to accommodate
webhook request handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:37:26 +09:00

195 lines
4 KiB
Go

package health
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type Server struct {
server *http.Server
mux *http.ServeMux
mu sync.RWMutex
ready bool
checks map[string]Check
startTime time.Time
}
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"`
}
func NewServer(host string, port int) *Server {
mux := http.NewServeMux()
s := &Server{
mux: mux,
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
}
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
addr := fmt.Sprintf("%s:%d", host, port)
s.server = &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
return s
}
// Mux returns the underlying ServeMux so additional routes can be registered.
func (s *Server) Mux() *http.ServeMux {
return s.mux
}
// StartTLS starts the server with TLS using the provided certificate and key files.
func (s *Server) StartTLS(certFile, keyFile string) error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("failed to load TLS cert: %w", err)
}
s.server.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
return s.server.ListenAndServeTLS("", "")
}
func (s *Server) Start() error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
return s.server.ListenAndServe()
}
func (s *Server) StartContext(ctx context.Context) error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
errCh := make(chan error, 1)
go func() {
errCh <- s.server.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
return s.server.Shutdown(context.Background())
}
}
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
s.ready = false
s.mu.Unlock()
return s.server.Shutdown(ctx)
}
func (s *Server) SetReady(ready bool) {
s.mu.Lock()
s.ready = ready
s.mu.Unlock()
}
func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
s.mu.Lock()
defer s.mu.Unlock()
status, msg := checkFn()
s.checks[name] = Check{
Name: name,
Status: statusString(status),
Message: msg,
Timestamp: time.Now(),
}
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
}
json.NewEncoder(w).Encode(resp)
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
s.mu.RLock()
ready := s.ready
checks := make(map[string]Check)
for k, v := range s.checks {
checks[k] = v
}
s.mu.RUnlock()
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
return
}
for _, check := range checks {
if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
return
}
}
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{
Status: "ready",
Uptime: uptime.String(),
Checks: checks,
})
}
// RegisterOnMux registers /health and /ready handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
func (s *Server) RegisterOnMux(mux *http.ServeMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
}
func statusString(ok bool) string {
if ok {
return "ok"
}
return "fail"
}