| package backoff |
| |
| import ( |
| "context" |
| "errors" |
| "time" |
| ) |
| |
| // DefaultMaxElapsedTime sets a default limit for the total retry duration. |
| const DefaultMaxElapsedTime = 15 * time.Minute |
| |
| // Operation is the function Retry calls. It is invoked at least once and may be |
| // retried on error. Return a Permanent error to stop retrying immediately, or a |
| // RetryAfterError to control the delay before the next attempt. |
| type Operation[T any] func() (T, error) |
| |
| // Notify is called after a failed attempt that will be retried, with the |
| // operation error and the backoff duration before the next attempt. It is |
| // called once per retry, not for the final error that stops Retry (a permanent |
| // error, an exhausted limit, or a cancelled context). |
| type Notify func(error, time.Duration) |
| |
| // retryOptions holds configuration settings for the retry mechanism. |
| type retryOptions struct { |
| BackOff BackOff // Strategy for calculating backoff periods. |
| Timer timer // Timer to manage retry delays. |
| Notify Notify // Optional function called before each backoff wait. |
| MaxTries uint // Maximum number of retry attempts. |
| MaxElapsedTime time.Duration // Maximum total time for all retries. |
| } |
| |
| // RetryOption configures the behavior of Retry. |
| type RetryOption func(*retryOptions) |
| |
| // WithBackOff configures the backoff policy used between attempts. The default |
| // is NewExponentialBackOff. |
| // |
| // Retry calls Reset on the policy before the first attempt, so a previously |
| // used policy may be passed. A BackOff is stateful and not safe for concurrent |
| // use: give each concurrent Retry call its own BackOff rather than sharing one. |
| func WithBackOff(b BackOff) RetryOption { |
| return func(args *retryOptions) { |
| args.BackOff = b |
| } |
| } |
| |
| // withTimer sets a custom timer for managing delays between retries. |
| func withTimer(t timer) RetryOption { |
| return func(args *retryOptions) { |
| args.Timer = t |
| } |
| } |
| |
| // WithNotify sets a function called after each failed attempt that will be |
| // retried. See Notify for exactly when it fires. |
| func WithNotify(n Notify) RetryOption { |
| return func(args *retryOptions) { |
| args.Notify = n |
| } |
| } |
| |
| // WithMaxTries limits the total number of attempts, not retries: WithMaxTries(1) |
| // runs the operation once and does not retry. When the limit is reached, Retry |
| // returns a *RetryError with Cause ErrExhausted. The default, 0, means no limit. |
| func WithMaxTries(n uint) RetryOption { |
| return func(args *retryOptions) { |
| args.MaxTries = n |
| } |
| } |
| |
| // WithMaxElapsedTime limits the total wall-clock time spent retrying, measured |
| // from when Retry is called. When the limit is reached, Retry returns a |
| // *RetryError with Cause ErrMaxElapsedTime. |
| // |
| // The limit is checked only between attempts: it gates whether another attempt |
| // is scheduled. It does not interrupt an operation that is already running, nor |
| // a backoff wait already in progress, and Retry stops early rather than |
| // starting a backoff that would overrun the limit. |
| // |
| // This differs from bounding Retry with a context deadline (e.g. |
| // context.WithTimeout): a context deadline is reactive — it interrupts the |
| // backoff wait and, if the operation observes the context, can abort an |
| // in-flight attempt — and Retry reports it with Cause context.DeadlineExceeded. |
| // |
| // The default is DefaultMaxElapsedTime (15 minutes), so both limits are active |
| // at once unless overridden. Pass 0 to disable the elapsed-time limit and rely |
| // on the context (or WithMaxTries) instead. |
| func WithMaxElapsedTime(d time.Duration) RetryOption { |
| return func(args *retryOptions) { |
| args.MaxElapsedTime = d |
| } |
| } |
| |
| // Retry attempts the operation until it succeeds, returns a Permanent error, |
| // or backoff completes. It ensures the operation is executed at least once. |
| // |
| // On success it returns the operation result and a nil error. On any failure |
| // it returns the last result and a *RetryError whose Cause reports why it |
| // stopped — ErrPermanent, ErrExhausted, ErrMaxElapsedTime, or the context |
| // cancellation cause — and whose LastErr holds the last operation error. |
| // See RetryError and AsRetryError. |
| // |
| // ctx bounds the retry loop: its cancellation or deadline stops further |
| // attempts and interrupts the wait between them. The operation receives no |
| // context, so capture ctx inside the operation if you want cancellation to |
| // abort an in-flight attempt. To bound only how long backoff keeps retrying, |
| // without affecting in-flight attempts, use WithMaxElapsedTime instead. |
| func Retry[T any](ctx context.Context, operation Operation[T], opts ...RetryOption) (T, error) { |
| // Initialize default retry options. |
| args := &retryOptions{ |
| BackOff: NewExponentialBackOff(), |
| Timer: &defaultTimer{}, |
| MaxElapsedTime: DefaultMaxElapsedTime, |
| } |
| |
| // Apply user-provided options to the default settings. |
| for _, opt := range opts { |
| opt(args) |
| } |
| |
| defer args.Timer.Stop() |
| |
| startedAt := time.Now() |
| args.BackOff.Reset() |
| for numTries := uint(1); ; numTries++ { |
| // Execute the operation. |
| res, err := operation() |
| if err == nil { |
| return res, nil |
| } |
| |
| // Stop immediately on a permanent error; surface it as a RetryError. |
| var perm *permanent |
| if errors.As(err, &perm) { |
| return res, &RetryError{LastErr: perm.err, Cause: ErrPermanent} |
| } |
| |
| // Stop retrying if maximum tries exceeded. |
| if args.MaxTries > 0 && numTries >= args.MaxTries { |
| return res, &RetryError{LastErr: err, Cause: ErrExhausted} |
| } |
| |
| // Stop retrying if context is cancelled. |
| if cerr := context.Cause(ctx); cerr != nil { |
| return res, &RetryError{LastErr: err, Cause: cerr} |
| } |
| |
| // Calculate next backoff duration. |
| next := args.BackOff.NextBackOff() |
| if next == Stop { |
| return res, &RetryError{LastErr: err, Cause: ErrExhausted} |
| } |
| |
| // Reset backoff if RetryAfterError is encountered. |
| var retryAfter *RetryAfterError |
| if errors.As(err, &retryAfter) { |
| next = retryAfter.Duration |
| args.BackOff.Reset() |
| } |
| |
| // Stop retrying if maximum elapsed time exceeded. |
| if args.MaxElapsedTime > 0 && time.Since(startedAt)+next > args.MaxElapsedTime { |
| return res, &RetryError{LastErr: err, Cause: ErrMaxElapsedTime} |
| } |
| |
| // Notify on error if a notifier function is provided. |
| if args.Notify != nil { |
| args.Notify(err, next) |
| } |
| |
| // Wait for the next backoff period or context cancellation. |
| args.Timer.Start(next) |
| select { |
| case <-args.Timer.C(): |
| case <-ctx.Done(): |
| return res, &RetryError{LastErr: err, Cause: context.Cause(ctx)} |
| } |
| } |
| } |