├── .github └── workflows │ ├── go.yaml │ └── stale.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── backoff.go ├── backoff_test.go ├── error.go ├── example_test.go ├── exponential.go ├── exponential_test.go ├── go.mod ├── retry.go ├── retry_test.go ├── ticker.go ├── ticker_test.go ├── timer.go └── tries_test.go /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.23 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test -v ./... 27 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | 24 | # IDEs 25 | .idea/ 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [5.0.0] - 2024-12-19 9 | 10 | ### Added 11 | 12 | - RetryAfterError can be returned from an operation to indicate how long to wait before the next retry. 13 | 14 | ### Changed 15 | 16 | - Retry function now accepts additional options for specifying max number of tries and max elapsed time. 17 | - Retry function now accepts a context.Context. 18 | - Operation function signature changed to return result (any type) and error. 19 | 20 | ### Removed 21 | 22 | - RetryNotify* and RetryWithData functions. Only single Retry function remains. 23 | - Optional arguments from ExponentialBackoff constructor. 24 | - Clock and Timer interfaces. 25 | 26 | ### Fixed 27 | 28 | - The original error is returned from Retry if there's a PermanentError. (#144) 29 | - The Retry function respects the wrapped PermanentError. (#140) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Cenk Altı 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exponential Backoff [![GoDoc][godoc image]][godoc] 2 | 3 | This is a Go port of the exponential backoff algorithm from [Google's HTTP Client Library for Java][google-http-java-client]. 4 | 5 | [Exponential backoff][exponential backoff wiki] 6 | is an algorithm that uses feedback to multiplicatively decrease the rate of some process, 7 | in order to gradually find an acceptable rate. 8 | The retries exponentially increase and stop increasing when a certain threshold is met. 9 | 10 | ## Usage 11 | 12 | Import path is `github.com/cenkalti/backoff/v5`. Please note the version part at the end. 13 | 14 | For most cases, use `Retry` function. See [example_test.go][example] for an example. 15 | 16 | If you have specific needs, copy `Retry` function (from [retry.go][retry-src]) into your code and modify it as needed. 17 | 18 | ## Contributing 19 | 20 | * I would like to keep this library as small as possible. 21 | * Please don't send a PR without opening an issue and discussing it first. 22 | * If proposed change is not a common use case, I will probably not accept it. 23 | 24 | [godoc]: https://pkg.go.dev/github.com/cenkalti/backoff/v5 25 | [godoc image]: https://godoc.org/github.com/cenkalti/backoff?status.png 26 | 27 | [google-http-java-client]: https://github.com/google/google-http-java-client/blob/da1aa993e90285ec18579f1553339b00e19b3ab5/google-http-client/src/main/java/com/google/api/client/util/ExponentialBackOff.java 28 | [exponential backoff wiki]: http://en.wikipedia.org/wiki/Exponential_backoff 29 | 30 | [retry-src]: https://github.com/cenkalti/backoff/blob/v5/retry.go 31 | [example]: https://github.com/cenkalti/backoff/blob/v5/example_test.go 32 | -------------------------------------------------------------------------------- /backoff.go: -------------------------------------------------------------------------------- 1 | // Package backoff implements backoff algorithms for retrying operations. 2 | // 3 | // Use Retry function for retrying operations that may fail. 4 | // If Retry does not meet your needs, 5 | // copy/paste the function into your project and modify as you wish. 6 | // 7 | // There is also Ticker type similar to time.Ticker. 8 | // You can use it if you need to work with channels. 9 | // 10 | // See Examples section below for usage examples. 11 | package backoff 12 | 13 | import "time" 14 | 15 | // BackOff is a backoff policy for retrying an operation. 16 | type BackOff interface { 17 | // NextBackOff returns the duration to wait before retrying the operation, 18 | // backoff.Stop to indicate that no more retries should be made. 19 | // 20 | // Example usage: 21 | // 22 | // duration := backoff.NextBackOff() 23 | // if duration == backoff.Stop { 24 | // // Do not retry operation. 25 | // } else { 26 | // // Sleep for duration and retry operation. 27 | // } 28 | // 29 | NextBackOff() time.Duration 30 | 31 | // Reset to initial state. 32 | Reset() 33 | } 34 | 35 | // Stop indicates that no more retries should be made for use in NextBackOff(). 36 | const Stop time.Duration = -1 37 | 38 | // ZeroBackOff is a fixed backoff policy whose backoff time is always zero, 39 | // meaning that the operation is retried immediately without waiting, indefinitely. 40 | type ZeroBackOff struct{} 41 | 42 | func (b *ZeroBackOff) Reset() {} 43 | 44 | func (b *ZeroBackOff) NextBackOff() time.Duration { return 0 } 45 | 46 | // StopBackOff is a fixed backoff policy that always returns backoff.Stop for 47 | // NextBackOff(), meaning that the operation should never be retried. 48 | type StopBackOff struct{} 49 | 50 | func (b *StopBackOff) Reset() {} 51 | 52 | func (b *StopBackOff) NextBackOff() time.Duration { return Stop } 53 | 54 | // ConstantBackOff is a backoff policy that always returns the same backoff delay. 55 | // This is in contrast to an exponential backoff policy, 56 | // which returns a delay that grows longer as you call NextBackOff() over and over again. 57 | type ConstantBackOff struct { 58 | Interval time.Duration 59 | } 60 | 61 | func (b *ConstantBackOff) Reset() {} 62 | func (b *ConstantBackOff) NextBackOff() time.Duration { return b.Interval } 63 | 64 | func NewConstantBackOff(d time.Duration) *ConstantBackOff { 65 | return &ConstantBackOff{Interval: d} 66 | } 67 | -------------------------------------------------------------------------------- /backoff_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestNextBackOffMillis(t *testing.T) { 9 | subtestNextBackOff(t, 0, new(ZeroBackOff)) 10 | subtestNextBackOff(t, Stop, new(StopBackOff)) 11 | } 12 | 13 | func subtestNextBackOff(t *testing.T, expectedValue time.Duration, backOffPolicy BackOff) { 14 | for i := 0; i < 10; i++ { 15 | next := backOffPolicy.NextBackOff() 16 | if next != expectedValue { 17 | t.Errorf("got: %d expected: %d", next, expectedValue) 18 | } 19 | } 20 | } 21 | 22 | func TestConstantBackOff(t *testing.T) { 23 | backoff := NewConstantBackOff(time.Second) 24 | if backoff.NextBackOff() != time.Second { 25 | t.Error("invalid interval") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // PermanentError signals that the operation should not be retried. 9 | type PermanentError struct { 10 | Err error 11 | } 12 | 13 | // Permanent wraps the given err in a *PermanentError. 14 | func Permanent(err error) error { 15 | if err == nil { 16 | return nil 17 | } 18 | return &PermanentError{ 19 | Err: err, 20 | } 21 | } 22 | 23 | // Error returns a string representation of the Permanent error. 24 | func (e *PermanentError) Error() string { 25 | return e.Err.Error() 26 | } 27 | 28 | // Unwrap returns the wrapped error. 29 | func (e *PermanentError) Unwrap() error { 30 | return e.Err 31 | } 32 | 33 | // RetryAfterError signals that the operation should be retried after the given duration. 34 | type RetryAfterError struct { 35 | Duration time.Duration 36 | } 37 | 38 | // RetryAfter returns a RetryAfter error that specifies how long to wait before retrying. 39 | func RetryAfter(seconds int) error { 40 | return &RetryAfterError{Duration: time.Duration(seconds) * time.Second} 41 | } 42 | 43 | // Error returns a string representation of the RetryAfter error. 44 | func (e *RetryAfterError) Error() string { 45 | return fmt.Sprintf("retry after %s", e.Duration) 46 | } 47 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package backoff_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/cenkalti/backoff/v5" 12 | ) 13 | 14 | func ExampleRetry() { 15 | // Define an operation function that returns a value and an error. 16 | // The value can be any type. 17 | // We'll pass this operation to Retry function. 18 | operation := func() (string, error) { 19 | // An example request that may fail. 20 | resp, err := http.Get("http://httpbin.org/get") 21 | if err != nil { 22 | return "", err 23 | } 24 | defer resp.Body.Close() 25 | 26 | // In case on non-retriable error, return Permanent error to stop retrying. 27 | // For this HTTP example, client errors are non-retriable. 28 | if resp.StatusCode == 400 { 29 | return "", backoff.Permanent(errors.New("bad request")) 30 | } 31 | 32 | // If we are being rate limited, return a RetryAfter to specify how long to wait. 33 | // This will also reset the backoff policy. 34 | if resp.StatusCode == 429 { 35 | seconds, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64) 36 | if err == nil { 37 | return "", backoff.RetryAfter(int(seconds)) 38 | } 39 | } 40 | 41 | // Return successful response. 42 | return "hello", nil 43 | } 44 | 45 | result, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(backoff.NewExponentialBackOff())) 46 | if err != nil { 47 | fmt.Println("Error:", err) 48 | return 49 | } 50 | 51 | // Operation is successful. 52 | 53 | fmt.Println(result) 54 | // Output: hello 55 | } 56 | 57 | func ExampleTicker() { 58 | // An operation that may fail. 59 | operation := func() (string, error) { 60 | return "hello", nil 61 | } 62 | 63 | ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 64 | defer ticker.Stop() 65 | 66 | var result string 67 | var err error 68 | 69 | // Ticks will continue to arrive when the previous operation is still running, 70 | // so operations that take a while to fail could run in quick succession. 71 | for range ticker.C { 72 | if result, err = operation(); err != nil { 73 | log.Println(err, "will retry...") 74 | continue 75 | } 76 | 77 | break 78 | } 79 | 80 | if err != nil { 81 | // Operation has failed. 82 | fmt.Println("Error:", err) 83 | return 84 | } 85 | 86 | // Operation is successful. 87 | 88 | fmt.Println(result) 89 | // Output: hello 90 | } 91 | -------------------------------------------------------------------------------- /exponential.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "math/rand/v2" 5 | "time" 6 | ) 7 | 8 | /* 9 | ExponentialBackOff is a backoff implementation that increases the backoff 10 | period for each retry attempt using a randomization function that grows exponentially. 11 | 12 | NextBackOff() is calculated using the following formula: 13 | 14 | randomized interval = 15 | RetryInterval * (random value in range [1 - RandomizationFactor, 1 + RandomizationFactor]) 16 | 17 | In other words NextBackOff() will range between the randomization factor 18 | percentage below and above the retry interval. 19 | 20 | For example, given the following parameters: 21 | 22 | RetryInterval = 2 23 | RandomizationFactor = 0.5 24 | Multiplier = 2 25 | 26 | the actual backoff period used in the next retry attempt will range between 1 and 3 seconds, 27 | multiplied by the exponential, that is, between 2 and 6 seconds. 28 | 29 | Note: MaxInterval caps the RetryInterval and not the randomized interval. 30 | 31 | Example: Given the following default arguments, for 9 tries the sequence will be: 32 | 33 | Request # RetryInterval (seconds) Randomized Interval (seconds) 34 | 35 | 1 0.5 [0.25, 0.75] 36 | 2 0.75 [0.375, 1.125] 37 | 3 1.125 [0.562, 1.687] 38 | 4 1.687 [0.8435, 2.53] 39 | 5 2.53 [1.265, 3.795] 40 | 6 3.795 [1.897, 5.692] 41 | 7 5.692 [2.846, 8.538] 42 | 8 8.538 [4.269, 12.807] 43 | 9 12.807 [6.403, 19.210] 44 | 45 | Note: Implementation is not thread-safe. 46 | */ 47 | type ExponentialBackOff struct { 48 | InitialInterval time.Duration 49 | RandomizationFactor float64 50 | Multiplier float64 51 | MaxInterval time.Duration 52 | 53 | currentInterval time.Duration 54 | } 55 | 56 | // Default values for ExponentialBackOff. 57 | const ( 58 | DefaultInitialInterval = 500 * time.Millisecond 59 | DefaultRandomizationFactor = 0.5 60 | DefaultMultiplier = 1.5 61 | DefaultMaxInterval = 60 * time.Second 62 | ) 63 | 64 | // NewExponentialBackOff creates an instance of ExponentialBackOff using default values. 65 | func NewExponentialBackOff() *ExponentialBackOff { 66 | return &ExponentialBackOff{ 67 | InitialInterval: DefaultInitialInterval, 68 | RandomizationFactor: DefaultRandomizationFactor, 69 | Multiplier: DefaultMultiplier, 70 | MaxInterval: DefaultMaxInterval, 71 | } 72 | } 73 | 74 | // Reset the interval back to the initial retry interval and restarts the timer. 75 | // Reset must be called before using b. 76 | func (b *ExponentialBackOff) Reset() { 77 | b.currentInterval = b.InitialInterval 78 | } 79 | 80 | // NextBackOff calculates the next backoff interval using the formula: 81 | // 82 | // Randomized interval = RetryInterval * (1 ± RandomizationFactor) 83 | func (b *ExponentialBackOff) NextBackOff() time.Duration { 84 | if b.currentInterval == 0 { 85 | b.currentInterval = b.InitialInterval 86 | } 87 | 88 | next := getRandomValueFromInterval(b.RandomizationFactor, rand.Float64(), b.currentInterval) 89 | b.incrementCurrentInterval() 90 | return next 91 | } 92 | 93 | // Increments the current interval by multiplying it with the multiplier. 94 | func (b *ExponentialBackOff) incrementCurrentInterval() { 95 | // Check for overflow, if overflow is detected set the current interval to the max interval. 96 | if float64(b.currentInterval) >= float64(b.MaxInterval)/b.Multiplier { 97 | b.currentInterval = b.MaxInterval 98 | } else { 99 | b.currentInterval = time.Duration(float64(b.currentInterval) * b.Multiplier) 100 | } 101 | } 102 | 103 | // Returns a random value from the following interval: 104 | // 105 | // [currentInterval - randomizationFactor * currentInterval, currentInterval + randomizationFactor * currentInterval]. 106 | func getRandomValueFromInterval(randomizationFactor, random float64, currentInterval time.Duration) time.Duration { 107 | if randomizationFactor == 0 { 108 | return currentInterval // make sure no randomness is used when randomizationFactor is 0. 109 | } 110 | var delta = randomizationFactor * float64(currentInterval) 111 | var minInterval = float64(currentInterval) - delta 112 | var maxInterval = float64(currentInterval) + delta 113 | 114 | // Get a random value from the range [minInterval, maxInterval]. 115 | // The formula used below has a +1 because if the minInterval is 1 and the maxInterval is 3 then 116 | // we want a 33% chance for selecting either 1, 2 or 3. 117 | return time.Duration(minInterval + (random * (maxInterval - minInterval + 1))) 118 | } 119 | -------------------------------------------------------------------------------- /exponential_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestBackOff(t *testing.T) { 10 | var ( 11 | testInitialInterval = 500 * time.Millisecond 12 | testRandomizationFactor = 0.1 13 | testMultiplier = 2.0 14 | testMaxInterval = 5 * time.Second 15 | ) 16 | 17 | exp := NewExponentialBackOff() 18 | exp.InitialInterval = testInitialInterval 19 | exp.RandomizationFactor = testRandomizationFactor 20 | exp.Multiplier = testMultiplier 21 | exp.MaxInterval = testMaxInterval 22 | exp.Reset() 23 | 24 | var expectedResults = []time.Duration{500, 1000, 2000, 4000, 5000, 5000, 5000, 5000, 5000, 5000} 25 | for i, d := range expectedResults { 26 | expectedResults[i] = d * time.Millisecond 27 | } 28 | 29 | for _, expected := range expectedResults { 30 | assertEquals(t, expected, exp.currentInterval) 31 | // Assert that the next backoff falls in the expected range. 32 | var minInterval = expected - time.Duration(testRandomizationFactor*float64(expected)) 33 | var maxInterval = expected + time.Duration(testRandomizationFactor*float64(expected)) 34 | var actualInterval = exp.NextBackOff() 35 | if !(minInterval <= actualInterval && actualInterval <= maxInterval) { 36 | t.Error("error") 37 | } 38 | } 39 | } 40 | 41 | func TestGetRandomizedInterval(t *testing.T) { 42 | // 33% chance of being 1. 43 | assertEquals(t, 1, getRandomValueFromInterval(0.5, 0, 2)) 44 | assertEquals(t, 1, getRandomValueFromInterval(0.5, 0.33, 2)) 45 | // 33% chance of being 2. 46 | assertEquals(t, 2, getRandomValueFromInterval(0.5, 0.34, 2)) 47 | assertEquals(t, 2, getRandomValueFromInterval(0.5, 0.66, 2)) 48 | // 33% chance of being 3. 49 | assertEquals(t, 3, getRandomValueFromInterval(0.5, 0.67, 2)) 50 | assertEquals(t, 3, getRandomValueFromInterval(0.5, 0.99, 2)) 51 | } 52 | 53 | func TestBackOffOverflow(t *testing.T) { 54 | var ( 55 | testInitialInterval time.Duration = math.MaxInt64 / 2 56 | testMaxInterval time.Duration = math.MaxInt64 57 | testMultiplier = 2.1 58 | ) 59 | 60 | exp := NewExponentialBackOff() 61 | exp.InitialInterval = testInitialInterval 62 | exp.Multiplier = testMultiplier 63 | exp.MaxInterval = testMaxInterval 64 | exp.Reset() 65 | 66 | exp.NextBackOff() 67 | // Assert that when an overflow is possible, the current interval time.Duration is set to the max interval time.Duration. 68 | assertEquals(t, testMaxInterval, exp.currentInterval) 69 | } 70 | 71 | func assertEquals(t *testing.T, expected, value time.Duration) { 72 | if expected != value { 73 | t.Errorf("got: %d, expected: %d", value, expected) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cenkalti/backoff/v5 2 | 3 | go 1.23 4 | -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | // DefaultMaxElapsedTime sets a default limit for the total retry duration. 10 | const DefaultMaxElapsedTime = 15 * time.Minute 11 | 12 | // Operation is a function that attempts an operation and may be retried. 13 | type Operation[T any] func() (T, error) 14 | 15 | // Notify is a function called on operation error with the error and backoff duration. 16 | type Notify func(error, time.Duration) 17 | 18 | // retryOptions holds configuration settings for the retry mechanism. 19 | type retryOptions struct { 20 | BackOff BackOff // Strategy for calculating backoff periods. 21 | Timer timer // Timer to manage retry delays. 22 | Notify Notify // Optional function to notify on each retry error. 23 | MaxTries uint // Maximum number of retry attempts. 24 | MaxElapsedTime time.Duration // Maximum total time for all retries. 25 | } 26 | 27 | type RetryOption func(*retryOptions) 28 | 29 | // WithBackOff configures a custom backoff strategy. 30 | func WithBackOff(b BackOff) RetryOption { 31 | return func(args *retryOptions) { 32 | args.BackOff = b 33 | } 34 | } 35 | 36 | // withTimer sets a custom timer for managing delays between retries. 37 | func withTimer(t timer) RetryOption { 38 | return func(args *retryOptions) { 39 | args.Timer = t 40 | } 41 | } 42 | 43 | // WithNotify sets a notification function to handle retry errors. 44 | func WithNotify(n Notify) RetryOption { 45 | return func(args *retryOptions) { 46 | args.Notify = n 47 | } 48 | } 49 | 50 | // WithMaxTries limits the number of all attempts. 51 | func WithMaxTries(n uint) RetryOption { 52 | return func(args *retryOptions) { 53 | args.MaxTries = n 54 | } 55 | } 56 | 57 | // WithMaxElapsedTime limits the total duration for retry attempts. 58 | func WithMaxElapsedTime(d time.Duration) RetryOption { 59 | return func(args *retryOptions) { 60 | args.MaxElapsedTime = d 61 | } 62 | } 63 | 64 | // Retry attempts the operation until success, a permanent error, or backoff completion. 65 | // It ensures the operation is executed at least once. 66 | // 67 | // Returns the operation result or error if retries are exhausted or context is cancelled. 68 | func Retry[T any](ctx context.Context, operation Operation[T], opts ...RetryOption) (T, error) { 69 | // Initialize default retry options. 70 | args := &retryOptions{ 71 | BackOff: NewExponentialBackOff(), 72 | Timer: &defaultTimer{}, 73 | MaxElapsedTime: DefaultMaxElapsedTime, 74 | } 75 | 76 | // Apply user-provided options to the default settings. 77 | for _, opt := range opts { 78 | opt(args) 79 | } 80 | 81 | defer args.Timer.Stop() 82 | 83 | startedAt := time.Now() 84 | args.BackOff.Reset() 85 | for numTries := uint(1); ; numTries++ { 86 | // Execute the operation. 87 | res, err := operation() 88 | if err == nil { 89 | return res, nil 90 | } 91 | 92 | // Stop retrying if maximum tries exceeded. 93 | if args.MaxTries > 0 && numTries >= args.MaxTries { 94 | return res, err 95 | } 96 | 97 | // Handle permanent errors without retrying. 98 | var permanent *PermanentError 99 | if errors.As(err, &permanent) { 100 | return res, err 101 | } 102 | 103 | // Stop retrying if context is cancelled. 104 | if cerr := context.Cause(ctx); cerr != nil { 105 | return res, cerr 106 | } 107 | 108 | // Calculate next backoff duration. 109 | next := args.BackOff.NextBackOff() 110 | if next == Stop { 111 | return res, err 112 | } 113 | 114 | // Reset backoff if RetryAfterError is encountered. 115 | var retryAfter *RetryAfterError 116 | if errors.As(err, &retryAfter) { 117 | next = retryAfter.Duration 118 | args.BackOff.Reset() 119 | } 120 | 121 | // Stop retrying if maximum elapsed time exceeded. 122 | if args.MaxElapsedTime > 0 && time.Since(startedAt)+next > args.MaxElapsedTime { 123 | return res, err 124 | } 125 | 126 | // Notify on error if a notifier function is provided. 127 | if args.Notify != nil { 128 | args.Notify(err, next) 129 | } 130 | 131 | // Wait for the next backoff period or context cancellation. 132 | args.Timer.Start(next) 133 | select { 134 | case <-args.Timer.C(): 135 | case <-ctx.Done(): 136 | return res, context.Cause(ctx) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /retry_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | type testTimer struct { 14 | timer *time.Timer 15 | } 16 | 17 | func (t *testTimer) Start(duration time.Duration) { 18 | t.timer = time.NewTimer(0) 19 | } 20 | 21 | func (t *testTimer) Stop() { 22 | if t.timer != nil { 23 | t.timer.Stop() 24 | } 25 | } 26 | 27 | func (t *testTimer) C() <-chan time.Time { 28 | return t.timer.C 29 | } 30 | 31 | func TestRetry(t *testing.T) { 32 | const successOn = 3 33 | var i = 0 34 | 35 | // This function is successful on "successOn" calls. 36 | f := func() (bool, error) { 37 | i++ 38 | log.Printf("function is called %d. time\n", i) 39 | 40 | if i == successOn { 41 | log.Println("OK") 42 | return true, nil 43 | } 44 | 45 | log.Println("error") 46 | return false, errors.New("error") 47 | } 48 | 49 | _, err := Retry(context.Background(), f, WithBackOff(NewExponentialBackOff()), withTimer(&testTimer{})) 50 | if err != nil { 51 | t.Errorf("unexpected error: %s", err.Error()) 52 | } 53 | if i != successOn { 54 | t.Errorf("invalid number of retries: %d", i) 55 | } 56 | } 57 | 58 | func TestRetryWithData(t *testing.T) { 59 | const successOn = 3 60 | var i = 0 61 | 62 | // This function is successful on "successOn" calls. 63 | f := func() (int, error) { 64 | i++ 65 | log.Printf("function is called %d. time\n", i) 66 | 67 | if i == successOn { 68 | log.Println("OK") 69 | return 42, nil 70 | } 71 | 72 | log.Println("error") 73 | return 1, errors.New("error") 74 | } 75 | 76 | res, err := Retry(context.Background(), f, WithBackOff(NewExponentialBackOff()), withTimer(&testTimer{})) 77 | if err != nil { 78 | t.Errorf("unexpected error: %s", err.Error()) 79 | } 80 | if i != successOn { 81 | t.Errorf("invalid number of retries: %d", i) 82 | } 83 | if res != 42 { 84 | t.Errorf("invalid data in response: %d, expected 42", res) 85 | } 86 | } 87 | 88 | func TestRetryContext(t *testing.T) { 89 | var cancelOn = 3 90 | var i = 0 91 | 92 | ctx, cancel := context.WithCancelCause(context.Background()) 93 | defer cancel(context.Canceled) 94 | 95 | expectedErr := errors.New("custom error") 96 | 97 | // This function cancels context on "cancelOn" calls. 98 | f := func() (bool, error) { 99 | i++ 100 | log.Printf("function is called %d. time\n", i) 101 | 102 | // cancelling the context in the operation function is not a typical 103 | // use-case, however it allows to get predictable test results. 104 | if i == cancelOn { 105 | cancel(expectedErr) 106 | } 107 | 108 | log.Println("error") 109 | return false, fmt.Errorf("error (%d)", i) 110 | } 111 | 112 | _, err := Retry(ctx, f, WithBackOff(NewConstantBackOff(time.Millisecond)), withTimer(&testTimer{})) 113 | if err == nil { 114 | t.Errorf("error is unexpectedly nil") 115 | } 116 | if !errors.Is(err, expectedErr) { 117 | t.Errorf("unexpected error: %s", err.Error()) 118 | } 119 | if i != cancelOn { 120 | t.Errorf("invalid number of retries: %d", i) 121 | } 122 | } 123 | 124 | func TestRetryPermanent(t *testing.T) { 125 | ensureRetries := func(test string, shouldRetry bool, f func() (int, error), expectRes int) { 126 | numRetries := -1 127 | maxRetries := 1 128 | 129 | res, _ := Retry( 130 | context.Background(), 131 | func() (int, error) { 132 | numRetries++ 133 | if numRetries >= maxRetries { 134 | return -1, Permanent(errors.New("forced")) 135 | } 136 | return f() 137 | }, 138 | WithBackOff(NewExponentialBackOff()), 139 | withTimer(&testTimer{}), 140 | ) 141 | 142 | if shouldRetry && numRetries == 0 { 143 | t.Errorf("Test: '%s', backoff should have retried", test) 144 | } 145 | 146 | if !shouldRetry && numRetries > 0 { 147 | t.Errorf("Test: '%s', backoff should not have retried", test) 148 | } 149 | 150 | if res != expectRes { 151 | t.Errorf("Test: '%s', got res %d but expected %d", test, res, expectRes) 152 | } 153 | } 154 | 155 | for _, testCase := range []struct { 156 | name string 157 | f func() (int, error) 158 | shouldRetry bool 159 | res int 160 | }{ 161 | { 162 | "nil test", 163 | func() (int, error) { 164 | return 1, nil 165 | }, 166 | false, 167 | 1, 168 | }, 169 | { 170 | "io.EOF", 171 | func() (int, error) { 172 | return 2, io.EOF 173 | }, 174 | true, 175 | -1, 176 | }, 177 | { 178 | "Permanent(io.EOF)", 179 | func() (int, error) { 180 | return 3, Permanent(io.EOF) 181 | }, 182 | false, 183 | 3, 184 | }, 185 | { 186 | "Wrapped: Permanent(io.EOF)", 187 | func() (int, error) { 188 | return 4, fmt.Errorf("Wrapped error: %w", Permanent(io.EOF)) 189 | }, 190 | false, 191 | 4, 192 | }, 193 | } { 194 | ensureRetries(testCase.name, testCase.shouldRetry, testCase.f, testCase.res) 195 | } 196 | } 197 | 198 | func TestPermanent(t *testing.T) { 199 | want := errors.New("foo") 200 | other := errors.New("bar") 201 | var err error = Permanent(want) 202 | 203 | got := errors.Unwrap(err) 204 | if got != want { 205 | t.Errorf("got %v, want %v", got, want) 206 | } 207 | 208 | if is := errors.Is(err, want); !is { 209 | t.Errorf("err: %v is not %v", err, want) 210 | } 211 | 212 | if is := errors.Is(err, other); is { 213 | t.Errorf("err: %v is %v", err, other) 214 | } 215 | 216 | wrapped := fmt.Errorf("wrapped: %w", err) 217 | var permanent *PermanentError 218 | if !errors.As(wrapped, &permanent) { 219 | t.Errorf("errors.As(%v, %v)", wrapped, permanent) 220 | } 221 | 222 | err = Permanent(nil) 223 | if err != nil { 224 | t.Errorf("got %v, want nil", err) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /ticker.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // Ticker holds a channel that delivers `ticks' of a clock at times reported by a BackOff. 9 | // 10 | // Ticks will continue to arrive when the previous operation is still running, 11 | // so operations that take a while to fail could run in quick succession. 12 | type Ticker struct { 13 | C <-chan time.Time 14 | c chan time.Time 15 | b BackOff 16 | timer timer 17 | stop chan struct{} 18 | stopOnce sync.Once 19 | } 20 | 21 | // NewTicker returns a new Ticker containing a channel that will send 22 | // the time at times specified by the BackOff argument. Ticker is 23 | // guaranteed to tick at least once. The channel is closed when Stop 24 | // method is called or BackOff stops. It is not safe to manipulate the 25 | // provided backoff policy (notably calling NextBackOff or Reset) 26 | // while the ticker is running. 27 | func NewTicker(b BackOff) *Ticker { 28 | c := make(chan time.Time) 29 | t := &Ticker{ 30 | C: c, 31 | c: c, 32 | b: b, 33 | timer: &defaultTimer{}, 34 | stop: make(chan struct{}), 35 | } 36 | t.b.Reset() 37 | go t.run() 38 | return t 39 | } 40 | 41 | // Stop turns off a ticker. After Stop, no more ticks will be sent. 42 | func (t *Ticker) Stop() { 43 | t.stopOnce.Do(func() { close(t.stop) }) 44 | } 45 | 46 | func (t *Ticker) run() { 47 | c := t.c 48 | defer close(c) 49 | 50 | // Ticker is guaranteed to tick at least once. 51 | afterC := t.send(time.Now()) 52 | 53 | for { 54 | if afterC == nil { 55 | return 56 | } 57 | 58 | select { 59 | case tick := <-afterC: 60 | afterC = t.send(tick) 61 | case <-t.stop: 62 | t.c = nil // Prevent future ticks from being sent to the channel. 63 | return 64 | } 65 | } 66 | } 67 | 68 | func (t *Ticker) send(tick time.Time) <-chan time.Time { 69 | select { 70 | case t.c <- tick: 71 | case <-t.stop: 72 | return nil 73 | } 74 | 75 | next := t.b.NextBackOff() 76 | if next == Stop { 77 | t.Stop() 78 | return nil 79 | } 80 | 81 | t.timer.Start(next) 82 | return t.timer.C() 83 | } 84 | -------------------------------------------------------------------------------- /ticker_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "testing" 7 | ) 8 | 9 | func TestTicker(t *testing.T) { 10 | const successOn = 3 11 | var i = 0 12 | 13 | // This function is successful on "successOn" calls. 14 | f := func() error { 15 | i++ 16 | log.Printf("function is called %d. time\n", i) 17 | 18 | if i == successOn { 19 | log.Println("OK") 20 | return nil 21 | } 22 | 23 | log.Println("error") 24 | return errors.New("error") 25 | } 26 | 27 | b := NewExponentialBackOff() 28 | ticker := NewTicker(b) 29 | ticker.timer = &testTimer{} 30 | 31 | var err error 32 | for range ticker.C { 33 | if err = f(); err != nil { 34 | t.Log(err) 35 | continue 36 | } 37 | 38 | break 39 | } 40 | if err != nil { 41 | t.Errorf("unexpected error: %s", err.Error()) 42 | } 43 | if i != successOn { 44 | t.Errorf("invalid number of retries: %d", i) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /timer.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import "time" 4 | 5 | type timer interface { 6 | Start(duration time.Duration) 7 | Stop() 8 | C() <-chan time.Time 9 | } 10 | 11 | // defaultTimer implements Timer interface using time.Timer 12 | type defaultTimer struct { 13 | timer *time.Timer 14 | } 15 | 16 | // C returns the timers channel which receives the current time when the timer fires. 17 | func (t *defaultTimer) C() <-chan time.Time { 18 | return t.timer.C 19 | } 20 | 21 | // Start starts the timer to fire after the given duration 22 | func (t *defaultTimer) Start(duration time.Duration) { 23 | if t.timer == nil { 24 | t.timer = time.NewTimer(duration) 25 | } else { 26 | t.timer.Reset(duration) 27 | } 28 | } 29 | 30 | // Stop is called when the timer is not used anymore and resources may be freed. 31 | func (t *defaultTimer) Stop() { 32 | if t.timer != nil { 33 | t.timer.Stop() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tries_test.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package backoff 4 | 5 | import ( 6 | "errors" 7 | "math/rand/v2" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestMaxTriesHappy(t *testing.T) { 13 | max := 17 + rand.IntN(13) 14 | bo := WithMaxRetries(&ZeroBackOff{}, uint64(max)) 15 | 16 | // Load up the tries count, but reset should clear the record 17 | for ix := 0; ix < max/2; ix++ { 18 | bo.NextBackOff() 19 | } 20 | bo.Reset() 21 | 22 | // Now fill the tries count all the way up 23 | for ix := 0; ix < max; ix++ { 24 | d := bo.NextBackOff() 25 | if d == Stop { 26 | t.Errorf("returned Stop on try %d", ix) 27 | } 28 | } 29 | 30 | // We have now called the BackOff max number of times, we expect 31 | // the next result to be Stop, even if we try it multiple times 32 | for ix := 0; ix < 7; ix++ { 33 | d := bo.NextBackOff() 34 | if d != Stop { 35 | t.Error("invalid next back off") 36 | } 37 | } 38 | 39 | // Reset makes it all work again 40 | bo.Reset() 41 | d := bo.NextBackOff() 42 | if d == Stop { 43 | t.Error("returned Stop after reset") 44 | } 45 | } 46 | 47 | // https://github.com/cenkalti/backoff/issues/80 48 | func TestMaxTriesZero(t *testing.T) { 49 | var called int 50 | 51 | b := WithMaxRetries(&ZeroBackOff{}, 0) 52 | 53 | err := Retry(func() error { 54 | called++ 55 | return errors.New("err") 56 | }, b) 57 | 58 | if err == nil { 59 | t.Errorf("error expected, nil found") 60 | } 61 | if called != 1 { 62 | t.Errorf("operation is called %d times", called) 63 | } 64 | } 65 | --------------------------------------------------------------------------------