Skip to content

Commit 618a133

Browse files
committed
Introduce TryAgainAfter
1 parent 67ab22c commit 618a133

6 files changed

+178
-44
lines changed

docs/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,14 @@ If a matcher returns `StopTrying` for `error`, or calls `StopTrying(...).Now()`,
554554

555555
> 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.
556556
557+
### Changing the Polling Interval Dynamically
558+
559+
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.
560+
561+
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.
562+
563+
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.
564+
557565
### Modifying Default Intervals
558566

559567
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:

gomega_dsl.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -406,40 +406,43 @@ func ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{})
406406
}
407407

408408
/*
409-
StopTrying can be used to signal to Eventually and Consistently that the polled function will not change
410-
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.
409+
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.
411410
412411
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.
413412
414-
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.
413+
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.
415414
416415
Here are a couple of examples. This is how you might use StopTrying() as an error to signal that Eventually should stop:
417416
418417
playerIndex, numPlayers := 0, 11
419418
Eventually(func() (string, error) {
420-
name := client.FetchPlayer(playerIndex)
421-
playerIndex += 1
422-
if playerIndex == numPlayers {
423-
return name, StopTrying("No more players left")
424-
} else {
425-
return name, nil
426-
}
419+
if playerIndex == numPlayers {
420+
return "", StopTrying("no more players left")
421+
}
422+
name := client.FetchPlayer(playerIndex)
423+
playerIndex += 1
424+
return name, nil
427425
}).Should(Equal("Patrick Mahomes"))
428426
429-
note that the final `name` returned alongside `StopTrying()` will be processed.
430-
431427
And here's an example where `StopTrying().Now()` is called to halt execution immediately:
432428
433429
Eventually(func() []string {
434430
names, err := client.FetchAllPlayers()
435431
if err == client.IRRECOVERABLE_ERROR {
436-
StopTrying("Irrecoverable error occurred").Now()
432+
StopTrying("Irrecoverable error occurred").Wrap(err).Now()
437433
}
438434
return names
439435
}).Should(ContainElement("Patrick Mahomes"))
440436
*/
441437
var StopTrying = internal.StopTrying
442438

439+
/*
440+
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()`
441+
442+
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`.
443+
*/
444+
var TryAgainAfter = internal.TryAgainAfter
445+
443446
// SetDefaultEventuallyTimeout sets the default timeout duration for Eventually. Eventually will repeatedly poll your condition until it succeeds, or until this timeout elapses.
444447
func SetDefaultEventuallyTimeout(t time.Duration) {
445448
Default.SetDefaultEventuallyTimeout(t)

internal/async_assertion.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
350350
defer lock.Unlock()
351351
message := ""
352352
if err != nil {
353+
//TODO - formatting for TryAgainAfter?
353354
if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() {
354355
message = err.Error()
355356
for _, attachment := range asyncSignal.Attachments {
@@ -385,16 +386,25 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
385386
}
386387

387388
for {
388-
if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() {
389-
fail("Told to stop trying")
390-
return false
389+
var nextPoll <-chan time.Time = nil
390+
var isTryAgainAfterError = false
391+
392+
if asyncSignal, ok := AsAsyncSignalError(err); ok {
393+
if asyncSignal.IsStopTrying() {
394+
fail("Told to stop trying")
395+
return false
396+
}
397+
if asyncSignal.IsTryAgainAfter() {
398+
nextPoll = time.After(asyncSignal.TryAgainDuration())
399+
isTryAgainAfterError = true
400+
}
391401
}
392402

393403
if err == nil && matches == desiredMatch {
394404
if assertion.asyncType == AsyncAssertionTypeEventually {
395405
return true
396406
}
397-
} else {
407+
} else if !isTryAgainAfterError {
398408
if assertion.asyncType == AsyncAssertionTypeConsistently {
399409
fail("Failed")
400410
return false
@@ -410,8 +420,12 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
410420
}
411421
}
412422

423+
if nextPoll == nil {
424+
nextPoll = assertion.afterPolling()
425+
}
426+
413427
select {
414-
case <-assertion.afterPolling():
428+
case <-nextPoll:
415429
v, e := pollActual()
416430
lock.Lock()
417431
value, err = v, e
@@ -431,6 +445,10 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
431445
fail("Timed out")
432446
return false
433447
} else {
448+
if isTryAgainAfterError {
449+
fail("Timed out while waiting on TryAgainAfter")
450+
return false
451+
}
434452
return true
435453
}
436454
}

internal/async_assertion_test.go

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,6 @@ sprocket:
12321232

12331233
})
12341234
})
1235-
12361235
})
12371236

