// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package mock
import (
"context"
)
// Retrier implements Retrier for testing
type Retrier struct {
RetryFunc func(context.Context, func() error) error
}
// RetryWithBackoff implements the Retrier interface
func (m *Retrier) RetryWithBackoff(
ctx context.Context,
operation func() error,
) error {
if m.RetryFunc != nil {
return m.RetryFunc(ctx, operation)
}
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package retry provides a flexible and type-safe retry mechanism with
// exponential backoff. It allows for customizable retry strategies and
// notifications while maintaining context awareness and cancellation support.
package retry
import (
"context"
"errors"
"time"
"github.com/cenkalti/backoff/v4"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// Default configuration values for the exponential backoff strategy
const (
// Initial wait time between retries
defaultInitialInterval = 500 * time.Millisecond
// Maximum wait time between retries
defaultMaxInterval = 60 * time.Second
// Maximum total time for all retry attempts
defaultMaxElapsedTime = 1200 * time.Second
// A zero max elapsed time means try forever.
forever = 0
// Factor by which the wait time increases
defaultMultiplier = 2.0
)
// Retrier defines the interface for retry operations with backoff support.
// Implementations of this interface provide different retry strategies.
type Retrier interface {
// RetryWithBackoff executes an operation with a backoff strategy.
// It will repeatedly execute the operation until it succeeds or
// the context is canceled. The backoff strategy determines the
// delay between retry attempts.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - op: The operation to retry, returns error if the attempt failed
//
// Returns:
// - *sdkErrors.SDKError: nil if successful, or one of the following:
// - ErrRetryMaxElapsedTimeReached: if maximum elapsed time is reached
// - ErrRetryContextCanceled: if context is canceled
// - The last error from the operation
RetryWithBackoff(
ctx context.Context, op func() *sdkErrors.SDKError,
) *sdkErrors.SDKError
}
// TypedRetrier provides type-safe retry operations for functions that return
// both a value and an error. It wraps a base Retrier to provide typed results.
type TypedRetrier[T any] struct {
retrier Retrier
}
// NewTypedRetrier creates a new TypedRetrier with the given base Retrier.
// This allows for type-safe retry operations while reusing existing retry
// logic.
//
// Parameters:
// - r: The base Retrier implementation to wrap
//
// Returns:
// - *TypedRetrier[T]: A new TypedRetrier instance for the specified type
//
// Example:
//
// retrier := NewTypedRetrier[string](NewExponentialRetrier())
// result, err := retrier.RetryWithBackoff(ctx, func() (
// string, *sdkErrors.SDKError) {
// return callExternalService()
// })
func NewTypedRetrier[T any](r Retrier) *TypedRetrier[T] {
return &TypedRetrier[T]{retrier: r}
}
// RetryWithBackoff executes a typed operation with a backoff strategy.
// It preserves the return value while maintaining retry functionality.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - op: The operation to retry, returns both a value and an error
//
// Returns:
// - T: The result value from the successful operation
// - *sdkErrors.SDKError: nil if successful, or one of the following errors:
// - ErrRetryMaxElapsedTimeReached: if maximum elapsed time is reached
// - ErrRetryContextCanceled: if context is canceled
// - The wrapped error from the operation if it fails
func (r *TypedRetrier[T]) RetryWithBackoff(
ctx context.Context,
op func() (T, *sdkErrors.SDKError),
) (T, *sdkErrors.SDKError) {
var result T
err := r.retrier.RetryWithBackoff(ctx, func() *sdkErrors.SDKError {
var opErr *sdkErrors.SDKError
result, opErr = op()
return opErr
})
return result, err
}
// NotifyFn is a callback function type for retry notifications.
// It provides information about each retry attempt, including the error,
// current interval duration, and total elapsed time.
type NotifyFn func(
err *sdkErrors.SDKError, duration, totalDuration time.Duration,
)
// RetrierOption is a function type for configuring ExponentialRetrier.
// It follows the functional options pattern for flexible configuration.
type RetrierOption func(*ExponentialRetrier)
// ExponentialRetrier implements Retrier using exponential backoff strategy.
// It provides configurable retry intervals and maximum attempt durations.
type ExponentialRetrier struct {
newBackOff func() backoff.BackOff
notify NotifyFn
}
// BackOffOption is a function type for configuring ExponentialBackOff.
// It allows fine-tuning of the backoff strategy parameters.
type BackOffOption func(*backoff.ExponentialBackOff)
// NewExponentialRetrier creates a new ExponentialRetrier with configurable
// settings. Default values provide sensible backoff behavior for most use
// cases.
//
// Default settings:
// - InitialInterval: 500ms
// - MaxInterval: 60s
// - MaxElapsedTime: 1200s (20 minutes)
// - Multiplier: 2.0
//
// Parameters:
// - opts: Optional configuration functions to customize retry behavior
//
// Returns:
// - *ExponentialRetrier: A configured retrier instance ready for use
//
// Example:
//
// retrier := NewExponentialRetrier(
// WithBackOffOptions(
// WithInitialInterval(100 * time.Millisecond),
// WithMaxInterval(5 * time.Second),
// ),
// WithNotify(func(err *sdkErrors.SDKError, d, total time.Duration) {
// log.Printf("Retry attempt failed: %v", err)
// }),
// )
func NewExponentialRetrier(opts ...RetrierOption) *ExponentialRetrier {
b := backoff.NewExponentialBackOff()
b.InitialInterval = defaultInitialInterval
b.MaxInterval = defaultMaxInterval
b.MaxElapsedTime = defaultMaxElapsedTime
b.Multiplier = defaultMultiplier
r := &ExponentialRetrier{
newBackOff: func() backoff.BackOff {
return b
},
}
for _, opt := range opts {
opt(r)
}
return r
}
// RetryWithBackoff implements the Retrier interface using exponential backoff.
// It executes the operation repeatedly until success or context cancellation.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - operation: The function to retry that returns an error
//
// Returns:
// - *sdkErrors.SDKError: nil if the operation eventually succeeds, or one of:
// - ErrRetryMaxElapsedTimeReached: if maximum elapsed time is reached
// - ErrRetryContextCanceled: if context is canceled
// - The last error from the operation
func (r *ExponentialRetrier) RetryWithBackoff(
ctx context.Context,
operation func() *sdkErrors.SDKError,
) *sdkErrors.SDKError {
b := r.newBackOff()
totalDuration := time.Duration(0)
// Wrap operation to convert SDKError to plain error for backoff library
wrappedOp := func() error {
sdkErr := operation()
if sdkErr == nil {
return nil
}
return sdkErr
}
err := backoff.RetryNotify(
wrappedOp,
backoff.WithContext(b, ctx),
func(err error, duration time.Duration) {
totalDuration += duration
if r.notify != nil {
// Convert plain error back to SDKError for notification
var sdkErr *sdkErrors.SDKError
if errors.As(err, &sdkErr) {
r.notify(sdkErr, duration, totalDuration)
} else {
// Wrap plain error if it's not already an SDKError
wrapped := sdkErrors.ErrRetryOperationFailed.Wrap(err)
r.notify(wrapped, duration, totalDuration)
}
}
},
)
if err == nil {
return nil
}
// Check if error is already an SDKError
var sdkErr *sdkErrors.SDKError
if errors.As(err, &sdkErr) {
return sdkErr
}
// Wrap context errors appropriately
if errors.Is(err, context.Canceled) {
failErr := sdkErrors.ErrRetryContextCanceled.Wrap(err)
failErr.Msg = "retry operation canceled"
return failErr
}
if errors.Is(err, context.DeadlineExceeded) {
failErr := sdkErrors.ErrRetryMaxElapsedTimeReached.Wrap(err)
failErr.Msg = "maximum retry elapsed time exceeded"
return failErr
}
// Wrap any other error
failErr := sdkErrors.ErrRetryOperationFailed.Wrap(err)
failErr.Msg = "retry operation failed"
return failErr
}
// WithBackOffOptions configures the backoff settings using the provided
// options. Multiple options can be combined to customize the retry behavior.
//
// Parameters:
// - opts: One or more BackOffOption functions to configure the backoff
// strategy
//
// Returns:
// - RetrierOption: A configuration function for ExponentialRetrier
//
// Example:
//
// retrier := NewExponentialRetrier(
// WithBackOffOptions(
// WithInitialInterval(1 * time.Second),
// WithMaxElapsedTime(1 * time.Minute),
// ),
// )
func WithBackOffOptions(opts ...BackOffOption) RetrierOption {
return func(r *ExponentialRetrier) {
b := r.newBackOff().(*backoff.ExponentialBackOff)
for _, opt := range opts {
opt(b)
}
}
}
// WithInitialInterval sets the initial interval between retries.
// This is the starting point for the exponential backoff calculation.
//
// Parameters:
// - d: The initial wait duration before the first retry
//
// Returns:
// - BackOffOption: A configuration function for ExponentialBackOff
func WithInitialInterval(d time.Duration) BackOffOption {
return func(b *backoff.ExponentialBackOff) {
b.InitialInterval = d
}
}
// WithMaxInterval sets the maximum interval between retries.
// The interval will never exceed this value, regardless of the multiplier.
//
// Parameters:
// - d: The maximum wait duration between retry attempts
//
// Returns:
// - BackOffOption: A configuration function for ExponentialBackOff
func WithMaxInterval(d time.Duration) BackOffOption {
return func(b *backoff.ExponentialBackOff) {
b.MaxInterval = d
}
}
// WithMaxElapsedTime sets the maximum total time for retries.
// The retry operation will stop after this duration, even if not successful.
// Set to 0 to retry indefinitely (until context is canceled).
//
// Parameters:
// - d: The maximum total duration for all retry attempts
//
// Returns:
// - BackOffOption: A configuration function for ExponentialBackOff
func WithMaxElapsedTime(d time.Duration) BackOffOption {
return func(b *backoff.ExponentialBackOff) {
b.MaxElapsedTime = d
}
}
// WithMultiplier sets the multiplier for increasing intervals.
// Each retry interval is multiplied by this value, up to MaxInterval.
//
// Parameters:
// - m: The multiplier factor (e.g., 2.0 doubles the interval each time)
//
// Returns:
// - BackOffOption: A configuration function for ExponentialBackOff
func WithMultiplier(m float64) BackOffOption {
return func(b *backoff.ExponentialBackOff) {
b.Multiplier = m
}
}
// WithRandomizationFactor sets the randomization factor for backoff intervals.
// The actual interval will be randomized between
// [interval * (1 - factor), interval * (1 + factor)].
//
// A factor of 0 disables randomization (deterministic intervals).
// A factor of 0.5 (the default) means intervals can vary by ±50%.
// This randomization helps prevent thundering herd issues in distributed
// systems.
//
// Parameters:
// - factor: The randomization factor (0.0 to 1.0)
//
// Returns:
// - BackOffOption: A configuration function for ExponentialBackOff
func WithRandomizationFactor(factor float64) BackOffOption {
return func(b *backoff.ExponentialBackOff) {
b.RandomizationFactor = factor
}
}
// WithNotify is an option to set the notification callback.
// The callback is called after each failed attempt, allowing you to log
// or monitor retry behavior.
//
// Parameters:
// - fn: Callback function invoked after each failed retry attempt
//
// Returns:
// - RetrierOption: A configuration function for ExponentialRetrier
//
// Example:
//
// retrier := NewExponentialRetrier(
// WithNotify(func(err *sdkErrors.SDKError, d, total time.Duration) {
// log.Printf("Attempt failed after %v, total time %v: %v",
// d, total, err)
// }),
// )
func WithNotify(fn NotifyFn) RetrierOption {
return func(r *ExponentialRetrier) {
r.notify = fn
}
}
// Handler represents a function that returns a value and an error.
// It's used with the Do helper function for simple retry operations.
type Handler[T any] func() (T, *sdkErrors.SDKError)
// Do provides a simplified way to retry a typed operation with configurable
// settings. It creates a TypedRetrier with exponential backoff and applies
// any provided options.
//
// This is a convenience function for common retry scenarios where you don't
// need to create and manage a retrier instance explicitly.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - handler: The function to retry that returns a value and error
// - options: Optional configuration for the retry behavior
//
// Returns:
// - T: The result value from the successful operation
// - *sdkErrors.SDKError: nil if successful, or one of the following errors:
// - ErrRetryMaxElapsedTimeReached: if maximum elapsed time is reached
// - ErrRetryContextCanceled: if context is canceled
// - The wrapped error from the handler if it fails
//
// Example:
//
// result, err := Do(ctx, func() (string, *sdkErrors.SDKError) {
// return fetchData()
// }, WithNotify(logRetryAttempts))
func Do[T any](
ctx context.Context, handler Handler[T], options ...RetrierOption,
) (T, *sdkErrors.SDKError) {
return NewTypedRetrier[T](
NewExponentialRetrier(options...),
).RetryWithBackoff(ctx, handler)
}
// Forever retries an operation indefinitely with exponential backoff until it
// succeeds or the context is canceled. It sets MaxElapsedTime to 0, which means
// the retry loop will continue forever (or until the context is canceled).
//
// This is a convenience function that sets up exponential backoff with sensible
// defaults for infinite retry scenarios.
//
// Default settings:
// - InitialInterval: 500ms
// - MaxInterval: 60s
// - MaxElapsedTime: 0 (retry forever)
// - Multiplier: 2.0
//
// Parameters:
// - ctx: Context for cancellation control (the only way to stop retrying)
// - handler: The function to retry that returns a value and error
// - options: Optional configuration for retry behavior
//
// Note: User-provided options are applied AFTER the default settings and will
// override them. If you pass WithBackOffOptions(WithMaxElapsedTime(...)), it
// will override the "forever" behavior. This allows power users to customize
// the retry behavior while keeping the convenience of preset defaults.
//
// Returns:
// - T: The result value from the successful operation
// - *sdkErrors.SDKError: nil if successful, or one of the following errors:
// - ErrRetryContextCanceled: if context is canceled
// - The wrapped error from the handler if all retries fail
//
// Example:
//
// // Retry forever with custom notification
// result, err := Forever(ctx, func() (string, *sdkErrors.SDKError) {
// return fetchData()
// }, WithNotify(func(err *sdkErrors.SDKError, d, total time.Duration) {
// log.Printf("Retry failed: %v (attempt duration: %v, total: %v)",
// err, d, total)
// }))
//
// // Override behavior (will now stop after 1 minute
// // instead of retrying forever)
// result, err := Forever(ctx, func() (string, *sdkErrors.SDKError) {
// return fetchData()
// }, WithBackOffOptions(WithMaxElapsedTime(1 * time.Minute)))
func Forever[T any](
ctx context.Context, handler Handler[T], options ...RetrierOption,
) (T, *sdkErrors.SDKError) {
ro := WithBackOffOptions(WithMaxElapsedTime(forever))
ros := []RetrierOption{ro}
ros = append(ros, options...)
return NewTypedRetrier[T](
NewExponentialRetrier(ros...),
).RetryWithBackoff(ctx, handler)
}