Documentation
¶
Overview ¶
Package resilience provides context-aware retry policies with classifiers, cancellation-aware backoff, hedged execution, circuit breakers, and injectable clocks.
The three policies answer different failures. Retry handles an operation that failed; Hedge handles one that is merely slow, by starting another before the first has finished; Breaker handles a dependency that is failing enough that calling it again is the wrong move.
Retry has a second form for work that is "until this is true" rather than "until this succeeds": RetryUntil, whose probe reports whether a condition held. Polling until a deadline has no meaningful attempt count, and deriving one is how a poll ends up quietly short of its own budget, so RetryUntil takes its bound from the context. Both forms report a give-up as a *RetryError satisfying errors.Is(err, ErrGaveUp), whichever bound ended the loop.
The package is deliberately three policies, not a resilience framework. failsafe-go covers far more — timeout, fallback, rate limiting, bulkhead, adaptive concurrency and throttling, HTTP and gRPC integrations — is actively maintained, and should be preferred when breadth is what is wanted. What it cannot express is that a result may own something: every policy that discards a result leaks it when the result is a connection or a file. Hedge's Discard closes that hole here, and the separate failsafeown module closes it over failsafe-go's own policies.
Retry and Breaker compose by nesting: run the breaker inside the retried function, and classify ErrOpen as not retryable so a tripped breaker ends the loop instead of consuming attempts. Both take a Clock, so tests drive time explicitly; neither waits except Retry's backoff sleep.
Index ¶
- Variables
- func Hedge[T any](ctx context.Context, policy HedgePolicy[T], ...) (T, error)
- func Retry[T any](ctx context.Context, policy Policy, fn func(context.Context) (T, error)) (T, error)
- func RetryUntil[T any](ctx context.Context, policy UntilPolicy, ...) (T, error)
- type Backoff
- type Breaker
- type BreakerError
- type BreakerHooks
- type BreakerPolicy
- type Classifier
- type Clock
- type Counts
- type HedgeBudget
- type HedgeHooks
- type HedgePolicy
- type LatencyDelay
- type ManualClock
- type Policy
- type PolicyError
- type RetryError
- type State
- type Timer
- type Trip
- type UntilPolicy
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( ErrInvalidPolicy = errors.New("invalid resilience policy") ErrInvalidBackoff = errors.New("invalid retry backoff") ErrInvalidBreaker = errors.New("invalid breaker policy") ErrInvalidBudget = errors.New("invalid hedge budget") ErrNilFunction = errors.New("nil resilience function") ErrNilClock = errors.New("nil resilience clock") ErrNilTrip = errors.New("nil breaker trip") // ErrNoBound is a policy that bounds nothing: no attempt count, and a // context with no deadline to inherit one from. ErrNoBound = errors.New("no retry bound: MaxAttempts is 0 and the context has no deadline") // ErrOpen is the rejection a Breaker returns instead of running a call. ErrOpen = errors.New("circuit breaker open") // ErrGaveUp reports that a policy stopped before its work succeeded, // whether the attempt count ran out or the context ended the loop. Every // give-up in this package satisfies it, so one question has one answer: // // if errors.Is(err, resilience.ErrGaveUp) { ... } // // It exists because the alternative was a caller having to handle two // unrelated error types for one outcome, and which of the two it received // depending on arithmetic it did not write — see Retry. ErrGaveUp = errors.New("gave up before succeeding") )
Functions ¶
func Hedge ¶ added in v0.4.0
func Hedge[T any](ctx context.Context, policy HedgePolicy[T], fn func(context.Context, int) (T, error)) (T, error)
Hedge runs fn as a race against its own latency: the first attempt starts at once, and each later one starts when Delay says the ones already running have taken too long. The first success wins, the rest are cancelled, and any result that arrives anyway is handed to Discard.
value, err := resilience.Hedge(ctx, policy, func(ctx context.Context, attempt int) (*Response, error) {
return client.Fetch(ctx, request)
})
It is the tail-latency counterpart of Retry. Retry waits for an attempt to fail before starting the next and cannot help an attempt that is merely slow; Hedge starts the next while the previous is still running, so p99 latency falls toward p50 at the cost of extra load — which is what Budget is for. An attempt that fails does not wait for its Delay: the next starts at once, since the evidence to act on has already arrived.
fn receives the attempt number, zero-based, and a context that is cancelled once another attempt wins. It must be safe to run concurrently with itself: hedging replays the operation, so an operation that is not replay-safe must not be hedged.
Example ¶
A hedge produces one result per attempt and returns one, so the losers must be disposed or they leak. When the result is an owned resource, Discard is its Drop — the case every other hedging library leaves to the caller to notice.
package main
import (
"context"
"fmt"
"time"
"github.com/apsis-io/velocity/ownership"
"github.com/apsis-io/velocity/resilience"
)
func main() {
slow := make(chan struct{})
policy := resilience.HedgePolicy[*ownership.Owner[string]]{
MaxAttempts: 2,
Delay: func(int) time.Duration { return 5 * time.Millisecond },
Discard: func(o *ownership.Owner[string]) error {
return o.Release() // runs the loser's Drop
},
}
winner, err := resilience.Hedge(context.Background(), policy,
func(ctx context.Context, attempt int) (*ownership.Owner[string], error) {
name := fmt.Sprintf("conn-%d", attempt)
conn, _ := ownership.New(name, ownership.WithDrop(func(string) error {
fmt.Println("closed", name)
return nil
}))
if attempt == 0 {
<-slow // overtaken by the hedge
}
return conn, nil
})
if err != nil {
return
}
name, _ := winner.View(func(s string) (string, error) { return s, nil })
fmt.Println("using", name)
close(slow)
// The loser's connection is closed by Discard once it arrives, so wait
// for that before releasing ours.
time.Sleep(50 * time.Millisecond)
_ = winner.Release()
}
Output: using conn-1 closed conn-0 closed conn-1
func Retry ¶
func Retry[T any](ctx context.Context, policy Policy, fn func(context.Context) (T, error)) (T, error)
Retry runs fn until it succeeds, a non-retryable error, the attempt limit, or context cancellation.
Two of those are decisions and the rest are give-ups, and the distinction is the error a caller receives:
- A non-retryable error is returned unchanged. The policy chose to stop, so it is an answer rather than running out of options, and it does not satisfy ErrGaveUp.
- The attempt limit, a context that ended before an attempt could start, and a backoff sleep a context interrupted are all give-ups: the policy ran out of bound rather than out of reasons. Each reports a *RetryError satisfying errors.Is(err, ErrGaveUp), so the one question a caller has — did my bound stop this — has one answer whichever bound ended the loop.
The last two used to return the bare context cause, which meant the error type a caller received depended on whether the attempt count or the deadline happened to bind first — and therefore on arithmetic the caller wrote rather than on the situation.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/apsis-io/velocity/resilience"
)
func main() {
attempts := 0
backoff, _ := resilience.ExponentialBackoff(0, 0, 0)
value, err := resilience.Retry(context.Background(), resilience.Policy{
MaxAttempts: 3,
Backoff: backoff,
}, func(context.Context) (string, error) {
attempts++
if attempts < 2 {
return "", errors.New("try again")
}
return "ok", nil
})
fmt.Println(value, err)
}
Output: ok <nil>
func RetryUntil ¶ added in v0.5.0
func RetryUntil[T any](ctx context.Context, policy UntilPolicy, probe func(context.Context) (T, bool, error)) (T, error)
RetryUntil calls probe until it reports the condition satisfied, the probe returns an error, or the bound ends the attempt loop.
It is Retry for work that is "until this is true" rather than "until this succeeds" — a lock that is held, a socket that answers, a process that is gone. Those are the same loop, but Retry's error-shaped predicate inverts them: the probe has to return a sentinel error to mean "not yet", the classifier has to recognise that sentinel as retryable, and a probe with nothing to return has to invent a zero value to satisfy T. Here the condition is what the probe reports, and there is no sentinel to invert.
A non-nil error from probe ends the loop and is returned unchanged: it is a failure, not "not yet". A probe that wants an error to be retried returns satisfied false with a nil error, which is the same as not being satisfied.
The give-up is a *RetryError satisfying errors.Is(err, ErrGaveUp), as it is for Retry, so a caller asking whether its bound was reached has one answer whichever policy it used.
Example ¶
Polling until a condition holds needs no attempt count: the context is the bound, so there is no integer to derive and no way for the loop to end up short of the budget it was given.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/apsis-io/velocity/resilience"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
attempts := 0
_, err := resilience.RetryUntil(ctx, resilience.UntilPolicy{
Backoff: func(int) time.Duration { return 10 * time.Millisecond },
}, func(context.Context) (int, bool, error) {
attempts++
return attempts, attempts == 3, nil
})
fmt.Println("satisfied after", attempts, "probes:", err)
// A condition that never holds gives up on the deadline, and says so with
// one error whatever ended the loop.
_, err = resilience.RetryUntil(ctx, resilience.UntilPolicy{},
func(context.Context) (int, bool, error) { return 0, false, nil })
fmt.Println("gave up:", errors.Is(err, resilience.ErrGaveUp))
}
Output: satisfied after 3 probes: <nil> gave up: true
Types ¶
type Breaker ¶
type Breaker struct {
// contains filtered or unexported fields
}
Breaker is a circuit breaker: it stops calling a resource that keeps failing, and after OpenFor lets a few calls through to find out whether it has recovered. Fail fast is the whole point, so a rejected call returns ErrOpen immediately; nothing in a Breaker waits.
Opening schedules the move to HalfOpen through the Clock's AfterFunc, so State and Hooks.OnStateChange reflect the elapsed OpenFor on their own rather than at the next call. Every operation also applies any due transition itself, so a late timer changes nothing.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/apsis-io/velocity/resilience"
)
func main() {
breaker, _ := resilience.NewBreaker(resilience.BreakerPolicy{
Trip: resilience.ConsecutiveFailures(2),
OpenFor: time.Minute,
Failure: func(err error) bool { return !errors.Is(err, context.Canceled) },
})
unreachable := func(context.Context) (string, error) { return "", errors.New("connection refused") }
for range 3 {
_, err := breaker.Do(context.Background(), unreachable)
fmt.Println(breaker.State(), errors.Is(err, resilience.ErrOpen))
}
}
Output: closed false open false open true
Example (WithRetry) ¶
A Breaker inside Retry: the retry loop stops on the breaker's rejection, which cannot change until the clock does, instead of burning attempts.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/apsis-io/velocity/resilience"
)
func main() {
breaker, _ := resilience.NewBreaker(resilience.BreakerPolicy{
Trip: resilience.ConsecutiveFailures(2),
OpenFor: time.Minute,
})
calls := 0
_, err := resilience.Retry(context.Background(), resilience.Policy{
MaxAttempts: 10,
Retryable: func(err error) bool { return !errors.Is(err, resilience.ErrOpen) },
}, func(ctx context.Context) (string, error) {
return breaker.Do(ctx, func(context.Context) (string, error) {
calls++
return "", errors.New("connection refused")
})
})
fmt.Println(calls, errors.Is(err, resilience.ErrOpen))
}
Output: 2 true
func NewBreaker ¶
func NewBreaker(policy BreakerPolicy) (*Breaker, error)
NewBreaker validates the policy and returns a Closed breaker.
func (*Breaker) Allow ¶
Allow is Do for calls that cannot be wrapped in a function, such as a stream whose outcome is known only later. It admits or rejects now and returns a report to call exactly once with the outcome; later calls are ignored. A report that is never made leaves a HalfOpen probe slot occupied until the next transition, so prefer Do wherever it fits.
func (*Breaker) Do ¶
Do runs fn if the breaker admits it and reports the outcome. A rejection is a BreakerError satisfying errors.Is(err, ErrOpen); fn's own error is returned as is. A context that is already done is not counted as a failure, since the call never reached the resource.
fn must return normally. A panic is recorded as a failure so the probe slot it held is not lost, and then propagates.
type BreakerError ¶
BreakerError is a call the Breaker refused to make. State is Open, with RetryAfter the time until probes are admitted, or HalfOpen when every probe slot is taken, in which case RetryAfter is zero because the answer depends on the probes in flight rather than on the clock.
func (*BreakerError) Error ¶
func (e *BreakerError) Error() string
func (*BreakerError) Is ¶
func (e *BreakerError) Is(target error) bool
type BreakerHooks ¶
type BreakerHooks struct {
// OnStateChange reports each transition with the counts that ended the
// previous state.
OnStateChange func(from, to State, counts Counts)
}
BreakerHooks lets a caller observe transitions without the package owning metrics state. Nil callbacks are skipped. Hooks run outside the breaker's lock, on the goroutine whose call caused the transition.
type BreakerPolicy ¶
type BreakerPolicy struct {
// Trip decides when a Closed breaker opens.
Trip Trip
// OpenFor is how long the breaker stays Open before admitting probes.
OpenFor time.Duration
// Interval, when positive, resets Counts while Closed each time it
// elapses, so a ratio Trip judges recent behaviour rather than the whole
// history. Zero never resets until a transition.
Interval time.Duration
// MaxProbes bounds concurrent calls while HalfOpen; zero means one.
MaxProbes int
// SuccessesToClose is how many probes must succeed, with no failure in
// between, to close again; zero means one.
SuccessesToClose int
// Failure decides whether an error counts against the breaker. Nil counts
// every error, including the caller's own cancellation; most callers want
// to exclude that, since it says nothing about the protected resource:
//
// Failure: func(err error) bool { return !errors.Is(err, context.Canceled) }
Failure Classifier
Clock Clock
Hooks BreakerHooks
}
BreakerPolicy configures a Breaker. Trip and OpenFor are required.
type Classifier ¶
Classifier decides whether an error should be retried. Nil retries all errors.
type Clock ¶
type Clock interface {
Now() time.Time
Sleep(context.Context, time.Duration) error
// AfterFunc runs f on its own goroutine once d has elapsed, unless the
// returned Timer is stopped first.
AfterFunc(d time.Duration, f func()) Timer
}
Clock supplies time to Retry and Breaker: the current reading, cancellation-aware sleeping, and deferred execution. ManualClock implements it for tests.
type Counts ¶
type Counts struct {
Requests int
Successes int
Failures int
ConsecutiveSuccesses int
ConsecutiveFailures int
}
Counts are the outcomes recorded since the last state change or Interval reset. They describe completed calls only; an admitted call that has not reported yet is in none of them.
type HedgeBudget ¶ added in v0.4.0
type HedgeBudget struct {
// contains filtered or unexported fields
}
HedgeBudget bounds the extra load hedging creates, as a fraction of the traffic that justified it. Each execution credits the budget once, and each speculative attempt spends one credit, so a ratio of 0.1 permits a hedge on roughly one call in ten however the delays fall.
Without one, a dependency that has become slow enough to trigger hedging receives several times its usual load at exactly the wrong moment. Share one budget per dependency across every policy that hedges it.
A nil *HedgeBudget permits every hedge, which is the right choice only when the operation is cheap or the caller is the only client.
func NewHedgeBudget ¶ added in v0.4.0
func NewHedgeBudget(ratio float64, burst int) (*HedgeBudget, error)
NewHedgeBudget permits hedges at ratio of total executions — 0.1 for ten percent — allowing up to burst of them back to back. The budget starts empty, so hedging begins only once traffic has paid for it.
func (*HedgeBudget) Tokens ¶ added in v0.4.0
func (b *HedgeBudget) Tokens() float64
Tokens reports the credits available for hedging now, which is how a test or a metric observes the budget without inferring it from behaviour.
type HedgeHooks ¶ added in v0.4.0
type HedgeHooks struct {
// OnAttempt fires as each attempt starts. hedge is false for the first
// attempt and true for every speculative one.
OnAttempt func(attempt int, hedge bool)
// OnAttemptComplete fires as each attempt finishes, including attempts
// that lose the race and those cancelled after a winner is chosen. won
// reports whether this attempt's value was the one returned.
OnAttemptComplete func(attempt int, duration time.Duration, err error, won bool)
// OnDiscard fires for each losing result handed to Discard, with
// whatever Discard returned. It is the only report of a disposal
// failure: the caller already has their value, and a hedge that could
// not clean up after itself is an operational event, not a call error.
OnDiscard func(attempt int, err error)
}
HedgeHooks lets a caller observe hedged execution without the package owning metrics state. Nil callbacks are skipped.
type HedgePolicy ¶ added in v0.4.0
type HedgePolicy[T any] struct { // MaxAttempts bounds attempts in total, the first included. It must be // at least 2; one attempt is not a hedge. MaxAttempts int // Delay reports how long to wait before starting attempt n while the // attempts already running have neither succeeded nor failed. It is the // same Backoff that Retry uses, so a fixed delay is // func(int) time.Duration { return d } and a widening one is // ExponentialBackoff. Delay Backoff // Budget bounds how much extra load hedging is allowed to create. A nil // Budget hedges whenever the policy says to, which multiplies load on // the very dependency that is already slow. Budget *HedgeBudget // Discard disposes the result of an attempt that arrived after the // winner, which is otherwise leaked: a hedge produces N results and // returns one. Nil means T needs no disposal. For an owned resource, // this is its Drop. Discard func(T) error // Retryable decides whether a failed attempt should be followed by // another. Nil hedges past every error. A non-retryable error ends the // execution immediately and is returned as is. Retryable Classifier Clock Clock Hooks HedgeHooks }
HedgePolicy configures Hedge. MaxAttempts and Delay are required.
type LatencyDelay ¶ added in v0.4.0
type LatencyDelay struct {
// contains filtered or unexported fields
}
LatencyDelay derives a hedge delay from observed latency instead of a guess. A static delay has to be chosen for a latency distribution the caller does not yet know and that shifts under load; this hedges an attempt once it has taken longer than the given quantile of recent successful ones, so "too slow" is defined by what the dependency has actually been doing.
latency, err := resilience.NewLatencyDelay(0.95, 256, 20, time.Second)
policy := resilience.HedgePolicy[T]{
MaxAttempts: 2,
Delay: latency.Delay,
Hooks: resilience.HedgeHooks{OnAttemptComplete: latency.Observe},
}
The two halves are wired separately because Hooks has one slot: a caller with their own instrumentation calls Observe from inside it. Only successful attempts are recorded — a failure's duration says how fast the dependency broke, not how long its work takes.
It is safe for concurrent use; share one per dependency, as the samples are only meaningful across executions.
func NewLatencyDelay ¶ added in v0.4.0
func NewLatencyDelay(quantile float64, window, minSamples int, warmup time.Duration) (*LatencyDelay, error)
NewLatencyDelay records the last window successful durations and hedges at the given quantile of them. Until minSamples have been recorded there is nothing to estimate from and warmup is used instead; pass a warmup longer than any plausible response to hold hedging off entirely until then.
func (*LatencyDelay) Delay ¶ added in v0.4.0
func (d *LatencyDelay) Delay(int) time.Duration
Delay reports the current quantile of recorded durations, for every attempt: an attempt is slow by the same standard whichever one it is. It has the shape of Backoff.
func (*LatencyDelay) Observe ¶ added in v0.4.0
Observe records one attempt's duration. It has the shape of HedgeHooks.OnAttemptComplete so it can be assigned to it directly.
func (*LatencyDelay) Samples ¶ added in v0.4.0
func (d *LatencyDelay) Samples() int
Samples reports how many durations have been recorded, up to the window, which is how a test or a metric sees whether the estimate is warm.
type ManualClock ¶
type ManualClock struct {
// contains filtered or unexported fields
}
ManualClock is a Clock that moves only when told to, for deterministic tests of Retry and Breaker. Sleep advances the clock by the requested delay instead of waiting, and honours a context that is already done. AfterFunc callbacks run synchronously, in due order, on the goroutine that advances the clock past them.
clock := resilience.NewManualClock(time.Unix(0, 0))
breaker, _ := resilience.NewBreaker(resilience.BreakerPolicy{Clock: clock, ...})
clock.Advance(policy.OpenFor) // now half-open, hook already fired
It is safe for concurrent use.
func NewManualClock ¶
func NewManualClock(start time.Time) *ManualClock
NewManualClock returns a clock reading start.
func (*ManualClock) Advance ¶
func (c *ManualClock) Advance(d time.Duration)
Advance moves the clock forward by d. Every AfterFunc that comes due runs in due order, with the clock reading its due time while it runs, so a callback that schedules further work sees the time it was scheduled for rather than the end of the jump.
func (*ManualClock) AfterFunc ¶
func (c *ManualClock) AfterFunc(d time.Duration, f func()) Timer
AfterFunc schedules f for when the clock reaches now+d. A non-positive d runs f immediately, on this goroutine.
func (*ManualClock) Set ¶
func (c *ManualClock) Set(t time.Time)
Set moves the clock to t, which may be earlier than now. Moving forward runs every AfterFunc that comes due, as Advance does.
func (*ManualClock) Sleep ¶
Sleep advances the clock by d and returns at once. A done context is reported instead, as the real clock would, but the clock still advances so a test's timeline stays consistent.
func (*ManualClock) Sleeps ¶
func (c *ManualClock) Sleeps() int
Sleeps reports how many times Sleep has been called, which is how a test counts backoff waits without timing anything.
type Policy ¶
type Policy struct {
MaxAttempts int
Retryable Classifier
Backoff Backoff
Clock Clock
}
Policy configures Retry.
MaxAttempts is the bound when positive. Zero means the caller's context is the bound, which is the right shape for work that has a deadline of its own: attempts = budget/interval is a derivation that binds first, ends the loop up to one interval short of the budget, and does so silently. A policy that bounds nothing — zero attempts and a context with no deadline — is ErrInvalidPolicy.
type PolicyError ¶
type PolicyError struct{ Cause error }
PolicyError identifies invalid retry or breaker configuration.
func (*PolicyError) Error ¶
func (e *PolicyError) Error() string
func (*PolicyError) Unwrap ¶
func (e *PolicyError) Unwrap() []error
type RetryError ¶
RetryError is every give-up in this package: a retry whose attempts ran out, a retry or a condition whose context ended the loop, and a condition that never held within its bound. Attempts counts the calls that ran, and Last is the last failure or the context cause, absent when a condition simply never held.
It is one type rather than one per policy so that errors.Is(err, ErrGaveUp) is the whole of the question a caller has to ask. The detail stays reachable: Unwrap yields both ErrGaveUp and Last, so a caller that wants the deadline specifically still finds it.
func (*RetryError) Error ¶
func (e *RetryError) Error() string
func (*RetryError) Unwrap ¶
func (e *RetryError) Unwrap() []error
type State ¶
type State int
State is a circuit breaker position.
const ( // Closed admits every call and counts outcomes toward tripping. Closed State = iota // Open rejects every call with ErrOpen until OpenFor has elapsed. Open // HalfOpen admits up to MaxProbes concurrent calls to test recovery: any // failure reopens, SuccessesToClose successes close. HalfOpen )
type Timer ¶
type Timer interface {
Stop() bool
}
Timer is a pending AfterFunc. Stop reports whether it prevented f from running.
type Trip ¶
Trip decides from the current Counts whether a Closed breaker should open. It is consulted after every failure while Closed.
func ConsecutiveFailures ¶
ConsecutiveFailures trips after n failures in a row.
func FailureRatio ¶
FailureRatio trips once at least minRequests have completed and the failing fraction of them reaches ratio.
type UntilPolicy ¶ added in v0.5.0
type UntilPolicy struct {
// MaxAttempts is the bound when positive. Zero means the caller's context
// is the bound, which for a condition is usually the natural one: polling
// until a deadline does not have a meaningful attempt count, and deriving
// one is how a poll ends up quietly short of the budget it was given.
MaxAttempts int
Backoff Backoff
Clock Clock
}
UntilPolicy configures RetryUntil. It is a separate type from Policy because a condition has no retryable-error notion to classify: a probe reports whether the condition held, and an error from it is a reason to stop. Reusing Policy would have meant a Retryable field that RetryUntil silently ignored, which is a field set in good faith and obeyed by nobody.