From ac5a938b70059f47f04c4eef9d4f100c9ca83e48 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 06:55:42 +0000 Subject: [PATCH] feat(process): add readiness polling helpers --- health.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++--- health_test.go | 27 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/health.go b/health.go index fba36f1..0cd54ed 100644 --- a/health.go +++ b/health.go @@ -118,11 +118,14 @@ func (h *HealthServer) Start() error { return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err) } + server := &http.Server{Handler: mux} + h.mu.Lock() h.listener = listener - h.server = &http.Server{Handler: mux} + h.server = server + h.mu.Unlock() go func() { - _ = h.server.Serve(listener) + _ = server.Serve(listener) }() return nil @@ -134,10 +137,17 @@ func (h *HealthServer) Start() error { // // _ = server.Stop(context.Background()) func (h *HealthServer) Stop(ctx context.Context) error { - if h.server == nil { + h.mu.Lock() + server := h.server + h.server = nil + h.listener = nil + h.ready = false + h.mu.Unlock() + + if server == nil { return nil } - return h.server.Shutdown(ctx) + return server.Shutdown(ctx) } // Addr returns the actual address the server is listening on. @@ -146,6 +156,8 @@ func (h *HealthServer) Stop(ctx context.Context) error { // // addr := server.Addr() func (h *HealthServer) Addr() string { + h.mu.Lock() + defer h.mu.Unlock() if h.listener != nil { return h.listener.Addr().String() } @@ -200,3 +212,52 @@ func ProbeHealth(addr string, timeoutMs int) (bool, string) { } return false, lastReason } + +// WaitForReady polls `/ready` until it responds 200 or the timeout expires. +// +// Example: +// +// if !process.WaitForReady("127.0.0.1:8080", 5_000) { +// return errors.New("service did not become ready") +// } +func WaitForReady(addr string, timeoutMs int) bool { + ok, _ := ProbeReady(addr, timeoutMs) + return ok +} + +// ProbeReady polls `/ready` until it responds 200 or the timeout expires. +// It returns the readiness status and the last observed failure reason. +// +// Example: +// +// ok, reason := process.ProbeReady("127.0.0.1:8080", 5_000) +func ProbeReady(addr string, timeoutMs int) (bool, string) { + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + url := fmt.Sprintf("http://%s/ready", addr) + + client := &http.Client{Timeout: 2 * time.Second} + var lastReason string + + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true, "" + } + lastReason = strings.TrimSpace(string(body)) + if lastReason == "" { + lastReason = resp.Status + } + } else { + lastReason = err.Error() + } + time.Sleep(200 * time.Millisecond) + } + + if lastReason == "" { + lastReason = "readiness check timed out" + } + return false, lastReason +} diff --git a/health_test.go b/health_test.go index d744661..386b2ed 100644 --- a/health_test.go +++ b/health_test.go @@ -90,3 +90,30 @@ func TestWaitForHealth_Unreachable(t *testing.T) { ok := WaitForHealth("127.0.0.1:19999", 500) assert.False(t, ok) } + +func TestWaitForReady_Reachable(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + defer func() { _ = hs.Stop(context.Background()) }() + + ok := WaitForReady(hs.Addr(), 2_000) + assert.True(t, ok) +} + +func TestWaitForReady_Unreachable(t *testing.T) { + ok := WaitForReady("127.0.0.1:19999", 500) + assert.False(t, ok) +} + +func TestHealthServer_StopMarksNotReady(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + + require.NotEmpty(t, hs.Addr()) + assert.True(t, hs.Ready()) + + require.NoError(t, hs.Stop(context.Background())) + + assert.False(t, hs.Ready()) + assert.NotEmpty(t, hs.Addr()) +}