Introduce TryAgainAfter
Onsi Fakhouri
1 year, 6 months ago
552 | 552 | If a matcher returns `StopTrying` for `error`, or calls `StopTrying(...).Now()`, `Eventually` and `Consistently` will stop polling and fail: `StopTrying` **always** signifies a failure. |
553 | 553 | |
554 | 554 | > 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. |
555 | ||
556 | ### Changing the Polling Interval Dynamically | |
557 | ||
558 | 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. | |
559 | ||
560 | 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. | |
561 | ||
562 | 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. | |
555 | 563 | |
556 | 564 | ### Modifying Default Intervals |
557 | 565 |
405 | 405 | } |
406 | 406 | |
407 | 407 | /* |
408 | StopTrying can be used to signal to Eventually and Consistently that the polled function will not change | |
409 | 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. | |
408 | 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. | |
410 | 409 | |
411 | 410 | 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. |
412 | 411 | |
413 | 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. | |
412 | 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. | |
414 | 413 | |
415 | 414 | Here are a couple of examples. This is how you might use StopTrying() as an error to signal that Eventually should stop: |
416 | 415 | |
417 | 416 | playerIndex, numPlayers := 0, 11 |
418 | 417 | Eventually(func() (string, error) { |
419 | name := client.FetchPlayer(playerIndex) | |
420 | playerIndex += 1 | |
421 | if playerIndex == numPlayers { | |
422 | return name, StopTrying("No more players left") | |
423 | } else { | |
424 | return name, nil | |
425 | } | |
418 | if playerIndex == numPlayers { | |
419 | return "", StopTrying("no more players left") | |
420 | } | |
421 | name := client.FetchPlayer(playerIndex) | |
422 | playerIndex += 1 | |
423 | return name, nil | |
426 | 424 | }).Should(Equal("Patrick Mahomes")) |
427 | ||
428 | note that the final `name` returned alongside `StopTrying()` will be processed. | |
429 | 425 | |
430 | 426 | And here's an example where `StopTrying().Now()` is called to halt execution immediately: |
431 | 427 | |
432 | 428 | Eventually(func() []string { |
433 | 429 | names, err := client.FetchAllPlayers() |
434 | 430 | if err == client.IRRECOVERABLE_ERROR { |
435 | StopTrying("Irrecoverable error occurred").Now() | |
431 | StopTrying("Irrecoverable error occurred").Wrap(err).Now() | |
436 | 432 | } |
437 | 433 | return names |
438 | 434 | }).Should(ContainElement("Patrick Mahomes")) |
439 | 435 | */ |
440 | 436 | var StopTrying = internal.StopTrying |
441 | 437 | |
438 | /* | |
439 | 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()` | |
440 | ||
441 | 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`. | |
442 | */ | |
443 | var TryAgainAfter = internal.TryAgainAfter | |
444 | ||
442 | 445 | // SetDefaultEventuallyTimeout sets the default timeout duration for Eventually. Eventually will repeatedly poll your condition until it succeeds, or until this timeout elapses. |
443 | 446 | func SetDefaultEventuallyTimeout(t time.Duration) { |
444 | 447 | Default.SetDefaultEventuallyTimeout(t) |
349 | 349 | defer lock.Unlock() |
350 | 350 | message := "" |
351 | 351 | if err != nil { |
352 | //TODO - formatting for TryAgainAfter? | |
352 | 353 | if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() { |
353 | 354 | message = err.Error() |
354 | 355 | for _, attachment := range asyncSignal.Attachments { |
384 | 385 | } |
385 | 386 | |
386 | 387 | for { |
387 | if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() { | |
388 | fail("Told to stop trying") | |
389 | return false | |
388 | var nextPoll <-chan time.Time = nil | |
389 | var isTryAgainAfterError = false | |
390 | ||
391 | if asyncSignal, ok := AsAsyncSignalError(err); ok { | |
392 | if asyncSignal.IsStopTrying() { | |
393 | fail("Told to stop trying") | |
394 | return false | |
395 | } | |
396 | if asyncSignal.IsTryAgainAfter() { | |
397 | nextPoll = time.After(asyncSignal.TryAgainDuration()) | |
398 | isTryAgainAfterError = true | |
399 | } | |
390 | 400 | } |
391 | 401 | |
392 | 402 | if err == nil && matches == desiredMatch { |
393 | 403 | if assertion.asyncType == AsyncAssertionTypeEventually { |
394 | 404 | return true |
395 | 405 | } |
396 | } else { | |
406 | } else if !isTryAgainAfterError { | |
397 | 407 | if assertion.asyncType == AsyncAssertionTypeConsistently { |
398 | 408 | fail("Failed") |
399 | 409 | return false |
409 | 419 | } |
410 | 420 | } |
411 | 421 | |
422 | if nextPoll == nil { | |
423 | nextPoll = assertion.afterPolling() | |
424 | } | |
425 | ||
412 | 426 | select { |
413 | case <-assertion.afterPolling(): | |
427 | case <-nextPoll: | |
414 | 428 | v, e := pollActual() |
415 | 429 | lock.Lock() |
416 | 430 | value, err = v, e |
430 | 444 | fail("Timed out") |
431 | 445 | return false |
432 | 446 | } else { |
447 | if isTryAgainAfterError { | |
448 | fail("Timed out while waiting on TryAgainAfter") | |
449 | return false | |
450 | } | |
433 | 451 | return true |
434 | 452 | } |
435 | 453 | } |
1231 | 1231 | |
1232 | 1232 | }) |
1233 | 1233 | }) |
1234 | ||
1235 | 1234 | }) |
1236 | 1235 | |
1237 | 1236 | Describe("The StopTrying signal - when sent by the matcher", func() { |
1312 | 1311 | Eventually(nil).Should(QuickMatcher(func(actual any) (bool, error) { |
1313 | 1312 | panic("welp") |
1314 | 1313 | })) |
1314 | }) | |
1315 | }) | |
1316 | }) | |
1317 | ||
1318 | Describe("dynamically adjusting the polling interval", func() { | |
1319 | var i int | |
1320 | var times []time.Duration | |
1321 | var t time.Time | |
1322 | ||
1323 | BeforeEach(func() { | |
1324 | i = 0 | |
1325 | times = []time.Duration{} | |
1326 | t = time.Now() | |
1327 | }) | |
1328 | ||
1329 | Context("and the assertion eventually succeeds", func() { | |
1330 | It("adjusts the timing of the next iteration", func() { | |
1331 | Eventually(func() error { | |
1332 | times = append(times, time.Since(t)) | |
1333 | t = time.Now() | |
1334 | i += 1 | |
1335 | if i < 3 { | |
1336 | return errors.New("stay on target") | |
1337 | } | |
1338 | if i == 3 { | |
1339 | return TryAgainAfter(time.Millisecond * 200) | |
1340 | } | |
1341 | if i == 4 { | |
1342 | return errors.New("you've switched off your targeting computer") | |
1343 | } | |
1344 | if i == 5 { | |
1345 | TryAgainAfter(time.Millisecond * 100).Now() | |
1346 | } | |
1347 | if i == 6 { | |
1348 | return errors.New("stay on target") | |
1349 | } | |
1350 | return nil | |
1351 | }).ProbeEvery(time.Millisecond * 10).Should(Succeed()) | |
1352 | Ω(i).Should(Equal(7)) | |
1353 | Ω(times).Should(HaveLen(7)) | |
1354 | Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1355 | Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1356 | Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1357 | Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200)) | |
1358 | Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1359 | Ω(times[5]).Should(BeNumerically("~", time.Millisecond*100, time.Millisecond*100)) | |
1360 | Ω(times[6]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1361 | }) | |
1362 | }) | |
1363 | ||
1364 | Context("and the assertion timesout while waiting", func() { | |
1365 | It("fails with a timeout and emits the try again after error", func() { | |
1366 | ig.G.Eventually(func() (int, error) { | |
1367 | times = append(times, time.Since(t)) | |
1368 | t = time.Now() | |
1369 | i += 1 | |
1370 | if i < 3 { | |
1371 | return i, nil | |
1372 | } | |
1373 | if i == 3 { | |
1374 | return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam")) | |
1375 | } | |
1376 | return i, nil | |
1377 | }).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(Equal(4)) | |
1378 | Ω(i).Should(Equal(3)) | |
1379 | Ω(times).Should(HaveLen(3)) | |
1380 | Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1381 | Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1382 | Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1383 | ||
1384 | Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after")) | |
1385 | Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam")) | |
1386 | }) | |
1387 | }) | |
1388 | ||
1389 | Context("when used with Consistently", func() { | |
1390 | It("doesn't immediately count as a failure and adjusts the timing of the next iteration", func() { | |
1391 | Consistently(func() (int, error) { | |
1392 | times = append(times, time.Since(t)) | |
1393 | t = time.Now() | |
1394 | i += 1 | |
1395 | if i == 3 { | |
1396 | return i, TryAgainAfter(time.Millisecond * 200) | |
1397 | } | |
1398 | return i, nil | |
1399 | }).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 500).Should(BeNumerically("<", 1000)) | |
1400 | Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1401 | Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1402 | Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1403 | Ω(times[3]).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*200)) | |
1404 | Ω(times[4]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1405 | }) | |
1406 | ||
1407 | It("doesn count as a failure if a timeout occurs during the try again after window", func() { | |
1408 | ig.G.Consistently(func() (int, error) { | |
1409 | times = append(times, time.Since(t)) | |
1410 | t = time.Now() | |
1411 | i += 1 | |
1412 | if i == 3 { | |
1413 | return i, TryAgainAfter(time.Second * 10).Wrap(errors.New("bam")) | |
1414 | } | |
1415 | return i, nil | |
1416 | }).ProbeEvery(time.Millisecond * 10).WithTimeout(time.Millisecond * 300).Should(BeNumerically("<", 1000)) | |
1417 | Ω(times[0]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1418 | Ω(times[1]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1419 | Ω(times[2]).Should(BeNumerically("~", time.Millisecond*10, time.Millisecond*10)) | |
1420 | Ω(ig.FailureMessage).Should(ContainSubstring("Timed out while waiting on TryAgainAfter after")) | |
1421 | Ω(ig.FailureMessage).Should(ContainSubstring("Error: told to try again after 10s: bam")) | |
1315 | 1422 | }) |
1316 | 1423 | }) |
1317 | 1424 | }) |
2 | 2 | import ( |
3 | 3 | "errors" |
4 | 4 | "time" |
5 | "fmt" | |
5 | 6 | ) |
6 | 7 | |
7 | 8 | type AsyncSignalErrorType int |
11 | 12 | AsyncSignalErrorTypeTryAgainAfter |
12 | 13 | ) |
13 | 14 | |
14 | type StopTryingError interface { | |
15 | type AsyncSignalError interface { | |
15 | 16 | error |
16 | Wrap(err error) StopTryingError | |
17 | Attach(description string, obj any) StopTryingError | |
17 | Wrap(err error) AsyncSignalError | |
18 | Attach(description string, obj any) AsyncSignalError | |
18 | 19 | Now() |
19 | 20 | } |
20 | 21 | |
21 | type TryAgainAfterError interface { | |
22 | error | |
23 | Now() | |
24 | } | |
25 | 22 | |
26 | var StopTrying = func(message string) StopTryingError { | |
27 | return &AsyncSignalError{ | |
23 | var StopTrying = func(message string) AsyncSignalError { | |
24 | return &AsyncSignalErrorImpl{ | |
28 | 25 | message: message, |
29 | 26 | asyncSignalErrorType: AsyncSignalErrorTypeStopTrying, |
30 | 27 | } |
31 | 28 | } |
32 | 29 | |
33 | var TryAgainAfter = func(duration time.Duration) TryAgainAfterError { | |
34 | return &AsyncSignalError{ | |
30 | var TryAgainAfter = func(duration time.Duration) AsyncSignalError { | |
31 | return &AsyncSignalErrorImpl{ | |
32 | message: fmt.Sprintf("told to try again after %s", duration), | |
35 | 33 | duration: duration, |
36 | 34 | asyncSignalErrorType: AsyncSignalErrorTypeTryAgainAfter, |
37 | 35 | } |
42 | 40 | Object any |
43 | 41 | } |
44 | 42 | |
45 | type AsyncSignalError struct { | |
43 | type AsyncSignalErrorImpl struct { | |
46 | 44 | message string |
47 | 45 | wrappedErr error |
48 | 46 | asyncSignalErrorType AsyncSignalErrorType |
50 | 48 | Attachments []AsyncSignalErrorAttachment |
51 | 49 | } |
52 | 50 | |
53 | func (s *AsyncSignalError) Wrap(err error) StopTryingError { | |
51 | func (s *AsyncSignalErrorImpl) Wrap(err error) AsyncSignalError { | |
54 | 52 | s.wrappedErr = err |
55 | 53 | return s |
56 | 54 | } |
57 | 55 | |
58 | func (s *AsyncSignalError) Attach(description string, obj any) StopTryingError { | |
56 | func (s *AsyncSignalErrorImpl) Attach(description string, obj any) AsyncSignalError { | |
59 | 57 | s.Attachments = append(s.Attachments, AsyncSignalErrorAttachment{description, obj}) |
60 | 58 | return s |
61 | 59 | } |
62 | 60 | |
63 | func (s *AsyncSignalError) Error() string { | |
61 | func (s *AsyncSignalErrorImpl) Error() string { | |
64 | 62 | if s.wrappedErr == nil { |
65 | 63 | return s.message |
66 | 64 | } else { |
68 | 66 | } |
69 | 67 | } |
70 | 68 | |
71 | func (s *AsyncSignalError) Unwrap() error { | |
69 | func (s *AsyncSignalErrorImpl) Unwrap() error { | |
72 | 70 | if s == nil { |
73 | 71 | return nil |
74 | 72 | } |
75 | 73 | return s.wrappedErr |
76 | 74 | } |
77 | 75 | |
78 | func (s *AsyncSignalError) Now() { | |
76 | func (s *AsyncSignalErrorImpl) Now() { | |
79 | 77 | panic(s) |
80 | 78 | } |
81 | 79 | |
82 | func (s *AsyncSignalError) IsStopTrying() bool { | |
80 | func (s *AsyncSignalErrorImpl) IsStopTrying() bool { | |
83 | 81 | return s.asyncSignalErrorType == AsyncSignalErrorTypeStopTrying |
84 | 82 | } |
85 | 83 | |
86 | func (s *AsyncSignalError) IsTryAgainAfter() bool { | |
84 | func (s *AsyncSignalErrorImpl) IsTryAgainAfter() bool { | |
87 | 85 | return s.asyncSignalErrorType == AsyncSignalErrorTypeTryAgainAfter |
88 | 86 | } |
89 | 87 | |
90 | func (s *AsyncSignalError) TryAgainDuration() time.Duration { | |
88 | func (s *AsyncSignalErrorImpl) TryAgainDuration() time.Duration { | |
91 | 89 | return s.duration |
92 | 90 | } |
93 | 91 | |
94 | func AsAsyncSignalError(actual interface{}) (*AsyncSignalError, bool) { | |
92 | func AsAsyncSignalError(actual interface{}) (*AsyncSignalErrorImpl, bool) { | |
95 | 93 | if actual == nil { |
96 | 94 | return nil, false |
97 | 95 | } |
98 | 96 | if actualErr, ok := actual.(error); ok { |
99 | var target *AsyncSignalError | |
97 | var target *AsyncSignalErrorImpl | |
100 | 98 | if errors.As(actualErr, &target) { |
101 | 99 | return target, true |
102 | 100 | } else { |
15 | 15 | st := StopTrying("I've tried 17 times - give up!") |
16 | 16 | Ω(st.Error()).Should(Equal("I've tried 17 times - give up!")) |
17 | 17 | Ω(errors.Unwrap(st)).Should(BeNil()) |
18 | Ω(st.(*internal.AsyncSignalError).IsStopTrying()).Should(BeTrue()) | |
18 | Ω(st.(*internal.AsyncSignalErrorImpl).IsStopTrying()).Should(BeTrue()) | |
19 | 19 | }) |
20 | 20 | }) |
21 | 21 | |
31 | 31 | |
32 | 32 | Describe("When attaching objects", func() { |
33 | 33 | It("attaches them, with their descriptions", func() { |
34 | st := StopTrying("Welp!").Attach("Max retries attained", 17).Attach("Got this response", "FLOOP").(*internal.AsyncSignalError) | |
34 | st := StopTrying("Welp!").Attach("Max retries attained", 17).Attach("Got this response", "FLOOP").(*internal.AsyncSignalErrorImpl) | |
35 | 35 | Ω(st.Attachments).Should(HaveLen(2)) |
36 | 36 | Ω(st.Attachments[0]).Should(Equal(internal.AsyncSignalErrorAttachment{"Max retries attained", 17})) |
37 | 37 | Ω(st.Attachments[1]).Should(Equal(internal.AsyncSignalErrorAttachment{"Got this response", "FLOOP"})) |
40 | 40 | |
41 | 41 | Describe("when invoking Now()", func() { |
42 | 42 | It("should panic with itself", func() { |
43 | st := StopTrying("bam").(*internal.AsyncSignalError) | |
43 | st := StopTrying("bam").(*internal.AsyncSignalErrorImpl) | |
44 | 44 | Ω(st.Now).Should(PanicWith(st)) |
45 | 45 | }) |
46 | 46 | }) |