Files
httpx/balancer/health.go
Aleksey Shakhmatov 8d322123a4 Add load balancer with round-robin, failover, and weighted strategies
Implements balancer middleware with URL rewriting per-request:
- RoundRobin, Failover, and WeightedRandom endpoint selection strategies
- Background HealthChecker with configurable probe interval and path
- Thread-safe health state tracking with sync.RWMutex
2026-03-20 14:22:07 +03:00

163 lines
3.4 KiB
Go

package balancer
import (
"context"
"net/http"
"sync"
"time"
)
const (
defaultHealthInterval = 10 * time.Second
defaultHealthPath = "/health"
defaultHealthTimeout = 5 * time.Second
)
// HealthOption configures the HealthChecker.
type HealthOption func(*HealthChecker)
// WithHealthInterval sets the interval between health check probes.
// Default is 10 seconds.
func WithHealthInterval(d time.Duration) HealthOption {
return func(h *HealthChecker) {
h.interval = d
}
}
// WithHealthPath sets the HTTP path to probe for health checks.
// Default is "/health".
func WithHealthPath(path string) HealthOption {
return func(h *HealthChecker) {
h.path = path
}
}
// WithHealthTimeout sets the timeout for each health check request.
// Default is 5 seconds.
func WithHealthTimeout(d time.Duration) HealthOption {
return func(h *HealthChecker) {
h.timeout = d
}
}
// HealthChecker periodically probes endpoints to determine their health status.
type HealthChecker struct {
interval time.Duration
path string
timeout time.Duration
client *http.Client
mu sync.RWMutex
status map[string]bool
cancel context.CancelFunc
stopped chan struct{}
}
func newHealthChecker(opts ...HealthOption) *HealthChecker {
h := &HealthChecker{
interval: defaultHealthInterval,
path: defaultHealthPath,
timeout: defaultHealthTimeout,
status: make(map[string]bool),
}
for _, opt := range opts {
opt(h)
}
h.client = &http.Client{
Timeout: h.timeout,
}
return h
}
// Start begins the background health checking loop for the given endpoints.
// All endpoints are initially considered healthy.
func (h *HealthChecker) Start(endpoints []Endpoint) {
h.mu.Lock()
for _, ep := range endpoints {
h.status[ep.URL] = true
}
h.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
h.stopped = make(chan struct{})
go h.loop(ctx, endpoints)
}
// Stop terminates the background health checking goroutine and waits for
// it to finish.
func (h *HealthChecker) Stop() {
if h.cancel != nil {
h.cancel()
<-h.stopped
}
}
// IsHealthy reports whether the given endpoint is currently healthy.
func (h *HealthChecker) IsHealthy(ep Endpoint) bool {
h.mu.RLock()
defer h.mu.RUnlock()
healthy, ok := h.status[ep.URL]
if !ok {
return false
}
return healthy
}
// Healthy returns the subset of endpoints that are currently healthy.
func (h *HealthChecker) Healthy(endpoints []Endpoint) []Endpoint {
h.mu.RLock()
defer h.mu.RUnlock()
var result []Endpoint
for _, ep := range endpoints {
if h.status[ep.URL] {
result = append(result, ep)
}
}
return result
}
func (h *HealthChecker) loop(ctx context.Context, endpoints []Endpoint) {
defer close(h.stopped)
ticker := time.NewTicker(h.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.probe(ctx, endpoints)
}
}
}
func (h *HealthChecker) probe(ctx context.Context, endpoints []Endpoint) {
for _, ep := range endpoints {
healthy := h.check(ctx, ep)
h.mu.Lock()
h.status[ep.URL] = healthy
h.mu.Unlock()
}
}
func (h *HealthChecker) check(ctx context.Context, ep Endpoint) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL+h.path, nil)
if err != nil {
return false
}
resp, err := h.client.Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}