resiliency API

resiliency

package

API reference for the resiliency package.

S
struct

RetryOptions

RetryOptions configures retry behavior.

core/resiliency/retry.go:13-20
type RetryOptions struct

Fields

Name Type Description
Attempts int
InitialDelay time.Duration
MaxDelay time.Duration
Factor float64
Jitter float64
RetryIf func(error) bool
F
function

Retry

Retry executes fn up to Attempts times with exponential backoff.

Parameters

fn
func() error
opts
...func(*RetryOptions)

Returns

error
core/resiliency/retry.go:37-86
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))
F
function

validateRetryOptions

Parameters

options

Returns

error
core/resiliency/retry.go:88-103
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
	}
}
F
function

WithAttempts

WithAttempts sets the maximum number of retry attempts.

Parameters

n
int

Returns

func(*RetryOptions)
core/resiliency/retry.go:106-108
func WithAttempts(n int) func(*RetryOptions)

{
	return func(o *RetryOptions) { o.Attempts = n }
}
F
function

WithDelay

WithDelay sets the initial and max delay for backoff.

Parameters

Returns

func(*RetryOptions)
core/resiliency/retry.go:111-116
func WithDelay(initial, max time.Duration) func(*RetryOptions)

{
	return func(o *RetryOptions) {
		o.InitialDelay = initial
		o.MaxDelay = max
	}
}
F
function

WithFactor

WithFactor sets the backoff factor.

Parameters

f
float64

Returns

func(*RetryOptions)
core/resiliency/retry.go:119-121
func WithFactor(f float64) func(*RetryOptions)

{
	return func(o *RetryOptions) { o.Factor = f }
}
F
function

WithJitter

WithJitter adds random jitter to the retry delay to prevent thundering herd.

Parameters

jitterFactor
float64

Returns

func(*RetryOptions)
core/resiliency/retry.go:130-134
func WithJitter(jitterFactor float64) func(*RetryOptions)

{
	return func(o *RetryOptions) {
		o.Jitter = jitterFactor
	}
}

Example

resiliency.Retry(ctx, fn,
    resiliency.WithJitter(0.3),
)
F
function

WithRetryIf

WithRetryIf adds a condition for retrying based on the error.

Parameters

fn
func(error) bool

Returns

func(*RetryOptions)
core/resiliency/retry.go:145-149
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)
    }),
)
F
function

TestRetry

Parameters

core/resiliency/retry_test.go:10-64
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)
		}
	})
}
F
function

TestRetryRejectsInvalidOptions

Parameters

core/resiliency/retry_test.go:66-87
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)
	}
}
T
type

State

State represents the current state of a CircuitBreaker.

core/resiliency/breaker.go:14-14
type State int
S
struct

CircuitBreaker

CircuitBreaker protects a caller from repeated failures.

It implements a state machine with Closed, Open, and Half-Open states.

core/resiliency/breaker.go:36-45
type CircuitBreaker struct

Example

cb := resiliency.NewCircuitBreaker(3, time.Minute)
err := cb.Execute(func() error {
	return doRiskyOperation()
})

Methods

OnStateChange
Method

OnStateChange registers a callback for state transitions.

Parameters

fn func(from, to State)
func (*CircuitBreaker) OnStateChange(fn func(from, to State))
{
	cb.mu.Lock()
	defer cb.mu.Unlock()
	cb.onStateChange = fn
}
Execute
Method

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

fn func() error

Returns

error
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
Method

allow returns true if the current state permits a call through.

Returns

bool
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
Method

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
Method

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)
	}
}

Parameters

to State

Returns

func(State, State)
func (*CircuitBreaker) changeStateLocked(to State) (func(State, State), State)
{
	from := cb.state
	cb.state = to
	return cb.onStateChange, from
}
State
Method

State returns the current state of the circuit breaker.

Returns

func (*CircuitBreaker) State() State
{
	cb.mu.RLock()
	defer cb.mu.RUnlock()
	return cb.state
}

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
F
function

NewCircuitBreaker

NewCircuitBreaker creates a new circuit breaker.

Parameters

threshold
int
timeout

Returns

core/resiliency/breaker.go:48-54
func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker

{
	return &CircuitBreaker{
		threshold: threshold,
		timeout:   timeout,
		state:     StateClosed,
	}
}
F
function

TestCircuitBreaker

Parameters

core/resiliency/breaker_test.go:11-46
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())
	}
}
F
function

TestHalfOpenAllowsOnlyOneProbe

TestHalfOpenAllowsOnlyOneProbe verifies that in HalfOpen state only one
concurrent request is allowed through; all others receive ErrCircuitOpen.

Parameters

core/resiliency/breaker_test.go:50-102
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())
	}
}
F
function

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

core/resiliency/breaker_test.go:107-133
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())
	}
}
F
function

TestCircuitBreakerStateCallbackCanReenter

Parameters

core/resiliency/breaker_test.go:135-153
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")
	}
}
S
struct

RateLimiter

RateLimiter limits the rate of operations using a token bucket algorithm.

core/resiliency/ratelimit.go:21-27
type RateLimiter struct

Example

rl, err := resiliency.NewRateLimiter(100, 50)
if err := rl.Wait(ctx); err != nil { ... }

Methods

Allow
Method

Allow reports whether an operation is allowed now without blocking.

Returns

bool
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
Method

Wait blocks until a token is available or the context is cancelled.

Parameters

Returns

error
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
F
function

NewRateLimiter

NewRateLimiter creates a token bucket rate limiter.

Parameters

rate
int
burst
int

Returns

error
core/resiliency/ratelimit.go:30-43
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
}
S
struct

Bulkhead

Bulkhead limits the number of concurrent operations with a queue.

core/resiliency/ratelimit.go:84-87
type Bulkhead struct

Example

bh, err := resiliency.NewBulkhead(10, 5)
if err := bh.Execute(ctx, func() error { ... }); err != nil { ... }

Methods

Execute
Method

Execute runs fn if a slot is available, or returns ErrBulkheadFull if the queue is full.

Parameters

fn func() error

Returns

error
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()
	}
}
run
Method

Parameters

fn func() error

Returns

error
func (*Bulkhead) run(fn func() error) error
{
	defer func() {
		<-b.slots
	}()
	return fn()
}

Fields

Name Type Description
slots chan struct{}
queue chan struct{}
F
function

NewBulkhead

NewBulkhead creates a bulkhead with maxConcurrent slots and maxQueue waiters.

Parameters

maxConcurrent
int
maxQueue
int

Returns

error
core/resiliency/ratelimit.go:90-101
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
}
F
function

TestNewRateLimiterRejectsInvalidConfiguration

Parameters

core/resiliency/ratelimit_test.go:11-24
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)
		}
	}
}
F
function

TestBulkheadEnforcesConcurrencyAndQueueLimits

Parameters

core/resiliency/ratelimit_test.go:26-75
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)
	}
}
F
function

TestNewBulkheadRejectsInvalidConfiguration

Parameters

core/resiliency/ratelimit_test.go:77-84
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")
	}
}