Frontend Labs
⋯

Day 46 — Backend Health Checking

Learning Objectives

  • Build an active health checker with configurable failure thresholds
  • Emit state change events — hooks into your alert system
  • Understand the difference between active and passive health checking

Key Concepts

Active health check:
  Every N seconds: TCP connect or HTTP GET /health
  Track consecutive successes/failures
  Transition: healthy → unhealthy after K failures
  Transition: unhealthy → healthy after M successes

Passive health check:
  Observe in-flight traffic
  Track error rate over a time window
  Transition based on observed error rate

When to use which:
  Active: more reliable, detects failures before real traffic hits backend
  Passive: lower overhead, but detects failures only during real traffic
  Production: use both

Exercise: Active Health Checker

package main

import (
    "fmt"
    "net"
    "sync"
    "time"
)

type HealthState int

const (
    Healthy   HealthState = iota
    Unhealthy
    Unknown
)

func (s HealthState) String() string {
    switch s {
    case Healthy:   return "HEALTHY"
    case Unhealthy: return "UNHEALTHY"
    default:        return "UNKNOWN"
    }
}

type BackendHealth struct {
    addr             string
    state            HealthState
    consecutiveFails int
    consecutiveOK    int
    lastCheck        time.Time
    mu               sync.RWMutex
}

type HealthChecker struct {
    backends    []*BackendHealth
    interval    time.Duration
    failThresh  int // consecutive failures to mark unhealthy
    okThresh    int // consecutive successes to mark healthy
    onStateChange func(addr string, old, new HealthState)
}

func NewHealthChecker(addrs []string, interval time.Duration) *HealthChecker {
    hc := &HealthChecker{
        interval:   interval,
        failThresh: 3,
        okThresh:   2,
        onStateChange: func(addr string, old, new HealthState) {
            fmt.Printf("[HEALTH] %s: %s → %s\n", addr, old, new)
        },
    }
    for _, addr := range addrs {
        hc.backends = append(hc.backends, &BackendHealth{addr: addr, state: Unknown})
    }
    return hc
}

func (hc *HealthChecker) checkOne(b *BackendHealth) {
    // TCP health check: can we connect?
    conn, err := net.DialTimeout("tcp", b.addr, 2*time.Second)
    if err == nil {
        conn.Close()
    }

    b.mu.Lock()
    defer b.mu.Unlock()
    b.lastCheck = time.Now()

    oldState := b.state
    if err != nil {
        b.consecutiveFails++
        b.consecutiveOK = 0
        if b.consecutiveFails >= hc.failThresh {
            b.state = Unhealthy
        }
    } else {
        b.consecutiveOK++
        b.consecutiveFails = 0
        if b.consecutiveOK >= hc.okThresh {
            b.state = Healthy
        }
    }

    if b.state != oldState && hc.onStateChange != nil {
        go hc.onStateChange(b.addr, oldState, b.state)
    }
}

func (hc *HealthChecker) Run(stop <-chan struct{}) {
    ticker := time.NewTicker(hc.interval)
    defer ticker.Stop()
    for {
        select {
        case <-stop:
            return
        case <-ticker.C:
            for _, b := range hc.backends {
                go hc.checkOne(b)
            }
        }
    }
}

func (hc *HealthChecker) HealthyBackends() []string {
    var healthy []string
    for _, b := range hc.backends {
        b.mu.RLock()
        if b.state == Healthy {
            healthy = append(healthy, b.addr)
        }
        b.mu.RUnlock()
    }
    return healthy
}

func main() {
    checker := NewHealthChecker(
        []string{"127.0.0.1:8081", "127.0.0.1:8082", "127.0.0.1:8083"},
        5*time.Second,
    )

    stop := make(chan struct{})
    go checker.Run(stop)

    // Run for 30s, check healthy backends every 10s
    for i := 0; i < 3; i++ {
        time.Sleep(10 * time.Second)
        fmt.Printf("Healthy backends: %v\n", checker.HealthyBackends())
    }
    close(stop)
}

Real-World Context

Health checking is where most load balancer misconfigurations live. Typical mistakes: health check interval too long (60s → 60s of traffic to dead backend), failure threshold too high (3 failures × 5s interval = 15s of bad traffic), health endpoint too expensive (full DB check on each probe overwhelms the DB during recovery).


Generated from week7.md · 8 Oct 2026 16:36