188 lines
4.3 KiB
Go
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
|
|
}
|