Skip to content

Commit

Permalink
Introduce TryAgainAfter
Browse files Browse the repository at this point in the history
  • Loading branch information
onsi committed Oct 26, 2022
1 parent 67ab22c commit 618a133
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 44 deletions.
8 changes: 8 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,14 @@ If a matcher returns `StopTrying` for `error`, or calls `StopTrying(...).Now()`,

> Note: An alternative mechanism for having matchers bail out early is documented in the [custom matchers section below](#aborting-eventuallyconsistently). This mechanism, which entails implementing a `MatchMayChangeIntheFuture(<actual>) bool` method, allows matchers to signify that no future change is possible out-of-band of the call to the matcher.
### Changing the Polling Interval Dynamically

You typically configure the polling interval for `Eventually` and `Consistently` using the `.WithPolling()` or `.ProbeEvery()` chaining methods. Sometimes, however, a polled function or matcher might want to signal that a service is unavailable but should be tried again after a certain duration.

You can signal this to both `Eventually` and `Consistently` using `TryAgainAfter(<duration>)`. This error-signal operates like `StopTrying()`: you can return `TryAgainAfter(<duration>)` as an error or throw a panic via `TryAgainAfter(<duration>).Now()`. In either case, both `Eventually` and `Consistently` will wait for the specified duration before trying again.

If a timeout occurs after the `TryAgainAfter` signal is sent but _before_ the next poll occurs both `Eventually` _and_ `Consistently` will always fail and print out the content of `TryAgainAfter`. The default message is `"told to try again after <duration>"` however, as with `StopTrying` you can use `.Wrap()` and `.Attach()` to wrap an error and attach additional objects to include in the message, respectively.

### Modifying Default Intervals

By default, `Eventually` will poll every 10 milliseconds for up to 1 second and `Consistently` will monitor every 10 milliseconds for up to 100 milliseconds. You can modify these defaults across your test suite with:
Expand Down
29 changes: 16 additions & 13 deletions gomega_dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,40 +406,43 @@ func ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{})
}

/*
StopTrying can be used to signal to Eventually and Consistently that the polled function will not change
and that they should stop trying. In the case of Eventually, if a match does not occur in this, final, iteration then a failure will result. In the case of Consistently, as long as this last iteration satisfies the match, the assertion will be considered successful.
StopTrying can be used to signal to Eventually and Consistentlythat they should abort and stop trying. This always results in a failure of the assertion - and the failure message is the content of the StopTrying signal.
You can send the StopTrying signal by either returning StopTrying("message") as an error from your passed-in function _or_ by calling StopTrying("message").Now() to trigger a panic and end execution.
StopTrying has the same signature as `fmt.Errorf`, and you can use `%w` to wrap StopTrying around another error. Doing so signals to Gomega that the assertion should (a) stop trying _and_ that (b) an underlying error has occurred. This, in turn, implies that no match should be attempted as the returned values cannot be trusted.
You can also wrap StopTrying around an error with `StopTrying("message").Wrap(err)` and can attach additional objects via `StopTrying("message").Attach("description", object). When rendered, the signal will include the wrapped error and any attached objects rendered using Gomega's default formatting.
Here are a couple of examples. This is how you might use StopTrying() as an error to signal that Eventually should stop:
playerIndex, numPlayers := 0, 11
Eventually(func() (string, error) {
name := client.FetchPlayer(playerIndex)
playerIndex += 1
if playerIndex == numPlayers {
return name, StopTrying("No more players left")
} else {
return name, nil
}
if playerIndex == numPlayers {
return "", StopTrying("no more players left")
}
name := client.FetchPlayer(playerIndex)
playerIndex += 1
return name, nil
}).Should(Equal("Patrick Mahomes"))
note that the final `name` returned alongside `StopTrying()` will be processed.
And here's an example where `StopTrying().Now()` is called to halt execution immediately:
Eventually(func() []string {
names, err := client.FetchAllPlayers()
if err == client.IRRECOVERABLE_ERROR {
StopTrying("Irrecoverable error occurred").Now()
StopTrying("Irrecoverable error occurred").Wrap(err).Now()
}
return names
}).Should(ContainElement("Patrick Mahomes"))
*/
var StopTrying = internal.StopTrying

