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 ¶
- Variables
- func Do[T any](ctx context.Context, mw Middleware, fn func(ctx context.Context) (T, error)) (T, error)
- type Backoff
- type Breaker
- type BreakerConfig
- type Bulkhead
- type BulkheadConfig
- type Clock
- type Counts
- type FakeClock
- type JitterSource
- type Middleware
- type Operation
- type RateLimiter
- type RateLimiterConfig
- type Retry
- type RetryConfig
- type State
- type Timeout
- type TimeoutConfig
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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.
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.
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.
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
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
ConstantBackoff waits the same delay before every retry.
func ExponentialBackoff ¶ added in v0.25.0
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) 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.
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 ¶
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 ¶
NewFakeClock returns a FakeClock anchored at start.
func (*FakeClock) Advance ¶
Advance moves the virtual clock forward by d and fires every timer whose deadline has passed.
func (*FakeClock) BlockedSleepers ¶
BlockedSleepers returns how many timers are currently pending. Tests use it to wait until a goroutine has parked on After/Sleep before advancing.
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 ¶
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 ¶
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 )
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.