From f0ebfc864d0588f39765c9c38e30aacb1dce7588 Mon Sep 17 00:00:00 2001 From: Plamen Bardarov Date: Mon, 4 Nov 2024 10:59:01 +0200 Subject: [PATCH] retry with backoff tests --- .../lib/common/common.go | 13 ++- .../lib/common/common_suite_test.go | 13 +++ .../lib/common/common_test.go | 110 ++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/code.cloudfoundry.org/lib/common/common_suite_test.go create mode 100644 src/code.cloudfoundry.org/lib/common/common_test.go diff --git a/src/code.cloudfoundry.org/lib/common/common.go b/src/code.cloudfoundry.org/lib/common/common.go index 80d15280b..d36c0c154 100644 --- a/src/code.cloudfoundry.org/lib/common/common.go +++ b/src/code.cloudfoundry.org/lib/common/common.go @@ -3,11 +3,15 @@ package common import ( "code.cloudfoundry.org/lager/v3/lagerflags" "fmt" + "github.com/pkg/errors" "math" - "syscall" "time" ) +type temporaryError interface { + Temporary() bool +} + type RetryableFunc[T any] func() (T, error) func GetLagerConfig() lagerflags.LagerConfig { @@ -47,8 +51,11 @@ func RetryWithBackoff[T any](interval int, maxRetries int, fn RetryableFunc[T]) } func isRetryableError(err error) bool { - if errno, ok := err.(syscall.Errno); ok { - return errno.Temporary() + var tempErr temporaryError + + if errors.As(err, &tempErr) { + return tempErr.Temporary() } + return false } diff --git a/src/code.cloudfoundry.org/lib/common/common_suite_test.go b/src/code.cloudfoundry.org/lib/common/common_suite_test.go new file mode 100644 index 000000000..087a1e585 --- /dev/null +++ b/src/code.cloudfoundry.org/lib/common/common_suite_test.go @@ -0,0 +1,13 @@ +package common_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCommon(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Common Suite") +} diff --git a/src/code.cloudfoundry.org/lib/common/common_test.go b/src/code.cloudfoundry.org/lib/common/common_test.go new file mode 100644 index 000000000..024084437 --- /dev/null +++ b/src/code.cloudfoundry.org/lib/common/common_test.go @@ -0,0 +1,110 @@ +package common_test + +import ( + "code.cloudfoundry.org/lib/common" + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "time" +) + +// Mock retryable error that implements temporaryError +type mockTemporaryError struct { + msg string +} + +func (e *mockTemporaryError) Error() string { return e.msg } +func (e *mockTemporaryError) Temporary() bool { return true } + +// Mock non-retryable error that does not implement temporaryError +type mockNonRetryableError struct { + msg string +} + +func (e *mockNonRetryableError) Error() string { return e.msg } + +var _ = Describe("Retry With Backoff", func() { + var ( + callCount int + ) + + BeforeEach(func() { + callCount = 0 + }) + + Context("when function succeeds the first try", func() { + It("returns result without retries", func() { + fn := func() (int, error) { + callCount++ + return 42, nil + } + + result, err := common.RetryWithBackoff(100, 3, fn) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(42)) + Expect(callCount).To(Equal(1)) + }) + }) + + Context("when function fails with a non-retryable error", func() { + It("returns the error immediately", func() { + fn := func() (int, error) { + callCount++ + return 0, &mockNonRetryableError{"non-retryable error"} + } + + result, err := common.RetryWithBackoff(100, 3, fn) + Expect(err).To(MatchError("non-retryable error")) + Expect(result).To(Equal(0)) + Expect(callCount).To(Equal(1)) + }) + }) + + Context("when function fails with a retryable error", func() { + It("retries the function up to maxRetries times and eventually succeeds", func() { + fn := func() (int, error) { + callCount++ + if callCount == 3 { + return 42, nil + } + return 0, &mockTemporaryError{"retryable error"} + } + + result, err := common.RetryWithBackoff(100, 3, fn) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(42)) + Expect(callCount).To(Equal(3)) + }) + }) + + Context("when the maxRetries are exhausted", func() { + It("returns the last error", func() { + fn := func() (int, error) { + callCount++ + return 0, &mockTemporaryError{fmt.Sprintf("retryable error %d", callCount)} + } + + result, err := common.RetryWithBackoff(100, 3, fn) + Expect(err).To(MatchError(fmt.Sprintf("failed after 3 maxRetries: retryable error %d", 3))) + Expect(result).To(Equal(0)) + Expect(callCount).To(Equal(3)) + }) + }) + + Context("exponential backoff timing", func() { + It("should do exponential backoff each retry", func() { + start := time.Now() + + fn := func() (int, error) { + return 0, &mockTemporaryError{msg: "temporary error"} + } + + common.RetryWithBackoff(50, 3, fn) + elapsed := time.Since(start) + + // 50ms * (2^0 + 2^1 + 2^2) = 350ms + expectedTime := 350 * time.Millisecond + Expect(elapsed).To(BeNumerically(">=", expectedTime)) + }) + }) +})