Replace balancer panic with deferred error; test HealthChecker

A malformed endpoint URL panicked inside Transport, crashing the host app
(often at startup from external config). Capture the parse error and surface
it from the transport on first use instead. Add the previously untested
HealthChecker coverage (initial probe, recovery, Stop termination, unknown
endpoint), raising balancer coverage from ~41% to ~87%. Default the health
probe path to /healthz to match this library's own server.
This commit is contained in:
2026-05-23 13:47:33 +03:00
parent b07d487e63
commit 01478be0dc
4 changed files with 134 additions and 3 deletions

99
balancer/health_test.go Normal file
View File

@@ -0,0 +1,99 @@
package balancer
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestHealthChecker_InitialProbeClassifiesEndpoints(t *testing.T) {
healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer healthy.Close()
unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer unhealthy.Close()
eps := []Endpoint{{URL: healthy.URL}, {URL: unhealthy.URL}}
hc := newHealthChecker()
hc.Start(eps) // runs an initial synchronous probe
defer hc.Stop()
if !hc.IsHealthy(eps[0]) {
t.Errorf("healthy endpoint reported unhealthy")
}
if hc.IsHealthy(eps[1]) {
t.Errorf("unhealthy endpoint reported healthy")
}
got := hc.Healthy(eps)
if len(got) != 1 || got[0].URL != healthy.URL {
t.Errorf("Healthy() = %v, want only %s", got, healthy.URL)
}
}
func TestHealthChecker_DetectsRecovery(t *testing.T) {
var up atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if up.Load() {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
eps := []Endpoint{{URL: srv.URL}}
hc := newHealthChecker()
hc.Start(eps)
defer hc.Stop()
if hc.IsHealthy(eps[0]) {
t.Fatalf("endpoint should start unhealthy")
}
// Recover the backend and force a deterministic re-probe.
up.Store(true)
hc.probe(context.Background(), eps)
if !hc.IsHealthy(eps[0]) {
t.Fatalf("endpoint should be healthy after recovery")
}
}
func TestHealthChecker_StopTerminatesLoop(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
hc := newHealthChecker(WithHealthInterval(time.Millisecond))
hc.Start([]Endpoint{{URL: srv.URL}})
done := make(chan struct{})
go func() {
hc.Stop()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Stop did not return within 2s — loop goroutine leaked")
}
}
func TestHealthChecker_UnknownEndpointIsUnhealthy(t *testing.T) {
hc := newHealthChecker()
if hc.IsHealthy(Endpoint{URL: "http://never-probed.example"}) {
t.Error("unknown endpoint should be reported unhealthy")
}
}