├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── README.md ├── go.mod ├── go.sum ├── repeater.go ├── repeater_test.go ├── strategy.go └── strategy_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | # Unless a later match takes precedence, @umputun will be requested for 3 | # review when someone opens a pull request. 4 | 5 | * @umputun 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [umputun] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | tags: 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: set up go 1.23 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: "1.23" 18 | id: go 19 | 20 | - name: checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: build and test 24 | run: | 25 | go get -v 26 | go test -timeout=60s -race -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp 27 | cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov 28 | go build -race 29 | 30 | - name: golangci-lint 31 | uses: golangci/golangci-lint-action@v4 32 | with: 33 | version: latest 34 | 35 | - name: install goveralls 36 | run: go install github.com/mattn/goveralls@latest 37 | 38 | - name: submit coverage 39 | run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov 40 | env: 41 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | govet: 3 | shadow: true 4 | gocyclo: 5 | min-complexity: 15 6 | maligned: 7 | suggest-new: true 8 | goconst: 9 | min-len: 2 10 | min-occurrences: 2 11 | misspell: 12 | locale: US 13 | lll: 14 | line-length: 140 15 | gocritic: 16 | enabled-tags: 17 | - performance 18 | - style 19 | - experimental 20 | disabled-checks: 21 | - wrapperFunc 22 | 23 | linters: 24 | enable: 25 | - staticcheck 26 | - revive 27 | - govet 28 | - unconvert 29 | - gosec 30 | - unparam 31 | - typecheck 32 | - ineffassign 33 | - stylecheck 34 | - gochecknoinits 35 | - copyloopvar 36 | - gocritic 37 | - nakedret 38 | - gosimple 39 | - prealloc 40 | - unused 41 | - contextcheck 42 | - copyloopvar 43 | - decorder 44 | - errorlint 45 | - exptostd 46 | - gochecknoglobals 47 | - gofmt 48 | - goimports 49 | - nilerr 50 | - predeclared 51 | - testifylint 52 | - thelper 53 | fast: false 54 | disable-all: true 55 | 56 | 57 | run: 58 | concurrency: 4 59 | 60 | issues: 61 | exclude-rules: 62 | - text: "G114: Use of net/http serve function that has no support for setting timeouts" 63 | linters: 64 | - gosec 65 | - linters: 66 | - unparam 67 | - revive 68 | path: _test\.go$ 69 | text: "unused-parameter" 70 | - linters: 71 | - prealloc 72 | path: _test\.go$ 73 | text: "Consider pre-allocating" 74 | - linters: 75 | - gosec 76 | - intrange 77 | path: _test\.go$ 78 | exclude-use-default: false 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Repeater 2 | 3 | [![Build Status](https://github.com/go-pkgz/repeater/workflows/build/badge.svg)](https://github.com/go-pkgz/repeater/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/repeater)](https://goreportcard.com/report/github.com/go-pkgz/repeater) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/repeater/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/repeater?branch=master) 4 | 5 | Package repeater implements a functional mechanism to repeat operations with different retry strategies. 6 | 7 | ## Install and update 8 | 9 | `go get -u github.com/go-pkgz/repeater` 10 | 11 | ## Usage 12 | 13 | ### Basic Example with Exponential Backoff 14 | 15 | ```go 16 | // create repeater with exponential backoff 17 | r := repeater.NewBackoff(5, time.Second) // 5 attempts starting with 1s delay 18 | 19 | err := r.Do(ctx, func() error { 20 | // do something that may fail 21 | return nil 22 | }) 23 | ``` 24 | 25 | ### Fixed Delay with Critical Error 26 | 27 | ```go 28 | // create repeater with fixed delay 29 | r := repeater.NewFixed(3, 100*time.Millisecond) 30 | 31 | criticalErr := errors.New("critical error") 32 | 33 | err := r.Do(ctx, func() error { 34 | // do something that may fail 35 | return fmt.Errorf("temp error") 36 | }, criticalErr) // will stop immediately if criticalErr returned 37 | ``` 38 | 39 | ### Custom Backoff Strategy 40 | 41 | ```go 42 | r := repeater.NewBackoff(5, time.Second, 43 | repeater.WithMaxDelay(10*time.Second), 44 | repeater.WithBackoffType(repeater.BackoffLinear), 45 | repeater.WithJitter(0.1), 46 | ) 47 | 48 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 49 | defer cancel() 50 | 51 | err := r.Do(ctx, func() error { 52 | // do something that may fail 53 | return nil 54 | }) 55 | ``` 56 | 57 | ### Stop on Any Error 58 | 59 | ```go 60 | r := repeater.NewFixed(3, time.Millisecond) 61 | 62 | err := r.Do(ctx, func() error { 63 | return errors.New("some error") 64 | }, repeater.ErrAny) // will stop on any error 65 | ``` 66 | 67 | ## Strategies 68 | 69 | The package provides several retry strategies: 70 | 71 | 1. **Fixed Delay** - each retry happens after a fixed time interval 72 | 2. **Backoff** - delay between retries increases according to the chosen algorithm: 73 | - Constant - same delay between attempts 74 | - Linear - delay increases linearly 75 | - Exponential - delay doubles with each attempt 76 | 77 | Backoff strategy can be customized with: 78 | - Maximum delay cap 79 | - Jitter to prevent thundering herd 80 | - Different backoff types (constant/linear/exponential) 81 | 82 | ### Custom Strategies 83 | 84 | You can implement your own retry strategy by implementing the Strategy interface: 85 | 86 | ```go 87 | type Strategy interface { 88 | // NextDelay returns delay for the next attempt 89 | // attempt starts from 1 90 | NextDelay(attempt int) time.Duration 91 | } 92 | ``` 93 | 94 | Example of a custom strategy that increases delay by a custom factor: 95 | 96 | ```go 97 | // CustomStrategy implements Strategy with custom factor-based delays 98 | type CustomStrategy struct { 99 | Initial time.Duration 100 | Factor float64 101 | } 102 | 103 | func (s CustomStrategy) NextDelay(attempt int) time.Duration { 104 | if attempt <= 0 { 105 | return 0 106 | } 107 | delay := time.Duration(float64(s.Initial) * math.Pow(s.Factor, float64(attempt-1))) 108 | return delay 109 | } 110 | 111 | // Usage 112 | strategy := &CustomStrategy{Initial: time.Second, Factor: 1.5} 113 | r := repeater.NewWithStrategy(5, strategy) 114 | err := r.Do(ctx, func() error { 115 | // attempts will be delayed by: 1s, 1.5s, 2.25s, 3.37s, 5.06s 116 | return nil 117 | }) 118 | ``` 119 | 120 | ## Options 121 | 122 | For backoff strategy, several options are available: 123 | 124 | ```go 125 | WithMaxDelay(time.Duration) // set maximum delay between retries 126 | WithBackoffType(BackoffType) // set backoff type (constant/linear/exponential) 127 | WithJitter(float64) // add randomness to delays (0-1.0) 128 | ``` 129 | 130 | ## Error Handling 131 | 132 | - Stops on context cancellation 133 | - Can stop on specific errors (pass them as additional parameters to Do) 134 | - Special `ErrAny` to stop on any error 135 | - Returns last error if all attempts fail 136 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-pkgz/repeater/v2 2 | 3 | go 1.23 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /repeater.go: -------------------------------------------------------------------------------- 1 | // Package repeater implements retry functionality with different strategies. 2 | // It provides fixed delays and various backoff strategies (constant, linear, exponential) with jitter support. 3 | // The package allows custom retry strategies and error-specific handling. Context-aware implementation 4 | // supports cancellation and timeouts. 5 | package repeater 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "time" 11 | ) 12 | 13 | // ErrAny is a special sentinel error that, when passed as a critical error to Do, 14 | // makes it fail on any error from the function 15 | var ErrAny = errors.New("any error") 16 | 17 | // Repeater holds configuration for retry operations 18 | type Repeater struct { 19 | strategy Strategy 20 | attempts int 21 | } 22 | 23 | // NewWithStrategy creates a repeater with a custom retry strategy 24 | func NewWithStrategy(attempts int, strategy Strategy) *Repeater { 25 | if attempts <= 0 { 26 | attempts = 1 27 | } 28 | if strategy == nil { 29 | strategy = NewFixedDelay(time.Second) 30 | } 31 | return &Repeater{ 32 | attempts: attempts, 33 | strategy: strategy, 34 | } 35 | } 36 | 37 | // NewBackoff creates a repeater with backoff strategy 38 | // Default settings (can be overridden with options): 39 | // - 30s max delay 40 | // - exponential backoff 41 | // - 10% jitter 42 | func NewBackoff(attempts int, initial time.Duration, opts ...backoffOption) *Repeater { 43 | return NewWithStrategy(attempts, newBackoff(initial, opts...)) 44 | } 45 | 46 | // NewFixed creates a repeater with fixed delay strategy 47 | func NewFixed(attempts int, delay time.Duration) *Repeater { 48 | return NewWithStrategy(attempts, NewFixedDelay(delay)) 49 | } 50 | 51 | // Do repeats fun until it succeeds or max attempts reached 52 | // terminates immediately on context cancellation or if err matches any in termErrs. 53 | // if errs contains ErrAny, terminates on any error. 54 | func (r *Repeater) Do(ctx context.Context, fun func() error, termErrs ...error) error { 55 | var lastErr error 56 | 57 | inErrors := func(err error) bool { 58 | for _, e := range termErrs { 59 | if errors.Is(e, ErrAny) { 60 | return true 61 | } 62 | if errors.Is(err, e) { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | 69 | for attempt := 0; attempt < r.attempts; attempt++ { 70 | // check context before each attempt 71 | if err := ctx.Err(); err != nil { 72 | return err 73 | } 74 | 75 | var err error 76 | if err = fun(); err == nil { 77 | return nil 78 | } 79 | 80 | lastErr = err 81 | if inErrors(err) { 82 | return err 83 | } 84 | 85 | // don't sleep after the last attempt 86 | if attempt < r.attempts-1 { 87 | delay := r.strategy.NextDelay(attempt + 1) 88 | if delay > 0 { 89 | select { 90 | case <-ctx.Done(): 91 | return ctx.Err() 92 | case <-time.After(delay): 93 | } 94 | } 95 | } 96 | } 97 | 98 | return lastErr 99 | } 100 | -------------------------------------------------------------------------------- /repeater_test.go: -------------------------------------------------------------------------------- 1 | package repeater 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRepeater(t *testing.T) { 15 | t.Run("zero or negative attempts converted to 1", func(t *testing.T) { 16 | r := NewFixed(0, time.Millisecond) 17 | assert.Equal(t, 1, r.attempts) 18 | r = NewFixed(-1, time.Millisecond) 19 | assert.Equal(t, 1, r.attempts) 20 | }) 21 | 22 | t.Run("nil strategy defaults to fixed 1s", func(t *testing.T) { 23 | r := NewWithStrategy(1, nil) 24 | s, ok := r.strategy.(FixedDelay) 25 | require.True(t, ok) 26 | assert.Equal(t, time.Second, s.Delay) 27 | }) 28 | } 29 | 30 | func TestDo(t *testing.T) { 31 | t.Run("success first try", func(t *testing.T) { 32 | calls := 0 33 | r := NewFixed(3, time.Millisecond) 34 | err := r.Do(context.Background(), func() error { 35 | calls++ 36 | return nil 37 | }) 38 | require.NoError(t, err) 39 | assert.Equal(t, 1, calls) 40 | }) 41 | 42 | t.Run("success after retries", func(t *testing.T) { 43 | calls := 0 44 | r := NewFixed(3, time.Millisecond) 45 | err := r.Do(context.Background(), func() error { 46 | calls++ 47 | if calls < 3 { 48 | return errors.New("not yet") 49 | } 50 | return nil 51 | }) 52 | require.NoError(t, err) 53 | assert.Equal(t, 3, calls) 54 | }) 55 | 56 | t.Run("failure after all attempts", func(t *testing.T) { 57 | calls := 0 58 | r := NewFixed(3, time.Millisecond) 59 | err := r.Do(context.Background(), func() error { 60 | calls++ 61 | return errors.New("always fails") 62 | }) 63 | require.Error(t, err) 64 | assert.Equal(t, "always fails", err.Error()) 65 | assert.Equal(t, 3, calls) 66 | }) 67 | 68 | t.Run("stops on critical error", func(t *testing.T) { 69 | calls := 0 70 | criticalErr := errors.New("critical") 71 | r := NewFixed(5, time.Millisecond) 72 | err := r.Do(context.Background(), func() error { 73 | calls++ 74 | return criticalErr 75 | }, criticalErr) 76 | require.ErrorIs(t, err, criticalErr) 77 | assert.Equal(t, 1, calls) 78 | }) 79 | 80 | t.Run("stops on wrapped critical error", func(t *testing.T) { 81 | calls := 0 82 | criticalErr := errors.New("critical") 83 | r := NewFixed(5, time.Millisecond) 84 | err := r.Do(context.Background(), func() error { 85 | calls++ 86 | return errors.Join(errors.New("wrapped"), criticalErr) 87 | }, criticalErr) 88 | require.ErrorIs(t, err, criticalErr) 89 | assert.Equal(t, 1, calls) 90 | }) 91 | } 92 | 93 | func TestDoContext(t *testing.T) { 94 | t.Run("respects cancellation", func(t *testing.T) { 95 | ctx, cancel := context.WithCancel(context.Background()) 96 | defer cancel() 97 | 98 | calls := 0 99 | r := NewFixed(5, 100*time.Millisecond) 100 | 101 | go func() { 102 | time.Sleep(50 * time.Millisecond) 103 | cancel() 104 | }() 105 | 106 | err := r.Do(ctx, func() error { 107 | calls++ 108 | return errors.New("failed") 109 | }) 110 | 111 | require.ErrorIs(t, err, context.Canceled) 112 | assert.Equal(t, 1, calls) 113 | }) 114 | 115 | t.Run("timeout before first attempt", func(t *testing.T) { 116 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) 117 | defer cancel() 118 | 119 | time.Sleep(5 * time.Millisecond) // ensure timeout 120 | calls := 0 121 | r := NewFixed(5, time.Millisecond) 122 | err := r.Do(ctx, func() error { 123 | calls++ 124 | return errors.New("failed") 125 | }) 126 | require.ErrorIs(t, err, context.DeadlineExceeded) 127 | assert.Equal(t, 0, calls) 128 | }) 129 | } 130 | 131 | func TestDoWithErrAny(t *testing.T) { 132 | t.Run("stops on any error", func(t *testing.T) { 133 | calls := 0 134 | r := NewFixed(5, time.Millisecond) 135 | err := r.Do(context.Background(), func() error { 136 | calls++ 137 | return errors.New("some error") 138 | }, ErrAny) 139 | require.Error(t, err) 140 | assert.Equal(t, 1, calls, "should stop on first error with ErrAny") 141 | }) 142 | 143 | t.Run("combines with other critical errors", func(t *testing.T) { 144 | counts := make(map[string]int) 145 | r := NewFixed(5, time.Millisecond) 146 | criticalErr := errors.New("critical") 147 | 148 | err := r.Do(context.Background(), func() error { 149 | // return different errors 150 | counts["total"]++ 151 | if counts["total"] == 2 { 152 | return criticalErr 153 | } 154 | return errors.New("non-critical") 155 | }, criticalErr, ErrAny) 156 | 157 | require.Error(t, err) 158 | assert.Equal(t, 1, counts["total"], "should stop on first error when ErrAny is used") 159 | }) 160 | } 161 | 162 | func TestNewBackoff(t *testing.T) { 163 | r := NewBackoff(5, time.Second) 164 | assert.Equal(t, 5, r.attempts) 165 | 166 | st, ok := r.strategy.(*backoff) 167 | require.True(t, ok) 168 | 169 | // check defaults 170 | assert.Equal(t, time.Second, st.initial) 171 | assert.Equal(t, 30*time.Second, st.maxDelay) 172 | assert.Equal(t, BackoffExponential, st.btype) 173 | assert.InDelta(t, 0.1, st.jitter, 0.0001, "default jitter") 174 | 175 | // check with options 176 | r = NewBackoff(5, time.Second, 177 | WithMaxDelay(5*time.Second), 178 | WithBackoffType(BackoffLinear), 179 | WithJitter(0.2), 180 | ) 181 | st, ok = r.strategy.(*backoff) 182 | require.True(t, ok) 183 | assert.Equal(t, time.Second, st.initial) 184 | assert.Equal(t, 5*time.Second, st.maxDelay) 185 | assert.Equal(t, BackoffLinear, st.btype) 186 | assert.InDelta(t, 0.2, st.jitter, 0.0001, "custom jitter") 187 | } 188 | 189 | func TestBackoffReal(t *testing.T) { 190 | startTime := time.Now() 191 | var attempts []time.Time 192 | 193 | expectedAttempts := 4 194 | r := NewBackoff(expectedAttempts, 10*time.Millisecond, WithJitter(0)) 195 | 196 | // record all attempt times 197 | err := r.Do(context.Background(), func() error { 198 | attempts = append(attempts, time.Now()) 199 | return errors.New("test error") 200 | }) 201 | require.Error(t, err) 202 | 203 | assert.Len(t, attempts, expectedAttempts, "should make exactly %d attempts", expectedAttempts) 204 | 205 | // first attempt should be immediate 206 | assert.Less(t, attempts[0].Sub(startTime), 5*time.Millisecond) 207 | 208 | // check intervals between attempts 209 | var intervals []time.Duration 210 | for i := 1; i < len(attempts); i++ { 211 | intervals = append(intervals, attempts[i].Sub(attempts[i-1])) 212 | t.Logf("attempt %d interval: %v", i, intervals[i-1]) 213 | } 214 | 215 | // check total time for all attempts 216 | // with exponential backoff and 10ms initial delay we expect: 217 | // - attempt 1 - immediate (0ms) 218 | // - attempt 2 - after 10ms delay (total ~10ms) 219 | // - attempt 3 - after 20ms delay (total ~30ms) 220 | // - attempt 4 - after 40ms delay (total ~70ms) 221 | totalTime := attempts[len(attempts)-1].Sub(startTime) 222 | assert.Greater(t, totalTime, 65*time.Millisecond) 223 | assert.Less(t, totalTime, 75*time.Millisecond) 224 | } 225 | 226 | func ExampleRepeater_Do() { 227 | // create repeater with exponential backoff 228 | r := NewBackoff(5, time.Second) 229 | 230 | err := r.Do(context.Background(), func() error { 231 | // simulating successful operation 232 | return nil 233 | }) 234 | 235 | fmt.Printf("completed with error: %v", err) 236 | // Output: completed with error: 237 | } 238 | 239 | func ExampleNewFixed() { 240 | // create repeater with fixed 100ms delay between attempts 241 | r := NewFixed(3, 100*time.Millisecond) 242 | 243 | // retry on "temp error" but give up immediately on "critical error" 244 | criticalErr := errors.New("critical error") 245 | 246 | // run Do and check the returned error 247 | err := r.Do(context.Background(), func() error { 248 | // simulating critical error 249 | return criticalErr 250 | }, criticalErr) 251 | 252 | if err != nil { 253 | fmt.Printf("got expected error: %v", err) 254 | } 255 | // Output: got expected error: critical error 256 | } 257 | 258 | func ExampleNewBackoff() { 259 | // create backoff repeater with custom settings 260 | r := NewBackoff(3, time.Millisecond, 261 | WithMaxDelay(10*time.Millisecond), 262 | WithBackoffType(BackoffLinear), 263 | WithJitter(0), 264 | ) 265 | 266 | var attempts int 267 | err := r.Do(context.Background(), func() error { 268 | attempts++ 269 | if attempts < 3 { 270 | return errors.New("temporary error") 271 | } 272 | return nil 273 | }) 274 | 275 | fmt.Printf("completed with error: %v after %d attempts", err, attempts) 276 | // Output: completed with error: after 3 attempts 277 | } 278 | -------------------------------------------------------------------------------- /strategy.go: -------------------------------------------------------------------------------- 1 | package repeater 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // Strategy defines how to calculate delays between retries 9 | type Strategy interface { 10 | // NextDelay returns delay for the next attempt, attempt starts from 1 11 | NextDelay(attempt int) time.Duration 12 | } 13 | 14 | // FixedDelay implements fixed time delay between attempts 15 | type FixedDelay struct { 16 | Delay time.Duration 17 | } 18 | 19 | // NewFixedDelay creates a new FixedDelay strategy 20 | func NewFixedDelay(delay time.Duration) FixedDelay { 21 | return FixedDelay{Delay: delay} 22 | } 23 | 24 | // NextDelay returns fixed delay 25 | func (s FixedDelay) NextDelay(_ int) time.Duration { 26 | return s.Delay 27 | } 28 | 29 | // BackoffType represents the backoff strategy type 30 | type BackoffType int 31 | 32 | const ( 33 | // BackoffConstant keeps delays the same between attempts 34 | BackoffConstant BackoffType = iota 35 | // BackoffLinear increases delays linearly between attempts 36 | BackoffLinear 37 | // BackoffExponential increases delays exponentially between attempts 38 | BackoffExponential 39 | ) 40 | 41 | // backoff implements various backoff strategies with optional jitter 42 | type backoff struct { 43 | initial time.Duration 44 | maxDelay time.Duration 45 | btype BackoffType 46 | jitter float64 47 | } 48 | 49 | type backoffOption func(*backoff) 50 | 51 | // WithMaxDelay sets maximum delay for the backoff strategy 52 | func WithMaxDelay(d time.Duration) backoffOption { //nolint:revive // unexported type is used in the same package 53 | return func(b *backoff) { 54 | b.maxDelay = d 55 | } 56 | } 57 | 58 | // WithBackoffType sets backoff type for the strategy 59 | func WithBackoffType(t BackoffType) backoffOption { //nolint:revive // unexported type is used in the same package 60 | return func(b *backoff) { 61 | b.btype = t 62 | } 63 | } 64 | 65 | // WithJitter sets jitter factor for the backoff strategy 66 | func WithJitter(factor float64) backoffOption { //nolint:revive // unexported type is used in the same package 67 | return func(b *backoff) { 68 | b.jitter = factor 69 | } 70 | } 71 | 72 | func newBackoff(initial time.Duration, opts ...backoffOption) *backoff { 73 | b := &backoff{ 74 | initial: initial, 75 | maxDelay: 30 * time.Second, 76 | btype: BackoffExponential, 77 | jitter: 0.1, 78 | } 79 | 80 | for _, opt := range opts { 81 | opt(b) 82 | } 83 | 84 | return b 85 | } 86 | 87 | // NextDelay returns delay for the next attempt 88 | func (s backoff) NextDelay(attempt int) time.Duration { 89 | if attempt <= 0 { 90 | return 0 91 | } 92 | 93 | var delay time.Duration 94 | switch s.btype { 95 | case BackoffConstant: 96 | delay = s.initial 97 | case BackoffLinear: 98 | delay = s.initial * time.Duration(attempt) 99 | case BackoffExponential: 100 | delay = s.initial * time.Duration(1<<(attempt-1)) 101 | } 102 | 103 | if s.maxDelay > 0 && delay > s.maxDelay { 104 | delay = s.maxDelay 105 | } 106 | 107 | if s.jitter > 0 { 108 | jitter := float64(delay) * s.jitter 109 | delay = time.Duration(float64(delay) + (rand.Float64()*jitter - jitter/2)) //nolint:gosec // no need for secure random here 110 | } 111 | 112 | return delay 113 | } 114 | -------------------------------------------------------------------------------- /strategy_test.go: -------------------------------------------------------------------------------- 1 | package repeater 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFixedDelay(t *testing.T) { 11 | s := NewFixedDelay(time.Second) 12 | assert.Equal(t, time.Second, s.NextDelay(1)) 13 | assert.Equal(t, time.Second, s.NextDelay(5)) 14 | assert.Equal(t, time.Second, s.NextDelay(0)) 15 | } 16 | 17 | func TestBackoff(t *testing.T) { 18 | t.Run("base cases", func(t *testing.T) { 19 | s := newBackoff(time.Second, WithJitter(0)) 20 | assert.Equal(t, 0*time.Second, s.NextDelay(0)) 21 | assert.Equal(t, 1*time.Second, s.NextDelay(1)) 22 | }) 23 | 24 | t.Run("backoff types", func(t *testing.T) { 25 | initial := 100 * time.Millisecond 26 | 27 | t.Run("constant", func(t *testing.T) { 28 | s := newBackoff(initial, WithJitter(0), WithBackoffType(BackoffConstant)) 29 | assert.Equal(t, initial, s.NextDelay(1)) 30 | assert.Equal(t, initial, s.NextDelay(2)) 31 | assert.Equal(t, initial, s.NextDelay(3)) 32 | }) 33 | 34 | t.Run("linear", func(t *testing.T) { 35 | s := newBackoff(initial, WithJitter(0), WithBackoffType(BackoffLinear)) 36 | assert.Equal(t, 1*initial, s.NextDelay(1)) 37 | assert.Equal(t, 2*initial, s.NextDelay(2)) 38 | assert.Equal(t, 3*initial, s.NextDelay(3)) 39 | }) 40 | 41 | t.Run("exponential", func(t *testing.T) { 42 | s := newBackoff(initial, WithJitter(0), WithBackoffType(BackoffExponential)) 43 | assert.Equal(t, 1*initial, s.NextDelay(1)) 44 | assert.Equal(t, 2*initial, s.NextDelay(2)) 45 | assert.Equal(t, 4*initial, s.NextDelay(3)) 46 | assert.Equal(t, 8*initial, s.NextDelay(4)) 47 | }) 48 | }) 49 | 50 | t.Run("max delay", func(t *testing.T) { 51 | s := newBackoff(time.Second, WithJitter(0), WithMaxDelay(2*time.Second)) 52 | assert.Equal(t, 1*time.Second, s.NextDelay(1)) 53 | assert.Equal(t, 2*time.Second, s.NextDelay(2)) 54 | assert.Equal(t, 2*time.Second, s.NextDelay(3)) // capped at max delay 55 | }) 56 | 57 | t.Run("jitter", func(t *testing.T) { 58 | initial := time.Second 59 | s := newBackoff(initial, WithJitter(0.1)) // 10% jitter 60 | 61 | for i := 0; i < 10; i++ { 62 | delay := s.NextDelay(1) 63 | assert.GreaterOrEqual(t, delay, 950*time.Millisecond) // initial - 5% jitter 64 | assert.Less(t, delay, 1050*time.Millisecond) // initial + 5% jitter 65 | } 66 | }) 67 | 68 | t.Run("all options", func(t *testing.T) { 69 | s := newBackoff(time.Second, 70 | WithBackoffType(BackoffLinear), 71 | WithMaxDelay(3*time.Second), 72 | WithJitter(0.2), 73 | ) 74 | 75 | assert.Equal(t, time.Second, s.initial) 76 | assert.Equal(t, 3*time.Second, s.maxDelay) 77 | assert.Equal(t, BackoffLinear, s.btype) 78 | assert.InDelta(t, 0.2, s.jitter, 0.0001, "custom jitter") 79 | }) 80 | 81 | t.Run("defaults", func(t *testing.T) { 82 | s := newBackoff(time.Second) 83 | assert.Equal(t, time.Second, s.initial) 84 | assert.Equal(t, 30*time.Second, s.maxDelay) 85 | assert.Equal(t, BackoffExponential, s.btype) 86 | assert.InDelta(t, 0.1, s.jitter, 0.0001, "default jitter") 87 | }) 88 | } 89 | --------------------------------------------------------------------------------