resilience

package
v0.25.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package resilience provides composable fault-tolerance primitives for Go services — a circuit breaker, retry with backoff, timeout, bulkhead (concurrency limiter), and a token-bucket rate limiter, modelled on gobreaker / resilience4j.

Every primitive shares the same shape so they compose cleanly:

type Operation func(ctx context.Context) (any, error)
type Middleware func(Operation) Operation

A primitive exposes Execute(ctx, fn) for runtime use and a Middleware() method for static composition. Wrap chains middlewares outermost-first:

guard := resilience.Wrap(
    breaker.Middleware(),   // outermost: sees retry's final result
    retry.Middleware(),     // re-runs the timeout-wrapped call
    timeout.Middleware(),   // innermost: bounds each attempt
)
out, err := guard(fn)(ctx)

The generic Do[T] helpers recover the concrete return type without the caller writing a type assertion.

All primitives are goroutine-safe and honour context cancellation. Time is read through an injectable Clock so timing behaviour can be tested deterministically (see NewFakeClock).

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrBulkheadFull = errors.New("resilience: bulkhead is full")

ErrBulkheadFull is returned when a Bulkhead has no free execution slot and no free queue slot, so the call is rejected immediately rather than piling on.

View Source
var ErrOpenCircuit = errors.New("resilience: circuit breaker is open")

ErrOpenCircuit is returned by the breaker when the circuit is open (or the half-open trial quota is exhausted) and the call is short-circuited.

View Source
var ErrRateLimited = errors.New("resilience: rate limited")

ErrRateLimited is returned by the non-blocking Allow path when no token is available. Execute does not return it — Execute blocks until a token frees up or ctx is cancelled.

View Source
var ErrTimeout = errors.New("resilience: operation timed out")

ErrTimeout is returned by Timeout when fn does not complete within the configured duration. The derived context passed to fn is cancelled at the same moment, so a well-behaved fn observes ctx.Done() too.

Functions

func Do

func Do[T any](ctx context.Context, mw Middleware, fn func(ctx context.Context) (T, error)) (T, error)

Do adapts a typed function into an Operation, runs it through mw, and returns the result reasserted to T. A nil mw runs fn directly. If the underlying Operation returns a value that is not a T (only possible when an outer layer substitutes a fallback), the zero value of T is returned alongside any error.

Types

type Backoff added in v0.25.0

type Backoff func(attempt int) time.Duration

Backoff computes how long to wait before the attempt-th retry. attempt is 1-based: attempt 1 is the wait after the first call failed, attempt 2 the wait after the second, and so on. A non-positive return means "retry immediately". Implementations must be safe for concurrent use.

func ConstantBackoff added in v0.25.0

func ConstantBackoff(delay time.Duration) Backoff

ConstantBackoff waits the same delay before every retry.

func ExponentialBackoff added in v0.25.0

func ExponentialBackoff(base time.Duration, factor float64, max time.Duration) Backoff

ExponentialBackoff waits base * factor^(attempt-1), capped at max (max <= 0 means uncapped). factor <= 0 is treated as 2.

func ExponentialJitterBackoff added in v0.25.0

func ExponentialJitterBackoff(base time.Duration, factor float64, max time.Duration, src JitterSource) Backoff

ExponentialJitterBackoff applies full jitter to ExponentialBackoff: the wait is a uniform random value in [0, exp(attempt)]. src supplies the randomness; pass NewLCGJitter(seed) for deterministic tests. A nil src falls back to a fixed-seed source.

type Breaker

type Breaker struct {
	// contains filtered or unexported fields
}

Breaker is a goroutine-safe circuit breaker.

Example

ExampleBreaker drives a circuit breaker through its full lifecycle using an injected FakeClock so the cooldown is deterministic: consecutive failures trip it OPEN, further calls are rejected fast with ErrOpenCircuit, advancing the clock past the cooldown promotes it to HALF-OPEN, and a trial success CLOSES it again.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/devituz/lagodev/resilience"
)

