├── .github ├── dependabot.yml └── workflows │ └── workflow.yaml ├── .gitignore ├── .godocdown.tmpl ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── current.txt ├── examples ├── custom_retry_function_test.go ├── delay_based_on_error_test.go ├── errors_history_test.go └── http_get_test.go ├── generic.txt ├── go.mod ├── go.sum ├── options.go ├── retry.go └── retry_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | golangci-lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version: stable 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v6 19 | with: 20 | version: latest 21 | tests: 22 | runs-on: ${{ matrix.os }} 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | go-version: ['1.20', '1.21', '1.22', '1.23', '1.24'] 27 | os: [ubuntu-latest, macos-latest, windows-latest] 28 | env: 29 | OS: ${{ matrix.os }} 30 | GOVERSION: ${{ matrix.go-version }} 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Setup Go ${{ matrix.go-version }} 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: ${{ matrix.go-version }} 37 | check-latest: true 38 | cache: true 39 | - name: Display Go version 40 | run: go version 41 | - name: Install dependencies 42 | run: make setup 43 | - name: Test 44 | run: make ci 45 | - name: Archive code coverage results 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: code-coverage-report-${{ matrix.os }}-${{ matrix.go-version }} 49 | path: coverage.txt 50 | 51 | coverage: 52 | needs: tests 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Download a linux coverage report 57 | uses: actions/download-artifact@v4 58 | with: 59 | name: code-coverage-report-ubuntu-latest-1.24 60 | - name: Upload coverage to Codecov 61 | uses: codecov/codecov-action@v4 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | fail_ci_if_error: true 65 | flags: unittest 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | # dep 17 | vendor/ 18 | Gopkg.lock 19 | 20 | # cover 21 | coverage.txt 22 | -------------------------------------------------------------------------------- /.godocdown.tmpl: -------------------------------------------------------------------------------- 1 | # {{ .Name }} 2 | 3 | [![Release](https://img.shields.io/github/release/avast/retry-go.svg?style=flat-square)](https://github.com/avast/retry-go/releases/latest) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 5 | ![GitHub Actions](https://github.com/avast/retry-go/actions/workflows/workflow.yaml/badge.svg) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/avast/retry-go?style=flat-square)](https://goreportcard.com/report/github.com/avast/retry-go) 7 | [![Go Reference](https://pkg.go.dev/badge/github.com/avast/retry-go/v4.svg)](https://pkg.go.dev/github.com/avast/retry-go/v4) 8 | [![codecov.io](https://codecov.io/github/avast/retry-go/coverage.svg?branch=master)](https://codecov.io/github/avast/retry-go?branch=master) 9 | [![Sourcegraph](https://sourcegraph.com/github.com/avast/retry-go/-/badge.svg)](https://sourcegraph.com/github.com/avast/retry-go?badge) 10 | 11 | {{ .EmitSynopsis }} 12 | 13 | {{ .EmitUsage }} 14 | 15 | ## Contributing 16 | 17 | Contributions are very much welcome. 18 | 19 | ### Makefile 20 | 21 | Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc... 22 | 23 | Try `make help` for more information. 24 | 25 | ### Before pull request 26 | 27 | > maybe you need `make setup` in order to setup environment 28 | 29 | please try: 30 | * run tests (`make test`) 31 | * run linter (`make lint`) 32 | * if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`) 33 | 34 | ### README 35 | 36 | README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown). 37 | 38 | Never edit README.md direct, because your change will be lost. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Avast 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES?=$$(go list ./... | grep -v /vendor/) 2 | TEST_PATTERN?=. 3 | TEST_OPTIONS?= 4 | VERSION?=$$(cat VERSION) 5 | LINTER?=$$(which golangci-lint) 6 | LINTER_VERSION=1.50.0 7 | 8 | ifeq ($(OS),Windows_NT) 9 | LINTER_FILE=golangci-lint-$(LINTER_VERSION)-windows-amd64.zip 10 | LINTER_UNPACK= >| app.zip; unzip -j app.zip -d $$GOPATH/bin; rm app.zip 11 | else ifeq ($(OS), Darwin) 12 | LINTER_FILE=golangci-lint-$(LINTER_VERSION)-darwin-amd64.tar.gz 13 | LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint" 14 | else 15 | LINTER_FILE=golangci-lint-$(LINTER_VERSION)-linux-amd64.tar.gz 16 | LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint" 17 | endif 18 | 19 | setup: 20 | go install github.com/pierrre/gotestcover@latest 21 | go install golang.org/x/tools/cmd/cover@latest 22 | go install github.com/robertkrimen/godocdown/godocdown@latest 23 | go mod download 24 | 25 | generate: ## Generate README.md 26 | godocdown >| README.md 27 | 28 | test: generate test_and_cover_report lint 29 | 30 | test_and_cover_report: 31 | gotestcover $(TEST_OPTIONS) -covermode=atomic -coverprofile=coverage.txt $(SOURCE_FILES) -run $(TEST_PATTERN) -timeout=2m 32 | 33 | cover: test ## Run all the tests and opens the coverage report 34 | go tool cover -html=coverage.txt 35 | 36 | fmt: ## gofmt and goimports all go files 37 | find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done 38 | 39 | lint: ## Run all the linters 40 | @if [ "$(LINTER)" = "" ]; then\ 41 | curl -L https://github.com/golangci/golangci-lint/releases/download/v$(LINTER_VERSION)/$(LINTER_FILE) $(LINTER_UNPACK) ;\ 42 | chmod +x $$GOPATH/bin/golangci-lint;\ 43 | fi 44 | 45 | golangci-lint run 46 | 47 | ci: test_and_cover_report ## Run all the tests but no linters - use https://golangci.com integration instead 48 | 49 | build: 50 | go build 51 | 52 | release: ## Release new version 53 | git tag | grep -q $(VERSION) && echo This version was released! Increase VERSION! || git tag $(VERSION) && git push origin $(VERSION) && git tag v$(VERSION) && git push origin v$(VERSION) 54 | 55 | # Absolutely awesome: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 56 | help: 57 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 58 | 59 | .DEFAULT_GOAL := build 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retry 2 | 3 | [![Release](https://img.shields.io/github/release/avast/retry-go.svg?style=flat-square)](https://github.com/avast/retry-go/releases/latest) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 5 | ![GitHub Actions](https://github.com/avast/retry-go/actions/workflows/workflow.yaml/badge.svg) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/avast/retry-go?style=flat-square)](https://goreportcard.com/report/github.com/avast/retry-go) 7 | [![Go Reference](https://pkg.go.dev/badge/github.com/avast/retry-go/v4.svg)](https://pkg.go.dev/github.com/avast/retry-go/v4) 8 | [![codecov.io](https://codecov.io/github/avast/retry-go/coverage.svg?branch=master)](https://codecov.io/github/avast/retry-go?branch=master) 9 | [![Sourcegraph](https://sourcegraph.com/github.com/avast/retry-go/-/badge.svg)](https://sourcegraph.com/github.com/avast/retry-go?badge) 10 | 11 | Simple library for retry mechanism 12 | 13 | Slightly inspired by 14 | [Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry) 15 | 16 | # SYNOPSIS 17 | 18 | HTTP GET with retry: 19 | 20 | url := "http://example.com" 21 | var body []byte 22 | 23 | err := retry.Do( 24 | func() error { 25 | resp, err := http.Get(url) 26 | if err != nil { 27 | return err 28 | } 29 | defer resp.Body.Close() 30 | body, err = ioutil.ReadAll(resp.Body) 31 | if err != nil { 32 | return err 33 | } 34 | return nil 35 | }, 36 | ) 37 | 38 | if err != nil { 39 | // handle error 40 | } 41 | 42 | fmt.Println(string(body)) 43 | 44 | HTTP GET with retry with data: 45 | 46 | url := "http://example.com" 47 | 48 | body, err := retry.DoWithData( 49 | func() ([]byte, error) { 50 | resp, err := http.Get(url) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer resp.Body.Close() 55 | body, err := ioutil.ReadAll(resp.Body) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return body, nil 61 | }, 62 | ) 63 | 64 | if err != nil { 65 | // handle error 66 | } 67 | 68 | fmt.Println(string(body)) 69 | 70 | [More examples](https://github.com/avast/retry-go/tree/master/examples) 71 | 72 | # SEE ALSO 73 | 74 | * [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly 75 | complicated interface. 76 | 77 | * [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for 78 | http calls with retries and backoff 79 | 80 | * [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the 81 | exponential backoff algorithm from Google's HTTP Client Library for Java. Really 82 | complicated interface. 83 | 84 | * [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good, 85 | slightly similar as this package, don't have 'simple' `Retry` method 86 | 87 | * [matryer/try](https://github.com/matryer/try) - very popular package, 88 | nonintuitive interface (for me) 89 | 90 | # BREAKING CHANGES 91 | 92 | * 4.0.0 93 | 94 | - infinity retry is possible by set `Attempts(0)` by PR [#49](https://github.com/avast/retry-go/pull/49) 95 | 96 | * 3.0.0 97 | 98 | - `DelayTypeFunc` accepts a new parameter `err` - this breaking change affects only your custom Delay Functions. This change allow [make delay functions based on error](examples/delay_based_on_error_test.go). 99 | 100 | * 1.0.2 -> 2.0.0 101 | 102 | - argument of `retry.Delay` is final delay (no multiplication by `retry.Units` anymore) 103 | - function `retry.Units` are removed 104 | - [more about this breaking change](https://github.com/avast/retry-go/issues/7) 105 | 106 | * 0.3.0 -> 1.0.0 107 | 108 | - `retry.Retry` function are changed to `retry.Do` function 109 | - `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are now implement via functions produces Options (aka `retry.OnRetry`) 110 | 111 | ## Usage 112 | 113 | #### func BackOffDelay 114 | 115 | ```go 116 | func BackOffDelay(n uint, _ error, config *Config) time.Duration 117 | ``` 118 | BackOffDelay is a DelayType which increases delay between consecutive retries 119 | 120 | #### func Do 121 | 122 | ```go 123 | func Do(retryableFunc RetryableFunc, opts ...Option) error 124 | ``` 125 | 126 | #### func DoWithData 127 | 128 | ```go 129 | func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (T, error) 130 | ``` 131 | 132 | #### func FixedDelay 133 | 134 | ```go 135 | func FixedDelay(_ uint, _ error, config *Config) time.Duration 136 | ``` 137 | FixedDelay is a DelayType which keeps delay the same through all iterations 138 | 139 | #### func IsRecoverable 140 | 141 | ```go 142 | func IsRecoverable(err error) bool 143 | ``` 144 | IsRecoverable checks if error is an instance of `unrecoverableError` 145 | 146 | #### func RandomDelay 147 | 148 | ```go 149 | func RandomDelay(_ uint, _ error, config *Config) time.Duration 150 | ``` 151 | RandomDelay is a DelayType which picks a random delay up to config.maxJitter 152 | 153 | #### func Unrecoverable 154 | 155 | ```go 156 | func Unrecoverable(err error) error 157 | ``` 158 | Unrecoverable wraps an error in `unrecoverableError` struct 159 | 160 | #### type Config 161 | 162 | ```go 163 | type Config struct { 164 | } 165 | ``` 166 | 167 | 168 | #### type DelayTypeFunc 169 | 170 | ```go 171 | type DelayTypeFunc func(n uint, err error, config *Config) time.Duration 172 | ``` 173 | 174 | DelayTypeFunc is called to return the next delay to wait after the retriable 175 | function fails on `err` after `n` attempts. 176 | 177 | #### func CombineDelay 178 | 179 | ```go 180 | func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc 181 | ``` 182 | CombineDelay is a DelayType the combines all of the specified delays into a new 183 | DelayTypeFunc 184 | 185 | #### type Error 186 | 187 | ```go 188 | type Error []error 189 | ``` 190 | 191 | Error type represents list of errors in retry 192 | 193 | #### func (Error) As 194 | 195 | ```go 196 | func (e Error) As(target interface{}) bool 197 | ``` 198 | 199 | #### func (Error) Error 200 | 201 | ```go 202 | func (e Error) Error() string 203 | ``` 204 | Error method return string representation of Error It is an implementation of 205 | error interface 206 | 207 | #### func (Error) Is 208 | 209 | ```go 210 | func (e Error) Is(target error) bool 211 | ``` 212 | 213 | #### func (Error) Unwrap 214 | 215 | ```go 216 | func (e Error) Unwrap() error 217 | ``` 218 | Unwrap the last error for compatibility with `errors.Unwrap()`. When you need to 219 | unwrap all errors, you should use `WrappedErrors()` instead. 220 | 221 | err := Do( 222 | func() error { 223 | return errors.New("original error") 224 | }, 225 | Attempts(1), 226 | ) 227 | 228 | fmt.Println(errors.Unwrap(err)) # "original error" is printed 229 | 230 | Added in version 4.2.0. 231 | 232 | #### func (Error) WrappedErrors 233 | 234 | ```go 235 | func (e Error) WrappedErrors() []error 236 | ``` 237 | WrappedErrors returns the list of errors that this Error is wrapping. It is an 238 | implementation of the `errwrap.Wrapper` interface in package 239 | [errwrap](https://github.com/hashicorp/errwrap) so that `retry.Error` can be 240 | used with that library. 241 | 242 | #### type OnRetryFunc 243 | 244 | ```go 245 | type OnRetryFunc func(attempt uint, err error) 246 | ``` 247 | 248 | Function signature of OnRetry function 249 | 250 | #### type Option 251 | 252 | ```go 253 | type Option func(*Config) 254 | ``` 255 | 256 | Option represents an option for retry. 257 | 258 | #### func Attempts 259 | 260 | ```go 261 | func Attempts(attempts uint) Option 262 | ``` 263 | Attempts set count of retry. Setting to 0 will retry until the retried function 264 | succeeds. default is 10 265 | 266 | #### func AttemptsForError 267 | 268 | ```go 269 | func AttemptsForError(attempts uint, err error) Option 270 | ``` 271 | AttemptsForError sets count of retry in case execution results in given `err` 272 | Retries for the given `err` are also counted against total retries. The retry 273 | will stop if any of given retries is exhausted. 274 | 275 | added in 4.3.0 276 | 277 | #### func Context 278 | 279 | ```go 280 | func Context(ctx context.Context) Option 281 | ``` 282 | Context allow to set context of retry default are Background context 283 | 284 | example of immediately cancellation (maybe it isn't the best example, but it 285 | describes behavior enough; I hope) 286 | 287 | ctx, cancel := context.WithCancel(context.Background()) 288 | cancel() 289 | 290 | retry.Do( 291 | func() error { 292 | ... 293 | }, 294 | retry.Context(ctx), 295 | ) 296 | 297 | #### func Delay 298 | 299 | ```go 300 | func Delay(delay time.Duration) Option 301 | ``` 302 | Delay set delay between retry default is 100ms 303 | 304 | #### func DelayType 305 | 306 | ```go 307 | func DelayType(delayType DelayTypeFunc) Option 308 | ``` 309 | DelayType set type of the delay between retries default is BackOff 310 | 311 | #### func LastErrorOnly 312 | 313 | ```go 314 | func LastErrorOnly(lastErrorOnly bool) Option 315 | ``` 316 | return the direct last error that came from the retried function default is 317 | false (return wrapped errors with everything) 318 | 319 | #### func MaxDelay 320 | 321 | ```go 322 | func MaxDelay(maxDelay time.Duration) Option 323 | ``` 324 | MaxDelay set maximum delay between retry does not apply by default 325 | 326 | #### func MaxJitter 327 | 328 | ```go 329 | func MaxJitter(maxJitter time.Duration) Option 330 | ``` 331 | MaxJitter sets the maximum random Jitter between retries for RandomDelay 332 | 333 | #### func OnRetry 334 | 335 | ```go 336 | func OnRetry(onRetry OnRetryFunc) Option 337 | ``` 338 | OnRetry function callback are called each retry 339 | 340 | log each retry example: 341 | 342 | retry.Do( 343 | func() error { 344 | return errors.New("some error") 345 | }, 346 | retry.OnRetry(func(n uint, err error) { 347 | log.Printf("#%d: %s\n", n, err) 348 | }), 349 | ) 350 | 351 | #### func RetryIf 352 | 353 | ```go 354 | func RetryIf(retryIf RetryIfFunc) Option 355 | ``` 356 | RetryIf controls whether a retry should be attempted after an error (assuming 357 | there are any retry attempts remaining) 358 | 359 | skip retry if special error example: 360 | 361 | retry.Do( 362 | func() error { 363 | return errors.New("special error") 364 | }, 365 | retry.RetryIf(func(err error) bool { 366 | if err.Error() == "special error" { 367 | return false 368 | } 369 | return true 370 | }) 371 | ) 372 | 373 | By default RetryIf stops execution if the error is wrapped using 374 | `retry.Unrecoverable`, so above example may also be shortened to: 375 | 376 | retry.Do( 377 | func() error { 378 | return retry.Unrecoverable(errors.New("special error")) 379 | } 380 | ) 381 | 382 | #### func UntilSucceeded 383 | 384 | ```go 385 | func UntilSucceeded() Option 386 | ``` 387 | UntilSucceeded will retry until the retried function succeeds. Equivalent to 388 | setting Attempts(0). 389 | 390 | #### func WithTimer 391 | 392 | ```go 393 | func WithTimer(t Timer) Option 394 | ``` 395 | WithTimer provides a way to swap out timer module implementations. This 396 | primarily is useful for mocking/testing, where you may not want to explicitly 397 | wait for a set duration for retries. 398 | 399 | example of augmenting time.After with a print statement 400 | 401 | type struct MyTimer {} 402 | 403 | func (t *MyTimer) After(d time.Duration) <- chan time.Time { 404 | fmt.Print("Timer called!") 405 | return time.After(d) 406 | } 407 | 408 | retry.Do( 409 | func() error { ... }, 410 | retry.WithTimer(&MyTimer{}) 411 | ) 412 | 413 | #### func WrapContextErrorWithLastError 414 | 415 | ```go 416 | func WrapContextErrorWithLastError(wrapContextErrorWithLastError bool) Option 417 | ``` 418 | WrapContextErrorWithLastError allows the context error to be returned wrapped 419 | with the last error that the retried function returned. This is only applicable 420 | when Attempts is set to 0 to retry indefinitly and when using a context to 421 | cancel / timeout 422 | 423 | default is false 424 | 425 | ctx, cancel := context.WithCancel(context.Background()) 426 | defer cancel() 427 | 428 | retry.Do( 429 | func() error { 430 | ... 431 | }, 432 | retry.Context(ctx), 433 | retry.Attempts(0), 434 | retry.WrapContextErrorWithLastError(true), 435 | ) 436 | 437 | #### type RetryIfFunc 438 | 439 | ```go 440 | type RetryIfFunc func(error) bool 441 | ``` 442 | 443 | Function signature of retry if function 444 | 445 | #### type RetryableFunc 446 | 447 | ```go 448 | type RetryableFunc func() error 449 | ``` 450 | 451 | Function signature of retryable function 452 | 453 | #### type RetryableFuncWithData 454 | 455 | ```go 456 | type RetryableFuncWithData[T any] func() (T, error) 457 | ``` 458 | 459 | Function signature of retryable function with data 460 | 461 | #### type Timer 462 | 463 | ```go 464 | type Timer interface { 465 | After(time.Duration) <-chan time.Time 466 | } 467 | ``` 468 | 469 | Timer represents the timer used to track time for a retry. 470 | 471 | ## Contributing 472 | 473 | Contributions are very much welcome. 474 | 475 | ### Makefile 476 | 477 | Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc... 478 | 479 | Try `make help` for more information. 480 | 481 | ### Before pull request 482 | 483 | > maybe you need `make setup` in order to setup environment 484 | 485 | please try: 486 | * run tests (`make test`) 487 | * run linter (`make lint`) 488 | * if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`) 489 | 490 | ### README 491 | 492 | README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown). 493 | 494 | Never edit README.md direct, because your change will be lost. 495 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 4.6.1 2 | -------------------------------------------------------------------------------- /current.txt: -------------------------------------------------------------------------------- 1 | goos: darwin 2 | goarch: amd64 3 | pkg: github.com/avast/retry-go/v4 4 | cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz 5 | BenchmarkDo-16 3 474128987 ns/op 2730 B/op 48 allocs/op 6 | BenchmarkDo-16 3 441499631 ns/op 2725 B/op 47 allocs/op 7 | BenchmarkDo-16 3 449390845 ns/op 2693 B/op 47 allocs/op 8 | BenchmarkDo-16 3 488695333 ns/op 2725 B/op 47 allocs/op 9 | BenchmarkDo-16 2 601685067 ns/op 2704 B/op 48 allocs/op 10 | BenchmarkDo-16 3 336872997 ns/op 2693 B/op 47 allocs/op 11 | BenchmarkDo-16 3 384347911 ns/op 2725 B/op 47 allocs/op 12 | BenchmarkDo-16 3 480906307 ns/op 2693 B/op 47 allocs/op 13 | BenchmarkDo-16 3 455362447 ns/op 2693 B/op 47 allocs/op 14 | BenchmarkDo-16 3 443170384 ns/op 2693 B/op 47 allocs/op 15 | BenchmarkDoNoErrors-16 6872852 159.4 ns/op 208 B/op 4 allocs/op 16 | BenchmarkDoNoErrors-16 7650360 161.3 ns/op 208 B/op 4 allocs/op 17 | BenchmarkDoNoErrors-16 7235683 159.3 ns/op 208 B/op 4 allocs/op 18 | BenchmarkDoNoErrors-16 7465636 160.2 ns/op 208 B/op 4 allocs/op 19 | BenchmarkDoNoErrors-16 7549692 160.7 ns/op 208 B/op 4 allocs/op 20 | BenchmarkDoNoErrors-16 7510610 159.8 ns/op 208 B/op 4 allocs/op 21 | BenchmarkDoNoErrors-16 7438124 160.3 ns/op 208 B/op 4 allocs/op 22 | BenchmarkDoNoErrors-16 7416504 160.2 ns/op 208 B/op 4 allocs/op 23 | BenchmarkDoNoErrors-16 7356183 160.4 ns/op 208 B/op 4 allocs/op 24 | BenchmarkDoNoErrors-16 7393480 160.1 ns/op 208 B/op 4 allocs/op 25 | PASS 26 | ok github.com/avast/retry-go/v4 35.971s 27 | -------------------------------------------------------------------------------- /examples/custom_retry_function_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "github.com/avast/retry-go/v4" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // RetriableError is a custom error that contains a positive duration for the next retry 17 | type RetriableError struct { 18 | Err error 19 | RetryAfter time.Duration 20 | } 21 | 22 | // Error returns error message and a Retry-After duration 23 | func (e *RetriableError) Error() string { 24 | return fmt.Sprintf("%s (retry after %v)", e.Err.Error(), e.RetryAfter) 25 | } 26 | 27 | var _ error = (*RetriableError)(nil) 28 | 29 | // TestCustomRetryFunction shows how to use a custom retry function 30 | func TestCustomRetryFunction(t *testing.T) { 31 | attempts := 5 // server succeeds after 5 attempts 32 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | if attempts > 0 { 34 | // inform the client to retry after one second using standard 35 | // HTTP 429 status code with Retry-After header in seconds 36 | w.Header().Add("Retry-After", "1") 37 | w.WriteHeader(http.StatusTooManyRequests) 38 | w.Write([]byte("Server limit reached")) 39 | attempts-- 40 | return 41 | } 42 | w.WriteHeader(http.StatusOK) 43 | w.Write([]byte("hello")) 44 | })) 45 | defer ts.Close() 46 | 47 | var body []byte 48 | 49 | err := retry.Do( 50 | func() error { 51 | resp, err := http.Get(ts.URL) 52 | 53 | if err == nil { 54 | defer func() { 55 | if err := resp.Body.Close(); err != nil { 56 | panic(err) 57 | } 58 | }() 59 | body, err = ioutil.ReadAll(resp.Body) 60 | if resp.StatusCode != 200 { 61 | err = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) 62 | if resp.StatusCode == http.StatusTooManyRequests { 63 | // check Retry-After header if it contains seconds to wait for the next retry 64 | if retryAfter, e := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 32); e == nil { 65 | // the server returns 0 to inform that the operation cannot be retried 66 | if retryAfter <= 0 { 67 | return retry.Unrecoverable(err) 68 | } 69 | return &RetriableError{ 70 | Err: err, 71 | RetryAfter: time.Duration(retryAfter) * time.Second, 72 | } 73 | } 74 | // A real implementation should also try to http.Parse the retryAfter response header 75 | // to conform with HTTP specification. Herein we know here that we return only seconds. 76 | } 77 | } 78 | } 79 | 80 | return err 81 | }, 82 | retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration { 83 | fmt.Println("Server fails with: " + err.Error()) 84 | if retriable, ok := err.(*RetriableError); ok { 85 | fmt.Printf("Client follows server recommendation to retry after %v\n", retriable.RetryAfter) 86 | return retriable.RetryAfter 87 | } 88 | // apply a default exponential back off strategy 89 | return retry.BackOffDelay(n, err, config) 90 | }), 91 | ) 92 | 93 | fmt.Println("Server responds with: " + string(body)) 94 | 95 | assert.NoError(t, err) 96 | assert.Equal(t, "hello", string(body)) 97 | } 98 | -------------------------------------------------------------------------------- /examples/delay_based_on_error_test.go: -------------------------------------------------------------------------------- 1 | // This test delay is based on kind of error 2 | // e.g. HTTP response [Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) 3 | package retry_test 4 | 5 | import ( 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | "time" 12 | 13 | "github.com/avast/retry-go/v4" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | type RetryAfterError struct { 18 | response http.Response 19 | } 20 | 21 | func (err RetryAfterError) Error() string { 22 | return fmt.Sprintf( 23 | "Request to %s fail %s (%d)", 24 | err.response.Request.RequestURI, 25 | err.response.Status, 26 | err.response.StatusCode, 27 | ) 28 | } 29 | 30 | type SomeOtherError struct { 31 | err string 32 | retryAfter time.Duration 33 | } 34 | 35 | func (err SomeOtherError) Error() string { 36 | return err.err 37 | } 38 | 39 | func TestCustomRetryFunctionBasedOnKindOfError(t *testing.T) { 40 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | fmt.Fprintln(w, "hello") 42 | })) 43 | defer ts.Close() 44 | 45 | var body []byte 46 | 47 | err := retry.Do( 48 | func() error { 49 | resp, err := http.Get(ts.URL) 50 | 51 | if err == nil { 52 | defer func() { 53 | if err := resp.Body.Close(); err != nil { 54 | panic(err) 55 | } 56 | }() 57 | body, err = ioutil.ReadAll(resp.Body) 58 | } 59 | 60 | return err 61 | }, 62 | retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration { 63 | switch e := err.(type) { 64 | case RetryAfterError: 65 | if t, err := parseRetryAfter(e.response.Header.Get("Retry-After")); err == nil { 66 | return time.Until(t) 67 | } 68 | case SomeOtherError: 69 | return e.retryAfter 70 | } 71 | 72 | //default is backoffdelay 73 | return retry.BackOffDelay(n, err, config) 74 | }), 75 | ) 76 | 77 | assert.NoError(t, err) 78 | assert.NotEmpty(t, body) 79 | } 80 | 81 | // use https://github.com/aereal/go-httpretryafter instead 82 | func parseRetryAfter(_ string) (time.Time, error) { 83 | return time.Now().Add(1 * time.Second), nil 84 | } 85 | -------------------------------------------------------------------------------- /examples/errors_history_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/avast/retry-go/v4" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | // TestErrorHistory shows an example of how to get all the previous errors when 14 | // retry.Do ends in success 15 | func TestErrorHistory(t *testing.T) { 16 | attempts := 3 // server succeeds after 3 attempts 17 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | if attempts > 0 { 19 | attempts-- 20 | w.WriteHeader(http.StatusBadGateway) 21 | return 22 | } 23 | w.WriteHeader(http.StatusOK) 24 | })) 25 | defer ts.Close() 26 | var allErrors []error 27 | err := retry.Do( 28 | func() error { 29 | resp, err := http.Get(ts.URL) 30 | if err != nil { 31 | return err 32 | } 33 | defer resp.Body.Close() 34 | if resp.StatusCode != 200 { 35 | return fmt.Errorf("failed HTTP - %d", resp.StatusCode) 36 | } 37 | return nil 38 | }, 39 | retry.OnRetry(func(n uint, err error) { 40 | allErrors = append(allErrors, err) 41 | }), 42 | ) 43 | assert.NoError(t, err) 44 | assert.Len(t, allErrors, 3) 45 | } 46 | -------------------------------------------------------------------------------- /examples/http_get_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/avast/retry-go/v4" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestGet(t *testing.T) { 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | fmt.Fprintln(w, "hello") 17 | })) 18 | defer ts.Close() 19 | 20 | var body []byte 21 | 22 | err := retry.Do( 23 | func() error { 24 | resp, err := http.Get(ts.URL) 25 | 26 | if err == nil { 27 | defer func() { 28 | if err := resp.Body.Close(); err != nil { 29 | panic(err) 30 | } 31 | }() 32 | body, err = ioutil.ReadAll(resp.Body) 33 | } 34 | 35 | return err 36 | }, 37 | ) 38 | 39 | assert.NoError(t, err) 40 | assert.NotEmpty(t, body) 41 | } 42 | -------------------------------------------------------------------------------- /generic.txt: -------------------------------------------------------------------------------- 1 | goos: darwin 2 | goarch: amd64 3 | pkg: github.com/avast/retry-go/v4 4 | cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz 5 | BenchmarkDo-16 3 406306609 ns/op 2701 B/op 48 allocs/op 6 | BenchmarkDo-16 3 419470846 ns/op 2693 B/op 47 allocs/op 7 | BenchmarkDo-16 2 567716303 ns/op 2696 B/op 47 allocs/op 8 | BenchmarkDo-16 2 562713288 ns/op 2696 B/op 47 allocs/op 9 | BenchmarkDo-16 3 418301987 ns/op 2693 B/op 47 allocs/op 10 | BenchmarkDo-16 2 541207332 ns/op 2696 B/op 47 allocs/op 11 | BenchmarkDo-16 2 526211617 ns/op 2696 B/op 47 allocs/op 12 | BenchmarkDo-16 2 517419526 ns/op 2696 B/op 47 allocs/op 13 | BenchmarkDo-16 3 478391497 ns/op 2693 B/op 47 allocs/op 14 | BenchmarkDo-16 3 452548175 ns/op 2725 B/op 47 allocs/op 15 | BenchmarkDoWithData-16 3 463040866 ns/op 2693 B/op 47 allocs/op 16 | BenchmarkDoWithData-16 3 496158943 ns/op 2693 B/op 47 allocs/op 17 | BenchmarkDoWithData-16 3 488367012 ns/op 2725 B/op 47 allocs/op 18 | BenchmarkDoWithData-16 3 454618897 ns/op 2693 B/op 47 allocs/op 19 | BenchmarkDoWithData-16 3 435430056 ns/op 2693 B/op 47 allocs/op 20 | BenchmarkDoWithData-16 2 552289967 ns/op 2744 B/op 48 allocs/op 21 | BenchmarkDoWithData-16 3 569748815 ns/op 2693 B/op 47 allocs/op 22 | BenchmarkDoWithData-16 3 416597207 ns/op 2725 B/op 47 allocs/op 23 | BenchmarkDoWithData-16 3 358455415 ns/op 2725 B/op 47 allocs/op 24 | BenchmarkDoWithData-16 3 455297803 ns/op 2725 B/op 47 allocs/op 25 | BenchmarkDoNoErrors-16 7035135 161.9 ns/op 208 B/op 4 allocs/op 26 | BenchmarkDoNoErrors-16 7389806 161.3 ns/op 208 B/op 4 allocs/op 27 | BenchmarkDoNoErrors-16 7394016 161.5 ns/op 208 B/op 4 allocs/op 28 | BenchmarkDoNoErrors-16 7380039 162.2 ns/op 208 B/op 4 allocs/op 29 | BenchmarkDoNoErrors-16 7424865 162.2 ns/op 208 B/op 4 allocs/op 30 | BenchmarkDoNoErrors-16 7111860 160.5 ns/op 208 B/op 4 allocs/op 31 | BenchmarkDoNoErrors-16 7285305 162.6 ns/op 208 B/op 4 allocs/op 32 | BenchmarkDoNoErrors-16 7410627 160.7 ns/op 208 B/op 4 allocs/op 33 | BenchmarkDoNoErrors-16 7340961 161.6 ns/op 208 B/op 4 allocs/op 34 | BenchmarkDoNoErrors-16 7295727 164.1 ns/op 208 B/op 4 allocs/op 35 | BenchmarkDoWithDataNoErrors-16 7357304 159.9 ns/op 208 B/op 4 allocs/op 36 | BenchmarkDoWithDataNoErrors-16 6649852 166.9 ns/op 208 B/op 4 allocs/op 37 | BenchmarkDoWithDataNoErrors-16 6938404 176.3 ns/op 208 B/op 4 allocs/op 38 | BenchmarkDoWithDataNoErrors-16 7181965 160.4 ns/op 208 B/op 4 allocs/op 39 | BenchmarkDoWithDataNoErrors-16 7311484 166.2 ns/op 208 B/op 4 allocs/op 40 | BenchmarkDoWithDataNoErrors-16 6939157 169.7 ns/op 208 B/op 4 allocs/op 41 | BenchmarkDoWithDataNoErrors-16 6648344 179.0 ns/op 208 B/op 4 allocs/op 42 | BenchmarkDoWithDataNoErrors-16 6794847 177.0 ns/op 208 B/op 4 allocs/op 43 | BenchmarkDoWithDataNoErrors-16 6782588 171.4 ns/op 208 B/op 4 allocs/op 44 | BenchmarkDoWithDataNoErrors-16 7279119 166.9 ns/op 208 B/op 4 allocs/op 45 | PASS 46 | ok github.com/avast/retry-go/v4 73.128s 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avast/retry-go/v4 2 | 3 | go 1.20 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 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "math/rand" 7 | "time" 8 | ) 9 | 10 | // Function signature of retry if function 11 | type RetryIfFunc func(error) bool 12 | 13 | // Function signature of OnRetry function 14 | type OnRetryFunc func(attempt uint, err error) 15 | 16 | // DelayTypeFunc is called to return the next delay to wait after the retriable function fails on `err` after `n` attempts. 17 | type DelayTypeFunc func(n uint, err error, config *Config) time.Duration 18 | 19 | // Timer represents the timer used to track time for a retry. 20 | type Timer interface { 21 | After(time.Duration) <-chan time.Time 22 | } 23 | 24 | type Config struct { 25 | attempts uint 26 | attemptsForError map[error]uint 27 | delay time.Duration 28 | maxDelay time.Duration 29 | maxJitter time.Duration 30 | onRetry OnRetryFunc 31 | retryIf RetryIfFunc 32 | delayType DelayTypeFunc 33 | lastErrorOnly bool 34 | context context.Context 35 | timer Timer 36 | wrapContextErrorWithLastError bool 37 | 38 | maxBackOffN uint 39 | } 40 | 41 | // Option represents an option for retry. 42 | type Option func(*Config) 43 | 44 | func emptyOption(c *Config) {} 45 | 46 | // return the direct last error that came from the retried function 47 | // default is false (return wrapped errors with everything) 48 | func LastErrorOnly(lastErrorOnly bool) Option { 49 | return func(c *Config) { 50 | c.lastErrorOnly = lastErrorOnly 51 | } 52 | } 53 | 54 | // Attempts set count of retry. Setting to 0 will retry until the retried function succeeds. 55 | // default is 10 56 | func Attempts(attempts uint) Option { 57 | return func(c *Config) { 58 | c.attempts = attempts 59 | } 60 | } 61 | 62 | // UntilSucceeded will retry until the retried function succeeds. Equivalent to setting Attempts(0). 63 | func UntilSucceeded() Option { 64 | return func(c *Config) { 65 | c.attempts = 0 66 | } 67 | } 68 | 69 | // AttemptsForError sets count of retry in case execution results in given `err` 70 | // Retries for the given `err` are also counted against total retries. 71 | // The retry will stop if any of given retries is exhausted. 72 | // 73 | // added in 4.3.0 74 | func AttemptsForError(attempts uint, err error) Option { 75 | return func(c *Config) { 76 | c.attemptsForError[err] = attempts 77 | } 78 | } 79 | 80 | // Delay set delay between retry 81 | // default is 100ms 82 | func Delay(delay time.Duration) Option { 83 | return func(c *Config) { 84 | c.delay = delay 85 | } 86 | } 87 | 88 | // MaxDelay set maximum delay between retry 89 | // does not apply by default 90 | func MaxDelay(maxDelay time.Duration) Option { 91 | return func(c *Config) { 92 | c.maxDelay = maxDelay 93 | } 94 | } 95 | 96 | // MaxJitter sets the maximum random Jitter between retries for RandomDelay 97 | func MaxJitter(maxJitter time.Duration) Option { 98 | return func(c *Config) { 99 | c.maxJitter = maxJitter 100 | } 101 | } 102 | 103 | // DelayType set type of the delay between retries 104 | // default is BackOff 105 | func DelayType(delayType DelayTypeFunc) Option { 106 | if delayType == nil { 107 | return emptyOption 108 | } 109 | return func(c *Config) { 110 | c.delayType = delayType 111 | } 112 | } 113 | 114 | // BackOffDelay is a DelayType which increases delay between consecutive retries 115 | func BackOffDelay(n uint, _ error, config *Config) time.Duration { 116 | // 1 << 63 would overflow signed int64 (time.Duration), thus 62. 117 | const max uint = 62 118 | 119 | if config.maxBackOffN == 0 { 120 | if config.delay <= 0 { 121 | config.delay = 1 122 | } 123 | 124 | config.maxBackOffN = max - uint(math.Floor(math.Log2(float64(config.delay)))) 125 | } 126 | 127 | if n > config.maxBackOffN { 128 | n = config.maxBackOffN 129 | } 130 | 131 | return config.delay << n 132 | } 133 | 134 | // FixedDelay is a DelayType which keeps delay the same through all iterations 135 | func FixedDelay(_ uint, _ error, config *Config) time.Duration { 136 | return config.delay 137 | } 138 | 139 | // RandomDelay is a DelayType which picks a random delay up to config.maxJitter 140 | func RandomDelay(_ uint, _ error, config *Config) time.Duration { 141 | return time.Duration(rand.Int63n(int64(config.maxJitter))) 142 | } 143 | 144 | // CombineDelay is a DelayType the combines all of the specified delays into a new DelayTypeFunc 145 | func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc { 146 | const maxInt64 = uint64(math.MaxInt64) 147 | 148 | return func(n uint, err error, config *Config) time.Duration { 149 | var total uint64 150 | for _, delay := range delays { 151 | total += uint64(delay(n, err, config)) 152 | if total > maxInt64 { 153 | total = maxInt64 154 | } 155 | } 156 | 157 | return time.Duration(total) 158 | } 159 | } 160 | 161 | // OnRetry function callback are called each retry 162 | // 163 | // log each retry example: 164 | // 165 | // retry.Do( 166 | // func() error { 167 | // return errors.New("some error") 168 | // }, 169 | // retry.OnRetry(func(n uint, err error) { 170 | // log.Printf("#%d: %s\n", n, err) 171 | // }), 172 | // ) 173 | func OnRetry(onRetry OnRetryFunc) Option { 174 | if onRetry == nil { 175 | return emptyOption 176 | } 177 | return func(c *Config) { 178 | c.onRetry = onRetry 179 | } 180 | } 181 | 182 | // RetryIf controls whether a retry should be attempted after an error 183 | // (assuming there are any retry attempts remaining) 184 | // 185 | // skip retry if special error example: 186 | // 187 | // retry.Do( 188 | // func() error { 189 | // return errors.New("special error") 190 | // }, 191 | // retry.RetryIf(func(err error) bool { 192 | // if err.Error() == "special error" { 193 | // return false 194 | // } 195 | // return true 196 | // }) 197 | // ) 198 | // 199 | // By default RetryIf stops execution if the error is wrapped using `retry.Unrecoverable`, 200 | // so above example may also be shortened to: 201 | // 202 | // retry.Do( 203 | // func() error { 204 | // return retry.Unrecoverable(errors.New("special error")) 205 | // } 206 | // ) 207 | func RetryIf(retryIf RetryIfFunc) Option { 208 | if retryIf == nil { 209 | return emptyOption 210 | } 211 | return func(c *Config) { 212 | c.retryIf = retryIf 213 | } 214 | } 215 | 216 | // Context allow to set context of retry 217 | // default are Background context 218 | // 219 | // example of immediately cancellation (maybe it isn't the best example, but it describes behavior enough; I hope) 220 | // 221 | // ctx, cancel := context.WithCancel(context.Background()) 222 | // cancel() 223 | // 224 | // retry.Do( 225 | // func() error { 226 | // ... 227 | // }, 228 | // retry.Context(ctx), 229 | // ) 230 | func Context(ctx context.Context) Option { 231 | return func(c *Config) { 232 | c.context = ctx 233 | } 234 | } 235 | 236 | // WithTimer provides a way to swap out timer module implementations. 237 | // This primarily is useful for mocking/testing, where you may not want to explicitly wait for a set duration 238 | // for retries. 239 | // 240 | // example of augmenting time.After with a print statement 241 | // 242 | // type struct MyTimer {} 243 | // 244 | // func (t *MyTimer) After(d time.Duration) <- chan time.Time { 245 | // fmt.Print("Timer called!") 246 | // return time.After(d) 247 | // } 248 | // 249 | // retry.Do( 250 | // func() error { ... }, 251 | // retry.WithTimer(&MyTimer{}) 252 | // ) 253 | func WithTimer(t Timer) Option { 254 | return func(c *Config) { 255 | c.timer = t 256 | } 257 | } 258 | 259 | // WrapContextErrorWithLastError allows the context error to be returned wrapped with the last error that the 260 | // retried function returned. This is only applicable when Attempts is set to 0 to retry indefinitly and when 261 | // using a context to cancel / timeout 262 | // 263 | // default is false 264 | // 265 | // ctx, cancel := context.WithCancel(context.Background()) 266 | // defer cancel() 267 | // 268 | // retry.Do( 269 | // func() error { 270 | // ... 271 | // }, 272 | // retry.Context(ctx), 273 | // retry.Attempts(0), 274 | // retry.WrapContextErrorWithLastError(true), 275 | // ) 276 | func WrapContextErrorWithLastError(wrapContextErrorWithLastError bool) Option { 277 | return func(c *Config) { 278 | c.wrapContextErrorWithLastError = wrapContextErrorWithLastError 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | /* 2 | Simple library for retry mechanism 3 | 4 | Slightly inspired by [Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry) 5 | 6 | # SYNOPSIS 7 | 8 | HTTP GET with retry: 9 | 10 | url := "http://example.com" 11 | var body []byte 12 | 13 | err := retry.Do( 14 | func() error { 15 | resp, err := http.Get(url) 16 | if err != nil { 17 | return err 18 | } 19 | defer resp.Body.Close() 20 | body, err = ioutil.ReadAll(resp.Body) 21 | if err != nil { 22 | return err 23 | } 24 | return nil 25 | }, 26 | ) 27 | 28 | if err != nil { 29 | // handle error 30 | } 31 | 32 | fmt.Println(string(body)) 33 | 34 | HTTP GET with retry with data: 35 | 36 | url := "http://example.com" 37 | 38 | body, err := retry.DoWithData( 39 | func() ([]byte, error) { 40 | resp, err := http.Get(url) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer resp.Body.Close() 45 | body, err := ioutil.ReadAll(resp.Body) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return body, nil 51 | }, 52 | ) 53 | 54 | if err != nil { 55 | // handle error 56 | } 57 | 58 | fmt.Println(string(body)) 59 | 60 | [More examples](https://github.com/avast/retry-go/tree/master/examples) 61 | 62 | # SEE ALSO 63 | 64 | * [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly complicated interface. 65 | 66 | * [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for http calls with retries and backoff 67 | 68 | * [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the exponential backoff algorithm from Google's HTTP Client Library for Java. Really complicated interface. 69 | 70 | * [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good, slightly similar as this package, don't have 'simple' `Retry` method 71 | 72 | * [matryer/try](https://github.com/matryer/try) - very popular package, nonintuitive interface (for me) 73 | 74 | # BREAKING CHANGES 75 | 76 | * 4.0.0 77 | - infinity retry is possible by set `Attempts(0)` by PR [#49](https://github.com/avast/retry-go/pull/49) 78 | 79 | * 3.0.0 80 | - `DelayTypeFunc` accepts a new parameter `err` - this breaking change affects only your custom Delay Functions. This change allow [make delay functions based on error](examples/delay_based_on_error_test.go). 81 | 82 | * 1.0.2 -> 2.0.0 83 | - argument of `retry.Delay` is final delay (no multiplication by `retry.Units` anymore) 84 | - function `retry.Units` are removed 85 | - [more about this breaking change](https://github.com/avast/retry-go/issues/7) 86 | 87 | * 0.3.0 -> 1.0.0 88 | - `retry.Retry` function are changed to `retry.Do` function 89 | - `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are now implement via functions produces Options (aka `retry.OnRetry`) 90 | */ 91 | package retry 92 | 93 | import ( 94 | "context" 95 | "errors" 96 | "fmt" 97 | "strings" 98 | "time" 99 | ) 100 | 101 | // Function signature of retryable function 102 | type RetryableFunc func() error 103 | 104 | // Function signature of retryable function with data 105 | type RetryableFuncWithData[T any] func() (T, error) 106 | 107 | // Default timer is a wrapper around time.After 108 | type timerImpl struct{} 109 | 110 | func (t *timerImpl) After(d time.Duration) <-chan time.Time { 111 | return time.After(d) 112 | } 113 | 114 | func Do(retryableFunc RetryableFunc, opts ...Option) error { 115 | retryableFuncWithData := func() (any, error) { 116 | return nil, retryableFunc() 117 | } 118 | 119 | _, err := DoWithData(retryableFuncWithData, opts...) 120 | return err 121 | } 122 | 123 | func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (T, error) { 124 | var n uint 125 | var emptyT T 126 | 127 | // default 128 | config := newDefaultRetryConfig() 129 | 130 | // apply opts 131 | for _, opt := range opts { 132 | opt(config) 133 | } 134 | 135 | if err := context.Cause(config.context); err != nil { 136 | return emptyT, err 137 | } 138 | 139 | // Setting attempts to 0 means we'll retry until we succeed 140 | var lastErr error 141 | if config.attempts == 0 { 142 | for { 143 | t, err := retryableFunc() 144 | if err == nil { 145 | return t, nil 146 | } 147 | 148 | if !IsRecoverable(err) { 149 | return emptyT, err 150 | } 151 | 152 | if !config.retryIf(err) { 153 | return emptyT, err 154 | } 155 | 156 | lastErr = err 157 | 158 | config.onRetry(n, err) 159 | n++ 160 | select { 161 | case <-config.timer.After(delay(config, n, err)): 162 | case <-config.context.Done(): 163 | if config.wrapContextErrorWithLastError { 164 | return emptyT, Error{context.Cause(config.context), lastErr} 165 | } 166 | return emptyT, context.Cause(config.context) 167 | } 168 | } 169 | } 170 | 171 | errorLog := Error{} 172 | 173 | attemptsForError := make(map[error]uint, len(config.attemptsForError)) 174 | for err, attempts := range config.attemptsForError { 175 | attemptsForError[err] = attempts 176 | } 177 | 178 | shouldRetry := true 179 | for shouldRetry { 180 | t, err := retryableFunc() 181 | if err == nil { 182 | return t, nil 183 | } 184 | 185 | errorLog = append(errorLog, unpackUnrecoverable(err)) 186 | 187 | if !config.retryIf(err) { 188 | break 189 | } 190 | 191 | config.onRetry(n, err) 192 | 193 | for errToCheck, attempts := range attemptsForError { 194 | if errors.Is(err, errToCheck) { 195 | attempts-- 196 | attemptsForError[errToCheck] = attempts 197 | shouldRetry = shouldRetry && attempts > 0 198 | } 199 | } 200 | 201 | // if this is last attempt - don't wait 202 | if n == config.attempts-1 { 203 | break 204 | } 205 | n++ 206 | select { 207 | case <-config.timer.After(delay(config, n, err)): 208 | case <-config.context.Done(): 209 | if config.lastErrorOnly { 210 | return emptyT, context.Cause(config.context) 211 | } 212 | 213 | return emptyT, append(errorLog, context.Cause(config.context)) 214 | } 215 | 216 | shouldRetry = shouldRetry && n < config.attempts 217 | } 218 | 219 | if config.lastErrorOnly { 220 | return emptyT, errorLog.Unwrap() 221 | } 222 | return emptyT, errorLog 223 | } 224 | 225 | func newDefaultRetryConfig() *Config { 226 | return &Config{ 227 | attempts: uint(10), 228 | attemptsForError: make(map[error]uint), 229 | delay: 100 * time.Millisecond, 230 | maxJitter: 100 * time.Millisecond, 231 | onRetry: func(n uint, err error) {}, 232 | retryIf: IsRecoverable, 233 | delayType: CombineDelay(BackOffDelay, RandomDelay), 234 | lastErrorOnly: false, 235 | context: context.Background(), 236 | timer: &timerImpl{}, 237 | } 238 | } 239 | 240 | // Error type represents list of errors in retry 241 | type Error []error 242 | 243 | // Error method return string representation of Error 244 | // It is an implementation of error interface 245 | func (e Error) Error() string { 246 | logWithNumber := make([]string, len(e)) 247 | for i, l := range e { 248 | if l != nil { 249 | logWithNumber[i] = fmt.Sprintf("#%d: %s", i+1, l.Error()) 250 | } 251 | } 252 | 253 | return fmt.Sprintf("All attempts fail:\n%s", strings.Join(logWithNumber, "\n")) 254 | } 255 | 256 | func (e Error) Is(target error) bool { 257 | for _, v := range e { 258 | if errors.Is(v, target) { 259 | return true 260 | } 261 | } 262 | return false 263 | } 264 | 265 | func (e Error) As(target interface{}) bool { 266 | for _, v := range e { 267 | if errors.As(v, target) { 268 | return true 269 | } 270 | } 271 | return false 272 | } 273 | 274 | /* 275 | Unwrap the last error for compatibility with `errors.Unwrap()`. 276 | When you need to unwrap all errors, you should use `WrappedErrors()` instead. 277 | 278 | err := Do( 279 | func() error { 280 | return errors.New("original error") 281 | }, 282 | Attempts(1), 283 | ) 284 | 285 | fmt.Println(errors.Unwrap(err)) # "original error" is printed 286 | 287 | Added in version 4.2.0. 288 | */ 289 | func (e Error) Unwrap() error { 290 | return e[len(e)-1] 291 | } 292 | 293 | // WrappedErrors returns the list of errors that this Error is wrapping. 294 | // It is an implementation of the `errwrap.Wrapper` interface 295 | // in package [errwrap](https://github.com/hashicorp/errwrap) so that 296 | // `retry.Error` can be used with that library. 297 | func (e Error) WrappedErrors() []error { 298 | return e 299 | } 300 | 301 | type unrecoverableError struct { 302 | error 303 | } 304 | 305 | func (e unrecoverableError) Error() string { 306 | if e.error == nil { 307 | return "unrecoverable error" 308 | } 309 | return e.error.Error() 310 | } 311 | 312 | func (e unrecoverableError) Unwrap() error { 313 | return e.error 314 | } 315 | 316 | // Unrecoverable wraps an error in `unrecoverableError` struct 317 | func Unrecoverable(err error) error { 318 | return unrecoverableError{err} 319 | } 320 | 321 | // IsRecoverable checks if error is an instance of `unrecoverableError` 322 | func IsRecoverable(err error) bool { 323 | return !errors.Is(err, unrecoverableError{}) 324 | } 325 | 326 | // Adds support for errors.Is usage on unrecoverableError 327 | func (unrecoverableError) Is(err error) bool { 328 | _, isUnrecoverable := err.(unrecoverableError) 329 | return isUnrecoverable 330 | } 331 | 332 | func unpackUnrecoverable(err error) error { 333 | if unrecoverable, isUnrecoverable := err.(unrecoverableError); isUnrecoverable { 334 | return unrecoverable.error 335 | } 336 | 337 | return err 338 | } 339 | 340 | func delay(config *Config, n uint, err error) time.Duration { 341 | delayTime := config.delayType(n, err, config) 342 | if config.maxDelay > 0 && delayTime > config.maxDelay { 343 | delayTime = config.maxDelay 344 | } 345 | 346 | return delayTime 347 | } 348 | -------------------------------------------------------------------------------- /retry_test.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestDoWithDataAllFailed(t *testing.T) { 15 | var retrySum uint 16 | v, err := DoWithData( 17 | func() (int, error) { return 7, errors.New("test") }, 18 | OnRetry(func(n uint, err error) { retrySum += n }), 19 | Delay(time.Nanosecond), 20 | ) 21 | assert.Error(t, err) 22 | assert.Equal(t, 0, v) 23 | 24 | expectedErrorFormat := `All attempts fail: 25 | #1: test 26 | #2: test 27 | #3: test 28 | #4: test 29 | #5: test 30 | #6: test 31 | #7: test 32 | #8: test 33 | #9: test 34 | #10: test` 35 | assert.Len(t, err, 10) 36 | fmt.Println(err.Error()) 37 | assert.Equal(t, expectedErrorFormat, err.Error(), "retry error format") 38 | assert.Equal(t, uint(45), retrySum, "right count of retry") 39 | } 40 | 41 | func TestDoFirstOk(t *testing.T) { 42 | var retrySum uint 43 | err := Do( 44 | func() error { return nil }, 45 | OnRetry(func(n uint, err error) { retrySum += n }), 46 | ) 47 | assert.NoError(t, err) 48 | assert.Equal(t, uint(0), retrySum, "no retry") 49 | } 50 | 51 | func TestDoWithDataFirstOk(t *testing.T) { 52 | returnVal := 1 53 | 54 | var retrySum uint 55 | val, err := DoWithData( 56 | func() (int, error) { return returnVal, nil }, 57 | OnRetry(func(n uint, err error) { retrySum += n }), 58 | ) 59 | assert.NoError(t, err) 60 | assert.Equal(t, returnVal, val) 61 | assert.Equal(t, uint(0), retrySum, "no retry") 62 | } 63 | 64 | func TestRetryIf(t *testing.T) { 65 | var retryCount uint 66 | err := Do( 67 | func() error { 68 | if retryCount >= 2 { 69 | return errors.New("special") 70 | } else { 71 | return errors.New("test") 72 | } 73 | }, 74 | OnRetry(func(n uint, err error) { retryCount++ }), 75 | RetryIf(func(err error) bool { 76 | return err.Error() != "special" 77 | }), 78 | Delay(time.Nanosecond), 79 | ) 80 | assert.Error(t, err) 81 | 82 | expectedErrorFormat := `All attempts fail: 83 | #1: test 84 | #2: test 85 | #3: special` 86 | assert.Len(t, err, 3) 87 | assert.Equal(t, expectedErrorFormat, err.Error(), "retry error format") 88 | assert.Equal(t, uint(2), retryCount, "right count of retry") 89 | } 90 | 91 | func TestRetryIf_ZeroAttempts(t *testing.T) { 92 | var retryCount, onRetryCount uint 93 | err := Do( 94 | func() error { 95 | if retryCount >= 2 { 96 | return errors.New("special") 97 | } else { 98 | retryCount++ 99 | return errors.New("test") 100 | } 101 | }, 102 | OnRetry(func(n uint, err error) { onRetryCount = n }), 103 | RetryIf(func(err error) bool { 104 | return err.Error() != "special" 105 | }), 106 | Delay(time.Nanosecond), 107 | Attempts(0), 108 | ) 109 | assert.Error(t, err) 110 | 111 | assert.Equal(t, "special", err.Error(), "retry error format") 112 | assert.Equal(t, retryCount, onRetryCount+1, "right count of retry") 113 | } 114 | 115 | func TestZeroAttemptsWithError(t *testing.T) { 116 | const maxErrors = 999 117 | count := 0 118 | 119 | err := Do( 120 | func() error { 121 | if count < maxErrors { 122 | count += 1 123 | return errors.New("test") 124 | } 125 | 126 | return nil 127 | }, 128 | Attempts(0), 129 | MaxDelay(time.Nanosecond), 130 | ) 131 | assert.NoError(t, err) 132 | 133 | assert.Equal(t, count, maxErrors) 134 | } 135 | 136 | func TestZeroAttemptsWithoutError(t *testing.T) { 137 | count := 0 138 | 139 | err := Do( 140 | func() error { 141 | count++ 142 | 143 | return nil 144 | }, 145 | Attempts(0), 146 | ) 147 | assert.NoError(t, err) 148 | 149 | assert.Equal(t, count, 1) 150 | } 151 | 152 | func TestZeroAttemptsWithUnrecoverableError(t *testing.T) { 153 | err := Do( 154 | func() error { 155 | return Unrecoverable(assert.AnError) 156 | }, 157 | Attempts(0), 158 | MaxDelay(time.Nanosecond), 159 | ) 160 | assert.Error(t, err) 161 | assert.Equal(t, Unrecoverable(assert.AnError), err) 162 | } 163 | 164 | func TestAttemptsForError(t *testing.T) { 165 | count := uint(0) 166 | testErr := os.ErrInvalid 167 | attemptsForTestError := uint(3) 168 | err := Do( 169 | func() error { 170 | count++ 171 | return testErr 172 | }, 173 | AttemptsForError(attemptsForTestError, testErr), 174 | Attempts(5), 175 | ) 176 | assert.Error(t, err) 177 | assert.Equal(t, attemptsForTestError, count) 178 | } 179 | 180 | func TestDefaultSleep(t *testing.T) { 181 | start := time.Now() 182 | err := Do( 183 | func() error { return errors.New("test") }, 184 | Attempts(3), 185 | ) 186 | dur := time.Since(start) 187 | assert.Error(t, err) 188 | assert.Greater(t, dur, 300*time.Millisecond, "3 times default retry is longer then 300ms") 189 | } 190 | 191 | func TestFixedSleep(t *testing.T) { 192 | start := time.Now() 193 | err := Do( 194 | func() error { return errors.New("test") }, 195 | Attempts(3), 196 | DelayType(FixedDelay), 197 | ) 198 | dur := time.Since(start) 199 | assert.Error(t, err) 200 | assert.Less(t, dur, 500*time.Millisecond, "3 times default retry is shorter then 500ms") 201 | } 202 | 203 | func TestLastErrorOnly(t *testing.T) { 204 | var retrySum uint 205 | err := Do( 206 | func() error { return fmt.Errorf("%d", retrySum) }, 207 | OnRetry(func(n uint, err error) { retrySum += 1 }), 208 | Delay(time.Nanosecond), 209 | LastErrorOnly(true), 210 | ) 211 | assert.Error(t, err) 212 | assert.Equal(t, "9", err.Error()) 213 | } 214 | 215 | func TestUnrecoverableError(t *testing.T) { 216 | attempts := 0 217 | testErr := errors.New("error") 218 | expectedErr := Error{testErr} 219 | err := Do( 220 | func() error { 221 | attempts++ 222 | return Unrecoverable(testErr) 223 | }, 224 | Attempts(2), 225 | ) 226 | assert.Equal(t, expectedErr, err) 227 | assert.Equal(t, testErr, errors.Unwrap(err)) 228 | assert.Equal(t, 1, attempts, "unrecoverable error broke the loop") 229 | } 230 | 231 | func TestCombineFixedDelays(t *testing.T) { 232 | if os.Getenv("OS") == "macos-latest" { 233 | t.Skip("Skipping testing in MacOS GitHub actions - too slow, duration is wrong") 234 | } 235 | 236 | start := time.Now() 237 | err := Do( 238 | func() error { return errors.New("test") }, 239 | Attempts(3), 240 | DelayType(CombineDelay(FixedDelay, FixedDelay)), 241 | ) 242 | dur := time.Since(start) 243 | assert.Error(t, err) 244 | assert.Greater(t, dur, 400*time.Millisecond, "3 times combined, fixed retry is greater then 400ms") 245 | assert.Less(t, dur, 500*time.Millisecond, "3 times combined, fixed retry is less then 500ms") 246 | } 247 | 248 | func TestRandomDelay(t *testing.T) { 249 | if os.Getenv("OS") == "macos-latest" { 250 | t.Skip("Skipping testing in MacOS GitHub actions - too slow, duration is wrong") 251 | } 252 | 253 | start := time.Now() 254 | err := Do( 255 | func() error { return errors.New("test") }, 256 | Attempts(3), 257 | DelayType(RandomDelay), 258 | MaxJitter(50*time.Millisecond), 259 | ) 260 | dur := time.Since(start) 261 | assert.Error(t, err) 262 | assert.Greater(t, dur, 2*time.Millisecond, "3 times random retry is longer then 2ms") 263 | assert.Less(t, dur, 150*time.Millisecond, "3 times random retry is shorter then 150ms") 264 | } 265 | 266 | func TestMaxDelay(t *testing.T) { 267 | if os.Getenv("OS") == "macos-latest" { 268 | t.Skip("Skipping testing in MacOS GitHub actions - too slow, duration is wrong") 269 | } 270 | 271 | start := time.Now() 272 | err := Do( 273 | func() error { return errors.New("test") }, 274 | Attempts(5), 275 | Delay(10*time.Millisecond), 276 | MaxDelay(50*time.Millisecond), 277 | ) 278 | dur := time.Since(start) 279 | assert.Error(t, err) 280 | assert.Greater(t, dur, 120*time.Millisecond, "5 times with maximum delay retry is less than 120ms") 281 | assert.Less(t, dur, 275*time.Millisecond, "5 times with maximum delay retry is longer than 275ms") 282 | } 283 | 284 | func TestBackOffDelay(t *testing.T) { 285 | for _, c := range []struct { 286 | label string 287 | delay time.Duration 288 | expectedMaxN uint 289 | n uint 290 | expectedDelay time.Duration 291 | }{ 292 | { 293 | label: "negative-delay", 294 | delay: -1, 295 | expectedMaxN: 62, 296 | n: 2, 297 | expectedDelay: 4, 298 | }, 299 | { 300 | label: "zero-delay", 301 | delay: 0, 302 | expectedMaxN: 62, 303 | n: 65, 304 | expectedDelay: 1 << 62, 305 | }, 306 | { 307 | label: "one-second", 308 | delay: time.Second, 309 | expectedMaxN: 33, 310 | n: 62, 311 | expectedDelay: time.Second << 33, 312 | }, 313 | } { 314 | t.Run( 315 | c.label, 316 | func(t *testing.T) { 317 | config := Config{ 318 | delay: c.delay, 319 | } 320 | delay := BackOffDelay(c.n, nil, &config) 321 | assert.Equal(t, c.expectedMaxN, config.maxBackOffN, "max n mismatch") 322 | assert.Equal(t, c.expectedDelay, delay, "delay duration mismatch") 323 | }, 324 | ) 325 | } 326 | } 327 | 328 | func TestCombineDelay(t *testing.T) { 329 | f := func(d time.Duration) DelayTypeFunc { 330 | return func(_ uint, _ error, _ *Config) time.Duration { 331 | return d 332 | } 333 | } 334 | const max = time.Duration(1<<63 - 1) 335 | for _, c := range []struct { 336 | label string 337 | delays []time.Duration 338 | expected time.Duration 339 | }{ 340 | { 341 | label: "empty", 342 | }, 343 | { 344 | label: "single", 345 | delays: []time.Duration{ 346 | time.Second, 347 | }, 348 | expected: time.Second, 349 | }, 350 | { 351 | label: "negative", 352 | delays: []time.Duration{ 353 | time.Second, 354 | -time.Millisecond, 355 | }, 356 | expected: time.Second - time.Millisecond, 357 | }, 358 | { 359 | label: "overflow", 360 | delays: []time.Duration{ 361 | max, 362 | time.Second, 363 | time.Millisecond, 364 | }, 365 | expected: max, 366 | }, 367 | } { 368 | t.Run( 369 | c.label, 370 | func(t *testing.T) { 371 | funcs := make([]DelayTypeFunc, len(c.delays)) 372 | for i, d := range c.delays { 373 | funcs[i] = f(d) 374 | } 375 | actual := CombineDelay(funcs...)(0, nil, nil) 376 | assert.Equal(t, c.expected, actual, "delay duration mismatch") 377 | }, 378 | ) 379 | } 380 | } 381 | 382 | func TestContext(t *testing.T) { 383 | const defaultDelay = 100 * time.Millisecond 384 | t.Run("cancel before", func(t *testing.T) { 385 | ctx, cancel := context.WithCancel(context.Background()) 386 | cancel() 387 | 388 | retrySum := 0 389 | start := time.Now() 390 | err := Do( 391 | func() error { return errors.New("test") }, 392 | OnRetry(func(n uint, err error) { retrySum += 1 }), 393 | Context(ctx), 394 | ) 395 | dur := time.Since(start) 396 | assert.Error(t, err) 397 | assert.True(t, dur < defaultDelay, "immediately cancellation") 398 | assert.Equal(t, 0, retrySum, "called at most once") 399 | }) 400 | 401 | t.Run("cancel in retry progress", func(t *testing.T) { 402 | ctx, cancel := context.WithCancel(context.Background()) 403 | 404 | retrySum := 0 405 | err := Do( 406 | func() error { return errors.New("test") }, 407 | OnRetry(func(n uint, err error) { 408 | retrySum += 1 409 | if retrySum > 1 { 410 | cancel() 411 | } 412 | }), 413 | Context(ctx), 414 | ) 415 | assert.Error(t, err) 416 | 417 | expectedErrorFormat := `All attempts fail: 418 | #1: test 419 | #2: test 420 | #3: context canceled` 421 | assert.Len(t, err, 3) 422 | assert.Equal(t, expectedErrorFormat, err.Error(), "retry error format") 423 | assert.Equal(t, 2, retrySum, "called at most once") 424 | }) 425 | 426 | t.Run("cancel in retry progress - last error only", func(t *testing.T) { 427 | ctx, cancel := context.WithCancel(context.Background()) 428 | 429 | retrySum := 0 430 | err := Do( 431 | func() error { return errors.New("test") }, 432 | OnRetry(func(n uint, err error) { 433 | retrySum += 1 434 | if retrySum > 1 { 435 | cancel() 436 | } 437 | }), 438 | Context(ctx), 439 | LastErrorOnly(true), 440 | ) 441 | assert.Equal(t, context.Canceled, err) 442 | 443 | assert.Equal(t, 2, retrySum, "called at most once") 444 | }) 445 | 446 | t.Run("cancel in retry progress - infinite attempts", func(t *testing.T) { 447 | go func() { 448 | ctx, cancel := context.WithCancel(context.Background()) 449 | 450 | retrySum := 0 451 | err := Do( 452 | func() error { return errors.New("test") }, 453 | OnRetry(func(n uint, err error) { 454 | fmt.Println(n) 455 | retrySum += 1 456 | if retrySum > 1 { 457 | cancel() 458 | } 459 | }), 460 | Context(ctx), 461 | Attempts(0), 462 | ) 463 | 464 | assert.Equal(t, context.Canceled, err) 465 | 466 | assert.Equal(t, 2, retrySum, "called at most once") 467 | }() 468 | }) 469 | 470 | t.Run("cancelled on retry infinte attempts - wraps context error with last retried function error", func(t *testing.T) { 471 | ctx, cancel := context.WithCancel(context.Background()) 472 | defer cancel() 473 | 474 | retrySum := 0 475 | err := Do( 476 | func() error { return fooErr{str: fmt.Sprintf("error %d", retrySum+1)} }, 477 | OnRetry(func(n uint, err error) { 478 | retrySum += 1 479 | if retrySum == 2 { 480 | cancel() 481 | } 482 | }), 483 | Context(ctx), 484 | Attempts(0), 485 | WrapContextErrorWithLastError(true), 486 | ) 487 | assert.ErrorIs(t, err, context.Canceled) 488 | assert.ErrorIs(t, err, fooErr{str: "error 2"}) 489 | }) 490 | 491 | t.Run("timed out on retry infinte attempts - wraps context error with last retried function error", func(t *testing.T) { 492 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500) 493 | defer cancel() 494 | 495 | retrySum := 0 496 | err := Do( 497 | func() error { return fooErr{str: fmt.Sprintf("error %d", retrySum+1)} }, 498 | OnRetry(func(n uint, err error) { 499 | retrySum += 1 500 | }), 501 | Context(ctx), 502 | Attempts(0), 503 | WrapContextErrorWithLastError(true), 504 | ) 505 | assert.ErrorIs(t, err, context.DeadlineExceeded) 506 | assert.ErrorIs(t, err, fooErr{str: "error 2"}) 507 | }) 508 | } 509 | 510 | type testTimer struct { 511 | called bool 512 | } 513 | 514 | func (t *testTimer) After(d time.Duration) <-chan time.Time { 515 | t.called = true 516 | return time.After(d) 517 | } 518 | 519 | func TestTimerInterface(t *testing.T) { 520 | var timer testTimer 521 | err := Do( 522 | func() error { return errors.New("test") }, 523 | Attempts(1), 524 | Delay(10*time.Millisecond), 525 | MaxDelay(50*time.Millisecond), 526 | WithTimer(&timer), 527 | ) 528 | 529 | assert.Error(t, err) 530 | 531 | } 532 | 533 | func TestErrorIs(t *testing.T) { 534 | var e Error 535 | expectErr := errors.New("error") 536 | closedErr := os.ErrClosed 537 | e = append(e, expectErr) 538 | e = append(e, closedErr) 539 | 540 | assert.True(t, errors.Is(e, expectErr)) 541 | assert.True(t, errors.Is(e, closedErr)) 542 | assert.False(t, errors.Is(e, errors.New("error"))) 543 | } 544 | 545 | type fooErr struct{ str string } 546 | 547 | func (e fooErr) Error() string { 548 | return e.str 549 | } 550 | 551 | type barErr struct{ str string } 552 | 553 | func (e barErr) Error() string { 554 | return e.str 555 | } 556 | 557 | func TestErrorAs(t *testing.T) { 558 | var e Error 559 | fe := fooErr{str: "foo"} 560 | e = append(e, fe) 561 | 562 | var tf fooErr 563 | var tb barErr 564 | 565 | assert.True(t, errors.As(e, &tf)) 566 | assert.False(t, errors.As(e, &tb)) 567 | assert.Equal(t, "foo", tf.str) 568 | } 569 | 570 | func TestUnwrap(t *testing.T) { 571 | testError := errors.New("test error") 572 | err := Do( 573 | func() error { 574 | return testError 575 | }, 576 | Attempts(1), 577 | ) 578 | 579 | assert.Error(t, err) 580 | assert.Equal(t, testError, errors.Unwrap(err)) 581 | } 582 | 583 | func BenchmarkDo(b *testing.B) { 584 | testError := errors.New("test error") 585 | 586 | for i := 0; i < b.N; i++ { 587 | _ = Do( 588 | func() error { 589 | return testError 590 | }, 591 | Attempts(10), 592 | Delay(0), 593 | ) 594 | } 595 | } 596 | 597 | func BenchmarkDoWithData(b *testing.B) { 598 | testError := errors.New("test error") 599 | 600 | for i := 0; i < b.N; i++ { 601 | _, _ = DoWithData( 602 | func() (int, error) { 603 | return 0, testError 604 | }, 605 | Attempts(10), 606 | Delay(0), 607 | ) 608 | } 609 | } 610 | 611 | func BenchmarkDoNoErrors(b *testing.B) { 612 | for i := 0; i < b.N; i++ { 613 | _ = Do( 614 | func() error { 615 | return nil 616 | }, 617 | Attempts(10), 618 | Delay(0), 619 | ) 620 | } 621 | } 622 | 623 | func BenchmarkDoWithDataNoErrors(b *testing.B) { 624 | for i := 0; i < b.N; i++ { 625 | _, _ = DoWithData( 626 | func() (int, error) { 627 | return 0, nil 628 | }, 629 | Attempts(10), 630 | Delay(0), 631 | ) 632 | } 633 | } 634 | 635 | func TestIsRecoverable(t *testing.T) { 636 | err := errors.New("err") 637 | assert.True(t, IsRecoverable(err)) 638 | 639 | err = Unrecoverable(err) 640 | assert.False(t, IsRecoverable(err)) 641 | 642 | err = fmt.Errorf("wrapping: %w", err) 643 | assert.False(t, IsRecoverable(err)) 644 | } 645 | --------------------------------------------------------------------------------