github.com/go-playground/pkg/v5@v5.29.1/errors/retrier.go (about)

     1  //go:build go1.18
     2  // +build go1.18
     3  
     4  package errorsext
     5  
     6  import (
     7  	"context"
     8  	"time"
     9  
    10  	. "github.com/go-playground/pkg/v5/values/result"
    11  )
    12  
    13  // MaxAttemptsMode is used to set the mode for the maximum number of attempts.
    14  //
    15  // eg. Should the max attempts apply to all errors, just ones not determined to be retryable, reset on retryable errors, etc.
    16  type MaxAttemptsMode uint8
    17  
    18  const (
    19  	// MaxAttemptsNonRetryableReset will apply the max attempts to all errors not determined to be retryable, but will
    20  	// reset the attempts if a retryable error is encountered after a non-retryable error.
    21  	MaxAttemptsNonRetryableReset MaxAttemptsMode = iota
    22  
    23  	// MaxAttemptsNonRetryable will apply the max attempts to all errors not determined to be retryable.
    24  	MaxAttemptsNonRetryable
    25  
    26  	// MaxAttempts will apply the max attempts to all errors, even those determined to be retryable.
    27  	MaxAttempts
    28  
    29  	// MaxAttemptsUnlimited will not apply a maximum number of attempts.
    30  	MaxAttemptsUnlimited
    31  )
    32  
    33  // BackoffFn is a function used to apply a backoff strategy to the retryable function.
    34  //
    35  // It accepts `E` in cases where the amount of time to backoff is dynamic, for example when and http request fails
    36  // with a 429 status code, the `Retry-After` header can be used to determine how long to backoff. It is not required
    37  // to use or handle `E` and can be ignored if desired.
    38  type BackoffFn[E any] func(ctx context.Context, attempt int, e E)
    39  
    40  // IsRetryableFn2 is called to determine if the type E is retryable.
    41  type IsRetryableFn2[E any] func(ctx context.Context, e E) (isRetryable bool)
    42  
    43  // EarlyReturnFn is the function that can be used to bypass all retry logic, no matter the MaxAttemptsMode, for when the
    44  // type of `E` will never succeed and should not be retried.
    45  //
    46  // eg. If retrying an HTTP request and getting 400 Bad Request, it's unlikely to ever succeed and should not be retried.
    47  type EarlyReturnFn[E any] func(ctx context.Context, e E) (earlyReturn bool)
    48  
    49  // Retryer is used to retry any fallible operation.
    50  type Retryer[T, E any] struct {
    51  	isRetryableFn   IsRetryableFn2[E]
    52  	isEarlyReturnFn EarlyReturnFn[E]
    53  	maxAttemptsMode MaxAttemptsMode
    54  	maxAttempts     uint8
    55  	bo              BackoffFn[E]
    56  	timeout         time.Duration
    57  }
    58  
    59  // NewRetryer returns a new `Retryer` with sane default values.
    60  //
    61  // The default values are:
    62  // - `MaxAttemptsMode` is `MaxAttemptsNonRetryableReset`.
    63  // - `MaxAttempts` is 5.
    64  // - `Timeout` is 0 no context timeout.
    65  // - `IsRetryableFn` will always return false as `E` is unknown until defined.
    66  // - `BackoffFn` will sleep for 200ms. It's recommended to use exponential backoff for production.
    67  // - `EarlyReturnFn` will be None.
    68  func NewRetryer[T, E any]() Retryer[T, E] {
    69  	return Retryer[T, E]{
    70  		isRetryableFn:   func(_ context.Context, _ E) bool { return false },
    71  		maxAttemptsMode: MaxAttemptsNonRetryableReset,
    72  		maxAttempts:     5,
    73  		bo: func(ctx context.Context, attempt int, _ E) {
    74  			t := time.NewTimer(time.Millisecond * 200)
    75  			defer t.Stop()
    76  			select {
    77  			case <-ctx.Done():
    78  			case <-t.C:
    79  			}
    80  		},
    81  	}
    82  }
    83  
    84  // IsRetryableFn sets the `IsRetryableFn` for the `Retryer`.
    85  func (r Retryer[T, E]) IsRetryableFn(fn IsRetryableFn2[E]) Retryer[T, E] {
    86  	if fn == nil {
    87  		fn = func(_ context.Context, _ E) bool { return false }
    88  	}
    89  	r.isRetryableFn = fn
    90  	return r
    91  }
    92  
    93  // IsEarlyReturnFn sets the `EarlyReturnFn` for the `Retryer`.
    94  //
    95  // NOTE: If the `EarlyReturnFn` and `IsRetryableFn` are both set and a conflicting `IsRetryableFn` will take precedence.
    96  func (r Retryer[T, E]) IsEarlyReturnFn(fn EarlyReturnFn[E]) Retryer[T, E] {
    97  	r.isEarlyReturnFn = fn
    98  	return r
    99  }
   100  
   101  // MaxAttempts sets the maximum number of attempts for the `Retryer`.
   102  //
   103  // NOTE: Max attempts is optional and if not set will retry indefinitely on retryable errors.
   104  func (r Retryer[T, E]) MaxAttempts(mode MaxAttemptsMode, maxAttempts uint8) Retryer[T, E] {
   105  	r.maxAttemptsMode, r.maxAttempts = mode, maxAttempts
   106  	return r
   107  }
   108  
   109  // Backoff sets the backoff function for the `Retryer`.
   110  func (r Retryer[T, E]) Backoff(fn BackoffFn[E]) Retryer[T, E] {
   111  	if fn == nil {
   112  		fn = func(_ context.Context, _ int, _ E) {}
   113  	}
   114  	r.bo = fn
   115  	return r
   116  }
   117  
   118  // Timeout sets the timeout for the `Retryer`. This is the timeout per `RetyableFn` attempt and not the entirety
   119  // of the `Retryer` execution.
   120  //
   121  // A timeout of 0 will disable the timeout and is the default.
   122  func (r Retryer[T, E]) Timeout(timeout time.Duration) Retryer[T, E] {
   123  	r.timeout = timeout
   124  	return r
   125  }
   126  
   127  // Do will execute the provided functions code and automatically retry using the provided retry function.
   128  func (r Retryer[T, E]) Do(ctx context.Context, fn RetryableFn[T, E]) Result[T, E] {
   129  	var attempt int
   130  	remaining := r.maxAttempts
   131  	for {
   132  		var result Result[T, E]
   133  		if r.timeout == 0 {
   134  			result = fn(ctx)
   135  		} else {
   136  			ctx, cancel := context.WithTimeout(ctx, r.timeout)
   137  			result = fn(ctx)
   138  			cancel()
   139  		}
   140  		if result.IsErr() {
   141  			err := result.Err()
   142  			isRetryable := r.isRetryableFn(ctx, err)
   143  			if !isRetryable && r.isEarlyReturnFn != nil && r.isEarlyReturnFn(ctx, err) {
   144  				return result
   145  			}
   146  
   147  			switch r.maxAttemptsMode {
   148  			case MaxAttemptsUnlimited:
   149  				goto RETRY
   150  			case MaxAttemptsNonRetryableReset:
   151  				if isRetryable {
   152  					remaining = r.maxAttempts
   153  					goto RETRY
   154  				} else if remaining > 0 {
   155  					remaining--
   156  				}
   157  			case MaxAttemptsNonRetryable:
   158  				if isRetryable {
   159  					goto RETRY
   160  				} else if remaining > 0 {
   161  					remaining--
   162  				}
   163  			case MaxAttempts:
   164  				if remaining > 0 {
   165  					remaining--
   166  				}
   167  			}
   168  			if remaining == 0 {
   169  				return result
   170  			}
   171  
   172  		RETRY:
   173  			r.bo(ctx, attempt, err)
   174  			attempt++
   175  			continue
   176  		}
   177  		return result
   178  	}
   179  }