func main() {
	clock := resilience.NewFakeClock(time.Unix(0, 0))

	br := resilience.NewBreaker(resilience.BreakerConfig{
		Name:                "payments",
		ConsecutiveFailures: 2,               // trip after 2 failures in a row
		OpenTimeout:         5 * time.Second, // cooldown before half-open
		Clock:               clock,
		OnStateChange: func(name string, from, to resilience.State) {
			fmt.Printf("%s -> %s\n", from, to)
		},
	})

	ctx := context.Background()
	boom := errors.New("dependency down")
	fail := func(context.Context) (any, error) { return nil, boom }
	ok := func(context.Context) (any, error) { return "ok", nil }

	// Two consecutive failures trip the breaker open.
	_, _ = br.Execute(ctx, fail)
	_, _ = br.Execute(ctx, fail)
	fmt.Println("state:", br.State())

	// While open, calls are short-circuited without invoking fn.
	_, err := br.Execute(ctx, ok)
	fmt.Println("rejected fast:", errors.Is(err, resilience.ErrOpenCircuit))

	// Advance past the cooldown: the next observation promotes to half-open.
	clock.Advance(5 * time.Second)
	fmt.Println("state:", br.State())

	// A successful trial call closes the breaker again.
	if _, err := br.Execute(ctx, ok); err != nil {
		fmt.Println("trial:", err)
	}
	fmt.Println("state:", br.State())

}
Output:
closed -> open
state: open
rejected fast: true
open -> half-open
state: half-open
half-open -> closed
state: closed

func NewBreaker

func NewBreaker(cfg BreakerConfig) *Breaker

NewBreaker builds a Breaker from cfg, filling in defaults.

func (*Breaker) Counts

func (b *Breaker) Counts() Counts

Counts returns a snapshot of the current generation's tally.

func (*Breaker) Execute

func (b *Breaker) Execute(ctx context.Context, fn func(ctx context.Context) (any, error)) (any, error)

Execute runs fn under the breaker. It returns ErrOpenCircuit without invoking fn when the circuit is open or the half-open quota is exhausted.

func (*Breaker) Middleware

func (b *Breaker) Middleware() Middleware

Middleware adapts the breaker for use with Wrap.

func (*Breaker) State

func (b *Breaker) State() State

State returns the breaker's current state (recomputing an expired open circuit into half-open if its timeout has passed).

type BreakerConfig

type BreakerConfig struct {
	// Name is included in callbacks for observability.
	Name string
	// MaxRequests is the number of trial calls allowed in half-open before
	// the breaker decides. Defaults to 1.
	MaxRequests uint32
	// OpenTimeout is how long the breaker stays open before moving to
	// half-open. Defaults to 60s.
	OpenTimeout time.Duration
	// MinRequests is the minimum number of requests in a generation before
	// the failure-ratio threshold is considered. Defaults to 0 (always
	// considered).
	MinRequests uint32
	// FailureRatio trips the breaker when the failure ratio meets or
	// exceeds this value (and MinRequests is satisfied). 0 disables the
	// ratio rule.
	FailureRatio float64
	// ConsecutiveFailures trips the breaker when this many calls fail in a
	// row. 0 disables the consecutive rule. If both rules are disabled the
	// default ConsecutiveFailures of 5 is applied.
	ConsecutiveFailures uint32
	// IsSuccessful classifies an error as a success (true) or failure
	// (false). Defaults to "nil error == success". Use it to treat, e.g.,
	// context.Canceled or 4xx as non-failures.
	IsSuccessful func(err error) bool
	// OnStateChange is called on every state transition.
	OnStateChange func(name string, from, to State)
	// Clock is the time source; defaults to SystemClock.
	Clock Clock
}

BreakerConfig configures a Breaker. The zero value is usable; New applies sensible defaults for any unset field.

type Bulkhead added in v0.25.0

type Bulkhead struct {
	// contains filtered or unexported fields
}

Bulkhead is a semaphore-based concurrency limiter. It isolates a dependency so a slow one cannot exhaust the whole process: at most MaxConcurrent calls run together, at most MaxQueue more wait, and anything beyond is rejected with ErrBulkheadFull. It is goroutine-safe.

func NewBulkhead added in v0.25.0

func NewBulkhead(cfg BulkheadConfig) *Bulkhead

NewBulkhead builds a Bulkhead from cfg.

func (*Bulkhead) Execute added in v0.25.0

func (b *Bulkhead) Execute(ctx context.Context, fn func(ctx context.Context) (any, error)) (any, error)

Execute runs fn if a concurrency slot is free, or waits for one while a queue slot is held. It returns ErrBulkheadFull when both the running slots and the queue are saturated, and ctx.Err() if the context is cancelled while queued.

func (*Bulkhead) Middleware added in v0.25.0

func (b *Bulkhead) Middleware() Middleware

Middleware adapts the bulkhead for use with Wrap.

type BulkheadConfig added in v0.25.0

type BulkheadConfig struct {
	// MaxConcurrent caps the number of calls running at once. Defaults to 1.
	// Values < 1 are raised to 1.
	MaxConcurrent int
	// MaxQueue caps how many extra callers may wait for a slot. 0 means no
	// queue: a call is rejected the instant all slots are busy. Values < 0
	// are treated as 0.
	MaxQueue int
}