/*
TryAgainAfter(<duration>) allows you to adjust the polling interval for the _next_ iteration of `Eventually` or `Consistently`. Like `StopTrying` you can either return `TryAgainAfter` as an error or trigger it immedieately with `.Now()`
When `TryAgainAfter(<duration>` is triggered `Eventually` and `Consistently` will wait for that duration. If a timeout occurs before the next poll is triggered both `Eventually` and `Consistently` will always fail with the content of the TryAgainAfter message. As with StopTrying you can `.Wrap()` and error and `.Attach()` additional objects to `TryAgainAfter`.
*/
var TryAgainAfter = internal.TryAgainAfter

// SetDefaultEventuallyTimeout sets the default timeout duration for Eventually. Eventually will repeatedly poll your condition until it succeeds, or until this timeout elapses.
func SetDefaultEventuallyTimeout(t time.Duration) {
Default.SetDefaultEventuallyTimeout(t)
Expand Down
28 changes: 23 additions & 5 deletions internal/async_assertion.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
defer lock.Unlock()
message := ""
if err != nil {
//TODO - formatting for TryAgainAfter?
if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() {
message = err.Error()
for _, attachment := range asyncSignal.Attachments {
Expand Down Expand Up @@ -385,16 +386,25 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
}

for {
if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() {
fail("Told to stop trying")
return false
var nextPoll <-chan time.Time = nil
var isTryAgainAfterError = false

if asyncSignal, ok := AsAsyncSignalError(err); ok {
if asyncSignal.IsStopTrying() {
fail("Told to stop trying")
return false
}
if asyncSignal.IsTryAgainAfter() {
nextPoll = time.After(asyncSignal.TryAgainDuration())
isTryAgainAfterError = true
}
}

if err == nil && matches == desiredMatch {
if assertion.asyncType == AsyncAssertionTypeEventually {
return true
}
} else {
} else if !isTryAgainAfterError {
if assertion.asyncType == AsyncAssertionTypeConsistently {
fail("Failed")
return false
Expand All @@ -410,8 +420,12 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
}
}

if nextPoll == nil {
nextPoll = assertion.afterPolling()
}

select {
case <-assertion.afterPolling():
case <-nextPoll:
v, e := pollActual()
lock.Lock()
value, err = v, e
Expand All @@ -431,6 +445,10 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
fail("Timed out")
return false
} else {
if isTryAgainAfterError {
fail("Timed out while waiting on TryAgainAfter")
return false
}
return true
}
}
Expand Down
109 changes: 108 additions & 1 deletion internal/async_assertion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,6 @@ sprocket:

})
})

})

Describe("The StopTrying signal - when sent by the matcher", func() {
Expand Down Expand Up @@ -1317,6 +1316,114 @@ sprocket:
})
})

Describe("dynamically adjusting the polling interval", func() {
var i int
var times []time.Duration
var t time.Time

BeforeEach(func() {
i = 0
times = []time.Duration{}
t = time.Now()
})

Context("and the assertion eventually succeeds", func() {
It("adjusts the timing of the next iteration", func() {
Eventually(func() error {
times = append(times, time.Since(t))
t = time.Now()
i += 1
if i < 3 {
return errors.New("stay on target")
}
if i == 3 {
return TryAgainAfter(time.Millisecond * 200)
}
if i == 4 {
return errors.New("you've switched off your targeting computer")
}
if i == 5 {
TryAgainAfter(time.Millisecond * 100).Now()
}
if i == 6 {
return errors.New("stay on target")
}
return nil
}).ProbeEvery(time.Millisecond * 10).Should(Succeed())
Ω(i).Should(Equal(7))
Ω(times).Should(HaveLen(7))
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200))
Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[5]).Should(BeNumerically("~", time.Millisecond*100, time.Millisecond*100))
Ω(times[6]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
})
})

Context("and the assertion timesout while waiting", func() {
It("fails with a timeout and emits the try again after error", func() {
ig.G.Eventually(func() (int, error) {
times = append(times, time.Since(t))
t = time.Now()
i += 1
if i < 3 {
return i, nil
}
if i == 3 {
return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam"))
}
return i, nil
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(Equal(4))
Ω(i).Should(Equal(3))
Ω(times).Should(HaveLen(3))
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))

Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after"))
Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam"))
})
})

