| 
 | 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 | +}  | 
0 commit comments