diff --git a/LICENSE b/LICENSE index 71d81d0..717af18 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 Coder Technologies Inc. +Copyright (c) 2021 Coder Technologies Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2f03cc6..568af6b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,49 @@ # retry -An expressive, flexible retry package for Go. +An exponentially backing off retry package for Go. -[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/go.coder.com/retry) +[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/github.com/coder/retry) -## Features +``` +go get github.com/coder/retry +``` -- Backoff helper -- Retrying net.Listener wrapper +## Features +- Offers a `for` loop experience instead of closures +- Only 2 exported methods +- No external dependencies ## Examples -See [retry_example_test.go](retry_example_test.go) - -## We're Hiring! - -If you're a passionate Go developer, send your resume and/or GitHub link to [jobs@coder.com](mailto:jobs@coder.com). +Wait for connectivity to google.com, checking at most once every +second. +```go +func pingGoogle(ctx context.Context) error { + var err error + r := retry.New(time.Second, time.Second*10) + for r.Wait(ctx) { + _, err = http.Get("https://google.com") + if err != nil { + continue + } + break + } + return err +} +``` + +Wait for connectivity to google.com, checking at most 10 times. +```go +func pingGoogle(ctx context.Context) error { + var err error + r := retry.New(time.Second, time.Second*10) + for n := 0; r.Wait(ctx) && n < 10; n++ { + _, err = http.Get("https://google.com") + if err != nil { + continue + } + break + } + return err +} +``` \ No newline at end of file diff --git a/backoff.go b/backoff.go deleted file mode 100644 index 518246c..0000000 --- a/backoff.go +++ /dev/null @@ -1,54 +0,0 @@ -package retry - -import ( - "context" - "time" - - "github.com/pkg/errors" -) - -// Backoff holds state about a backoff loop in which -// there should be a delay in iterations. -type Backoff struct { - // These two fields must be initialized. - // Floor should never be greater than or equal - // to the Ceil in general. If it is, the Backoff - // will stop backing off and just sleep for the Floor - // in Wait(). - Floor time.Duration - Ceil time.Duration - - delay time.Duration -} - -func (b *Backoff) backoff() { - if b.Floor >= b.Ceil { - return - } - - const growth = 2 - b.delay *= growth - if b.delay > b.Ceil { - b.delay = b.Ceil - } -} - -// Wait should be called at the end of the loop. It will sleep -// for the necessary duration before the next iteration of the loop -// can begin. -// If the context is cancelled, Wait will return early with a non-nil error. -func (b *Backoff) Wait(ctx context.Context) error { - if b.delay < b.Floor { - b.delay = b.Floor - } - - select { - case <-ctx.Done(): - return errors.Wrapf(ctx.Err(), "failed to sleep delay %v for retry attempt", b.delay) - case <-time.After(b.delay): - } - - b.backoff() - - return nil -} diff --git a/backoff_test.go b/backoff_test.go deleted file mode 100644 index da9c840..0000000 --- a/backoff_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package retry_test - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - "go.coder.com/retry" -) - -func TestBackoff(t *testing.T) { - t.Parallel() - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - start := time.Now() - - b := &retry.Backoff{ - Floor: time.Millisecond, - Ceil: time.Second * 5, - } - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, time.Millisecond*100) - defer cancel() - - for time.Since(start) < time.Second { - err := b.Wait(ctx) - if err != nil { - return - } - } - - t.Errorf("succeeded: took: %v", time.Since(start)) - }) - - t.Run("success", func(t *testing.T) { - t.Parallel() - - start := time.Now() - - b := &retry.Backoff{ - Floor: time.Millisecond, - Ceil: time.Second * 5, - } - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, time.Second*2) - defer cancel() - - for time.Since(start) < time.Second { - err := b.Wait(ctx) - require.NoError(t, err, "took: %v", time.Since(start)) - } - }) -} diff --git a/doc.go b/doc.go index 591ed4e..8d01b39 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,2 @@ -// Package retry contains utilities for retrying an action until it succeeds. +// Package retry runs a failable function until it succeeds. package retry diff --git a/go.mod b/go.mod index b5ddf5f..12a1d62 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,3 @@ -module go.coder.com/retry +module github.com/coder/retry -require ( - github.com/davecgh/go-spew v1.1.0 // indirect - github.com/pkg/errors v0.8.0 - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.1.4 -) +go 1.17 diff --git a/go.sum b/go.sum index 7fed76a..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +0,0 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.1.4 h1:ToftOQTytwshuOSj6bDSolVUa3GINfJP/fg3OkkOzQQ= -github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/listener.go b/listener.go deleted file mode 100644 index 2f2553f..0000000 --- a/listener.go +++ /dev/null @@ -1,46 +0,0 @@ -package retry - -import ( - "context" - "log" - "net" - "time" -) - -type Listener struct { - LogTmpErr func(err error) - net.Listener -} - -func (l Listener) Accept() (net.Conn, error) { - b := &Backoff{ - Floor: 5 * time.Millisecond, - Ceil: time.Second, - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - for { - c, err := l.Listener.Accept() - if err == nil { - return c, nil - } - - ne, ok := err.(net.Error) - if !ok || !ne.Temporary() { - return nil, err - } - - if l.LogTmpErr == nil { - log.Printf("retry: temp error accepting next connection: %v", err) - } else { - l.LogTmpErr(err) - } - - err = b.Wait(ctx) - if err != nil { - return nil, err - } - } -} diff --git a/listener_test.go b/listener_test.go deleted file mode 100644 index 755f54d..0000000 --- a/listener_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package retry - -import ( - "net" - "testing" - - "github.com/pkg/errors" - "github.com/stretchr/testify/require" -) - - -type testListener struct { - acceptFn func() (net.Conn, error) -} - -func newTestListener(acceptFn func() (net.Conn, error)) net.Listener { - return &Listener{ - LogTmpErr: func(err error) {}, - Listener: &testListener{ - acceptFn: acceptFn, - }, - } -} - -func (l *testListener) Accept() (net.Conn, error) { - return l.acceptFn() -} - -func (l *testListener) Close() error { - panic("stub") -} - -func (l *testListener) Addr() net.Addr { - panic("stub") -} - -type testNetError struct { - temporary bool -} - -func (e *testNetError) Error() string { - return "test net error" -} - -func (e *testNetError) Temporary() bool { - return e.temporary -} - -func (e *testNetError) Timeout() bool { - panic("do not call") -} - -func TestListener(t *testing.T) { - t.Parallel() - t.Run("general error", func(t *testing.T) { - t.Parallel() - - expectedErr := errors.New("general error") - acceptFn := func() (net.Conn, error) { - return nil, expectedErr - } - - _, err := newTestListener(acceptFn).Accept() - require.Equal(t, expectedErr, err) - }) - t.Run("success", func(t *testing.T) { - t.Parallel() - - acceptFn := func() (net.Conn, error) { - return nil, nil - } - - _, err := newTestListener(acceptFn).Accept() - require.Nil(t, err) - }) - t.Run("non temp net error", func(t *testing.T) { - t.Parallel() - - expectedErr := &testNetError{false} - acceptFn := func() (net.Conn, error) { - return nil, expectedErr - } - - _, err := newTestListener(acceptFn).Accept() - require.Equal(t, expectedErr, err) - }) - t.Run("3x temp net error", func(t *testing.T) { - t.Parallel() - - callCount := 0 - acceptFn := func() (net.Conn, error) { - callCount++ - switch callCount { - case 1: - return nil, &testNetError{true} - case 2: - return nil, &testNetError{true} - case 3: - return nil, nil - default: - t.Fatalf("test listener called too many times; callCount: %v", callCount) - panic("unreachable") - } - } - - _, err := newTestListener(acceptFn).Accept() - require.Nil(t, err) - require.Equal(t, callCount, 3) - }) -} diff --git a/retrier.go b/retrier.go new file mode 100644 index 0000000..cfcbe7d --- /dev/null +++ b/retrier.go @@ -0,0 +1,36 @@ +package retry + +import ( + "context" + "time" +) + +// Retrier implements an exponentially backing off retry instance. +// Use New instead of creating this object directly. +type Retrier struct { + delay time.Duration + floor, ceil time.Duration +} + +// New creates a retrier that exponentially backs off from floor to ceil pauses. +func New(floor, ceil time.Duration) *Retrier { + return &Retrier{ + delay: floor, + floor: floor, + ceil: ceil, + } +} + +func (r *Retrier) Wait(ctx context.Context) bool { + const growth = 2 + r.delay *= growth + if r.delay > r.ceil { + r.delay = r.ceil + } + select { + case <-time.After(r.delay): + return true + case <-ctx.Done(): + return false + } +} diff --git a/retrier_test.go b/retrier_test.go new file mode 100644 index 0000000..c6ac1e7 --- /dev/null +++ b/retrier_test.go @@ -0,0 +1,17 @@ +package retry + +import ( + "context" + "testing" + "time" +) + +func TestContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + r := New(time.Hour, time.Hour) + for r.Wait(ctx) { + t.Fatalf("attempt allowed even though context cancelled") + } +} diff --git a/retry_example_test.go b/retry_example_test.go deleted file mode 100644 index a334e4d..0000000 --- a/retry_example_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package retry_test - -import ( - "context" - "log" - "net" - "time" - - "go.coder.com/retry" -) - -func ExampleBackoffSuccess() { - start := time.Now() - - b := &retry.Backoff{ - Floor: time.Millisecond, - Ceil: time.Second * 5, - } - - ctx := context.Background() - - for time.Since(start) < time.Second { - err := b.Wait(ctx) - if err != nil { - log.Fatalf("failed: took: %v: err: %v", time.Since(start), err) - } - } - - log.Printf("success: took: %v", time.Since(start)) -} - -func ExampleBackoffError() { - start := time.Now() - - b := &retry.Backoff{ - Floor: time.Millisecond, - Ceil: time.Second * 5, - } - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, time.Millisecond*100) - defer cancel() - - for time.Since(start) < time.Second { - err := b.Wait(ctx) - if err != nil { - log.Fatalf("failed: took: %v: err: %v", time.Since(start), err) - } - } - - log.Printf("success: took: %v", time.Since(start)) -} - -func ExampleListener() { - l, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - defer l.Close() - - l = retry.Listener{ - Listener: l, - } - - for { - c, err := l.Accept() - if err != nil { - log.Fatalf("failed to accept: %v", err) - } - defer c.Close() - - // ... - } -}