Context("when used with Consistently", func() {
It("doesn't immediately count as a failure and adjusts the timing of the next iteration", func() {
Consistently(func() (int, error) {
times = append(times, time.Since(t))
t = time.Now()
i += 1
if i == 3 {
return i, TryAgainAfter(time.Millisecond * 200)
}
return i, nil
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 500).Should(BeNumerically("<", 1000))
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200))
Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
})

It("doesn count as a failure if a timeout occurs during the try again after window", func() {
ig.G.Consistently(func() (int, error) {
times = append(times, time.Since(t))
t = time.Now()
i += 1
if i == 3 {
return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam"))
}
return i, nil
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(BeNumerically("<", 1000))
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
Ω(ig.FailureMessage).Should(ContainSubstring("Timed out while waiting on TryAgainAfter after"))
Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam"))
})
})
})

When("vetting optional description parameters", func() {
It("panics when Gomega matcher is at the beginning of optional description parameters", func() {
ig := NewInstrumentedGomega()
Expand Down
42 changes: 20 additions & 22 deletions internal/async_signal_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package internal
import (
"errors"
"time"
"fmt"
)

type AsyncSignalErrorType int
Expand All @@ -12,27 +13,24 @@ const (
AsyncSignalErrorTypeTryAgainAfter
)

type StopTryingError interface {
type AsyncSignalError interface {
error
Wrap(err error) StopTryingError
Attach(description string, obj any) StopTryingError
Wrap(err error) AsyncSignalError
Attach(description string, obj any) AsyncSignalError
Now()
}

type TryAgainAfterError interface {
error
Now()
}

var StopTrying = func(message string) StopTryingError {
return &AsyncSignalError{
var StopTrying = func(message string) AsyncSignalError {
return &AsyncSignalErrorImpl{
message: message,
asyncSignalErrorType: AsyncSignalErrorTypeStopTrying,
}
}

var TryAgainAfter = func(duration time.Duration) TryAgainAfterError {
return &AsyncSignalError{
var TryAgainAfter = func(duration time.Duration) AsyncSignalError {
return &AsyncSignalErrorImpl{
message: fmt.Sprintf("told to try again after %s", duration),
duration: duration,
asyncSignalErrorType: AsyncSignalErrorTypeTryAgainAfter,
}
Expand All @@ -43,61 +41,61 @@ type AsyncSignalErrorAttachment struct {
Object any
}

type AsyncSignalError struct {
type AsyncSignalErrorImpl struct {
message string
wrappedErr error
asyncSignalErrorType AsyncSignalErrorType
duration time.Duration
Attachments []AsyncSignalErrorAttachment
}

func (s *AsyncSignalError) Wrap(err error) StopTryingError {
func (s *AsyncSignalErrorImpl) Wrap(err error) AsyncSignalError {
s.wrappedErr = err
return s
}

func (s *AsyncSignalError) Attach(description string, obj any) StopTryingError {
func (s *AsyncSignalErrorImpl) Attach(description string, obj any) AsyncSignalError {
s.Attachments = append(s.Attachments, AsyncSignalErrorAttachment{description, obj})
return s
}

func (s *AsyncSignalError) Error() string {
func (s *AsyncSignalErrorImpl) Error() string {
if s.wrappedErr == nil {
return s.message
} else {
return s.message + ": " + s.wrappedErr.Error()
}
}

func (s *AsyncSignalError) Unwrap() error {
func (s *AsyncSignalErrorImpl) Unwrap() error {
if s == nil {
return nil
}
return s.wrappedErr
}

func (s *AsyncSignalError) Now() {
func (s *AsyncSignalErrorImpl) Now() {
panic(s)
}

func (s *AsyncSignalError) IsStopTrying() bool {
func (s *AsyncSignalErrorImpl) IsStopTrying() bool {
return s.asyncSignalErrorType == AsyncSignalErrorTypeStopTrying
}

func (s *AsyncSignalError) IsTryAgainAfter() bool {
func (s *AsyncSignalErrorImpl) IsTryAgainAfter() bool {
return s.asyncSignalErrorType == AsyncSignalErrorTypeTryAgainAfter
}

func (s *AsyncSignalError) TryAgainDuration() time.Duration {
func (s *AsyncSignalErrorImpl) TryAgainDuration() time.Duration {
return s.duration
}

func AsAsyncSignalError(actual interface{}) (*AsyncSignalError, bool) {
func AsAsyncSignalError(actual interface{}) (*AsyncSignalErrorImpl, bool) {
if actual == nil {
return nil, false
}
if actualErr, ok := actual.(error); ok {
var target *AsyncSignalError
var target *AsyncSignalErrorImpl
if errors.As(actualErr, &target) {
return target, true
} else {
Expand Down
Loading

0 comments on commit 618a133

Please sign in to comment.