diff --git a/README.md b/README.md index 2aeb8ca..fc118a2 100644 --- a/README.md +++ b/README.md @@ -5,85 +5,37 @@ Builds off of the wonderful work of https://github.com/sethvargo/go-retry but adds additional functionality: TODO: -- update godoc to my version -- update this documentation with changes +- add benchmarks of mine vs sethvargo (benchmark_test.go), and update the benchmarks section of the readme +- ensure godoc up to date -## Added features +## Synopsis -### infinite retries until error -Repeat will continually do whatever is in the RepeatFunc until it returns an error. - -It does not observe RetryableError, as there is no need for this functionality. - -### backoff reset -You might want to reset a backoff of non constant duration (eg if an activity happens that -says you should poll faster). - -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. +Retry is a Go library for facilitating retry logic and backoff strategies. It builds off the work of [go-retry](https://github.com/sethvargo/go-retry) and adds additional functionality, such as infinite retries until an error occurs and the ability to reset backoff durations. The library is highly extensible, allowing you to implement custom backoff strategies and integrate them seamlessly into your applications. ## 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: +- **Infinite Retries Until Error** - Repeat will continually do whatever is in the RepeatFunc until it returns an error. It does not observe RetryableError, as there is no need for this functionality. -```golang -package main +- **Backoff Reset** - You might want to reset a backoff of non-constant duration (e.g., if something happens that says you should poll faster, like a worker having a job available). -import ( - "context" - "database/sql" - "log" - "time" +- **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. - "github.com/swayne275/go-retry" -) +- **Independent** - No external dependencies besides the Go standard library, meaning it won't bloat your project. -func main() { - db, err := sql.Open("mysql", "...") - if err != nil { - log.Fatal(err) - } - - ctx := context.Background() - if 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 - }); err != nil { - log.Fatal(err) - } -} -``` +- **Concurrent** - Unless otherwise specified, everything is safe for concurrent use. -## Backoffs +### Backoff Strategies In addition to your own custom algorithms, there are built-in algorithms for backoff in the library. -### Constant +#### Constant Backoff +Retries at a constant interval. -A very rudimentary backoff, just returns a constant value. Here is an example: +Example: ```text -1s -> 1s -> 1s -> 1s -> 1s -> 1s +1s -> 1s -> 1s -> 1s -> 1s -> 1s -> 1s ``` Usage: @@ -92,10 +44,12 @@ Usage: NewConstant(1 * time.Second) ``` -### Exponential +#### Exponential Backoff +Retries with exponentially increasing intervals. Arguably the most common backoff, the next value is double the previous value. -Here is an example: + +Example: ```text 1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 64s @@ -107,15 +61,16 @@ Usage: NewExponential(1 * time.Second) ``` -### Fibonacci +#### Fibonacci Backoff +Retries with intervals following the Fibonacci sequence. + +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. -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: +Example: ```text -1s -> 1s -> 2s -> 3s -> 5s -> 8s -> 13s +1s -> 2s -> 3s -> 5s -> 8s -> 13s ``` Usage: @@ -124,61 +79,224 @@ Usage: NewFibonacci(1 * time.Second) ``` -## Modifiers (Middleware) +### Modifiers (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 +#### Jitter +Adds randomness to the backoff intervals to prevent thundering herd problems. -To reduce the changes of a thundering herd, add random jitter to the returned +To reduce the chances of a thundering herd, add random jitter to the returned value. ```golang -b, err := NewFibonacci(1 * time.Second) +backoff, err := NewFibonacci(1 * time.Second) // Return the next value, +/- 500ms -b, err = WithJitter(500*time.Millisecond, b) +backoffWithJitter, err := WithJitter(500*time.Millisecond, backoff) // Return the next value, +/- 5% of the result -b, err = WithJitterPercent(5, b) +backoffWithJitterPercent, err := WithJitterPercent(5, backoff) +``` + +#### Capped Duration +Limits the maximum duration between retries. + +To ensure an individual calculated duration never exceeds a value, use a cap: + +```golang +backoff, err := NewFibonacci(1 * time.Second) + +// Ensure the maximum value is 2s. In this example, the sleep values would be +// 1s, 1s, 2s, 2s, 2s, 2s... +backoffWithCap := WithCappedDuration(2 * time.Second, backoff) +``` + +#### Max Duration +Limits the maximum total time a backoff should execute. + +For a best-effort limit on the total execution time, specify a max duration: + +```golang +backoff, err := NewFibonacci(1 * time.Second) + +// Ensure the maximum total retry time is 5s. +backoffWithMaxDuration = WithMaxDuration(5 * time.Second, backoff) ``` -### MaxRetries +#### Max Retries +Limits the number of retry attempts. To terminate a retry, specify the maximum number of _retries_. Note this is _retries_, not _attempts_. Attempts is retries + 1. ```golang -b, err := NewFibonacci(1 * time.Second) +backoff, err := NewFibonacci(1 * time.Second) // Stop after 4 retries, when the 5th attempt has failed. In this example, the worst case elapsed // time would be 1s + 1s + 2s + 3s = 7s. -b = WithMaxRetries(4, b) +backoffWithMaxRetries = WithMaxRetries(4, backoff) ``` -### CappedDuration +#### Context-Aware Backoff +Stops the backoff if the provided context is Done. -To ensure an individual calculated duration never exceeds a value, use a cap: +```golang +backoff, err := NewFibonacci(1 * time.Second) +ctx, cancel := context.WithTimeout(context.Background, 1 * time.Millisecond) + +// backoff will return stop == true when context is cancelled +backoffWithContext := WithContext(ctx, backoff) +``` + +## Installation + +To install the library, use the following command: + +```sh +go get github.com/swayne275/go-retry +``` + +## Usage + +### Basic Retry + +This will retry the provided function until it either succeeds or returns a non-retryable error. ```golang -b, err := NewFibonacci(1 * time.Second) +package main -// 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) +import ( + "context" + "fmt" + "time" + + "github.com/swayne275/go-retry" + "github.com/swayne275/go-retry/backoff" +) + +func main() { + ctx := context.Background() + backoff := backoff.NewConstant(1 * time.Second) + + err := retry.Do(ctx, backoff, func(ctx context.Context) error { + // Your retryable function logic here + return nil + }) + + if err != nil { + fmt.Printf("Operation failed: %v\n", err) + } +} ``` -### WithMaxDuration +### Infinite Repeat Until Non Retryable Error -For a best-effort limit on the total execution time, specify a max duration: +This will repeat the function until it returns a non-retryable error. ```golang -b, err := NewFibonacci(1 * time.Second) +package main -// Ensure the maximum total retry time is 5s. -b = WithMaxDuration(5 * time.Second, b) +import ( + "context" + "fmt" + + "github.com/swayne275/go-retry/repeat" +) + +func main() { + ctx := context.Background() + backoff := backoff.NewExponential(1 * time.Second) + + err := repeat.Do(ctx, backoff, func(ctx context.Context) bool { + // Your function logic here - return false to stop repeating + return true + }) + + if err != nil { + // you can check why the repeat stopped with errors.Is() and the defined + // types in the repeat package + fmt.Printf("Operation failed: %v\n", err) + } +} +``` + +### Backoff Reset + +```golang +package main + +import ( + "context" + "fmt" + "time" + + "github.com/swayne275/go-retry" + "github.com/swayne275/go-retry/backoff" +) + +func main() { + ctx := context.Background() + backoff := backoff.NewExponential(1 * time.Second) + resetFunc := func() Backoff { + // define how the backoff shoudl be reset, likely something like: + newB, err := NewExponential(base) + if err != nil { + t.Fatalf("failed to reset exponential backoff: %v", err) + } + + return newB + } + backoffWithReset = backoff.WithReset(resetFunc, backoff) + + err := retry.Do(ctx, backoffWithReset, func(ctx context.Context) error { + // Your retryable function logic here + + // something happens that makes you want to reset back to a shorter backoff + b.Reset() + + return nil + }) + + if err != nil { + fmt.Printf("Operation failed: %v\n", err) + } +} +``` + +### Real World Example: Connecting to a SQL Database + +```golang +package main + +import ( + "context" + "database/sql" + "log" + "time" + + "github.com/swayne275/go-retry" +) + +func main() { + db, err := sql.Open("mysql", "...") + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + if err := retry.FibonacciRetry(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 + }); err != nil { + log.Fatal(err) + } +} ``` ## Benchmarks diff --git a/backoff/backoff.go b/backoff/backoff.go index 87e035d..995ada7 100644 --- a/backoff/backoff.go +++ b/backoff/backoff.go @@ -9,8 +9,6 @@ import ( "github.com/swayne275/go-retry/internal/random" ) -// TODO clean up interface, struct, etc - // Backoff is an interface that backs off. type Backoff interface { // Next returns the time duration to wait and whether to stop. diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go index a8a7a69..ed73c0e 100644 --- a/benchmark/benchmark_test.go +++ b/benchmark/benchmark_test.go @@ -11,8 +11,6 @@ import ( sethvargo "github.com/sethvargo/go-retry" ) -// TODO add benchmarks of mine vs sethvargo - func Benchmark(b *testing.B) { b.Run("cenkalti", func(b *testing.B) { backoff := cenkalti.NewExponentialBackOff() diff --git a/repeat/repeat.go b/repeat/repeat.go index 1419061..c06905c 100644 --- a/repeat/repeat.go +++ b/repeat/repeat.go @@ -8,8 +8,8 @@ import ( "github.com/swayne275/go-retry/backoff" ) -var errFunctionSignaledToStop = fmt.Errorf("function signaled to stop") -var errBackoffSignaledToStop = fmt.Errorf("backoff signaled to stop") +var ErrFunctionSignaledToStop = fmt.Errorf("function signaled to stop") +var ErrBackoffSignaledToStop = fmt.Errorf("backoff signaled to stop") // RepeatFunc is a function passed to retry. // It returns true if the function should be repeated, false otherwise. @@ -28,12 +28,12 @@ func Do(ctx context.Context, b backoff.Backoff, f RepeatFunc) error { } if !f(ctx) { - return errFunctionSignaledToStop + return ErrFunctionSignaledToStop } next, stop := b.Next() if stop { - return errBackoffSignaledToStop + return ErrBackoffSignaledToStop } // ctx.Done() has priority, so we test it alone first @@ -54,8 +54,8 @@ func Do(ctx context.Context, b backoff.Backoff, f RepeatFunc) error { } } -// RepeatFunc is a function passed to retry. -// It returns true if the function should be repeated, false otherwise. +// RepeatUntilErrorFunc is a function passed to retry. +// It returns an error if the function should be stopped, nil otherwise. type RepeatUntilErrorFunc func(ctx context.Context) error // DoUntilError wraps a function with a backoff to repeat until f returns an error, or @@ -71,12 +71,12 @@ func DoUntilError(ctx context.Context, b backoff.Backoff, f RepeatUntilErrorFunc } if err := f(ctx); err != nil { - return fmt.Errorf("%w: %w", errFunctionSignaledToStop, err) + return fmt.Errorf("%w: %w", ErrFunctionSignaledToStop, err) } next, stop := b.Next() if stop { - return errBackoffSignaledToStop + return ErrBackoffSignaledToStop } // ctx.Done() has priority, so we test it alone first @@ -96,3 +96,36 @@ func DoUntilError(ctx context.Context, b backoff.Backoff, f RepeatUntilErrorFunc } } } + +// ConstantRepeat is a wrapper around repeat that uses a constant backoff. It will +// repeat the function f until it returns false, or the context is canceled. +func ConstantRepeat(ctx context.Context, t time.Duration, f RepeatFunc) error { + b, err := backoff.NewConstant(t) + if err != nil { + return fmt.Errorf("failed to create constant backoff: %w", err) + } + + return Do(ctx, b, f) +} + +// ExponentialRetry is a wrapper around repeat that uses an exponential backoff. It will +// repeat the function f until it returns false, or the context is canceled. +func ExponentialRepeat(ctx context.Context, base time.Duration, f RepeatFunc) error { + b, err := backoff.NewExponential(base) + if err != nil { + return fmt.Errorf("failed to create exponential backoff: %w", err) + } + + return Do(ctx, b, f) +} + +// FibonacciRepeat is a wrapper around repeat that uses a FibonacciRetry backoff. It will +// repeat the function f until it returns false, or the context is canceled. +func FibonacciRepeat(ctx context.Context, base time.Duration, f RepeatFunc) error { + b, err := backoff.NewFibonacci(base) + if err != nil { + return fmt.Errorf("failed to create fibonacci backoff: %w", err) + + } + return Do(ctx, b, f) +} diff --git a/repeat/repeat_test.go b/repeat/repeat_test.go index 48e065c..19bb956 100644 --- a/repeat/repeat_test.go +++ b/repeat/repeat_test.go @@ -48,8 +48,8 @@ func TestDo(t *testing.T) { return cnt <= maxCnt } - if err = Do(context.Background(), b, retryFunc); err != errFunctionSignaledToStop { - t.Errorf("expected %q to be %q", err, errFunctionSignaledToStop) + if err = Do(context.Background(), b, retryFunc); err != ErrFunctionSignaledToStop { + t.Errorf("expected %q to be %q", err, ErrFunctionSignaledToStop) } if cnt != maxCnt+1 { t.Errorf("expected %d to be %d", cnt, maxCnt+1) @@ -65,8 +65,8 @@ func TestDo(t *testing.T) { retryFunc := func(_ context.Context) bool { return true } - if err := Do(context.Background(), backoff, retryFunc); err != errBackoffSignaledToStop { - t.Errorf("expected %q to be %q", err, errBackoffSignaledToStop) + if err := Do(context.Background(), backoff, retryFunc); err != ErrBackoffSignaledToStop { + t.Errorf("expected %q to be %q", err, ErrBackoffSignaledToStop) } }) } @@ -112,8 +112,8 @@ func TestDoUntilError(t *testing.T) { return nil } - if err = DoUntilError(context.Background(), b, retryFunc); !errors.Is(err, errFunctionSignaledToStop) { - t.Errorf("expected %q to contain %q", err, errFunctionSignaledToStop) + if err = DoUntilError(context.Background(), b, retryFunc); !errors.Is(err, ErrFunctionSignaledToStop) { + t.Errorf("expected %q to contain %q", err, ErrFunctionSignaledToStop) } if cnt != maxCnt+1 { t.Errorf("expected %d to be %d", cnt, maxCnt+1) @@ -129,8 +129,122 @@ func TestDoUntilError(t *testing.T) { retryFunc := func(_ context.Context) error { return nil } - if err := DoUntilError(context.Background(), maxRetryBackoff, retryFunc); err != errBackoffSignaledToStop { - t.Errorf("expected %q to be %q", err, errBackoffSignaledToStop) + if err := DoUntilError(context.Background(), maxRetryBackoff, retryFunc); err != ErrBackoffSignaledToStop { + t.Errorf("expected %q to be %q", err, ErrBackoffSignaledToStop) + } + }) +} + +func TestConstantRepeat(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) bool { return true } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := ConstantRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxCnt := 3 + f := func(_ context.Context) bool { + cnt++ + + return cnt <= maxCnt + } + + if err := ConstantRepeat(context.Background(), 1*time.Nanosecond, f); err != ErrFunctionSignaledToStop { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + if cnt != maxCnt+1 { + t.Errorf("expected %d to be %d", cnt, maxCnt+1) + } + }) +} + +func TestExponentialRepeat(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) bool { return true } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := ExponentialRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxCnt := 3 + f := func(_ context.Context) bool { + cnt++ + + return cnt <= maxCnt + } + + if err := ExponentialRepeat(context.Background(), 1*time.Nanosecond, f); err != ErrFunctionSignaledToStop { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + if cnt != maxCnt+1 { + t.Errorf("expected %d to be %d", cnt, maxCnt+1) + } + }) +} + +func TestFibonacciRepeat(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) bool { return true } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := FibonacciRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxCnt := 3 + f := func(_ context.Context) bool { + cnt++ + + return cnt <= maxCnt + } + + if err := FibonacciRepeat(context.Background(), 1*time.Nanosecond, f); err != ErrFunctionSignaledToStop { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + if cnt != maxCnt+1 { + t.Errorf("expected %d to be %d", cnt, maxCnt+1) } }) } diff --git a/repeat/repeat_utils.go b/repeat/repeat_utils.go deleted file mode 100644 index 1043f19..0000000 --- a/repeat/repeat_utils.go +++ /dev/null @@ -1,42 +0,0 @@ -package repeat - -import ( - "context" - "fmt" - "time" - - "github.com/swayne275/go-retry/backoff" -) - -// ConstantRepeat is a wrapper around repeat that uses a constant backoff. It will -// repeat the function f until it returns false, or the context is canceled. -func ConstantRepeat(ctx context.Context, t time.Duration, f RepeatFunc) error { - b, err := backoff.NewConstant(t) - if err != nil { - return fmt.Errorf("failed to create constant backoff: %w", err) - } - - return Do(ctx, b, f) -} - -// ExponentialRetry is a wrapper around repeat that uses an exponential backoff. It will -// repeat the function f until it returns false, or the context is canceled. -func ExponentialRepeat(ctx context.Context, base time.Duration, f RepeatFunc) error { - b, err := backoff.NewExponential(base) - if err != nil { - return fmt.Errorf("failed to create exponential backoff: %w", err) - } - - return Do(ctx, b, f) -} - -// FibonacciRepeat is a wrapper around repeat that uses a FibonacciRetry backoff. It will -// repeat the function f until it returns false, or the context is canceled. -func FibonacciRepeat(ctx context.Context, base time.Duration, f RepeatFunc) error { - b, err := backoff.NewFibonacci(base) - if err != nil { - return fmt.Errorf("failed to create fibonacci backoff: %w", err) - - } - return Do(ctx, b, f) -} diff --git a/repeat/repeat_utils_test.go b/repeat/repeat_utils_test.go deleted file mode 100644 index a1f5996..0000000 --- a/repeat/repeat_utils_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package repeat - -import ( - "context" - "testing" - "time" -) - -func TestConstantRepeat(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) bool { return true } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := ConstantRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { - t.Parallel() - - cnt := 0 - maxCnt := 3 - f := func(_ context.Context) bool { - cnt++ - - return cnt <= maxCnt - } - - if err := ConstantRepeat(context.Background(), 1*time.Nanosecond, f); err != errFunctionSignaledToStop { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - if cnt != maxCnt+1 { - t.Errorf("expected %d to be %d", cnt, maxCnt+1) - } - }) -} - -func TestExponentialRepeat(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) bool { return true } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := ExponentialRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { - t.Parallel() - - cnt := 0 - maxCnt := 3 - f := func(_ context.Context) bool { - cnt++ - - return cnt <= maxCnt - } - - if err := ExponentialRepeat(context.Background(), 1*time.Nanosecond, f); err != errFunctionSignaledToStop { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - if cnt != maxCnt+1 { - t.Errorf("expected %d to be %d", cnt, maxCnt+1) - } - }) -} - -func TestFibonacciRepeat(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) bool { return true } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := FibonacciRepeat(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RepeatFunc_false", func(t *testing.T) { - t.Parallel() - - cnt := 0 - maxCnt := 3 - f := func(_ context.Context) bool { - cnt++ - - return cnt <= maxCnt - } - - if err := FibonacciRepeat(context.Background(), 1*time.Nanosecond, f); err != errFunctionSignaledToStop { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - if cnt != maxCnt+1 { - t.Errorf("expected %d to be %d", cnt, maxCnt+1) - } - }) -} diff --git a/retry/retry.go b/retry/retry.go index 647bade..8a2647d 100644 --- a/retry/retry.go +++ b/retry/retry.go @@ -21,7 +21,7 @@ import ( "github.com/swayne275/go-retry/backoff" ) -var errFunctionReturnedNonRetryableError = fmt.Errorf("function returned non retryable error") +var ErrNonRetryable = fmt.Errorf("function returned non retryable error") var errBackoffSignaledToStop = fmt.Errorf("backoff signaled to stop") // RetryFunc is a function passed to retry. @@ -72,7 +72,7 @@ func Do(ctx context.Context, b backoff.Backoff, f RetryFunc) error { // Not retryable var rerr *retryableError if !errors.As(err, &rerr) { - return fmt.Errorf("%w: %w", errFunctionReturnedNonRetryableError, err) + return fmt.Errorf("%w: %w", ErrNonRetryable, err) } next, stop := b.Next() @@ -97,3 +97,36 @@ func Do(ctx context.Context, b backoff.Backoff, f RetryFunc) error { } } } + +// ConstantRetry is a wrapper around retry that uses a constant backoff. It will +// retry the function f until it returns a non-retryable error, or the context is canceled. +func ConstantRetry(ctx context.Context, t time.Duration, f RetryFunc) error { + b, err := backoff.NewConstant(t) + if err != nil { + return fmt.Errorf("failed to create constant backoff: %w", err) + } + + return Do(ctx, b, f) +} + +// ExponentialRetry is a wrapper around retry that uses an exponential backoff. It will +// retry the function f until it returns a non-retryable error, or the context is canceled. +func ExponentialRetry(ctx context.Context, base time.Duration, f RetryFunc) error { + b, err := backoff.NewExponential(base) + if err != nil { + return fmt.Errorf("failed to create exponential backoff: %w", err) + } + + return Do(ctx, b, f) +} + +// FibonacciRetry is a wrapper around retry that uses a FibonacciRetry backoff. It will +// retry the function f until it returns a non-retryable error, or the context is canceled. +func FibonacciRetry(ctx context.Context, base time.Duration, f RetryFunc) error { + b, err := backoff.NewFibonacci(base) + if err != nil { + return fmt.Errorf("failed to create fibonacci backoff: %w", err) + + } + return Do(ctx, b, f) +} diff --git a/retry/retry_test.go b/retry/retry_test.go index 0d61784..443789d 100644 --- a/retry/retry_test.go +++ b/retry/retry_test.go @@ -127,8 +127,8 @@ func TestDo(t *testing.T) { return RetryableError(fmt.Errorf("some retryable error")) } - if err = Do(context.Background(), b, retryFunc); !errors.Is(err, errFunctionReturnedNonRetryableError) { - t.Errorf("expected %q to contain %q", err, errFunctionReturnedNonRetryableError) + if err = Do(context.Background(), b, retryFunc); !errors.Is(err, ErrNonRetryable) { + t.Errorf("expected %q to contain %q", err, ErrNonRetryable) } if cnt != maxCnt+1 { t.Errorf("expected %d to be %d", cnt, maxCnt+1) @@ -191,3 +191,219 @@ func TestCancel(t *testing.T) { } } } + +func TestConstantRetry(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) error { + return RetryableError(fmt.Errorf("some retryable err")) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := ConstantRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + nonRetryableCnt := 3 + nonRetryableError := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + + if cnt > nonRetryableCnt { + return nonRetryableError + } + + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := ConstantRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, ErrNonRetryable) { + t.Errorf("expected %q to be %q", err, ErrNonRetryable) + } + if !errors.Is(err, nonRetryableError) { + t.Errorf("expected %q to be %q", err, nonRetryableError) + } + if cnt != nonRetryableCnt+1 { + t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) + } + }) + + t.Run("retry_until_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxRetries := 5 + nonRetryableErr := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + if cnt >= maxRetries { + return nonRetryableErr + } + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := ConstantRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, nonRetryableErr) { + t.Errorf("expected %q to be %q", err, nonRetryableErr) + } + if cnt != maxRetries { + t.Errorf("expected %d to be %d", cnt, maxRetries) + } + }) +} + +func TestExponentialRetry(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) error { + return RetryableError(fmt.Errorf("some retryable err")) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := ExponentialRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + nonRetryableCnt := 3 + nonRetriableErr := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + + if cnt > nonRetryableCnt { + return nonRetriableErr + } + + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := ExponentialRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, ErrNonRetryable) { + t.Errorf("expected %q to be %q", err, ErrNonRetryable) + } + if !errors.Is(err, nonRetriableErr) { + t.Errorf("expected %q to be %q", err, nonRetriableErr) + } + if cnt != nonRetryableCnt+1 { + t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) + } + }) + + t.Run("retry_until_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxRetries := 5 + nonRetriableErr := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + if cnt == maxRetries { + return nonRetriableErr + } + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := ExponentialRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, nonRetriableErr) { + t.Errorf("expected %q to be %q", err, nonRetriableErr) + } + if cnt != maxRetries { + t.Errorf("expected %d to be %d", cnt, maxRetries) + } + }) +} + +func TestFibonacciRetry(t *testing.T) { + t.Parallel() + + t.Run("exit_on_context_cancelled", func(t *testing.T) { + t.Parallel() + + f := func(_ context.Context) error { + return RetryableError(fmt.Errorf("some retryable err")) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Nanosecond) + cancel() + }() + + if err := FibonacciRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { + t.Errorf("expected %q to be %q", err, context.Canceled) + } + }) + + t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + nonRetryableCnt := 3 + nonRetriableErr := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + + if cnt > nonRetryableCnt { + return nonRetriableErr + } + + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := FibonacciRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, ErrNonRetryable) { + t.Errorf("expected %q to be %q", err, ErrNonRetryable) + } + if !errors.Is(err, nonRetriableErr) { + t.Errorf("expected %q to be %q", err, nonRetriableErr) + } + if cnt != nonRetryableCnt+1 { + t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) + } + }) + + t.Run("retry_until_nonretryable_error", func(t *testing.T) { + t.Parallel() + + cnt := 0 + maxRetries := 5 + nonRetryableErr := fmt.Errorf("some non-retryable error") + f := func(_ context.Context) error { + cnt++ + if cnt == maxRetries { + return nonRetryableErr + } + return RetryableError(fmt.Errorf("some retryable error")) + } + + err := FibonacciRetry(context.Background(), 1*time.Nanosecond, f) + if !errors.Is(err, nonRetryableErr) { + t.Errorf("expected %q to be %q", err, nonRetryableErr) + } + if cnt != maxRetries { + t.Errorf("expected %d to be %d", cnt, maxRetries) + } + }) +} diff --git a/retry/retry_utils.go b/retry/retry_utils.go deleted file mode 100644 index 1cc08b7..0000000 --- a/retry/retry_utils.go +++ /dev/null @@ -1,44 +0,0 @@ -package retry - -import ( - "context" - "fmt" - "time" - - "github.com/swayne275/go-retry/backoff" -) - -// TODO tests should include retryable errors and non retryable errors - -// ConstantRetry is a wrapper around retry that uses a constant backoff. It will -// retry the function f until it returns a non-retryable error, or the context is canceled. -func ConstantRetry(ctx context.Context, t time.Duration, f RetryFunc) error { - b, err := backoff.NewConstant(t) - if err != nil { - return fmt.Errorf("failed to create constant backoff: %w", err) - } - - return Do(ctx, b, f) -} - -// ExponentialRetry is a wrapper around retry that uses an exponential backoff. It will -// retry the function f until it returns a non-retryable error, or the context is canceled. -func ExponentialRetry(ctx context.Context, base time.Duration, f RetryFunc) error { - b, err := backoff.NewExponential(base) - if err != nil { - return fmt.Errorf("failed to create exponential backoff: %w", err) - } - - return Do(ctx, b, f) -} - -// FibonacciRetry is a wrapper around retry that uses a FibonacciRetry backoff. It will -// retry the function f until it returns a non-retryable error, or the context is canceled. -func FibonacciRetry(ctx context.Context, base time.Duration, f RetryFunc) error { - b, err := backoff.NewFibonacci(base) - if err != nil { - return fmt.Errorf("failed to create fibonacci backoff: %w", err) - - } - return Do(ctx, b, f) -} diff --git a/retry/retry_utils_test.go b/retry/retry_utils_test.go deleted file mode 100644 index aed11f3..0000000 --- a/retry/retry_utils_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package retry - -import ( - "context" - "errors" - "fmt" - "testing" - "time" -) - -func TestConstantRetry(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) error { - return RetryableError(fmt.Errorf("some retryable err")) - } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := ConstantRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { - t.Parallel() - - cnt := 0 - nonRetryableCnt := 3 - errNonRetryable := fmt.Errorf("some non-retryable error") - f := func(_ context.Context) error { - cnt++ - - if cnt > nonRetryableCnt { - return errNonRetryable - } - - return RetryableError(fmt.Errorf("some non-retryable error")) - } - - err := ConstantRetry(context.Background(), 1*time.Nanosecond, f) - if !errors.Is(err, errFunctionReturnedNonRetryableError) { - t.Errorf("expected %q to be %q", err, errFunctionReturnedNonRetryableError) - } - if !errors.Is(err, errNonRetryable) { - t.Errorf("expected %q to be %q", err, errNonRetryable) - } - if cnt != nonRetryableCnt+1 { - t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) - } - }) -} - -func TestExponentialRetry(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) error { - return RetryableError(fmt.Errorf("some retryable err")) - } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := ExponentialRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { - t.Parallel() - - cnt := 0 - nonRetryableCnt := 3 - errNonRetryable := fmt.Errorf("some non-retryable error") - f := func(_ context.Context) error { - cnt++ - - if cnt > nonRetryableCnt { - return errNonRetryable - } - - return RetryableError(fmt.Errorf("some non-retryable error")) - } - - err := ExponentialRetry(context.Background(), 1*time.Nanosecond, f) - if !errors.Is(err, errFunctionReturnedNonRetryableError) { - t.Errorf("expected %q to be %q", err, errFunctionReturnedNonRetryableError) - } - if !errors.Is(err, errNonRetryable) { - t.Errorf("expected %q to be %q", err, errNonRetryable) - } - if cnt != nonRetryableCnt+1 { - t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) - } - }) -} - -func TestFibonacciRetry(t *testing.T) { - t.Parallel() - - t.Run("exit_on_context_cancelled", func(t *testing.T) { - t.Parallel() - - f := func(_ context.Context) error { - return RetryableError(fmt.Errorf("some retryable err")) - } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(10 * time.Nanosecond) - cancel() - }() - - if err := FibonacciRetry(ctx, 1*time.Nanosecond, f); err != context.Canceled { - t.Errorf("expected %q to be %q", err, context.Canceled) - } - }) - - t.Run("exit_on_RetryFunc_nonretryable_error", func(t *testing.T) { - t.Parallel() - - cnt := 0 - nonRetryableCnt := 3 - errNonRetryable := fmt.Errorf("some non-retryable error") - f := func(_ context.Context) error { - cnt++ - - if cnt > nonRetryableCnt { - return errNonRetryable - } - - return RetryableError(fmt.Errorf("some non-retryable error")) - } - - err := FibonacciRetry(context.Background(), 1*time.Nanosecond, f) - if !errors.Is(err, errFunctionReturnedNonRetryableError) { - t.Errorf("expected %q to be %q", err, errFunctionReturnedNonRetryableError) - } - if !errors.Is(err, errNonRetryable) { - t.Errorf("expected %q to be %q", err, errNonRetryable) - } - if cnt != nonRetryableCnt+1 { - t.Errorf("expected %d to be %d", cnt, nonRetryableCnt+1) - } - }) -}