Skip to content

Commit 957642f

Browse files
feat: add circuit breaker package for API resilience
- Three states: Closed, Open, HalfOpen - Configurable max failures, timeout, and half-open max calls - Manager for multiple named breakers - Generic CallWithResult for typed returns - Thread-safe with RWMutex Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent bd7e1ae commit 957642f

2 files changed

Lines changed: 430 additions & 0 deletions

File tree

‎circuit/circuit.go‎

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Package circuit provides a circuit breaker pattern for resilience.
2+
// When failures exceed a threshold, the circuit opens and fails fast.
3+
// After a timeout, the circuit transitions to half-open to test recovery.
4+
package circuit
5+
6+
import (
7+
"errors"
8+
"sync"
9+
"time"
10+
)
11+
12+
// State represents the circuit breaker state.
13+
type State int
14+
15+
const (
16+
Closed State = iota // Normal operation
17+
Open // Failing fast
18+
HalfOpen // Testing recovery
19+
)
20+
21+
func (s State) String() string {
22+
switch s {
23+
case Closed:
24+
return "closed"
25+
case Open:
26+
return "open"
27+
case HalfOpen:
28+
return "half-open"
29+
default:
30+
return "unknown"
31+
}
32+
}
33+
34+
// Breaker is a circuit breaker.
35+
type Breaker struct {
36+
mu sync.RWMutex
37+
state State
38+
failures int
39+
lastFailureTime time.Time
40+
successCount int
41+
42+
maxFailures int
43+
timeout time.Duration
44+
halfOpenMaxCalls int
45+
}
46+
47+
// ErrOpen is returned when the circuit is open.
48+
var ErrOpen = errors.New("circuit breaker is open")
49+
50+
// Config configures a circuit breaker.
51+
type Config struct {
52+
MaxFailures int
53+
Timeout time.Duration
54+
HalfOpenMaxCalls int
55+
}
56+
57+
// DefaultConfig returns sensible defaults.
58+
func DefaultConfig() Config {
59+
return Config{
60+
MaxFailures: 5,
61+
Timeout: 30 * time.Second,
62+
HalfOpenMaxCalls: 3,
63+
}
64+
}
65+
66+
// New creates a new circuit breaker.
67+
func New(cfg Config) *Breaker {
68+
return &Breaker{
69+
maxFailures: cfg.MaxFailures,
70+
timeout: cfg.Timeout,
71+
halfOpenMaxCalls: cfg.HalfOpenMaxCalls,
72+
}
73+
}
74+
75+
// State returns the current state.
76+
func (b *Breaker) State() State {
77+
b.mu.RLock()
78+
defer b.mu.RUnlock()
79+
return b.state
80+
}
81+
82+
// Allow checks if a call should be allowed.
83+
func (b *Breaker) Allow() bool {
84+
b.mu.Lock()
85+
defer b.mu.Unlock()
86+
87+
if b.state == Open {
88+
if time.Since(b.lastFailureTime) > b.timeout {
89+
b.state = HalfOpen
90+
b.successCount = 0
91+
return true
92+
}
93+
return false
94+
}
95+
96+
if b.state == HalfOpen && b.successCount >= b.halfOpenMaxCalls {
97+
b.state = Closed
98+
b.failures = 0
99+
b.successCount = 0
100+
}
101+
102+
return true
103+
}
104+
105+
// RecordSuccess records a successful call.
106+
func (b *Breaker) RecordSuccess() {
107+
b.mu.Lock()
108+
defer b.mu.Unlock()
109+
110+
if b.state == HalfOpen {
111+
b.successCount++
112+
if b.successCount >= b.halfOpenMaxCalls {
113+
b.state = Closed
114+
b.failures = 0
115+
b.successCount = 0
116+
}
117+
return
118+
}
119+
120+
if b.state == Closed {
121+
b.failures = 0
122+
}
123+
}
124+
125+
// RecordFailure records a failed call.
126+
func (b *Breaker) RecordFailure() {
127+
b.mu.Lock()
128+
defer b.mu.Unlock()
129+
130+
b.failures++
131+
b.lastFailureTime = time.Now()
132+
133+
if b.state == HalfOpen {
134+
b.state = Open
135+
return
136+
}
137+
138+
if b.failures >= b.maxFailures {
139+
b.state = Open
140+
}
141+
}
142+
143+
// Call executes fn if the circuit allows it.
144+
func (b *Breaker) Call(fn func() error) error {
145+
if !b.Allow() {
146+
return ErrOpen
147+
}
148+
149+
err := fn()
150+
if err != nil {
151+
b.RecordFailure()
152+
return err
153+
}
154+
155+
b.RecordSuccess()
156+
return nil
157+
}
158+
159+
// CallWithResult executes fn if the circuit allows it.
160+
func CallWithResult[T any](b *Breaker, fn func() (T, error)) (T, error) {
161+
var zero T
162+
if !b.Allow() {
163+
return zero, ErrOpen
164+
}
165+
166+
result, err := fn()
167+
if err != nil {
168+
b.RecordFailure()
169+
return zero, err
170+
}
171+
172+
b.RecordSuccess()
173+
return result, nil
174+
}
175+
176+
// Stats returns breaker statistics.
177+
type Stats struct {
178+
State string `json:"state"`
179+
Failures int `json:"failures"`
180+
LastFailure time.Time `json:"last_failure,omitempty"`
181+
}
182+
183+
// Stats returns current statistics.
184+
func (b *Breaker) Stats() Stats {
185+
b.mu.RLock()
186+
defer b.mu.RUnlock()
187+
return Stats{
188+
State: b.state.String(),
189+
Failures: b.failures,
190+
LastFailure: b.lastFailureTime,
191+
}
192+
}
193+
194+
// Manager manages multiple named circuit breakers.
195+
type Manager struct {
196+
mu sync.RWMutex
197+
breakers map[string]*Breaker
198+
config Config
199+
}
200+
201+
// NewManager creates a new circuit breaker manager.
202+
func NewManager(cfg Config) *Manager {
203+
return &Manager{
204+
breakers: make(map[string]*Breaker),
205+
config: cfg,
206+
}
207+
}
208+
209+
// Get returns a breaker by name, creating it if needed.
210+
func (m *Manager) Get(name string) *Breaker {
211+
m.mu.RLock()
212+
b, ok := m.breakers[name]
213+
m.mu.RUnlock()
214+
if ok {
215+
return b
216+
}
217+
218+
m.mu.Lock()
219+
defer m.mu.Unlock()
220+
// Double-check
221+
if b, ok := m.breakers[name]; ok {
222+
return b
223+
}
224+
b = New(m.config)
225+
m.breakers[name] = b
226+
return b
227+
}
228+
229+
// List returns all breaker names and their stats.
230+
func (m *Manager) List() map[string]Stats {
231+
m.mu.RLock()
232+
defer m.mu.RUnlock()
233+
234+
out := make(map[string]Stats, len(m.breakers))
235+
for name, b := range m.breakers {
236+
out[name] = b.Stats()
237+
}
238+
return out
239+
}

0 commit comments

Comments
 (0)