veylant/internal/circuitbreaker/breaker.go
2026-02-23 13:35:04 +01:00

188 lines
4.3 KiB
Go

// Package circuitbreaker implements a per-provider circuit breaker.
// States: Closed (normal) → Open (failing, rejects requests) → HalfOpen (testing recovery).
// Transition Closed→Open: after `threshold` consecutive failures.
// Transition Open→HalfOpen: after `openTTL` has elapsed.
// Transition HalfOpen→Closed: on the first successful request.
// Transition HalfOpen→Open: on failure during half-open test.
package circuitbreaker
import (
"sync"
"time"
)
// State represents the circuit breaker state for a provider.
type State int
const (
Closed State = iota // Normal — requests allowed
Open // Tripped — requests rejected
HalfOpen // Recovery probe — one request allowed
)
func (s State) String() string {
switch s {
case Closed:
return "closed"
case Open:
return "open"
case HalfOpen:
return "half_open"
default:
return "unknown"
}
}
// Status is the read-only snapshot returned by the API.
type Status struct {
Provider string `json:"provider"`
State string `json:"state"`
Failures int `json:"failures"`
OpenedAt string `json:"opened_at,omitempty"` // RFC3339, only when Open/HalfOpen
}
type entry struct {
state State
failures int
openedAt time.Time
// halfOpenInFlight prevents concurrent requests during HalfOpen probe.
halfOpenInFlight bool
}
// Breaker is a thread-safe circuit breaker for multiple providers.
type Breaker struct {
mu sync.Mutex
states map[string]*entry
threshold int
openTTL time.Duration
}
// New creates a Breaker.
// - threshold: consecutive failures before opening the circuit.
// - openTTL: how long to wait in Open state before transitioning to HalfOpen.
func New(threshold int, openTTL time.Duration) *Breaker {
return &Breaker{
states: make(map[string]*entry),
threshold: threshold,
openTTL: openTTL,
}
}
func (b *Breaker) get(provider string) *entry {
e, ok := b.states[provider]
if !ok {
e = &entry{state: Closed}
b.states[provider] = e
}
return e
}
// Allow returns true if a request to the given provider should proceed.
// It also handles the Open→HalfOpen transition when the TTL has expired.
func (b *Breaker) Allow(provider string) bool {
b.mu.Lock()
defer b.mu.Unlock()
e := b.get(provider)
switch e.state {
case Closed:
return true
case Open:
if time.Since(e.openedAt) >= b.openTTL {
// Transition to HalfOpen — allow exactly one probe.
if !e.halfOpenInFlight {
e.state = HalfOpen
e.halfOpenInFlight = true
return true
}
}
return false
case HalfOpen:
// Only one in-flight request allowed during HalfOpen.
if !e.halfOpenInFlight {
e.halfOpenInFlight = true
return true
}
return false
}
return true
}
// Success records a successful response from a provider.
// Any non-Open circuit resets the failure counter; HalfOpen transitions to Closed.
func (b *Breaker) Success(provider string) {
b.mu.Lock()
defer b.mu.Unlock()
e := b.get(provider)
e.failures = 0
e.state = Closed
e.halfOpenInFlight = false
}
// Failure records a failed response from a provider.
// If threshold is reached the circuit transitions to Open.
// A failure during HalfOpen re-opens the circuit immediately.
func (b *Breaker) Failure(provider string) {
b.mu.Lock()
defer b.mu.Unlock()
e := b.get(provider)
e.halfOpenInFlight = false
switch e.state {
case Closed:
e.failures++
if e.failures >= b.threshold {
e.state = Open
e.openedAt = time.Now()
}
case HalfOpen:
// Re-open immediately.
e.state = Open
e.openedAt = time.Now()
e.failures++
}
}
// Status returns a read-only snapshot of the circuit state for a provider.
func (b *Breaker) Status(provider string) Status {
b.mu.Lock()
defer b.mu.Unlock()
e := b.get(provider)
s := Status{
Provider: provider,
State: e.state.String(),
Failures: e.failures,
}
if e.state == Open || e.state == HalfOpen {
s.OpenedAt = e.openedAt.Format(time.RFC3339)
}
return s
}
// Statuses returns snapshots for all known providers.
func (b *Breaker) Statuses() []Status {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]Status, 0, len(b.states))
for name, e := range b.states {
s := Status{
Provider: name,
State: e.state.String(),
Failures: e.failures,
}
if e.state == Open || e.state == HalfOpen {
s.OpenedAt = e.openedAt.Format(time.RFC3339)
}
out = append(out, s)
}
return out
}