refactor: rewrite handler-base as Go module
Replace Python handler-base library with Go module providing: - config: environment-based configuration - health: HTTP health/readiness server for k8s probes - natsutil: NATS/JetStream client with msgpack serialization - telemetry: OpenTelemetry tracing and metrics setup - clients: HTTP clients for LLM, embeddings, reranker, STT, TTS - handler: base Handler runner wiring NATS + health + telemetry Implements ADR-0061 Phase 1.
This commit is contained in:
86
health/health.go
Normal file
86
health/health.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Package health provides an HTTP server for Kubernetes liveness and readiness probes.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReadyFunc is called to determine if the service is ready. Return true if ready.
|
||||
type ReadyFunc func() bool
|
||||
|
||||
// Server serves /health and /ready endpoints.
|
||||
type Server struct {
|
||||
port int
|
||||
healthPath string
|
||||
readyPath string
|
||||
readyCheck ReadyFunc
|
||||
srv *http.Server
|
||||
ready atomic.Bool
|
||||
}
|
||||
|
||||
// New creates a health server on the given port.
|
||||
func New(port int, healthPath, readyPath string, readyCheck ReadyFunc) *Server {
|
||||
s := &Server{
|
||||
port: port,
|
||||
healthPath: healthPath,
|
||||
readyPath: readyPath,
|
||||
readyCheck: readyCheck,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(healthPath, s.handleHealth)
|
||||
mux.HandleFunc(readyPath, s.handleReady)
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins serving in the background. Call Stop to shut down.
|
||||
func (s *Server) Start() {
|
||||
ln, err := net.Listen("tcp", s.srv.Addr)
|
||||
if err != nil {
|
||||
slog.Error("health server listen failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("health server started", "port", s.port, "health", s.healthPath, "ready", s.readyPath)
|
||||
go func() {
|
||||
if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("health server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the server.
|
||||
func (s *Server) Stop(ctx context.Context) {
|
||||
if s.srv != nil {
|
||||
_ = s.srv.Shutdown(ctx)
|
||||
slog.Info("health server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "healthy"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.readyCheck != nil && !s.readyCheck() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
77
health/health_test.go
Normal file
77
health/health_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
srv := New(18080, "/health", "/ready", nil)
|
||||
srv.Start()
|
||||
defer srv.Stop(context.Background())
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
resp, err := http.Get("http://localhost:18080/health")
|
||||
if err != nil {
|
||||
t.Fatalf("health request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var data map[string]string
|
||||
_ = json.Unmarshal(body, &data)
|
||||
if data["status"] != "healthy" {
|
||||
t.Errorf("expected status 'healthy', got %q", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyEndpointDefault(t *testing.T) {
|
||||
srv := New(18081, "/health", "/ready", nil)
|
||||
srv.Start()
|
||||
defer srv.Stop(context.Background())
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
resp, err := http.Get("http://localhost:18081/ready")
|
||||
if err != nil {
|
||||
t.Fatalf("ready request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyEndpointNotReady(t *testing.T) {
|
||||
ready := false
|
||||
srv := New(18082, "/health", "/ready", func() bool { return ready })
|
||||
srv.Start()
|
||||
defer srv.Stop(context.Background())
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
resp, err := http.Get("http://localhost:18082/ready")
|
||||
if err != nil {
|
||||
t.Fatalf("ready request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 503 {
|
||||
t.Errorf("expected 503 when not ready, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ready = true
|
||||
resp2, err := http.Get("http://localhost:18082/ready")
|
||||
if err != nil {
|
||||
t.Fatalf("ready request failed: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Errorf("expected 200 when ready, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user