Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WithTimeout() and TryUntil() config options #6

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions retrier.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Retrier struct {

breakNext bool
sleepFunc func(time.Duration)
timeout time.Duration

intervalCalculator Strategy
strategyType string
Expand Down Expand Up @@ -72,6 +73,30 @@ func WithMaxAttempts(maxAttempts int) retrierOpt {
}
}

func WithTimeout(t time.Duration) retrierOpt {
if t < 0 {
panic("timeout must be a positive duration")
}

return func(r *Retrier) {
if r.maxAttempts == 0 {
// It's possible to mix and match timeouts and max attempts, but if we've set a timeout and no max attempts, then
// we should just go until the timeout
r.maxAttempts = math.MaxInt
}

r.timeout = t
}
}

func TryUntil(t time.Time) retrierOpt {
if t.Before(time.Now()) {
panic("until time must be in the future")
}

return WithTimeout(time.Until(t))
}

func WithRand(rand *rand.Rand) retrierOpt {
return func(r *Retrier) {
r.rand = rand
Expand Down Expand Up @@ -116,7 +141,8 @@ func WithSleepFunc(f func(time.Duration)) retrierOpt {
// the retrier
func NewRetrier(opts ...retrierOpt) *Retrier {
r := &Retrier{
rand: defaultRandom,
rand: defaultRandom,
timeout: time.Duration(math.MaxInt64),
}

for _, o := range opts {
Expand All @@ -125,8 +151,8 @@ func NewRetrier(opts ...retrierOpt) *Retrier {

// We use panics here rather than returning an error because all of these are logical issues caused by the programmer,
// they should never occur in normal running, and can't be logically recovered from
if r.maxAttempts == 0 && !r.forever {
panic("retriers must either run forever, or have a maximum attempt count")
if r.maxAttempts == 0 && !r.forever && r.timeout == 0 {
panic("retriers must run forever, have a maximum attempt count, or have a timeout")
}

if r.maxAttempts < 0 {
Expand Down Expand Up @@ -228,6 +254,9 @@ func (r *Retrier) Do(callback func(*Retrier) error) error {

// DoWithContext is a context-aware variant of Do.
func (r *Retrier) DoWithContext(ctx context.Context, callback func(*Retrier) error) error {
timeouter := time.NewTimer(r.timeout) // Defaults to math.MaxInt64 unless overridden
defer timeouter.Stop()

for {
// Perform the action the user has requested we retry
err := callback(r)
Expand All @@ -251,19 +280,21 @@ func (r *Retrier) DoWithContext(ctx context.Context, callback func(*Retrier) err
return err
}

if err := r.sleepOrDone(ctx, nextInterval); err != nil {
if err := r.sleepOrDone(ctx, nextInterval, timeouter); err != nil {
return err
}
}
}

func (r *Retrier) sleepOrDone(ctx context.Context, nextInterval time.Duration) error {
func (r *Retrier) sleepOrDone(ctx context.Context, nextInterval time.Duration, timeouter *time.Timer) error {
if r.sleepFunc == nil {
t := time.NewTimer(nextInterval)
defer t.Stop()
select {
case <-t.C:
return nil
case <-timeouter.C:
return fmt.Errorf("retrier timed out after %v", r.timeout)
case <-ctx.Done():
return ctx.Err()
}
Expand All @@ -275,6 +306,8 @@ func (r *Retrier) sleepOrDone(ctx context.Context, nextInterval time.Duration) e
close(sleepCh)
}()
select {
case <-timeouter.C:
return fmt.Errorf("retrier timed out after %v", r.timeout)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly this should be a public error value so that people can test for errors.Is(err, roko.TimeoutExceeded) or something

case <-sleepCh:
return nil
case <-ctx.Done():
Expand Down
17 changes: 17 additions & 0 deletions retrier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ func TestShouldGiveUp_WithMaxAttempts(t *testing.T) {
assert.Equal(t, 3, callcount)
}

func TestTimeout(t *testing.T) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test super doesn't work, because the timeout function measures time inside the callback as well as time outside the callback (ie time spent waiting). The call count flakes between 92 and 93 before the timeout completes

t.Parallel()

callCount := 0
err := NewRetrier(
WithStrategy(Constant(5*time.Millisecond)),
WithTimeout(500*time.Millisecond),
).Do(func(_ *Retrier) error {
callCount += 1
return errDummy
})

assert.Error(t, err)
assert.Equal(t, err, errors.New("retrier timed out after 500ms"))
assert.Equal(t, 100, callCount)
}

func TestShouldGiveUp_Break(t *testing.T) {
t.Parallel()

Expand Down