From e83b4a86bf8f4b5d791f3f477bd2d4265743b5cf Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Fri, 3 Jul 2020 14:26:31 -0400 Subject: [PATCH] Initial commit --- .github/CONTRIBUTING.md | 12 ++ .github/workflows/lock.yml | 23 ++++ .github/workflows/stale.yml | 28 +++++ .github/workflows/test.yml | 35 ++++++ LICENSE | 202 ++++++++++++++++++++++++++++++++ Makefile | 45 ++++++++ README.md | 195 +++++++++++++++++++++++++++++++ backoff.go | 135 ++++++++++++++++++++++ backoff_constant.go | 28 +++++ backoff_constant_test.go | 126 ++++++++++++++++++++ backoff_exponential.go | 43 +++++++ backoff_exponential_test.go | 114 ++++++++++++++++++ backoff_fibonacci.go | 50 ++++++++ backoff_fibonacci_test.go | 126 ++++++++++++++++++++ backoff_test.go | 225 ++++++++++++++++++++++++++++++++++++ benchmark/benchmark_test.go | 55 +++++++++ benchmark/go.mod | 9 ++ benchmark/go.sum | 15 +++ go.mod | 3 + go.sum | 0 retry.go | 76 ++++++++++++ retry_test.go | 162 ++++++++++++++++++++++++++ tools/go.mod | 9 ++ tools/go.sum | 37 ++++++ tools/tools.go | 9 ++ 25 files changed, 1762 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/workflows/lock.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/test.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 backoff.go create mode 100644 backoff_constant.go create mode 100644 backoff_constant_test.go create mode 100644 backoff_exponential.go create mode 100644 backoff_exponential_test.go create mode 100644 backoff_fibonacci.go create mode 100644 backoff_fibonacci_test.go create mode 100644 backoff_test.go create mode 100644 benchmark/benchmark_test.go create mode 100644 benchmark/go.mod create mode 100644 benchmark/go.sum create mode 100644 go.mod create mode 100644 go.sum create mode 100644 retry.go create mode 100644 retry_test.go create mode 100644 tools/go.mod create mode 100644 tools/go.sum create mode 100644 tools/tools.go diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..e9e5808 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contributing + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 0000000..ee25d98 --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,23 @@ +name: Lock closed +on: + schedule: + - cron: '0 0 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: '${{ github.token }}' + issue-lock-inactive-days: 14 + issue-lock-comment: |- + This issue has been automatically locked since there has not been any + recent activity after it was closed. Please open a new issue for + related bugs. + + pr-lock-inactive-days: 14 + pr-lock-comment: |- + This pull request has been automatically locked since there has not + been any recent activity after it was closed. Please open a new + issue for related bugs. diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..9b9abd9 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,28 @@ +name: Close stale +on: + schedule: + - cron: '0 0 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + + stale-issue-message: |- + This issue is stale because it has been open for 14 days with no + activity. It will automatically close after 7 more days of inactivity. + stale-issue-label: 'kind/stale' + exempt-issue-labels: 'bug,enhancement' + + stale-pr-message: |- + This Pull Request is stale because it has been open for 14 days with + no activity. It will automatically close after 7 more days of + inactivity. + stale-pr-label: 'kind/stale' + exempt-pr-labels: 'bug,enhancement' + + days-before-stale: 14 + days-before-close: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..05e8e05 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-go@v2 + with: + go-version: '1.14' + + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Lint + run: make fmtcheck staticcheck spellcheck + + - name: Test + run: make test-acc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef759fd --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +VETTERS = "asmdecl,assign,atomic,bools,buildtag,cgocall,composites,copylocks,errorsas,httpresponse,loopclosure,lostcancel,nilfunc,printf,shift,stdmethods,structtag,tests,unmarshal,unreachable,unsafeptr,unusedresult" +GOFMT_FILES = $(shell go list -f '{{.Dir}}' ./...) + +fmtcheck: + @command -v goimports > /dev/null 2>&1 || (cd tools && go get golang.org/x/tools/cmd/goimports) + @CHANGES="$$(goimports -d $(GOFMT_FILES))"; \ + if [ -n "$${CHANGES}" ]; then \ + echo "Unformatted (run goimports -w .):\n\n$${CHANGES}\n\n"; \ + exit 1; \ + fi + @# Annoyingly, goimports does not support the simplify flag. + @CHANGES="$$(gofmt -s -d $(GOFMT_FILES))"; \ + if [ -n "$${CHANGES}" ]; then \ + echo "Unformatted (run gofmt -s -w .):\n\n$${CHANGES}\n\n"; \ + exit 1; \ + fi +.PHONY: fmtcheck + +spellcheck: + @command -v misspell > /dev/null 2>&1 || (cd tools && go get github.com/client9/misspell/cmd/misspell) + @misspell -locale="US" -error -source="text" **/* +.PHONY: spellcheck + +staticcheck: + @command -v staticcheck > /dev/null 2>&1 || (cd tools && go get honnef.co/go/tools/cmd/staticcheck) + @staticcheck -checks="all" -tests $(GOFMT_FILES) +.PHONY: staticcheck + +test: + @go test \ + -count=1 \ + -short \ + -timeout=5m \ + -vet="${VETTERS}" \ + ./... +.PHONY: test + +test-acc: + @go test \ + -count=1 \ + -race \ + -timeout=10m \ + -vet="${VETTERS}" \ + ./... +.PHONY: test-acc diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c2fefc --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Retry + +[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/sethvargo/go-retry) + +Retry is a Go library for facilitating retry logic and backoff. It's highly +extensible with full control over how and when retries occur. You can also write +your own custom backoff functions by implementing the Backoff interface. + +## Features + +- **Extensible** - Inspired by Go's built-in HTTP package, this Go backoff and + retry library is extensible via middleware. You can write custom backoff + functions or use a provided filter. + +- **Independent** - No external dependencies besides the Go standard library, + meaning it won't bloat your project. + +- **Concurrent** - Unless otherwise specified, everything is safe for concurrent + use. + +- **Context-aware** - Use native Go contexts to control cancellation. + +## Usage + +Here is an example use for connecting to a database using Go's `database/sql` +package: + +```golang +package main + +import ( + "context" + "database/sql" + "log" + "time" + + "github.com/sethvargo/go-retry" +) + +func main() { + db, err := sql.Open("mysql", "...") + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + err := retry.Fibonacci(ctx, 1*time.Second, func(ctx context.Context) error { + if err := db.PingContext(ctx); err != nil { + // This marks the error as retryable + return retry.RetryableError(err) + } + return nil + }) + if err != nil { + log.Fatal(err) + } +} +``` + +## Backoffs + +In addition to your own custom algorithms, there are built-in algorithms for +backoff in the library. + +### Constant + +A very rudimentary backoff, just returns a constant value. Here is an example: + +```text +1s -> 1s -> 1s -> 1s -> 1s -> 1s +``` + +Usage: + +```golang +NewConstant(1 * time.Second) +``` + +### Exponential + +Arguably the most common backoff, the next value is double the previous value. +Here is an example: + +```text +1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 64s +``` + +Usage: + +```golang +NewExponential(1 * time.Second) +``` + +### Fibonacci + +The Fibonacci backoff uses the Fibonacci sequence to calculate the backoff. The +next value is the sum of the current value and the previous value. This means +retires happen quickly at first, but then gradually take slower, ideal for +network-type issues. Here is an example: + +```text +1s -> 1s -> 2s -> 3s -> 5s -> 8s -> 13s +``` + +Usage: + +```golang +NewFibonacci(1 * time.Second) +``` + +## Modifies (Middleware) + +The built-in backoff algorithms never terminate and have no caps or limits - you +control their behavior with middleware. There's built-in middleware, but you can +also write custom middleware. + +### Jitter + +To reduce the changes of a thundering herd, add random jitter to the returned +value. + +```golang +b, err := NewFibonacci(1 * time.Second) +if err != nil { + // handle err +} + +// Return the next value, +/- 500ms +b = WithJitter(500*time.Millisecond, b) + +// Return the next value, +/- 5% of the result +b = WithJitterPercent(5, b) +``` + +### MaxRetries + +To terminate a retry, specify the maximum number of _retry_ attempts. Note this +is _retries_, not _attempts_. Attempts is retries - 1. + +```golang +b, err := NewFibonacci(1 * time.Second) +if err != nil { + // handle err +} + +// Stop when the 5th retry has failed. In this example, the worst case elapsed +// time would be 1s + 1s + 2s + 3s = 7s. +b = WithMaxRetires(4, b) +``` + +### CappedDuration + +To ensure an individual calculated duration never exceeds a value, use a cap: + +```golang +b, err := NewFibonacci(1 * time.Second) +if err != nil { + // handle err +} + +// Ensure the maximum value is 2s. In this example, the sleep values would be +// 1s, 1s, 2s, 2s, 2s, 2s... +b = WithCappedDuration(2 * time.Second, b) +``` + +### WithMaxDuration + +For a best-effort limit on the total execution time, specify a max duration: + +```golang +b, err := NewFibonacci(1 * time.Second) +if err != nil { + // handle err +} + +// Ensure the maximum total retry time is 5s. +b = WithCappedDuration(5 * time.Second, b) +``` + +## Benchmarks + +Here are benchmarks against some other popular Go backoff and retry libraries. +You can run these benchmarks yourself via the `benchmark/` folder. Commas and +spacing fixed for clarity. + +```text +Benchmark/cenkalti-7 13,052,668 87.3 ns/op +Benchmark/lestrrat-7 902,044 1,355 ns/op +Benchmark/sethvargo-7 203,914,245 5.73 ns/op +``` + +## Notes and Caveats + +- Randomization uses `math/rand` seeded with the Unix timestamp instead of + `crypto/rand`. diff --git a/backoff.go b/backoff.go new file mode 100644 index 0000000..ffb29b3 --- /dev/null +++ b/backoff.go @@ -0,0 +1,135 @@ +package retry + +import ( + "math/rand" + "sync" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// Backoff is an interface that backs off. +type Backoff interface { + // Next returns the time duration to wait and whether to stop. + Next() (next time.Duration, stop bool) +} + +var _ Backoff = (BackoffFunc)(nil) + +// BackoffFunc is a backoff expressed as a function. +type BackoffFunc func() (time.Duration, bool) + +// Next implements Backoff. +func (b BackoffFunc) Next() (time.Duration, bool) { + return b() +} + +// WithJitter wraps a backoff function and adds the specified jitter. j can be +// interpreted as "+/- j". For example, if j were 5 seconds and the backoff +// returned 20s, the value could be between 15 and 25 seconds. The value can +// never be less than 0. +func WithJitter(j time.Duration, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + diff := time.Duration(rand.Int63n(int64(j)*2) - int64(j)) + val = val + diff + if val < 0 { + val = 0 + } + return val, false + }) +} + +// WithJitterPercent wraps a backoff function and adds the specified jitter +// percentage. j can be interpreted as "+/- j%". For example, if j were 5 and +// the backoff returned 20s, the value could be between 19 and 21 seconds. The +// value can never be less than 0 or greater than 100. +func WithJitterPercent(j uint64, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + // Get a value between -j and j, the convert to a percentage + top := rand.Int63n(int64(j)*2) - int64(j) + pct := 1 - float64(top)/100.0 + + val = time.Duration(float64(val) * pct) + if val < 0 { + val = 0 + } + return val, false + }) +} + +// WithMaxRetries executes the backoff function up until the maximum attempts. +func WithMaxRetries(max uint64, next Backoff) Backoff { + var l sync.Mutex + var attempt uint64 + + return BackoffFunc(func() (time.Duration, bool) { + l.Lock() + defer l.Unlock() + + if attempt >= max { + return 0, true + } + attempt++ + + val, stop := next.Next() + if stop { + return 0, true + } + + return val, false + }) +} + +// WithCappedDuration sets a maximum on the duration returned from the next +// backoff. This is NOT a total backoff time, but rather a cap on the maximum +// value a backoff can return. Without another middleware, the backoff will +// continue infinitely. +func WithCappedDuration(cap time.Duration, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + if val > cap { + val = cap + } + return val, false + }) +} + +// WithMaxDuration sets a maximum on the total amount of time a backoff should +// execute. It's best-effort, and should not be used to guarantee an exact +// amount of time. +func WithMaxDuration(timeout time.Duration, next Backoff) Backoff { + start := time.Now() + + return BackoffFunc(func() (time.Duration, bool) { + diff := timeout - time.Since(start) + if diff <= 0 { + return 0, true + } + + val, stop := next.Next() + if stop { + return 0, true + } + + if val > diff { + val = diff + } + return val, false + }) +} diff --git a/backoff_constant.go b/backoff_constant.go new file mode 100644 index 0000000..43dec40 --- /dev/null +++ b/backoff_constant.go @@ -0,0 +1,28 @@ +package retry + +import ( + "context" + "fmt" + "time" +) + +// Constant is a wrapper around Retry that uses a constant backoff. +func Constant(ctx context.Context, t time.Duration, f RetryFunc) error { + b, err := NewConstant(t) + if err != nil { + return err + } + return Do(ctx, b, f) +} + +// NewConstant creates a new constant backoff using the value t. The wait time +// is the provided constant value. +func NewConstant(t time.Duration) (Backoff, error) { + if t <= 0 { + return nil, fmt.Errorf("t must be greater than 0") + } + + return BackoffFunc(func() (time.Duration, bool) { + return t, false + }), nil +} diff --git a/backoff_constant_test.go b/backoff_constant_test.go new file mode 100644 index 0000000..c6e445e --- /dev/null +++ b/backoff_constant_test.go @@ -0,0 +1,126 @@ +package retry + +import ( + "fmt" + "reflect" + "sort" + "testing" + "time" +) + +func TestConstantBackoff(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + base time.Duration + tries int + exp []time.Duration + err bool + }{ + { + name: "zero", + err: true, + }, + { + name: "single", + base: 1 * time.Nanosecond, + tries: 1, + exp: []time.Duration{ + 1 * time.Nanosecond, + }, + }, + { + name: "max", + base: 10 * time.Millisecond, + tries: 5, + exp: []time.Duration{ + 10 * time.Millisecond, + 10 * time.Millisecond, + 10 * time.Millisecond, + 10 * time.Millisecond, + 10 * time.Millisecond, + }, + }, + { + name: "many", + base: 1 * time.Nanosecond, + tries: 14, + exp: []time.Duration{ + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + 1 * time.Nanosecond, + }, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + b, err := NewConstant(tc.base) + if (err != nil) != tc.err { + t.Fatal(err) + } + if b == nil { + return + } + + resultsCh := make(chan time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + go func() { + r, _ := b.Next() + resultsCh <- r + }() + } + + results := make([]time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + select { + case val := <-resultsCh: + results[i] = val + case <-time.After(5 * time.Second): + t.Fatal("timeout") + } + } + sort.Slice(results, func(i, j int) bool { + return results[i] < results[j] + }) + + if !reflect.DeepEqual(results, tc.exp) { + t.Errorf("expected \n\n%v\n\n to be \n\n%v\n\n", results, tc.exp) + } + }) + } +} + +func ExampleNewConstant() { + b, err := NewConstant(1 * time.Second) + if err != nil { + // handle err + } + + for i := 0; i < 5; i++ { + val, _ := b.Next() + fmt.Printf("%v\n", val) + } + // Output: + // 1s + // 1s + // 1s + // 1s + // 1s +} diff --git a/backoff_exponential.go b/backoff_exponential.go new file mode 100644 index 0000000..282b475 --- /dev/null +++ b/backoff_exponential.go @@ -0,0 +1,43 @@ +package retry + +import ( + "context" + "fmt" + "sync/atomic" + "time" +) + +type exponentialBackoff struct { + base time.Duration + attempt uint64 +} + +// Exponential is a wrapper around Retry that uses an exponential backoff. It's +// very efficient, but does not check for overflow, so ensure you bound the +// retry. +func Exponential(ctx context.Context, base time.Duration, f RetryFunc) error { + b, err := NewExponential(base) + if err != nil { + return err + } + return Do(ctx, b, f) +} + +// NewExponential creates a new exponential backoff using the starting value of +// base and doubling on each failure (1, 2, 4, 8, 16, 32, 64...), up to max. +// It's very efficient, but does not check for overflow, so ensure you bound the +// retry. +func NewExponential(base time.Duration) (Backoff, error) { + if base <= 0 { + return nil, fmt.Errorf("base must be greater than 0") + } + + return &exponentialBackoff{ + base: base, + }, nil +} + +// Next implements Backoff. It is safe for concurrent use. +func (b *exponentialBackoff) Next() (time.Duration, bool) { + return b.base << (atomic.AddUint64(&b.attempt, 1) - 1), false +} diff --git a/backoff_exponential_test.go b/backoff_exponential_test.go new file mode 100644 index 0000000..7e31e5b --- /dev/null +++ b/backoff_exponential_test.go @@ -0,0 +1,114 @@ +package retry + +import ( + "fmt" + "reflect" + "sort" + "testing" + "time" +) + +func TestExponentialBackoff(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + base time.Duration + tries int + exp []time.Duration + err bool + }{ + { + name: "zero", + err: true, + }, + { + name: "single", + base: 1 * time.Nanosecond, + tries: 1, + exp: []time.Duration{ + 1 * time.Nanosecond, + }, + }, + { + name: "many", + base: 1 * time.Nanosecond, + tries: 14, + exp: []time.Duration{ + 1 * time.Nanosecond, + 2 * time.Nanosecond, + 4 * time.Nanosecond, + 8 * time.Nanosecond, + 16 * time.Nanosecond, + 32 * time.Nanosecond, + 64 * time.Nanosecond, + 128 * time.Nanosecond, + 256 * time.Nanosecond, + 512 * time.Nanosecond, + 1024 * time.Nanosecond, + 2048 * time.Nanosecond, + 4096 * time.Nanosecond, + 8192 * time.Nanosecond, + }, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + b, err := NewExponential(tc.base) + if (err != nil) != tc.err { + t.Fatal(err) + } + if b == nil { + return + } + + resultsCh := make(chan time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + go func() { + r, _ := b.Next() + resultsCh <- r + }() + } + + results := make([]time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + select { + case val := <-resultsCh: + results[i] = val + case <-time.After(5 * time.Second): + t.Fatal("timeout") + } + } + sort.Slice(results, func(i, j int) bool { + return results[i] < results[j] + }) + + if !reflect.DeepEqual(results, tc.exp) { + t.Errorf("expected \n\n%v\n\n to be \n\n%v\n\n", results, tc.exp) + } + }) + } +} + +func ExampleNewExponential() { + b, err := NewExponential(1 * time.Second) + if err != nil { + // handle err + } + + for i := 0; i < 5; i++ { + val, _ := b.Next() + fmt.Printf("%v\n", val) + } + // Output: + // 1s + // 2s + // 4s + // 8s + // 16s +} diff --git a/backoff_fibonacci.go b/backoff_fibonacci.go new file mode 100644 index 0000000..be14825 --- /dev/null +++ b/backoff_fibonacci.go @@ -0,0 +1,50 @@ +package retry + +import ( + "context" + "fmt" + "sync/atomic" + "time" + "unsafe" +) + +type state [2]time.Duration + +type fibonacciBackoff struct { + state unsafe.Pointer +} + +// Fibonacci is a wrapper around Retry that uses a Fibonacci backoff. +func Fibonacci(ctx context.Context, base time.Duration, f RetryFunc) error { + b, err := NewFibonacci(base) + if err != nil { + return err + } + return Do(ctx, b, f) +} + +// NewFibonacci creates a new Fibonacci backoff using the starting value of +// base. The wait time is the sum of the previous two wait times on each failed +// attempt (1, 1, 2, 3, 5, 8, 13...). +func NewFibonacci(base time.Duration) (Backoff, error) { + if base <= 0 { + return nil, fmt.Errorf("base must be greater than 0") + } + + return &fibonacciBackoff{ + state: unsafe.Pointer(&state{0, base}), + }, nil +} + +// Next implements Backoff. It is safe for concurrent use. +func (b *fibonacciBackoff) Next() (time.Duration, bool) { + for { + curr := atomic.LoadPointer(&b.state) + currState := (*state)(curr) + next := currState[0] + currState[1] + + if atomic.CompareAndSwapPointer(&b.state, curr, unsafe.Pointer(&state{currState[1], next})) { + return next, false + } + } +} diff --git a/backoff_fibonacci_test.go b/backoff_fibonacci_test.go new file mode 100644 index 0000000..30144db --- /dev/null +++ b/backoff_fibonacci_test.go @@ -0,0 +1,126 @@ +package retry + +import ( + "fmt" + "reflect" + "sort" + "testing" + "time" +) + +func TestFibonacciBackoff(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + base time.Duration + tries int + exp []time.Duration + err bool + }{ + { + name: "zero", + err: true, + }, + { + name: "single", + base: 1 * time.Nanosecond, + tries: 1, + exp: []time.Duration{ + 1 * time.Nanosecond, + }, + }, + { + name: "max", + base: 10 * time.Millisecond, + tries: 5, + exp: []time.Duration{ + 10 * time.Millisecond, + 20 * time.Millisecond, + 30 * time.Millisecond, + 50 * time.Millisecond, + 80 * time.Millisecond, + }, + }, + { + name: "many", + base: 1 * time.Nanosecond, + tries: 14, + exp: []time.Duration{ + 1 * time.Nanosecond, + 2 * time.Nanosecond, + 3 * time.Nanosecond, + 5 * time.Nanosecond, + 8 * time.Nanosecond, + 13 * time.Nanosecond, + 21 * time.Nanosecond, + 34 * time.Nanosecond, + 55 * time.Nanosecond, + 89 * time.Nanosecond, + 144 * time.Nanosecond, + 233 * time.Nanosecond, + 377 * time.Nanosecond, + 610 * time.Nanosecond, + }, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + b, err := NewFibonacci(tc.base) + if (err != nil) != tc.err { + t.Fatal(err) + } + if b == nil { + return + } + + resultsCh := make(chan time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + go func() { + r, _ := b.Next() + resultsCh <- r + }() + } + + results := make([]time.Duration, tc.tries) + for i := 0; i < tc.tries; i++ { + select { + case val := <-resultsCh: + results[i] = val + case <-time.After(5 * time.Second): + t.Fatal("timeout") + } + } + sort.Slice(results, func(i, j int) bool { + return results[i] < results[j] + }) + + if !reflect.DeepEqual(results, tc.exp) { + t.Errorf("expected \n\n%v\n\n to be \n\n%v\n\n", results, tc.exp) + } + }) + } +} + +func ExampleNewFibonacci() { + b, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + for i := 0; i < 5; i++ { + val, _ := b.Next() + fmt.Printf("%v\n", val) + } + // Output: + // 1s + // 2s + // 3s + // 5s + // 8s +} diff --git a/backoff_test.go b/backoff_test.go new file mode 100644 index 0000000..13decdb --- /dev/null +++ b/backoff_test.go @@ -0,0 +1,225 @@ +package retry + +import ( + "context" + "testing" + "time" +) + +func ExampleBackoffFunc() { + // Example backoff middleware that adds the provided duration t to the result. + withShift := func(t time.Duration, next Backoff) BackoffFunc { + return func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + return val + t, false + } + } + + // Middlewrap wrap another backoff: + b, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle error + } + + ctx := context.Background() + if err := Do(ctx, withShift(5*time.Second, b), func(ctx context.Context) error { + // Actual retry logic here + return nil + }); err != nil { + // handle error + } +} + +func TestWithJitter(t *testing.T) { + t.Parallel() + + for i := 0; i < 100_000; i++ { + b := WithJitter(250*time.Millisecond, BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Second, false + })) + val, stop := b.Next() + if stop { + t.Errorf("should not stop") + } + + if min, max := 750*time.Millisecond, 1250*time.Millisecond; val < min || val > max { + t.Errorf("expected %v to be between %v and %v", val, min, max) + } + } +} + +func ExampleWithJitter() { + ctx := context.Background() + fib, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + err = Do(ctx, WithJitter(1*time.Second, fib), func(_ context.Context) error { + // TODO: logic here + return nil + }) + _ = err +} + +func TestWithJitterPercent(t *testing.T) { + t.Parallel() + + for i := 0; i < 100_000; i++ { + b := WithJitterPercent(5, BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Second, false + })) + val, stop := b.Next() + if stop { + t.Errorf("should not stop") + } + + if min, max := 950*time.Millisecond, 1050*time.Millisecond; val < min || val > max { + t.Errorf("expected %v to be between %v and %v", val, min, max) + } + } +} + +func ExampleWithJitterPercent() { + ctx := context.Background() + fib, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + err = Do(ctx, WithJitterPercent(5, fib), func(_ context.Context) error { + // TODO: logic here + return nil + }) + _ = err +} + +func TestWithMaxRetries(t *testing.T) { + t.Parallel() + + b := WithMaxRetries(3, BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Second, false + })) + + // First 3 attempts succeed + for i := 0; i < 3; i++ { + val, stop := b.Next() + if stop { + t.Errorf("should not stop") + } + if val != 1*time.Second { + t.Errorf("expected %v to be %v", val, 1*time.Second) + } + } + + // Now we stop + val, stop := b.Next() + if !stop { + t.Errorf("should stop") + } + if val != 0 { + t.Errorf("expected %v to be %v", val, 0) + } +} + +func ExampleWithMaxRetries() { + ctx := context.Background() + fib, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + err = Do(ctx, WithMaxRetries(3, fib), func(_ context.Context) error { + // TODO: logic here + return nil + }) + _ = err +} + +func TestWithCappedDuration(t *testing.T) { + t.Parallel() + + b := WithCappedDuration(3*time.Second, BackoffFunc(func() (time.Duration, bool) { + return 5 * time.Second, false + })) + + val, stop := b.Next() + if stop { + t.Errorf("should not stop") + } + if val != 3*time.Second { + t.Errorf("expected %v to be %v", val, 3*time.Second) + } +} + +func ExampleWithCappedDuration() { + ctx := context.Background() + fib, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + err = Do(ctx, WithCappedDuration(3*time.Second, fib), func(_ context.Context) error { + // TODO: logic here + return nil + }) + _ = err +} + +func TestWithMaxDuration(t *testing.T) { + t.Parallel() + + b := WithMaxDuration(250*time.Millisecond, BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Second, false + })) + + // Take once, within timeout. + val, stop := b.Next() + if stop { + t.Error("should not stop") + } + + if val > 250*time.Millisecond { + t.Errorf("expected %v to be less than %v", val, 250*time.Millisecond) + } + + time.Sleep(200 * time.Millisecond) + + // Take again, remainder contines + val, stop = b.Next() + if stop { + t.Error("should not stop") + } + + if val > 50*time.Millisecond { + t.Errorf("expected %v to be less than %v", val, 50*time.Millisecond) + } + + time.Sleep(50 * time.Millisecond) + + // Now we stop + val, stop = b.Next() + if !stop { + t.Errorf("should stop") + } + if val != 0 { + t.Errorf("expected %v to be %v", val, 0) + } +} + +func ExampleWithMaxDuration() { + ctx := context.Background() + fib, err := NewFibonacci(1 * time.Second) + if err != nil { + // handle err + } + + err = Do(ctx, WithMaxDuration(5*time.Second, fib), func(_ context.Context) error { + // TODO: logic here + return nil + }) + _ = err +} diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go new file mode 100644 index 0000000..ec3379e --- /dev/null +++ b/benchmark/benchmark_test.go @@ -0,0 +1,55 @@ +package benchmark + +import ( + "context" + "math" + "testing" + "time" + + cenkalti "github.com/cenkalti/backoff" + lestrrat "github.com/lestrrat-go/backoff" + sethvargo "github.com/sethvargo/go-retry" +) + +func Benchmark(b *testing.B) { + b.Run("cenkalti", func(b *testing.B) { + backoff := cenkalti.NewExponentialBackOff() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + backoff.NextBackOff() + } + }) + + b.Run("lestrrat", func(b *testing.B) { + policy := lestrrat.NewExponential( + lestrrat.WithFactor(0), + lestrrat.WithInterval(0), + lestrrat.WithJitterFactor(0), + lestrrat.WithMaxRetries(math.MaxInt64), + ) + backoff, cancel := policy.Start(context.Background()) + defer cancel() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + select { + case <-backoff.Done(): + b.Fatalf("ended early") + case <-backoff.Next(): + } + } + }) + + b.Run("sethvargo", func(b *testing.B) { + backoff, err := sethvargo.NewExponential(1 * time.Second) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + + for i := 0; i < b.N; i++ { + backoff.Next() + } + }) +} diff --git a/benchmark/go.mod b/benchmark/go.mod new file mode 100644 index 0000000..6eb1ee6 --- /dev/null +++ b/benchmark/go.mod @@ -0,0 +1,9 @@ +module github.com/sethvargo/go-retry/benchmark + +go 1.14 + +require ( + github.com/cenkalti/backoff v2.2.1+incompatible + github.com/lestrrat-go/backoff v1.0.0 + github.com/sethvargo/go-retry v0.0.0-20200703211810-8390cace92d3 +) diff --git a/benchmark/go.sum b/benchmark/go.sum new file mode 100644 index 0000000..b5e194c --- /dev/null +++ b/benchmark/go.sum @@ -0,0 +1,15 @@ +github.com/cenkalti/backoff v1.1.0 h1:QnvVp8ikKCDWOsFheytRCoYWYPO/ObCTBGxT19Hc+yE= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lestrrat-go/backoff v1.0.0 h1:nR+UgAhdhwfw2i+xznuHRlj81oMYa7u3lXun0xcsXUU= +github.com/lestrrat-go/backoff v1.0.0/go.mod h1:c7OnDlnHsFXbH1vyIS8+txH+THcc+QFlSQTrJVe4EIM= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sethvargo/go-retry v0.0.0-20200703211810-8390cace92d3 h1:h/E1woky2/x1yuR7+xtNhNUmGGD5tbNna37QsNvlhy4= +github.com/sethvargo/go-retry v0.0.0-20200703211810-8390cace92d3/go.mod h1:JzIOdZqQDNpPkQDmcqgtteAcxFLtYpNF/zJCM1ysDg8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fff5a4a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/sethvargo/go-retry + +go 1.14 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/retry.go b/retry.go new file mode 100644 index 0000000..ad3beb4 --- /dev/null +++ b/retry.go @@ -0,0 +1,76 @@ +// Package retry provides helpers for retrying. +// +// This package defines flexible interfaces for retrying Go functions that may +// be flakey or eventually consistent. It abstracts the "backoff" (how long to +// wait between tries) and "retry" (execute the function again) mechanisms for +// maximum flexibility. Furthermore, everything is an interface, so you can +// define your own implementations. +// +// The package is modeled after Go's built-in HTTP package, making it easy to +// customize the built-in backoff with your own custom logic. Additionally, +// callers specify which errors are retryable by wrapping them. This is helpful +// with complex operations where only certain results should retry. +package retry + +import ( + "context" + "errors" + "time" +) + +// RetryFunc is a function passed to retry. +type RetryFunc func(ctx context.Context) error + +type retryableError struct { + err error +} + +// RetryableError marks an error as retryable. +func RetryableError(err error) error { + if err == nil { + return nil + } + return &retryableError{err} +} + +// Unwrap implements error wrapping. +func (e *retryableError) Unwrap() error { + return e.err +} + +// Error returns the error string. +func (e *retryableError) Error() string { + if e.err == nil { + return "retryable: " + } + return "retryable: " + e.err.Error() +} + +// Do wraps a function with a backoff to retry. The provided context is the same +// context passed to the RetryFunc. +func Do(ctx context.Context, b Backoff, f RetryFunc) error { + for { + err := f(ctx) + if err == nil { + return nil + } + + // Not retryable + var rerr *retryableError + if !errors.As(err, &rerr) { + return err + } + + next, stop := b.Next() + if stop { + return err + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(next): + continue + } + } +} diff --git a/retry_test.go b/retry_test.go new file mode 100644 index 0000000..556ea94 --- /dev/null +++ b/retry_test.go @@ -0,0 +1,162 @@ +package retry_test + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/sethvargo/go-retry" +) + +func TestRetryableError(t *testing.T) { + t.Parallel() + + err := retry.RetryableError(fmt.Errorf("oops")) + if got, want := err.Error(), "retryable: "; !strings.Contains(got, want) { + t.Errorf("expected %v to contain %v", got, want) + } +} + +func TestDo(t *testing.T) { + t.Parallel() + + t.Run("exit_on_max_attempt", func(t *testing.T) { + t.Parallel() + + b := retry.BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Nanosecond, false + }) + + ctx := context.Background() + var i int + err := retry.Do(ctx, retry.WithMaxRetries(3, b), func(_ context.Context) error { + i++ + return retry.RetryableError(fmt.Errorf("oops")) + }) + if err == nil { + t.Fatal("expected err") + } + + // 1 + retries + if got, want := i, 4; got != want { + t.Errorf("expected %v to be %v", got, want) + } + }) + + t.Run("exit_on_non_retryable", func(t *testing.T) { + t.Parallel() + + b := retry.BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Nanosecond, false + }) + + ctx := context.Background() + var i int + err := retry.Do(ctx, retry.WithMaxRetries(3, b), func(_ context.Context) error { + i++ + return fmt.Errorf("oops") // not retryable + }) + if err == nil { + t.Fatal("expected err") + } + + if got, want := i, 1; got != want { + t.Errorf("expected %v to be %v", got, want) + } + }) + + t.Run("exit_no_error", func(t *testing.T) { + t.Parallel() + + b := retry.BackoffFunc(func() (time.Duration, bool) { + return 1 * time.Nanosecond, false + }) + + ctx := context.Background() + var i int + err := retry.Do(ctx, retry.WithMaxRetries(3, b), func(_ context.Context) error { + i++ + return nil // no error + }) + if err != nil { + t.Fatal("expected no err") + } + + if got, want := i, 1; got != want { + t.Errorf("expected %v to be %v", got, want) + } + }) + + t.Run("context_canceled", func(t *testing.T) { + t.Parallel() + + b := retry.BackoffFunc(func() (time.Duration, bool) { + return 5 * time.Second, false + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := retry.Do(ctx, b, func(_ context.Context) error { + return retry.RetryableError(fmt.Errorf("oops")) // no error + }) + if err != context.DeadlineExceeded { + t.Errorf("expected %v to be %v", err, context.DeadlineExceeded) + } + }) +} + +func ExampleDo_simple() { + ctx := context.Background() + b, err := retry.NewFibonacci(1 * time.Nanosecond) + if err != nil { + // handle error + } + + i := 0 + if err := retry.Do(ctx, retry.WithMaxRetries(3, b), func(ctx context.Context) error { + fmt.Printf("%d\n", i) + i++ + return retry.RetryableError(fmt.Errorf("oops")) + }); err != nil { + // handle error + } + + // Output: + // 0 + // 1 + // 2 + // 3 +} + +func ExampleDo_customRetry() { + ctx := context.Background() + b, err := retry.NewFibonacci(1 * time.Nanosecond) + if err != nil { + // handle error + } + + // This example demonstrates selectively retrying specific errors. Only errors + // wrapped with RetryableError are eligible to be retried. + if err := retry.Do(ctx, retry.WithMaxRetries(3, b), func(ctx context.Context) error { + resp, err := http.Get("https://google.com/") + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode / 100 { + case 4: + return fmt.Errorf("bad response: %v", resp.StatusCode) + case 5: + return retry.RetryableError(fmt.Errorf("bad response: %v", resp.StatusCode)) + default: + return nil + } + }); err != nil { + // handle error + } +} diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 0000000..e49f2db --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,9 @@ +module github.com/sethvargo/go-retry/tools + +go 1.14 + +require ( + github.com/client9/misspell v0.3.4 + golang.org/x/tools v0.0.0-20200702044944-0cc1aa72b347 + honnef.co/go/tools v0.0.1-2020.1.4 +) diff --git a/tools/go.sum b/tools/go.sum new file mode 100644 index 0000000..787ef0c --- /dev/null +++ b/tools/go.sum @@ -0,0 +1,37 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200702044944-0cc1aa72b347 h1:/e4fNMHdLn7SQSxTrRZTma2xjQW6ELdxcnpqMhpo9X4= +golang.org/x/tools v0.0.0-20200702044944-0cc1aa72b347/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 0000000..f0dbd47 --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,9 @@ +// +build tools + +package tools + +import ( + _ "github.com/client9/misspell/cmd/misspell" + _ "golang.org/x/tools/cmd/goimports" + _ "honnef.co/go/tools/cmd/staticcheck" +)