BulkheadConfig configures a Bulkhead. The zero value is usable; NewBulkhead applies defaults.

type Clock

type Clock interface {
	// Now returns the current time.
	Now() time.Time
	// After returns a channel that receives once d has elapsed.
	After(d time.Duration) <-chan time.Time
	// Sleep blocks for d (or until the channel from After would fire).
	Sleep(d time.Duration)
}

Clock abstracts the parts of the time package the primitives depend on so timing behaviour can be driven deterministically in tests. The production implementation (realClock) delegates to the standard library.

var SystemClock Clock = realClock{}

SystemClock is the production Clock used when no clock is injected.

type Counts

type Counts struct {
	Requests            uint32
	TotalSuccesses      uint32
	TotalFailures       uint32
	ConsecutiveSuccess  uint32
	ConsecutiveFailures uint32
}

Counts holds the breaker's rolling tally for the current generation. A generation resets on every state transition.

func (Counts) FailureRatio

func (c Counts) FailureRatio() float64

FailureRatio returns failures/requests for the current generation, or 0 when there have been no requests.

type FakeClock

type FakeClock struct {
	// contains filtered or unexported fields
}

FakeClock is a manually advanced Clock for deterministic tests. Time only moves when Advance is called; timers registered via After fire when the virtual clock passes their deadline. It is goroutine-safe.

func NewFakeClock

func NewFakeClock(start time.Time) *FakeClock

NewFakeClock returns a FakeClock anchored at start.

func (*FakeClock) Advance

func (c *FakeClock) Advance(d time.Duration)

Advance moves the virtual clock forward by d and fires every timer whose deadline has passed.

func (*FakeClock) After

func (c *FakeClock) After(d time.Duration) <-chan time.Time

After registers a timer. A non-positive d fires immediately.

func (*FakeClock) BlockedSleepers

func (c *FakeClock) BlockedSleepers() int

BlockedSleepers returns how many timers are currently pending. Tests use it to wait until a goroutine has parked on After/Sleep before advancing.

func (*FakeClock) Now

func (c *FakeClock) Now() time.Time

Now returns the current virtual time.

func (*FakeClock) Sleep

func (c *FakeClock) Sleep(d time.Duration)

Sleep blocks until the virtual clock advances past d. It is implemented on top of After so a blocked Sleep is released by Advance from another goroutine.

type JitterSource added in v0.25.0

type JitterSource interface {
	// Float64 returns a value in [0,1).
	Float64() float64
}

JitterSource yields a pseudo-random float in [0,1). It is injectable so jittered backoff is deterministic in tests; it must be safe for concurrent use. NewLCGJitter provides a seedable default that never touches the math/rand global.

func NewLCGJitter added in v0.25.0

func NewLCGJitter(seed uint64) JitterSource

NewLCGJitter returns a seedable JitterSource backed by a 64-bit LCG. It does not lock; wrap it if you share one source across goroutines that call Float64 concurrently. Retry copies a value into each Backoff closure, so the default per-Retry source is not shared.

type Middleware

type Middleware func(Operation) Operation

Middleware decorates an Operation with one resilience concern. Compose several with Wrap.

func Wrap

func Wrap(mws ...Middleware) Middleware

Wrap composes middlewares into a single Middleware. The first argument is the outermost layer: Wrap(a, b, c)(op) == a(b(c(op))). Calling Wrap with no arguments yields an identity middleware.

type Operation

type Operation func(ctx context.Context) (any, error)

Operation is the unit of work guarded by the resilience primitives. It returns an arbitrary result plus an error; primitives decide success or failure from the error (and, for the breaker, a configurable predicate).

type RateLimiter added in v0.25.0

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is a token-bucket limiter. Tokens accrue continuously at Rate up to Burst; each call spends one. Execute blocks until a token is available, Allow takes one without blocking. It is goroutine-safe.

func NewRateLimiter added in v0.25.0

func NewRateLimiter(cfg RateLimiterConfig) *RateLimiter

NewRateLimiter builds a RateLimiter from cfg.

func (*RateLimiter) Allow added in v0.25.0

func (rl *RateLimiter) Allow() bool

Allow reports whether a token was available and, if so, consumes it. It never blocks. Use it for best-effort shedding; use Execute to wait for a token.

func (*RateLimiter) Execute added in v0.25.0

func (rl *RateLimiter) Execute(ctx context.Context, fn func(ctx context.Context) (any, error)) (any, error)

