From 67ab22ccec8a5155eece7d7fb8a912f15e97fdca Mon Sep 17 00:00:00 2001 From: Onsi Fakhouri Date: Tue, 25 Oct 2022 21:34:54 -0600 Subject: [PATCH] Simplify StopTrying handling - StopTrying now always signifies a failure - StopTrying(message).Wrap(err) can wrap an error - STopTrying(message).Attach(description, object) can attach arbitrary objects to the error report. These are rendered with Gomega's default formatter. --- docs/index.md | 51 ++-- internal/async_assertion.go | 185 +++++------- internal/async_assertion_test.go | 438 +++++++++++++--------------- internal/async_signal_error.go | 74 ++++- internal/async_signal_error_test.go | 76 +++-- 5 files changed, 398 insertions(+), 426 deletions(-) diff --git a/docs/index.md b/docs/index.md index 18f28bec1..a4fb0a2fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -492,24 +492,23 @@ When no explicit duration is provided, `Consistently` will use the default durat ### Bailing Out Early - Polling Functions -There are cases where you need to signal to `Eventually` and `Consistently` that they should stop trying. Gomega provides`StopTrying(format string, args ...any)` to allow you to send that signal. There are two ways to use `StopTrying`. +There are cases where you need to signal to `Eventually` and `Consistently` that they should stop trying. Gomega provides`StopTrying(message string)` to allow you to send that signal. There are two ways to use `StopTrying`. First, you can return `StopTrying` as an error. Consider, for example, the case where `Eventually` is searching through a set of possible queries with a server: ```go 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 + return "", StopTrying("no more players left") } + name := client.FetchPlayer(playerIndex) + playerIndex += 1 + return name, nil }).Should(Equal("Patrick Mahomes")) ``` -Here we return a `StopTrying` error to tell `Eventually` that we've looked through all possible players and that it should stop. Note that `Eventually` will check last name returned by this function and succeed if that name is the desired name. +Here we return a `StopTrying` error to tell `Eventually` that we've looked through all possible players and that it should stop. You can also call `StopTrying(...).Now()` to immediately end execution of the function. Consider, for example, the case of a client communicating with a server that experiences an irrevocable error: @@ -523,35 +522,25 @@ Eventually(func() []string { }).Should(ContainElement("Patrick Mahomes")) ``` -calling `.Now()` will trigger a panic that will signal to `Eventually` that the it should stop trying. +calling `.Now()` will trigger a panic that will signal to `Eventually` that it should stop trying. -You can also use both verison of `StopTrying()` with `Consistently`. Since `Consistently` is validating that something is _true_ consitently for the entire requested duration sending a `StopTrying()` signal is interpreted as success. Here's a somewhat contrived example: +You can also return `StopTrying()` errors and use `StopTrying().Now()` with `Consistently`. -```go -go client.DoSomethingComplicated() -Consistently(func() int { - if client.Status() == client.DoneStatus { - StopTrying("Client finished").Now() - } - return client.NumErrors() -}).Should(Equal(0)) -``` +Both `Eventually` and `Consistently` always treat the `StopTrying()` signal as a failure. The failure message will include the message passed in to `StopTrying()`. -here we succeed because no errors were identified while the client was working. +You can add additional information to this failure message in a few ways. You can wrap an error via `StopTrying(message).Wrap(wrappedErr)` - now the output will read `: `. -`StopTrying` also allows you wrap an error using the `%w` verb. For example: +You can also attach arbitrary objects to `StopTrying()` via `StopTrying(message).Attach(description string, object any)`. Gomega will run the object through Gomega's standard formatting library to build a consistent representation for end users. You can attach multiple objects in this way and the output will look like: -```go -Eventually(func() []string { - names, err := client.FetchAllPlayers() - if err == client.TOKEN_EXPIRED || err == client.SEVER_GONE { - StopTrying("An irrecoverable error occurred: %w", err).Now() - } - return names -}).Should(ContainElement("Patrick Mahomes")) ``` +Told to stop trying after -Wrapping an error in this way allows you to simultaneously signal that `Eventually` should stop trying _and_ that the assertion should count as a failure regardless of the state of the match when `StopTrying` is returned/thrown. +: + : + + : + +``` ### Bailing Out Early - Matchers @@ -561,9 +550,9 @@ Just like functions being polled, matchers can also indicate if `Eventually`/`Co Match(actual interface{}) (success bool, err error) ``` -If a matcher returns `StopTrying` for `error`, or calls `StopTrying(...).Now()`, `Eventually` and `Consistently` will stop polling and use the returned value of `success` to determine if the match succeeded or not. To signal that the `success` values should wrap an error via `StopTrying(": %w", err)`. +If a matcher returns `StopTrying` for `error`, or calls `StopTrying(...).Now()`, `Eventually` and `Consistently` will stop polling and fail: `StopTrying` **always** signifies a failure. -> Note: An older mechanism for doing this is documented in the [custom matchers section below](#aborting-eventuallyconsistently) +> 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() bool` method, allows matchers to signify that no future change is possible out-of-band of the call to the matcher. ### Modifying Default Intervals diff --git a/internal/async_assertion.go b/internal/async_assertion.go index 89436f1c1..fd2572cb0 100644 --- a/internal/async_assertion.go +++ b/internal/async_assertion.go @@ -2,7 +2,6 @@ package internal import ( "context" - "errors" "fmt" "reflect" "runtime" @@ -129,25 +128,24 @@ func (assertion *AsyncAssertion) buildDescription(optionalDescription ...interfa return fmt.Sprintf(optionalDescription[0].(string), optionalDescription[1:]...) + "\n" } -func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (interface{}, error, *AsyncSignalError) { - var err error - var asyncSignal *AsyncSignalError = nil - +func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (interface{}, error) { if len(values) == 0 { - return nil, fmt.Errorf("No values were returned by the function passed to Gomega"), asyncSignal + return nil, fmt.Errorf("No values were returned by the function passed to Gomega") } + actual := values[0].Interface() - if asyncSignalErr, ok := AsAsyncSignalError(actual); ok { - asyncSignal = asyncSignalErr + if _, ok := AsAsyncSignalError(actual); ok { + return actual, actual.(error) } + + var err error for i, extraValue := range values[1:] { extra := extraValue.Interface() if extra == nil { continue } - if asyncSignalErr, ok := AsAsyncSignalError(extra); ok { - asyncSignal = asyncSignalErr - continue + if _, ok := AsAsyncSignalError(extra); ok { + return actual, extra.(error) } extraType := reflect.TypeOf(extra) zero := reflect.Zero(extraType).Interface() @@ -155,18 +153,14 @@ func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (in continue } if i == len(values)-2 && extraType.Implements(errInterface) { - err = fmt.Errorf("function returned error: %s\n%s", extra, format.Object(extra, 1)) - continue + err = fmt.Errorf("function returned error: %w", extra.(error)) } if err == nil { err = fmt.Errorf("Unexpected non-nil/non-zero return value at index %d:\n\t<%T>: %#v", i+1, extra, extra) } } - if err == nil { - err = errors.Unwrap(asyncSignal) - } - return actual, err, asyncSignal + return actual, err } func (assertion *AsyncAssertion) invalidFunctionError(t reflect.Type) error { @@ -197,9 +191,9 @@ You can learn more at https://onsi.github.io/gomega/#eventually `, assertion.asyncType, t, t.NumIn(), numProvided, have, assertion.asyncType) } -func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error, *AsyncSignalError), error) { +func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error), error) { if !assertion.actualIsFunc { - return func() (interface{}, error, *AsyncSignalError) { return assertion.actual, nil, nil }, nil + return func() (interface{}, error) { return assertion.actual, nil }, nil } actualValue := reflect.ValueOf(assertion.actual) actualType := reflect.TypeOf(assertion.actual) @@ -251,24 +245,22 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error return nil, assertion.argumentMismatchError(actualType, len(inValues)) } - return func() (actual interface{}, err error, asyncSignal *AsyncSignalError) { + return func() (actual interface{}, err error) { var values []reflect.Value assertionFailure = nil defer func() { if numOut == 0 && takesGomega { actual = assertionFailure } else { - actual, err, asyncSignal = assertion.processReturnValues(values) - if assertionFailure != nil { + actual, err = assertion.processReturnValues(values) + _, isAsyncError := AsAsyncSignalError(err) + if assertionFailure != nil && !isAsyncError { err = assertionFailure } } if e := recover(); e != nil { - if asyncSignalErr, ok := AsAsyncSignalError(e); ok { - asyncSignal = asyncSignalErr - if err == nil { - err = errors.Unwrap(asyncSignal) - } + if _, isAsyncError := AsAsyncSignalError(e); isAsyncError { + err = e.(error) } else if assertionFailure == nil { panic(e) } @@ -306,28 +298,18 @@ func (assertion *AsyncAssertion) afterPolling() <-chan time.Time { } } -func (assertion *AsyncAssertion) matcherSaysStopTrying(matcher types.GomegaMatcher, value interface{}) *AsyncSignalError { +func (assertion *AsyncAssertion) matcherSaysStopTrying(matcher types.GomegaMatcher, value interface{}) bool { if assertion.actualIsFunc || types.MatchMayChangeInTheFuture(matcher, value) { - return nil + return false } - return StopTrying("No future change is possible. Bailing out early").(*AsyncSignalError) + return true } -func (assertion *AsyncAssertion) pollMatcher(matcher types.GomegaMatcher, value interface{}, currentAsyncSignal *AsyncSignalError) (matches bool, err error, asyncSignal *AsyncSignalError) { - // we pass through the current StopTrying error and only overwrite it with what the matcher says if it is nil - asyncSignal = currentAsyncSignal - - if currentAsyncSignal == nil || !currentAsyncSignal.StopTrying() { - asyncSignal = assertion.matcherSaysStopTrying(matcher, value) - } - +func (assertion *AsyncAssertion) pollMatcher(matcher types.GomegaMatcher, value interface{}) (matches bool, err error) { defer func() { if e := recover(); e != nil { - if asyncSignalErr, ok := AsAsyncSignalError(e); ok { - if asyncSignal == nil { - asyncSignal = asyncSignalErr - } - err = asyncSignalErr + if _, isAsyncError := AsAsyncSignalError(e); isAsyncError { + err = e.(error) } else { panic(e) } @@ -335,12 +317,6 @@ func (assertion *AsyncAssertion) pollMatcher(matcher types.GomegaMatcher, value }() matches, err = matcher.Match(value) - if asyncSignalErr, ok := AsAsyncSignalError(err); ok { - err = errors.Unwrap(asyncSignalErr) - if asyncSignal == nil { - asyncSignal = asyncSignalErr - } - } return } @@ -352,6 +328,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch var matches bool var err error + var oracleMatcherSaysStop bool assertion.g.THelper() @@ -361,20 +338,27 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch return false } - value, err, asyncSignal := pollActual() - + value, err := pollActual() if err == nil { - matches, err, asyncSignal = assertion.pollMatcher(matcher, value, asyncSignal) + oracleMatcherSaysStop = assertion.matcherSaysStopTrying(matcher, value) + matches, err = assertion.pollMatcher(matcher, value) } messageGenerator := func() string { // can be called out of band by Ginkgo if the user requests a progress report lock.Lock() defer lock.Unlock() - errMsg := "" message := "" if err != nil { - errMsg = "Error: " + err.Error() + if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() { + message = err.Error() + for _, attachment := range asyncSignal.Attachments { + message += fmt.Sprintf("\n%s:\n", attachment.Description) + message += format.Object(attachment.Object, 1) + } + } else { + message = "Error: " + err.Error() + } } else { if desiredMatch { message = matcher.FailureMessage(value) @@ -383,7 +367,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch } } description := assertion.buildDescription(optionalDescription...) - return fmt.Sprintf("%s%s%s", description, message, errMsg) + return fmt.Sprintf("%s%s", description, message) } fail := func(preamble string) { @@ -400,74 +384,53 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch } } - if assertion.asyncType == AsyncAssertionTypeEventually { - for { - if err == nil && matches == desiredMatch { - return true - } - - if asyncSignal != nil && asyncSignal.StopTrying() { - fail(asyncSignal.Error() + " -") - return false - } + for { + if asyncSignal, ok := AsAsyncSignalError(err); ok && asyncSignal.IsStopTrying() { + fail("Told to stop trying") + return false + } - select { - case <-assertion.afterPolling(): - v, e, as := pollActual() - if as != nil && as.WasViaPanic() && as.StopTrying() { - // we were told to stop trying via panic - which means we dont' have reasonable new values - // we should simply use the old values and exit now - fail(as.Error() + " -") - return false - } - lock.Lock() - value, err, asyncSignal = v, e, as - lock.Unlock() - if err == nil { - m, e, as := assertion.pollMatcher(matcher, value, asyncSignal) - lock.Lock() - matches, err, asyncSignal = m, e, as - lock.Unlock() - } - case <-contextDone: - fail("Context was cancelled") - return false - case <-timeout: - fail("Timed out") - return false + if err == nil && matches == desiredMatch { + if assertion.asyncType == AsyncAssertionTypeEventually { + return true } - } - } else if assertion.asyncType == AsyncAssertionTypeConsistently { - for { - if !(err == nil && matches == desiredMatch) { + } else { + if assertion.asyncType == AsyncAssertionTypeConsistently { fail("Failed") return false } + } - if asyncSignal != nil && asyncSignal.StopTrying() { + if oracleMatcherSaysStop { + if assertion.asyncType == AsyncAssertionTypeEventually { + fail("No future change is possible. Bailing out early") + return false + } else { return true } + } - select { - case <-assertion.afterPolling(): - v, e, as := pollActual() - if as != nil && as.WasViaPanic() && as.StopTrying() { - // we were told to stop trying via panic - which means we made it this far and should return successfully - return true - } + select { + case <-assertion.afterPolling(): + v, e := pollActual() + lock.Lock() + value, err = v, e + lock.Unlock() + if err == nil { + oracleMatcherSaysStop = assertion.matcherSaysStopTrying(matcher, value) + m, e := assertion.pollMatcher(matcher, value) lock.Lock() - value, err, asyncSignal = v, e, as + matches, err = m, e lock.Unlock() - if err == nil { - m, e, as := assertion.pollMatcher(matcher, value, asyncSignal) - lock.Lock() - matches, err, asyncSignal = m, e, as - lock.Unlock() - } - case <-contextDone: - fail("Context was cancelled") + } + case <-contextDone: + fail("Context was cancelled") + return false + case <-timeout: + if assertion.asyncType == AsyncAssertionTypeEventually { + fail("Timed out") return false - case <-timeout: + } else { return true } } diff --git a/internal/async_assertion_test.go b/internal/async_assertion_test.go index 45dc74a5d..eb065ccf8 100644 --- a/internal/async_assertion_test.go +++ b/internal/async_assertion_test.go @@ -14,7 +14,8 @@ import ( ) type quickMatcher struct { - matchFunc func(actual any) (bool, error) + matchFunc func(actual any) (bool, error) + oracleFunc func(actual any) bool } func (q quickMatcher) Match(actual any) (bool, error) { @@ -29,8 +30,19 @@ func (q quickMatcher) NegatedFailureMessage(actual any) (message string) { return "QM negated failure message" } +func (q quickMatcher) MatchMayChangeInTheFuture(actual interface{}) bool { + if q.oracleFunc == nil { + return true + } + return q.oracleFunc(actual) +} + func QuickMatcher(matchFunc func(actual any) (bool, error)) OmegaMatcher { - return quickMatcher{matchFunc} + return quickMatcher{matchFunc, nil} +} + +func QuickMatcherWithOracle(matchFunc func(actual any) (bool, error), oracleFunc func(actual any) bool) OmegaMatcher { + return quickMatcher{matchFunc, oracleFunc} } type FakeGinkgoSpecContext struct { @@ -1033,311 +1045,263 @@ var _ = Describe("Asynchronous Assertions", func() { Ω(ig.FailureMessage).ShouldNot(ContainSubstring("No future change is possible.")) Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after")) }) + + It("exits early and passes when used with consistently", func() { + i := 0 + order := []string{} + Consistently(nil).Should(QuickMatcherWithOracle( + func(_ any) (bool, error) { + order = append(order, fmt.Sprintf("match %d", i)) + i += 1 + if i > 4 { + return false, nil + } + return true, nil + }, + func(_ any) bool { + order = append(order, fmt.Sprintf("oracle %d", i)) + if i == 3 { + return false + } + return true + }, + )) + Ω(i).Should(Equal(4)) + Ω(order).Should(Equal([]string{ + "oracle 0", + "match 0", + "oracle 1", + "match 1", + "oracle 2", + "match 2", + "oracle 3", + "match 3", + })) + }) }) Describe("The StopTrying signal - when sent by actual", func() { - Context("when success occurs on the last iteration", func() { - It("succeeds and stops when the signal is returned", func() { - possibilities := []string{"A", "B", "C"} - i := 0 - Eventually(func() (string, error) { - possibility := possibilities[i] + var i int + BeforeEach(func() { + i = 0 + }) + + Context("when returned as an additional error argument", func() { + It("stops trying and prints out the error", func() { + ig.G.Eventually(func() (int, error) { i += 1 - if i == len(possibilities) { - return possibility, StopTrying("Reached the end") - } else { - return possibility, nil + if i < 3 { + return i, nil } - }).Should(Equal("C")) + return 0, StopTrying("bam") + }).Should(Equal(3)) Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) - It("does not count as success if StopTrying wraps an error", func() { - possibilities := []string{"A", "B", "C"} - i := 0 - ig.G.Eventually(func() (string, error) { - possibility := possibilities[i] + It("fails, even if the match were to happen to succeed", func() { + ig.G.Eventually(func() (int, error) { i += 1 - if i == len(possibilities) { - return possibility, StopTrying("Reached the end: %w", errors.New("contrived")) - } else { - return possibility, nil + if i < 3 { + return i, nil } - }).Should(Equal("C")) + return i, StopTrying("bam") + }).Should(Equal(3)) Ω(i).Should(Equal(3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end:")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: contrived")) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) + }) - It("counts as success for consistently... unless an error is wrapped", func() { - i := 0 - Consistently(func() (int, error) { - i += 1 - if i >= 3 { - return i, StopTrying("Reached the end") - } - return i, nil - }).Should(BeNumerically("<=", 3)) - - i = 0 - Consistently(func() int { - i += 1 - if i >= 3 { - StopTrying("Reached the end").Now() - } - return i - }).Should(BeNumerically("<=", 3)) - - i = 0 - ig.G.Consistently(func() (int, error) { - i += 1 - if i >= 3 { - return i, StopTrying("Reached the end %w", errors.New("welp")) - } - return i, nil - }).Should(BeNumerically("<=", 3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Failed after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: welp")) - - i = 0 - ig.G.Consistently(func() (int, error) { + Context("when returned as the sole actual", func() { + It("stops trying and prints out the error", func() { + ig.G.Eventually(func() error { i += 1 - if i >= 3 { - StopTrying("Reached the end %w", errors.New("welp")).Now() + if i < 3 { + return errors.New("boom") } - return i, nil - }).Should(BeNumerically("<=", 3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Failed after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: welp")) + return StopTrying("bam") + }).Should(Succeed()) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) }) - Context("when success does not occur", func() { - It("fails and stops trying early", func() { - possibilities := []string{"A", "B", "C"} - i := 0 - ig.G.Eventually(func() (string, error) { - possibility := possibilities[i] + Context("when triggered via StopTrying.Now()", func() { + It("stops trying and prints out the error", func() { + ig.G.Eventually(func() int { i += 1 - if i == len(possibilities) { - return possibility, StopTrying("Reached the end") - } else { - return possibility, nil + if i < 3 { + return i } - }).Should(Equal("D")) + StopTrying("bam").Now() + return 0 + }).Should(Equal(3)) Ω(i).Should(Equal(3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Expected\n : C\nto equal\n : D")) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) - It("works even if the error is wrapped", func() { - possibilities := []string{"A", "B", "C"} - i := 0 - ig.G.Eventually(func() (string, error) { - possibility := possibilities[i] + It("works when used in conjunction with a Gomega and/or context", func() { + ctx := context.WithValue(context.Background(), "key", "A") + ig.G.Eventually(func(g Gomega, ctx context.Context, expected string) { i += 1 - if i == len(possibilities) { - return possibility, fmt.Errorf("Wrapped error: %w", StopTrying("Reached the end")) - } else { - return possibility, nil + if i < 3 { + g.Expect(ctx.Value("key")).To(Equal(expected)) } - }).Should(Equal("D")) + StopTrying("Out of tries").Now() + }).WithContext(ctx).WithArguments("B").Should(Succeed()) Ω(i).Should(Equal(3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Expected\n : C\nto equal\n : D")) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("Out of tries")) }) - It("allows the user to wrap an error and see that error", func() { - i := 0 - ig.G.Eventually(func() (string, error) { - if i == 0 { - i += 1 - return "C", nil - } - return "C", StopTrying("Reached the end: %w", fmt.Errorf("END_OF_RANGE")) - }).Should(Equal("D")) - Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end: END_OF_RANGE - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: END_OF_RANGE")) - Ω(ig.FailureMessage).ShouldNot(ContainSubstring("Expected"), "since we're wrapping an error, Gomega treats this like an actual that returned an error and so the match is not attempted") + It("still allows regular panics to get through", func() { + defer func() { + e := recover() + Ω(e).Should(Equal("welp")) + }() + Eventually(func() string { + panic("welp") + }).Should(Equal("A")) }) }) - Context("when StopTrying().Now() is called", func() { - It("halts execution, stops trying, and emits the last failure", func() { - possibilities := []string{"A", "B", "C"} - i := -1 - ig.G.Eventually(func() string { + Context("when used with consistently", func() { + It("always signifies a failure", func() { + ig.G.Consistently(func() (int, error) { i += 1 - if i < len(possibilities) { - return possibilities[i] - } else { - StopTrying("Out of tries").Now() - panic("welp") + if i >= 3 { + return i, StopTrying("bam") } - }).Should(Equal("D")) + return i, nil + }).Should(BeNumerically("<", 10)) Ω(i).Should(Equal(3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Out of tries - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Expected\n : C\nto equal\n : D")) - }) - - It("allows the user to wrap an error and see that error", func() { - i := 0 - ig.G.Eventually(func() (string, error) { - if i == 1 { - StopTrying("Reached the end: %w", fmt.Errorf("END_OF_RANGE")).Now() - } - i += 1 - return "C", nil - }).Should(Equal("D")) - Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end: END_OF_RANGE - after")) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) }) - It("still allows regular panics to get through", func() { - defer func() { - e := recover() - Ω(e).Should(Equal("welp")) - }() - Eventually(func() string { - panic("welp") - }).Should(Equal("A")) + Context("when StopTrying has attachments", func() { + It("formats them nicely", func() { + type widget struct { + Name string + DefronculatorCount int + } + type sprocket struct { + Type string + Duration time.Duration + } + + ig.G.Eventually(func() int { + StopTrying("bam").Wrap(errors.New("boom")). + Attach("widget", widget{"bob", 17}). + Attach("sprocket", sprocket{"james", time.Second}). + Now() + return 0 + }).Should(Equal(1)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring(`bam: boom +widget: + : { + Name: "bob", + DefronculatorCount: 17, + } +sprocket: + : {Type: "james", Duration: 1000000000}`)) + }) }) - Context("when used in conjunction wihth a Gomega and/or Context", func() { - It("correctly catches the StopTrying signal", func() { - i := 0 - ctx := context.WithValue(context.Background(), "key", "A") - ig.G.Eventually(func(g Gomega, ctx context.Context, expected string) { + Context("when wrapped by an outer error", func() { + It("still signals as StopTrying - but the outer-error is rendered, along with any attachments", func() { + ig.G.Eventually(func() error { i += 1 - if i >= 3 { - StopTrying("Out of tries").Now() - } - g.Expect(ctx.Value("key")).To(Equal(expected)) - }).WithContext(ctx).WithArguments("B").Should(Succeed()) - Ω(i).Should(Equal(3)) - Ω(ig.FailureMessage).Should(ContainSubstring("Out of tries - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Assertion in callback at")) - Ω(ig.FailureMessage).Should(ContainSubstring(": A")) + return fmt.Errorf("wizz: %w", StopTrying("bam").Wrap(errors.New("boom")). + Attach("widget", "bob"). + Attach("sprocket", 17)) + }).Should(Succeed()) + Ω(i).Should(Equal(1)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring(`wizz: bam: boom +widget: + : bob +sprocket: + : 17`)) + }) }) + }) Describe("The StopTrying signal - when sent by the matcher", func() { - Context("when a StopTrying signal is returned", func() { - It("stops trying", func() { - i := 0 + var i int + BeforeEach(func() { + i = 0 + }) + + Context("when returned as the error", func() { + It("stops retrying", func() { ig.G.Eventually(nil).Should(QuickMatcher(func(_ any) (bool, error) { i += 1 - if i < 4 { + if i < 3 { return false, nil } - return false, StopTrying("stopping") + return false, StopTrying("bam") })) - Ω(i).Should(Equal(4)) - Ω(ig.FailureMessage).Should(ContainSubstring("stopping - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("QM failure message")) - }) - - Context("when an error is wrapped", func() { - It("treats it like a matcher error and ignores the match value", func() { - i := 0 - ig.G.Eventually(nil).Should(QuickMatcher(func(_ any) (bool, error) { - i += 1 - if i < 4 { - return false, nil - } - return true, StopTrying("stopping %w", errors.New("bam")) - })) - - Ω(i).Should(Equal(4)) - Ω(ig.FailureMessage).Should(ContainSubstring("stopping bam - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: bam")) - Ω(ig.FailureMessage).ShouldNot(ContainSubstring("QM failure message")) - }) - - It("counts as a failure for consistently", func() { - i := 0 - ig.G.Consistently(nil).Should(QuickMatcher(func(_ any) (bool, error) { - i += 1 - if i < 4 { - return true, nil - } - return true, StopTrying("stopping %w", errors.New("bam")) - })) - - Ω(i).Should(Equal(4)) - Ω(ig.FailureMessage).Should(ContainSubstring("Failed after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: bam")) - }) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) - Context("when no error is wrapped", func() { - It("uses the match value but no longer keeps trying ", func() { - i := 0 - Eventually(nil).Should(QuickMatcher(func(_ any) (bool, error) { - i += 1 - if i < 4 { - return false, nil - } - return true, StopTrying("stopping") - })) - - //note that we expect to succeed because that last `true` value is used since no error is wrapped - Ω(i).Should(Equal(4)) - }) - - It("counts as success for consistently", func() { - i := 0 - Consistently(nil).Should(QuickMatcher(func(_ any) (bool, error) { - i += 1 - if i < 4 { - return true, nil - } - return true, StopTrying("stopping") - })) + It("fails regardless of the matchers value", func() { + ig.G.Eventually(nil).Should(QuickMatcher(func(_ any) (bool, error) { + i += 1 + if i < 3 { + return false, nil + } + return true, StopTrying("bam") + })) - //note that we expect to succeed because that last `true` value is used since no error is wrapped - Ω(i).Should(Equal(4)) - }) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) }) - Context("when StopTrying.Now() is thrown", func() { - It("stops trying and counts as a failure for Eventually", func() { - i := 0 + Context("when thrown with .Now()", func() { + It("stops retrying", func() { ig.G.Eventually(nil).Should(QuickMatcher(func(_ any) (bool, error) { i += 1 - if i >= 4 { - StopTrying("stopping").Now() + if i < 3 { + return false, nil } + StopTrying("bam").Now() return false, nil })) - Ω(i).Should(Equal(4)) - Ω(ig.FailureMessage).Should(ContainSubstring("stopping - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: stopping")) - - ig.G.Eventually(nil).ShouldNot(QuickMatcher(func(_ any) (bool, error) { - StopTrying("stopping").Now() - return false, nil - })) - Ω(ig.FailureMessage).Should(ContainSubstring("stopping - after")) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: stopping")) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) }) + }) - It("stops trying and counts as failure for Consistently", func() { - i := 0 + Context("when used with consistently", func() { + It("always signifies a failure", func() { ig.G.Consistently(nil).Should(QuickMatcher(func(_ any) (bool, error) { i += 1 - if i >= 4 { - StopTrying("stopping").Now() + if i < 3 { + return true, nil } - return true, nil + return true, StopTrying("bam") })) - Ω(i).Should(Equal(4)) - Ω(ig.FailureMessage).Should(ContainSubstring("Error: stopping")) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Told to stop trying after")) + Ω(ig.FailureMessage).Should(ContainSubstring("bam")) + }) }) diff --git a/internal/async_signal_error.go b/internal/async_signal_error.go index cbaa9f37e..abb26a2ba 100644 --- a/internal/async_signal_error.go +++ b/internal/async_signal_error.go @@ -2,32 +2,71 @@ package internal import ( "errors" - "fmt" + "time" +) + +type AsyncSignalErrorType int + +const ( + AsyncSignalErrorTypeStopTrying AsyncSignalErrorType = iota + AsyncSignalErrorTypeTryAgainAfter ) type StopTryingError interface { + error + Wrap(err error) StopTryingError + Attach(description string, obj any) StopTryingError + Now() +} + +type TryAgainAfterError interface { error Now() } -var StopTrying = func(format string, a ...any) StopTryingError { - err := fmt.Errorf(format, a...) +var StopTrying = func(message string) StopTryingError { + return &AsyncSignalError{ + message: message, + asyncSignalErrorType: AsyncSignalErrorTypeStopTrying, + } +} + +var TryAgainAfter = func(duration time.Duration) TryAgainAfterError { return &AsyncSignalError{ - message: err.Error(), - wrappedErr: errors.Unwrap(err), - stopTrying: true, + duration: duration, + asyncSignalErrorType: AsyncSignalErrorTypeTryAgainAfter, } } +type AsyncSignalErrorAttachment struct { + Description string + Object any +} + type AsyncSignalError struct { - message string - wrappedErr error - stopTrying bool - viaPanic bool + message string + wrappedErr error + asyncSignalErrorType AsyncSignalErrorType + duration time.Duration + Attachments []AsyncSignalErrorAttachment +} + +func (s *AsyncSignalError) Wrap(err error) StopTryingError { + s.wrappedErr = err + return s +} + +func (s *AsyncSignalError) Attach(description string, obj any) StopTryingError { + s.Attachments = append(s.Attachments, AsyncSignalErrorAttachment{description, obj}) + return s } func (s *AsyncSignalError) Error() string { - return s.message + if s.wrappedErr == nil { + return s.message + } else { + return s.message + ": " + s.wrappedErr.Error() + } } func (s *AsyncSignalError) Unwrap() error { @@ -38,16 +77,19 @@ func (s *AsyncSignalError) Unwrap() error { } func (s *AsyncSignalError) Now() { - s.viaPanic = true panic(s) } -func (s *AsyncSignalError) WasViaPanic() bool { - return s.viaPanic +func (s *AsyncSignalError) IsStopTrying() bool { + return s.asyncSignalErrorType == AsyncSignalErrorTypeStopTrying +} + +func (s *AsyncSignalError) IsTryAgainAfter() bool { + return s.asyncSignalErrorType == AsyncSignalErrorTypeTryAgainAfter } -func (s *AsyncSignalError) StopTrying() bool { - return s.stopTrying +func (s *AsyncSignalError) TryAgainDuration() time.Duration { + return s.duration } func AsAsyncSignalError(actual interface{}) (*AsyncSignalError, bool) { diff --git a/internal/async_signal_error_test.go b/internal/async_signal_error_test.go index e0ae0f29b..c8b42618d 100644 --- a/internal/async_signal_error_test.go +++ b/internal/async_signal_error_test.go @@ -10,46 +10,60 @@ import ( ) var _ = Describe("AsyncSignalError", func() { - Describe("building StopTrying errors", func() { - It("can build a formatted message", func() { - st := StopTrying("I've tried %d times - give up!", 17) - Ω(st.Error()).Should(Equal("I've tried 17 times - give up!")) - Ω(errors.Unwrap(st)).Should(BeNil()) + Describe("StopTrying", func() { + Describe("building StopTrying errors", func() { + It("returns a correctly configured StopTrying error", func() { + st := StopTrying("I've tried 17 times - give up!") + Ω(st.Error()).Should(Equal("I've tried 17 times - give up!")) + Ω(errors.Unwrap(st)).Should(BeNil()) + Ω(st.(*internal.AsyncSignalError).IsStopTrying()).Should(BeTrue()) + }) }) - It("can wrap other errors", func() { - st := StopTrying("Welp! Server said: %w", fmt.Errorf("ERR_GIVE_UP")) - Ω(st.Error()).Should(Equal("Welp! Server said: ERR_GIVE_UP")) - Ω(errors.Unwrap(st)).Should(Equal(fmt.Errorf("ERR_GIVE_UP"))) + Describe("Wrapping other errors", func() { + It("can wrap other errors", func() { + st := StopTrying("Welp! Time to give up") + Ω(st.Error()).Should(Equal("Welp! Time to give up")) + st = st.Wrap(fmt.Errorf("ERR_GIVE_UP")) + Ω(errors.Unwrap(st)).Should(Equal(fmt.Errorf("ERR_GIVE_UP"))) + Ω(st.Error()).Should(Equal("Welp! Time to give up: ERR_GIVE_UP")) + }) }) - }) - Describe("when invoking Now()", func() { - It("should not a panic occurred and panic with itself", func() { - st := StopTrying("bam").(*internal.AsyncSignalError) - Ω(st.WasViaPanic()).Should(BeFalse()) - Ω(st.Now).Should(PanicWith(st)) - Ω(st.WasViaPanic()).Should(BeTrue()) + Describe("When attaching objects", func() { + It("attaches them, with their descriptions", func() { + st := StopTrying("Welp!").Attach("Max retries attained", 17).Attach("Got this response", "FLOOP").(*internal.AsyncSignalError) + Ω(st.Attachments).Should(HaveLen(2)) + Ω(st.Attachments[0]).Should(Equal(internal.AsyncSignalErrorAttachment{"Max retries attained", 17})) + Ω(st.Attachments[1]).Should(Equal(internal.AsyncSignalErrorAttachment{"Got this response", "FLOOP"})) + }) }) - }) - Describe("AsAsyncSignalError", func() { - It("should return false for nils", func() { - st, ok := internal.AsAsyncSignalError(nil) - Ω(st).Should(BeNil()) - Ω(ok).Should(BeFalse()) + Describe("when invoking Now()", func() { + It("should panic with itself", func() { + st := StopTrying("bam").(*internal.AsyncSignalError) + Ω(st.Now).Should(PanicWith(st)) + }) }) - It("should work when passed a StopTrying error", func() { - st, ok := internal.AsAsyncSignalError(StopTrying("bam")) - Ω(st).Should(Equal(StopTrying("bam"))) - Ω(ok).Should(BeTrue()) - }) + Describe("AsAsyncSignalError", func() { + It("should return false for nils", func() { + st, ok := internal.AsAsyncSignalError(nil) + Ω(st).Should(BeNil()) + Ω(ok).Should(BeFalse()) + }) + + It("should work when passed a StopTrying error", func() { + st, ok := internal.AsAsyncSignalError(StopTrying("bam")) + Ω(st).Should(Equal(StopTrying("bam"))) + Ω(ok).Should(BeTrue()) + }) - It("should work when passed a wrapped error", func() { - st, ok := internal.AsAsyncSignalError(fmt.Errorf("STOP TRYING %w", StopTrying("bam"))) - Ω(st).Should(Equal(StopTrying("bam"))) - Ω(ok).Should(BeTrue()) + It("should work when passed a wrapped error", func() { + st, ok := internal.AsAsyncSignalError(fmt.Errorf("STOP TRYING %w", StopTrying("bam"))) + Ω(st).Should(Equal(StopTrying("bam"))) + Ω(ok).Should(BeTrue()) + }) }) }) })