12381237
Describe("The StopTrying signal - when sent by the matcher", func() {
@@ -1317,6 +1316,114 @@ sprocket:
13171316
})
13181317
})
13191318

1319+
Describe("dynamically adjusting the polling interval", func() {
1320+
var i int
1321+
var times []time.Duration
1322+
var t time.Time
1323+
1324+
BeforeEach(func() {
1325+
i = 0
1326+
times = []time.Duration{}
1327+
t = time.Now()
1328+
})
1329+
1330+
Context("and the assertion eventually succeeds", func() {
1331+
It("adjusts the timing of the next iteration", func() {
1332+
Eventually(func() error {
1333+
times = append(times, time.Since(t))
1334+
t = time.Now()
1335+
i += 1
1336+
if i < 3 {
1337+
return errors.New("stay on target")
1338+
}
1339+
if i == 3 {
1340+
return TryAgainAfter(time.Millisecond * 200)
1341+
}
1342+
if i == 4 {
1343+
return errors.New("you've switched off your targeting computer")
1344+
}
1345+
if i == 5 {
1346+
TryAgainAfter(time.Millisecond * 100).Now()
1347+
}
1348+
if i == 6 {
1349+
return errors.New("stay on target")
1350+
}
1351+
return nil
1352+
}).ProbeEvery(time.Millisecond * 10).Should(Succeed())
1353+
Ω(i).Should(Equal(7))
1354+
Ω(times).Should(HaveLen(7))
1355+
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1356+
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1357+
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1358+
Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200))
1359+
Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1360+
Ω(times[5]).Should(BeNumerically("~", time.Millisecond*100, time.Millisecond*100))
1361+
Ω(times[6]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1362+
})
1363+
})
1364+
1365+
Context("and the assertion timesout while waiting", func() {
1366+
It("fails with a timeout and emits the try again after error", func() {
1367+
ig.G.Eventually(func() (int, error) {
1368+
times = append(times, time.Since(t))
1369+
t = time.Now()
1370+
i += 1
1371+
if i < 3 {
1372+
return i, nil
1373+
}
1374+
if i == 3 {
1375+
return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam"))
1376+
}
1377+
return i, nil
1378+
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(Equal(4))
1379+
Ω(i).Should(Equal(3))
1380+
Ω(times).Should(HaveLen(3))
1381+
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1382+
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1383+
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1384+
1385+
Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after"))
1386+
Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam"))
1387+
})
1388+
})
1389+
1390+
Context("when used with Consistently", func() {
1391+
It("doesn't immediately count as a failure and adjusts the timing of the next iteration", func() {
1392+
Consistently(func() (int, error) {
1393+
times = append(times, time.Since(t))
1394+
t = time.Now()
1395+
i += 1
1396+
if i == 3 {
1397+
return i, TryAgainAfter(time.Millisecond * 200)
1398+
}
1399+
return i, nil
1400+
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 500).Should(BeNumerically("<", 1000))
1401+
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1402+
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1403+
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1404+
Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200))
1405+
Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1406+
})
1407+
1408+
It("doesn count as a failure if a timeout occurs during the try again after window", func() {
1409+
ig.G.Consistently(func() (int, error) {
1410+
times = append(times, time.Since(t))
1411+
t = time.Now()
1412+
i += 1
1413+
if i == 3 {
1414+
return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam"))
1415+
}
1416+
return i, nil
1417+
}).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(BeNumerically("<", 1000))
1418+
Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1419+
Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1420+
Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10))
1421+
Ω(ig.FailureMessage).Should(ContainSubstring("Timed out while waiting on TryAgainAfter after"))
1422+
Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam"))
1423+
})
1424+
})
1425+
})
1426+
13201427
When("vetting optional description parameters", func() {
13211428
It("panics when Gomega matcher is at the beginning of optional description parameters", func() {
13221429
ig := NewInstrumentedGomega()

internal/async_signal_error.go

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package internal
33
import (
44
"errors"
55
"time"
6+
"fmt"
67
)
78

89
type AsyncSignalErrorType int
@@ -12,27 +13,24 @@ const (
1213
AsyncSignalErrorTypeTryAgainAfter
1314
)
1415

15-
type StopTryingError interface {
16+
type AsyncSignalError interface {
1617
error
17-
Wrap(err error) StopTryingError
18-
Attach(description string, obj any) StopTryingError
18+
Wrap(err error) AsyncSignalError
19+
Attach(description string, obj any) AsyncSignalError
1920
Now()
2021
}
2122

22-
type TryAgainAfterError interface {
23-
error
24-
Now()
25-
}
2623

27-
var StopTrying = func(message string) StopTryingError {
28-
return &AsyncSignalError{
24+
var StopTrying = func(message string) AsyncSignalError {
25+
return &AsyncSignalErrorImpl{
2926
message: message,
3027
asyncSignalErrorType: AsyncSignalErrorTypeStopTrying,
3128
}
3229
}
3330

34-
var TryAgainAfter = func(duration time.Duration) TryAgainAfterError {
35-
return &AsyncSignalError{
31+
var TryAgainAfter = func(duration time.Duration) AsyncSignalError {
32+
return &AsyncSignalErrorImpl{
33+
message: fmt.Sprintf("told to try again after %s", duration),
3634
duration: duration,
3735
asyncSignalErrorType: AsyncSignalErrorTypeTryAgainAfter,
3836
}
@@ -43,61 +41,61 @@ type AsyncSignalErrorAttachment struct {
4341
Object any
4442
}
4543

46-
type AsyncSignalError struct {
44+
type AsyncSignalErrorImpl struct {
4745
message string
4846
wrappedErr error
4947
asyncSignalErrorType AsyncSignalErrorType
5048
duration time.Duration
5149
Attachments []AsyncSignalErrorAttachment
5250
}
5351

54-
func (s *AsyncSignalError) Wrap(err error) StopTryingError {
52+
func (s *AsyncSignalErrorImpl) Wrap(err error) AsyncSignalError {
5553
s.wrappedErr = err
5654
return s
5755
}
5856

59-
func (s *AsyncSignalError) Attach(description string, obj any) StopTryingError {
57+
func (s *AsyncSignalErrorImpl) Attach(description string, obj any) AsyncSignalError {
6058
s.Attachments = append(s.Attachments, AsyncSignalErrorAttachment{description, obj})
6159
return s
6260
}
6361

64-
func (s *AsyncSignalError) Error() string {
62+
func (s *AsyncSignalErrorImpl) Error() string {
6563
if s.wrappedErr == nil {
6664
return s.message
6765
} else {
6866
return s.message + ": " + s.wrappedErr.Error()
6967
}
7068
}
7169

72-
func (s *AsyncSignalError) Unwrap() error {
70+
func (s *AsyncSignalErrorImpl) Unwrap() error {
7371
if s == nil {
7472
return nil
7573
}
7674
return s.wrappedErr
7775
}
7876

79-
func (s *AsyncSignalError) Now() {
77+
func (s *AsyncSignalErrorImpl) Now() {
8078
panic(s)
8179
}
8280

83-
func (s *AsyncSignalError) IsStopTrying() bool {
81+
func (s *AsyncSignalErrorImpl) IsStopTrying() bool {
8482
return s.asyncSignalErrorType == AsyncSignalErrorTypeStopTrying
8583
}
8684

87-
func (s *AsyncSignalError) IsTryAgainAfter() bool {
85+
func (s *AsyncSignalErrorImpl) IsTryAgainAfter() bool {
8886
return s.asyncSignalErrorType == AsyncSignalErrorTypeTryAgainAfter
8987
}
9088

91-
func (s *AsyncSignalError) TryAgainDuration() time.Duration {
89+
func (s *AsyncSignalErrorImpl) TryAgainDuration() time.Duration {
9290
return s.duration
9391
}
9492

95-
func AsAsyncSignalError(actual interface{}) (*AsyncSignalError, bool) {
93+
func AsAsyncSignalError(actual interface{}) (*AsyncSignalErrorImpl, bool) {
9694
if actual == nil {
9795
return nil, false
9896
}
9997
if actualErr, ok := actual.(error); ok {
100-
var target *AsyncSignalError
98+
var target *AsyncSignalErrorImpl
10199
if errors.As(actualErr, &target) {
102100
return target, true
103101
} else {

0 commit comments

Comments
 (0)