// Package health runs periodic TCP/HTTP checks against backends and keeps // their healthy state in loadbalancer.Backend up to date. package health import ( "context" "fmt" "net" "net/http" "time" "github.com/owen/vibeproxy/internal/loadbalancer" "github.com/owen/vibeproxy/internal/models" ) // OnChange is invoked whenever a backend transitions healthy<->unhealthy. type OnChange func(hostID int64, b *loadbalancer.Backend, healthy bool) type Checker struct { client *http.Client onChange OnChange cancels map[int64]context.CancelFunc } func NewChecker(onChange OnChange) *Checker { return &Checker{ client: &http.Client{Timeout: 5 * time.Second}, onChange: onChange, cancels: map[int64]context.CancelFunc{}, } } // Watch starts a background goroutine checking b at its configured interval // until ctx is cancelled. hostID is used only for the OnChange callback. func (c *Checker) Watch(ctx context.Context, hostID int64, b *loadbalancer.Backend) { if b.Model.HealthCheckType == models.HealthCheckNone || b.Model.HealthCheckType == "" { return } interval := time.Duration(b.Model.IntervalSeconds) * time.Second if interval <= 0 { interval = 10 * time.Second } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() c.check(ctx, hostID, b) for { select { case <-ctx.Done(): return case <-ticker.C: c.check(ctx, hostID, b) } } }() } func (c *Checker) check(ctx context.Context, hostID int64, b *loadbalancer.Backend) { ok := false switch b.Model.HealthCheckType { case models.HealthCheckTCP: ok = c.checkTCP(ctx, b) case models.HealthCheckHTTP: ok = c.checkHTTP(ctx, b) default: ok = true } if changed, healthy := b.RecordCheck(ok); changed && c.onChange != nil { c.onChange(hostID, b, healthy) } } func (c *Checker) checkTCP(ctx context.Context, b *loadbalancer.Backend) bool { d := net.Dialer{Timeout: 3 * time.Second} addr := net.JoinHostPort(b.Model.Host, fmt.Sprintf("%d", b.Model.Port)) conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return false } conn.Close() return true } func (c *Checker) checkHTTP(ctx context.Context, b *loadbalancer.Backend) bool { path := b.Model.HealthCheckPath if path == "" { path = "/" } url := fmt.Sprintf("http://%s:%d%s", b.Model.Host, b.Model.Port, path) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return false } resp, err := c.client.Do(req) if err != nil { return false } defer resp.Body.Close() want := b.Model.HealthCheckStatus if want == 0 { want = 200 } return resp.StatusCode == want }