// Package errors provides utilities for error handling, including a flexible retry mechanism. package errors import ( "context" "math/rand" "time" ) // BackoffStrategy defines the interface for calculating retry delays. type BackoffStrategy interface { // Backoff returns the delay for a given attempt based on the base delay. Backoff(attempt int, baseDelay time.Duration) time.Duration } // ConstantBackoff provides a fixed delay for each retry attempt. type ConstantBackoff struct{} // Backoff returns the base delay regardless of the attempt number. // Implements BackoffStrategy with a constant delay. func (c ConstantBackoff) Backoff(_ int, baseDelay time.Duration) time.Duration { return baseDelay } // ExponentialBackoff provides an exponentially increasing delay for retry attempts. type ExponentialBackoff struct{} // Backoff returns a delay that doubles with each attempt, starting from the base delay. // Uses bit shifting for efficient exponential growth (e.g., baseDelay * 2^(attempt-1)). func (e ExponentialBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration { if attempt <= 1 { return baseDelay } return baseDelay * time.Duration(1< 0 && delay > r.maxDelay { delay = r.maxDelay } if r.jitter { delay = addJitter(delay) } // Wait with context select { case <-r.ctx.Done(): return r.ctx.Err() case <-time.After(delay): } } return lastErr } // ExecuteContext runs the provided function with retry logic, respecting context cancellation. // Returns nil on success or the last error if all attempts fail or context is cancelled. func (r *Retry) ExecuteContext(ctx context.Context, fn func() error) error { var lastErr error // If the retry instance already has a context, use it. Otherwise, use the provided one. // If both are provided, maybe create a derived context? For now, prioritize the one from WithContext. execCtx := r.ctx if execCtx == context.Background() && ctx != nil { // Use provided ctx if retry ctx is default and provided one isn't nil execCtx = ctx } else if ctx == nil { // Ensure we always have a non-nil context execCtx = context.Background() } // Note: This logic might need refinement depending on how contexts should interact. // A safer approach might be: if r.ctx != background, use it. Else use provided ctx. for attempt := 1; attempt <= r.maxAttempts; attempt++ { // Check context before executing the function select { case <-execCtx.Done(): return execCtx.Err() // Return context error immediately default: // Context is okay, proceed } err := fn() if err == nil { return nil // Success } // Check if retry is applicable based on the error if r.retryIf != nil && !r.retryIf(err) { return err // Not retryable, return the error } lastErr = err // Store the last encountered error // Execute the OnRetry callback if configured if r.onRetry != nil { r.onRetry(attempt, err) } // Exit loop if this was the last attempt if attempt == r.maxAttempts { break } // --- Calculate and apply delay --- currentDelay := r.backoff.Backoff(attempt, r.delay) if r.maxDelay > 0 && currentDelay > r.maxDelay { // Check maxDelay > 0 before capping currentDelay = r.maxDelay } if r.jitter { currentDelay = addJitter(currentDelay) } if currentDelay < 0 { // Ensure delay isn't negative after jitter currentDelay = 0 } // --- Wait for the delay or context cancellation --- select { case <-execCtx.Done(): // If context is cancelled during the wait, return the context error // Often more informative than returning the last application error. return execCtx.Err() case <-time.After(currentDelay): // Wait finished, continue to the next attempt } } // All attempts failed, return the last error encountered return lastErr } // Transform creates a new Retry instance with modified configuration. // Copies all settings from the original Retry and applies the given options. func (r *Retry) Transform(opts ...RetryOption) *Retry { newRetry := &Retry{ maxAttempts: r.maxAttempts, delay: r.delay, maxDelay: r.maxDelay, retryIf: r.retryIf, onRetry: r.onRetry, backoff: r.backoff, jitter: r.jitter, ctx: r.ctx, } for _, opt := range opts { opt(newRetry) } return newRetry } // WithBackoff sets the backoff strategy using the BackoffStrategy interface. // Returns a RetryOption; no-op if strategy is nil, retaining the existing strategy. func WithBackoff(strategy BackoffStrategy) RetryOption { return func(r *Retry) { if strategy != nil { r.backoff = strategy } } } // WithContext sets the context for cancellation and deadlines. // Returns a RetryOption; retains context.Background if ctx is nil. func WithContext(ctx context.Context) RetryOption { return func(r *Retry) { if ctx != nil { r.ctx = ctx } } } // WithDelay sets the initial delay between retries. // Returns a RetryOption; ensures non-negative delay by setting negatives to 0. func WithDelay(delay time.Duration) RetryOption { return func(r *Retry) { if delay < 0 { delay = 0 } r.delay = delay } } // WithJitter enables or disables jitter in the backoff delay. // Returns a RetryOption; toggles random delay variation. func WithJitter(jitter bool) RetryOption { return func(r *Retry) { r.jitter = jitter } } // WithMaxAttempts sets the maximum number of retry attempts. // Returns a RetryOption; ensures at least 1 attempt by adjusting lower values. func WithMaxAttempts(maxAttempts int) RetryOption { return func(r *Retry) { if maxAttempts < 1 { maxAttempts = 1 } r.maxAttempts = maxAttempts } } // WithMaxDelay sets the maximum delay between retries. // Returns a RetryOption; ensures non-negative delay by setting negatives to 0. func WithMaxDelay(maxDelay time.Duration) RetryOption { return func(r *Retry) { if maxDelay < 0 { maxDelay = 0 } r.maxDelay = maxDelay } } // WithOnRetry sets a callback to execute after each failed attempt. // Returns a RetryOption; callback receives attempt number and error. func WithOnRetry(onRetry func(attempt int, err error)) RetryOption { return func(r *Retry) { r.onRetry = onRetry } } // WithRetryIf sets the condition under which to retry. // Returns a RetryOption; retains IsRetryable default if retryIf is nil. func WithRetryIf(retryIf func(error) bool) RetryOption { return func(r *Retry) { if retryIf != nil { r.retryIf = retryIf } } } // ExecuteReply runs the provided function with retry logic and returns its result. // Returns the result and nil on success, or zero value and last error on failure; generic type T. func ExecuteReply[T any](r *Retry, fn func() (T, error)) (T, error) { var lastErr error var zero T for attempt := 1; attempt <= r.maxAttempts; attempt++ { result, err := fn() if err == nil { return result, nil } // Check if retry is applicable; return immediately if not retryable if r.retryIf != nil && !r.retryIf(err) { return zero, err } lastErr = err if r.onRetry != nil { r.onRetry(attempt, err) } if attempt == r.maxAttempts { break } // Calculate delay with backoff, cap at maxDelay, and apply jitter if enabled currentDelay := r.backoff.Backoff(attempt, r.delay) if currentDelay > r.maxDelay { currentDelay = r.maxDelay } if r.jitter { currentDelay = addJitter(currentDelay) } // Wait with respect to context cancellation or timeout select { case <-r.ctx.Done(): return zero, r.ctx.Err() case <-time.After(currentDelay): } } return zero, lastErr }