Frontend Labs
⋯

Day 51 — Resilience Patterns

Learning Objectives

  • Implement a circuit breaker that protects backends from cascading failures
  • Build exponential backoff with jitter for retry logic
  • Understand when to retry vs when to fail fast

Key Concepts

Circuit Breaker States:
  Closed (normal):  requests pass through, track failures
  Open (tripped):   reject all requests immediately (fast fail)
                    protect backend from being overwhelmed during recovery
  Half-Open (probe): after timeout, let ONE request through
                     if success → close; if fail → open again

Retry with exponential backoff + jitter:
  Attempt 1: wait 100ms ± 10ms
  Attempt 2: wait 200ms ± 20ms
  Attempt 3: wait 400ms ± 40ms
  Max 3 retries

Jitter prevents "thundering herd": without jitter, all retrying clients
retry at the same time (100ms, 200ms...) and overload the recovering backend.
With jitter: retries are spread out over the window.

Exercise: Circuit Breaker + Retry

package main

import (
    "errors"
    "fmt"
    "math/rand"
    "sync"
    "sync/atomic"
    "time"
)

var ErrCircuitOpen = errors.New("circuit breaker open")

type CBState int32

const (
    CBClosed   CBState = iota
    CBOpen
    CBHalfOpen
)

type CircuitBreaker struct {
    state          atomic.Int32
    failures       atomic.Int64
    successes      atomic.Int64
    failThresh     int64
    successThresh  int64
    openDuration   time.Duration
    openedAt       time.Time
    mu             sync.Mutex
}

func NewCircuitBreaker(failThresh, successThresh int64, openDuration time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        failThresh:    failThresh,
        successThresh: successThresh,
        openDuration:  openDuration,
    }
}

func (cb *CircuitBreaker) Allow() error {
    switch CBState(cb.state.Load()) {
    case CBClosed:
        return nil
    case CBOpen:
        cb.mu.Lock()
        defer cb.mu.Unlock()
        if time.Since(cb.openedAt) > cb.openDuration {
            cb.state.Store(int32(CBHalfOpen))
            cb.successes.Store(0)
            fmt.Println("[CB] OPEN → HALF-OPEN (probing)")
            return nil
        }
        return ErrCircuitOpen
    case CBHalfOpen:
        return nil // let one request through to probe
    }
    return nil
}

func (cb *CircuitBreaker) RecordSuccess() {
    cb.failures.Store(0)
    if CBState(cb.state.Load()) == CBHalfOpen {
        if cb.successes.Add(1) >= cb.successThresh {
            cb.state.Store(int32(CBClosed))
            fmt.Println("[CB] HALF-OPEN → CLOSED (recovered)")
        }
    }
}

func (cb *CircuitBreaker) RecordFailure() {
    if CBState(cb.state.Load()) == CBHalfOpen {
        cb.mu.Lock()
        cb.state.Store(int32(CBOpen))
        cb.openedAt = time.Now()
        cb.mu.Unlock()
        fmt.Println("[CB] HALF-OPEN → OPEN (probe failed)")
        return
    }

    failures := cb.failures.Add(1)
    if failures >= cb.failThresh {
        cb.mu.Lock()
        if CBState(cb.state.Load()) == CBClosed {
            cb.state.Store(int32(CBOpen))
            cb.openedAt = time.Now()
            fmt.Printf("[CB] CLOSED → OPEN (failed %d times)\n", failures)
        }
        cb.mu.Unlock()
    }
}

// RetryWithBackoff executes fn with exponential backoff + jitter
func RetryWithBackoff(maxAttempts int, base time.Duration, fn func() error) error {
    var lastErr error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        if attempt > 0 {
            // Exponential backoff with ±20% jitter
            delay := base * (1 << uint(attempt-1))
            jitter := time.Duration(float64(delay) * 0.2 * (rand.Float64()*2 - 1))
            time.Sleep(delay + jitter)
        }

        lastErr = fn()
        if lastErr == nil {
            return nil
        }

        // Don't retry on circuit breaker open — it's already protecting the backend
        if errors.Is(lastErr, ErrCircuitOpen) {
            return lastErr
        }
        fmt.Printf("[RETRY] attempt %d failed: %v\n", attempt+1, lastErr)
    }
    return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}

func main() {
    cb := NewCircuitBreaker(5, 2, 10*time.Second)
    failures := 0

    makeRequest := func() error {
        if err := cb.Allow(); err != nil {
            return err
        }
        failures++
        if failures <= 7 { // first 7 fail, then recover
            cb.RecordFailure()
            return fmt.Errorf("backend error")
        }
        cb.RecordSuccess()
        return nil
    }

    // Send 20 requests
    for i := 1; i <= 20; i++ {
        err := RetryWithBackoff(3, 100*time.Millisecond, makeRequest)
        state := CBState(cb.state.Load())
        stateStr := []string{"CLOSED", "OPEN", "HALF-OPEN"}[state]
        fmt.Printf("Request %d: err=%v state=%s\n", i, err, stateStr)
        time.Sleep(500 * time.Millisecond)
    }
}

Real-World Context

Netflix's Hystrix (now Resilience4j) popularized circuit breakers. Without them, a slow database causes HTTP handlers to block, threads fill up, and your service crashes — this is cascading failure. With a circuit breaker, slow database → circuit opens → requests fail fast → service stays responsive. This is OWASP A05 mitigation.


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