resiliency
packageAPI reference for the resiliency
package.
Imports
(9)RateLimiter
RateLimiter limits the rate of operations using a token bucket algorithm.
type RateLimiter struct
Example
rl, err := resiliency.NewRateLimiter(100, 50)
if err := rl.Wait(ctx); err != nil { ... }
Methods
Allow reports whether an operation is allowed now without blocking.
Returns
func (*RateLimiter) Allow() bool
{
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
rl.tokens += rl.rate * now.Sub(rl.lastTime).Seconds()
rl.lastTime = now
if rl.tokens > rl.burst {
rl.tokens = rl.burst
}
if rl.tokens >= 1 {
rl.tokens--
return true
}
return false
}
Wait blocks until a token is available or the context is cancelled.
Parameters
Returns
func (*RateLimiter) Wait(ctx context.Context) error
{
for {
if rl.Allow() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(math.Ceil(1.0/rl.rate*1e9)) * time.Nanosecond):
}
}
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex | |
| tokens | float64 | |
| rate | float64 | |
| burst | float64 | |
| lastTime | time.Time |
NewRateLimiter
NewRateLimiter creates a token bucket rate limiter.
Parameters
Returns
func NewRateLimiter(rate, burst int) (*RateLimiter, error)
{
if rate <= 0 {
return nil, fmt.Errorf("resiliency: rate must be positive")
}
if burst <= 0 {
return nil, fmt.Errorf("resiliency: burst must be positive")
}
return &RateLimiter{
tokens: float64(burst),
rate: float64(rate),
burst: float64(burst),
lastTime: time.Now(),
}, nil
}
Bulkhead
Bulkhead limits the number of concurrent operations with a queue.
type Bulkhead struct
Example
bh, err := resiliency.NewBulkhead(10, 5)
if err := bh.Execute(ctx, func() error { ... }); err != nil { ... }
Methods
Execute runs fn if a slot is available, or returns ErrBulkheadFull if the queue is full.
Parameters
Returns
func (*Bulkhead) Execute(ctx context.Context, fn func() error) error
{
select {
case b.slots <- struct{}{}:
return b.run(fn)
default:
}
select {
case b.queue <- struct{}{}:
default:
return ErrBulkheadFull
}
select {
case b.slots <- struct{}{}:
<-b.queue
return b.run(fn)
case <-ctx.Done():
<-b.queue
return ctx.Err()
}
}
Parameters
Returns
func (*Bulkhead) run(fn func() error) error
{
defer func() {
<-b.slots
}()
return fn()
}
Fields
| Name | Type | Description |
|---|---|---|
| slots | chan struct{} | |
| queue | chan struct{} |
NewBulkhead
NewBulkhead creates a bulkhead with maxConcurrent slots and maxQueue waiters.
Parameters
Returns
func NewBulkhead(maxConcurrent, maxQueue int) (*Bulkhead, error)
{
if maxConcurrent <= 0 {
return nil, fmt.Errorf("resiliency: maxConcurrent must be positive")
}
if maxQueue < 0 {
return nil, fmt.Errorf("resiliency: maxQueue cannot be negative")
}
return &Bulkhead{
slots: make(chan struct{}, maxConcurrent),
queue: make(chan struct{}, maxQueue),
}, nil
}
TestNewRateLimiterRejectsInvalidConfiguration
Parameters
func TestNewRateLimiterRejectsInvalidConfiguration(t *testing.T)
{
for _, test := range []struct {
rate int
burst int
}{
{rate: 0, burst: 1},
{rate: 1, burst: 0},
{rate: -1, burst: 1},
} {
if _, err := NewRateLimiter(test.rate, test.burst); err == nil {
t.Fatalf("NewRateLimiter(%d, %d) accepted invalid values", test.rate, test.burst)
}
}
}
TestBulkheadEnforcesConcurrencyAndQueueLimits
Parameters
func TestBulkheadEnforcesConcurrencyAndQueueLimits(t *testing.T)
{
bulkhead, err := NewBulkhead(1, 1)
if err != nil {
t.Fatal(err)
}
release := make(chan struct{})
started := make(chan struct{}, 2)
var running atomic.Int32
var peak atomic.Int32
run := func() error {
current := running.Add(1)
for {
previous := peak.Load()
if current <= previous || peak.CompareAndSwap(previous, current) {
break
}
}
started <- struct{}{}
<-release
running.Add(-1)
return nil
}
first := make(chan error, 1)
second := make(chan error, 1)
go func() { first <- bulkhead.Execute(context.Background(), run) }()
<-started
go func() { second <- bulkhead.Execute(context.Background(), run) }()
deadline := time.Now().Add(time.Second)
for len(bulkhead.queue) != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if err := bulkhead.Execute(context.Background(), run); !errors.Is(err, ErrBulkheadFull) {
t.Fatalf("third Execute() error = %v, want ErrBulkheadFull", err)
}
release <- struct{}{}
<-started
release <- struct{}{}
if err := <-first; err != nil {
t.Fatal(err)
}
if err := <-second; err != nil {
t.Fatal(err)
}
if got := peak.Load(); got != 1 {
t.Fatalf("peak concurrency = %d, want 1", got)
}
}
TestNewBulkheadRejectsInvalidConfiguration
Parameters
func TestNewBulkheadRejectsInvalidConfiguration(t *testing.T)
{
if _, err := NewBulkhead(0, 1); err == nil {
t.Fatal("NewBulkhead() accepted zero concurrency")
}
if _, err := NewBulkhead(1, -1); err == nil {
t.Fatal("NewBulkhead() accepted a negative queue")
}
}
RetryOptions
RetryOptions configures retry behavior.
type RetryOptions struct
Fields
| Name | Type | Description |
|---|---|---|
| Attempts | int | |
| InitialDelay | time.Duration | |
| MaxDelay | time.Duration | |
| Factor | float64 | |
| Jitter | float64 | |
| RetryIf | func(error) bool |
Retry
Retry executes fn up to Attempts times with exponential backoff.
Parameters
Returns
func Retry(ctx context.Context, fn func() error, opts ...func(*RetryOptions)) error
{
if fn == nil {
return errors.New("resiliency: retry function cannot be nil")
}
o := DefaultRetryOptions
for _, opt := range opts {
opt(&o)
}
if err := validateRetryOptions(o); err != nil {
return err
}
var lastErr error
for i := 0; i < o.Attempts; i++ {
if err := ctx.Err(); err != nil {
return err
}
if err := fn(); err == nil {
return nil
} else {
lastErr = err
if o.RetryIf != nil && !o.RetryIf(err) {
return err
}
}
if i < o.Attempts-1 {
delay := time.Duration(float64(o.InitialDelay) * math.Pow(o.Factor, float64(i)))
if delay > o.MaxDelay {
delay = o.MaxDelay
}
if o.Jitter > 0 {
jitter := time.Duration(float64(delay) * o.Jitter * rand.Float64())
delay += jitter
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
return lastErr
}
Example
err := resiliency.Retry(ctx, func() error {
return doNetworkCall()
}, resiliency.WithAttempts(5))
validateRetryOptions
Parameters
Returns
func validateRetryOptions(options RetryOptions) error
{
switch {
case options.Attempts < 1:
return errors.New("resiliency: retry attempts must be at least 1")
case options.InitialDelay < 0:
return errors.New("resiliency: initial delay cannot be negative")
case options.MaxDelay < options.InitialDelay:
return errors.New("resiliency: max delay cannot be less than initial delay")
case math.IsNaN(options.Factor) || math.IsInf(options.Factor, 0) || options.Factor < 1:
return fmt.Errorf("resiliency: retry factor must be finite and at least 1")
case math.IsNaN(options.Jitter) || math.IsInf(options.Jitter, 0) || options.Jitter < 0 || options.Jitter > 1:
return errors.New("resiliency: jitter must be between 0 and 1")
default:
return nil
}
}
WithAttempts
WithAttempts sets the maximum number of retry attempts.
Parameters
Returns
func WithAttempts(n int) func(*RetryOptions)
{
return func(o *RetryOptions) { o.Attempts = n }
}
WithDelay
WithDelay sets the initial and max delay for backoff.
Parameters
Returns
func WithDelay(initial, max time.Duration) func(*RetryOptions)
{
return func(o *RetryOptions) {
o.InitialDelay = initial
o.MaxDelay = max
}
}
WithFactor
WithFactor sets the backoff factor.
Parameters
Returns
func WithFactor(f float64) func(*RetryOptions)
{
return func(o *RetryOptions) { o.Factor = f }
}
WithJitter
WithJitter adds random jitter to the retry delay to prevent thundering herd.
Parameters
Returns
func WithJitter(jitterFactor float64) func(*RetryOptions)
{
return func(o *RetryOptions) {
o.Jitter = jitterFactor
}
}
Example
resiliency.Retry(ctx, fn,
resiliency.WithJitter(0.3),
)
WithRetryIf
WithRetryIf adds a condition for retrying based on the error.
Parameters
Returns
func WithRetryIf(fn func(error) bool) func(*RetryOptions)
{
return func(o *RetryOptions) {
o.RetryIf = fn
}
}
Example
resiliency.Retry(ctx, fn,
resiliency.WithRetryIf(func(err error) bool {
return !errors.Is(err, ErrFatal)
}),
)
TestRetry
Parameters
func TestRetry(t *testing.T)
{
t.Run("SuccessFirstTry", func(t *testing.T) {
calls := 0
err := Retry(context.Background(), func() error {
calls++
return nil
})
if err != nil || calls != 1 {
t.Errorf("Retry failed: %v, calls=%d", err, calls)
}
})
t.Run("SuccessAfterRetries", func(t *testing.T) {
calls := 0
err := Retry(context.Background(), func() error {
calls++
if calls < 3 {
return errors.New("fail")
}
return nil
}, WithAttempts(5), WithDelay(1*time.Millisecond, 10*time.Millisecond))
if err != nil || calls != 3 {
t.Errorf("Retry failed: %v, calls=%d", err, calls)
}
})
t.Run("FailureAllAttempts", func(t *testing.T) {
calls := 0
targetErr := errors.New("permanent fail")
err := Retry(context.Background(), func() error {
calls++
return targetErr
}, WithAttempts(3), WithDelay(1*time.Millisecond, 1*time.Millisecond))
if err != targetErr || calls != 3 {
t.Errorf("Retry should return last error: %v, calls=%d", err, calls)
}
})
t.Run("ContextCancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
err := Retry(ctx, func() error {
calls++
return errors.New("fail")
})
if err != context.Canceled || calls != 0 {
t.Errorf("Retry should stop on context cancel: %v", err)
}
})
}
TestRetryRejectsInvalidOptions
Parameters
func TestRetryRejectsInvalidOptions(t *testing.T)
{
calls := 0
fn := func() error {
calls++
return nil
}
tests := []func(*RetryOptions){
WithAttempts(0),
WithDelay(-time.Second, time.Second),
WithDelay(time.Second, 0),
WithFactor(0),
WithJitter(2),
}
for _, option := range tests {
if err := Retry(context.Background(), fn, option); err == nil {
t.Fatal("Retry() accepted invalid options")
}
}
if calls != 0 {
t.Fatalf("Retry() called function %d times for invalid options", calls)
}
}
State
State represents the current state of a CircuitBreaker.
type State int
CircuitBreaker
CircuitBreaker protects a caller from repeated failures.
It implements a state machine with Closed, Open, and Half-Open states.
type CircuitBreaker struct
Example
cb := resiliency.NewCircuitBreaker(3, time.Minute)
err := cb.Execute(func() error {
return doRiskyOperation()
})
Methods
OnStateChange registers a callback for state transitions.
Parameters
func (*CircuitBreaker) OnStateChange(fn func(from, to State))
{
cb.mu.Lock()
defer cb.mu.Unlock()
cb.onStateChange = fn
}
Execute calls fn if the circuit allows it, recording the outcome to drive state transitions. It returns ErrCircuitOpen when the circuit is in the Open state, otherwise it returns the error (if any) produced by fn.
Parameters
Returns
func (*CircuitBreaker) Execute(fn func() error) error
{
if !cb.allow() {
return ErrCircuitOpen
}
err := fn()
if err != nil {
cb.onFailure()
return err
}
cb.onSuccess()
return nil
}
allow returns true if the current state permits a call through.
Returns
func (*CircuitBreaker) allow() bool
{
cb.mu.Lock()
var callback func(State, State)
var from State
allowed := false
switch cb.state {
case StateClosed:
allowed = true
case StateOpen:
if time.Since(cb.lastFailure) > cb.timeout {
atomic.StoreInt32(&cb.probing, 0)
callback, from = cb.changeStateLocked(StateHalfOpen)
allowed = atomic.CompareAndSwapInt32(&cb.probing, 0, 1)
}
case StateHalfOpen:
allowed = atomic.CompareAndSwapInt32(&cb.probing, 0, 1)
}
cb.mu.Unlock()
if callback != nil {
callback(from, StateHalfOpen)
}
return allowed
}
onSuccess records a successful call and transitions out of HalfOpen if needed.
func (*CircuitBreaker) onSuccess()
{
cb.mu.Lock()
var callback func(State, State)
var from State
if cb.state == StateHalfOpen {
atomic.StoreInt32(&cb.probing, 0)
callback, from = cb.changeStateLocked(StateClosed)
}
cb.failures = 0
cb.mu.Unlock()
if callback != nil {
callback(from, StateClosed)
}
}
onFailure records a failed call and trips the circuit when the threshold is reached.
func (*CircuitBreaker) onFailure()
{
cb.mu.Lock()
var callback func(State, State)
var from State
to := cb.state
cb.failures++
cb.lastFailure = time.Now()
if cb.state == StateClosed && cb.failures >= cb.threshold {
to = StateOpen
callback, from = cb.changeStateLocked(to)
} else if cb.state == StateHalfOpen {
atomic.StoreInt32(&cb.probing, 0)
cb.failures = 0
to = StateOpen
callback, from = cb.changeStateLocked(to)
}
cb.mu.Unlock()
if callback != nil {
callback(from, to)
}
}
func (*CircuitBreaker) changeStateLocked(to State) (func(State, State), State)
{
from := cb.state
cb.state = to
return cb.onStateChange, from
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.RWMutex | |
| state | State | |
| failures | int | |
| threshold | int | |
| timeout | time.Duration | |
| lastFailure | time.Time | |
| onStateChange | func(from, to State) | |
| probing | int32 |
Uses
NewCircuitBreaker
NewCircuitBreaker creates a new circuit breaker.
Parameters
Returns
func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker
{
return &CircuitBreaker{
threshold: threshold,
timeout: timeout,
state: StateClosed,
}
}
TestCircuitBreaker
Parameters
func TestCircuitBreaker(t *testing.T)
{
cb := NewCircuitBreaker(2, 50*time.Millisecond)
// State: Closed
err := cb.Execute(func() error { return nil })
if err != nil || cb.State() != StateClosed {
t.Error("Circuit should be closed and return nil")
}
// First failure
_ = cb.Execute(func() error { return errors.New("fail1") })
if cb.State() != StateClosed {
t.Error("Circuit should still be closed after 1 failure")
}
// Second failure -> State: Open
_ = cb.Execute(func() error { return errors.New("fail2") })
if cb.State() != StateOpen {
t.Error("Circuit should be open after reaching threshold")
}
// While Open
err = cb.Execute(func() error { return nil })
if err != ErrCircuitOpen {
t.Errorf("Execute should return ErrCircuitOpen when open, got %v", err)
}
// Wait for timeout -> State: Half-Open (on next Execute)
time.Sleep(60 * time.Millisecond)
// First success in Half-Open -> State: Closed
err = cb.Execute(func() error { return nil })
if err != nil || cb.State() != StateClosed {
t.Errorf("Circuit should be closed after success in half-open, got state %v", cb.State())
}
}
TestHalfOpenAllowsOnlyOneProbe
TestHalfOpenAllowsOnlyOneProbe verifies that in HalfOpen state only one
concurrent request is allowed through; all others receive ErrCircuitOpen.
Parameters
func TestHalfOpenAllowsOnlyOneProbe(t *testing.T)
{
const timeout = 30 * time.Millisecond
cb := NewCircuitBreaker(1, timeout)
// Trip the breaker.
_ = cb.Execute(func() error { return errors.New("fail") })
if cb.State() != StateOpen {
t.Fatal("expected StateOpen after threshold reached")
}
// Wait for the open timeout to elapse so the next allow() transitions to HalfOpen.
time.Sleep(timeout + 10*time.Millisecond)
// Fire 5 concurrent requests; use a gate so they all call allow() at the same time.
const n = 5
var (
wg sync.WaitGroup
gate = make(chan struct{})
allowed int32
blocked int32
)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-gate
err := cb.Execute(func() error {
// Slow probe so concurrent goroutines can observe HalfOpen.
time.Sleep(20 * time.Millisecond)
return nil
})
if err == ErrCircuitOpen {
atomic.AddInt32(&blocked, 1)
} else if err == nil {
atomic.AddInt32(&allowed, 1)
}
}()
}
close(gate)
wg.Wait()
if allowed != 1 {
t.Errorf("expected exactly 1 probe allowed, got %d", allowed)
}
if blocked != n-1 {
t.Errorf("expected %d blocked, got %d", n-1, blocked)
}
if cb.State() != StateClosed {
t.Errorf("expected StateClosed after successful probe, got %v", cb.State())
}
}
TestHalfOpenFailureResetsCounter
TestHalfOpenFailureResetsCounter checks that a new HalfOpen cycle starts clean.
the failure counter is reset, so a fresh probe failure in the new HalfOpen
correctly re-opens without a stale count.
Parameters
func TestHalfOpenFailureResetsCounter(t *testing.T)
{
const timeout = 30 * time.Millisecond
cb := NewCircuitBreaker(2, timeout)
// Reach threshold to open breaker (2 failures).
_ = cb.Execute(func() error { return errors.New("f1") })
_ = cb.Execute(func() error { return errors.New("f2") })
if cb.State() != StateOpen {
t.Fatal("expected StateOpen")
}
// The first HalfOpen probe fails and returns to Open.
time.Sleep(timeout + 10*time.Millisecond)
_ = cb.Execute(func() error { return errors.New("probe fail") })
if cb.State() != StateOpen {
t.Fatalf("expected StateOpen after half-open probe failure, got %v", cb.State())
}
// Second HalfOpen: failures counter must have been reset to 0 on the previous
// A single failure in the new HalfOpen cycle should
// send the breaker back to Open immediately (not leave it closed/half-open).
time.Sleep(timeout + 10*time.Millisecond)
_ = cb.Execute(func() error { return errors.New("probe fail 2") })
if cb.State() != StateOpen {
t.Errorf("expected StateOpen after second half-open failure, got %v", cb.State())
}
}
TestCircuitBreakerStateCallbackCanReenter
Parameters
func TestCircuitBreakerStateCallbackCanReenter(t *testing.T)
{
cb := NewCircuitBreaker(1, time.Hour)
callbackDone := make(chan struct{})
cb.OnStateChange(func(_, to State) {
if got := cb.State(); got != to {
t.Errorf("State() = %v inside callback, want %v", got, to)
}
cb.OnStateChange(nil)
close(callbackDone)
})
_ = cb.Execute(func() error { return errors.New("failure") })
select {
case <-callbackDone:
case <-time.After(time.Second):
t.Fatal("state callback deadlocked")
}
}