Execute blocks until a token is available (or ctx is cancelled), spends it, then runs fn. It returns ctx.Err() if the context is cancelled while waiting.

func (*RateLimiter) Middleware added in v0.25.0

func (rl *RateLimiter) Middleware() Middleware

Middleware adapts the rate limiter for use with Wrap.

type RateLimiterConfig added in v0.25.0

type RateLimiterConfig struct {
	// Rate is the steady-state token refill rate in tokens per second.
	// Defaults to 1. Values <= 0 are raised to 1.
	Rate float64
	// Burst is the bucket capacity — the largest momentary burst allowed.
	// Defaults to 1. Values < 1 are raised to 1.
	Burst int
	// Clock is the time source; defaults to SystemClock. Inject a FakeClock
	// in tests to advance time deterministically.
	Clock Clock
}

RateLimiterConfig configures a RateLimiter. The zero value is usable; NewRateLimiter applies defaults.

type Retry added in v0.25.0

type Retry struct {
	// contains filtered or unexported fields
}

Retry re-runs an Operation up to MaxAttempts times, waiting per Backoff between attempts and honouring context cancellation while it waits. It is goroutine-safe: a single Retry can guard many concurrent calls.

func NewRetry added in v0.25.0

func NewRetry(cfg RetryConfig) *Retry

NewRetry builds a Retry from cfg, filling in defaults.

func (*Retry) Execute added in v0.25.0

func (r *Retry) Execute(ctx context.Context, fn func(ctx context.Context) (any, error)) (any, error)

Execute runs fn, retrying on failure per the configuration. It returns the result and error of the last attempt. If ctx is cancelled while waiting between attempts, it returns the last attempt's result with ctx.Err().

func (*Retry) Middleware added in v0.25.0

func (r *Retry) Middleware() Middleware

Middleware adapts the retry for use with Wrap.

type RetryConfig added in v0.25.0

type RetryConfig struct {
	// MaxAttempts is the total number of calls (initial try + retries).
	// Defaults to 3. Values < 1 are raised to 1.
	MaxAttempts int
	// Backoff computes the wait between attempts. Defaults to a constant
	// 100ms.
	Backoff Backoff
	// RetryIf decides whether an error is worth retrying. Defaults to
	// "retry on any non-nil error". It is never consulted for a nil error
	// (success), nor after the final attempt.
	RetryIf func(err error) bool
	// Clock is the time source used to sleep between attempts; defaults to
	// SystemClock. Inject a FakeClock in tests.
	Clock Clock
}

RetryConfig configures a Retry. The zero value is usable; NewRetry applies defaults for any unset field.

type State

type State int

State is the circuit breaker's operating state.

const (
	// StateClosed lets calls through and records outcomes.
	StateClosed State = iota
	// StateOpen rejects calls immediately with ErrOpenCircuit until the
	// open timeout elapses.
	StateOpen
	// StateHalfOpen permits a limited number of trial calls to probe
	// whether the dependency has recovered.
	StateHalfOpen
)

func (State) String

func (s State) String() string

type Timeout added in v0.25.0

type Timeout struct {
	// contains filtered or unexported fields
}

Timeout bounds each call to fn. It is goroutine-safe and adds no per-call state, so a single Timeout can guard many concurrent calls.

func NewTimeout added in v0.25.0

func NewTimeout(cfg TimeoutConfig) *Timeout

NewTimeout builds a Timeout from cfg.

func (*Timeout) Execute added in v0.25.0

func (t *Timeout) Execute(ctx context.Context, fn func(ctx context.Context) (any, error)) (any, error)

Execute runs fn under a derived context bounded by the configured Duration. If fn finishes first, its result is returned. If the bound elapses first, ErrTimeout is returned and the derived context is cancelled. fn keeps running in its goroutine until it observes the cancellation; Execute does not block on it, but it never leaks the goroutine's channel (buffered, size 1).

func (*Timeout) Middleware added in v0.25.0

func (t *Timeout) Middleware() Middleware

Middleware adapts the timeout for use with Wrap.

type TimeoutConfig added in v0.25.0

type TimeoutConfig struct {
	// Duration is the per-call bound. A non-positive Duration disables the
	// bound (fn runs without an added deadline).
	Duration time.Duration
	// Clock is reserved for time reads; defaults to SystemClock. The
	// derived deadline itself uses context.WithTimeout so cancellation
	// propagates to fn.
	Clock Clock
}

TimeoutConfig configures a Timeout. The zero value bounds nothing; provide a positive Duration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL