From 2f9f484889598fb3bcf1087f6d1c77b099c87998 Mon Sep 17 00:00:00 2001 From: Rahul Baruri Date: Mon, 16 Oct 2023 00:54:40 +0200 Subject: [PATCH] Added unit tests and README.md --- .github/workflow/go.yaml | 33 +++++++ .gitignore | 16 ++++ LICENSE | 2 +- README.md | 60 ++++++++++++ retry.go | 62 ++++++------- retry_test.go | 193 +++++++++++++++++++++++++++++++++++++++ utils.go | 24 ++--- utils_test.go | 47 ++++++++++ 8 files changed, 387 insertions(+), 50 deletions(-) create mode 100644 .github/workflow/go.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 retry_test.go create mode 100644 utils_test.go diff --git a/.github/workflow/go.yaml b/.github/workflow/go.yaml new file mode 100644 index 0000000..b4e4fe7 --- /dev/null +++ b/.github/workflow/go.yaml @@ -0,0 +1,33 @@ +name: All Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -timeout 300s -race -coverprofile=coverage.xml -covermode=atomic -v ./... + #- uses: actions/checkout@master + - uses: codecov/codecov-action@v1 + with: + files: ./coverage.xml + flags: unittests # optional + name: codecov-umbrella # optional + fail_ci_if_error: true # optional (default = false) + verbose: true # optional (default = false) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4f7ae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +.DS_Store + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/LICENSE b/LICENSE index 23518d1..66767d6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Rahul Baruri +Copyright (c) 2023 Rahul Baruri Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..0861801 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Retry + +An essential retry-operation related library for Golang to build fault-tolerant system. + +## Usages: + +```go +import("github.com/rbrahul/retry") +``` + +### Retry the operation maximum 10 times in 3 seconds delay in between +```go + +err := retry.Retry(func() bool { + err := doesHeavyLifting() + if err != nil { + return true // retry operation + } + return false // No need to retry + }, 10, 3*time.Second) + +if err != nil { + fmt.Error("Maxium retry exceeded") +} +``` + +### Retry failed operations with a deadline of 1 minute and with a random interval of 2 to 10 seconds. + +```go +err := retry.Retry(func() bool { + err := doesHeavyLifting() + if err != nil { + return true // retry operation + } + return false // No need to retry + }, 1 * time.Minute(), retry.RandomBackoff(2, 10)) + +if err == retry.ErrDeadlineExceeded { + fmt.Error("Retry deadline exceeded") +} + +``` +### Retry failed operations with a maximum 10 retries and with an Custom Backoff function. + +```go +err := retry.Retry(func() bool { + err := doesHeavyLifting() + if err != nil { + return true // retry operation + } + return false // No need to retry + }, 10, func(previousDelay uint64) uint64 { + return previousDelay * 1.5 + }) + + if err == retry.ErrMaximumRetryExceeded { + fmt.Error("Maxium retry exceeded") + } + +``` diff --git a/retry.go b/retry.go index 4e4e67e..eabea70 100644 --- a/retry.go +++ b/retry.go @@ -2,27 +2,33 @@ package retry import ( "errors" - "fmt" "math" "reflect" "time" ) type Retryable func() bool -type BackOffFn func(uint64) uint64 +type BackoffFunc func(uint64) uint64 -type retryManger struct { +type retryManager struct { maxRetry int64 - delayerFn BackOffFn + backoffFn BackoffFunc retryUntil time.Duration startedAt time.Time delay time.Duration - lastBackOff uint64 // in number of seconds + lastBackoff uint64 // in number of seconds } +var ( + ErrMaxRetryOrRetryUntilInvalidArg = errors.New("invalid argument type. maxRetry can be either integer, time.Duration or func(uint64) uint64") + ErrDelayOrBackOffFuncInvalidArg = errors.New("invalid argument type. delay can be either time.Duration or `func(uint64) uint64`") + ErrDeadlineExceeded = errors.New("retry deadline has been exceeded") + ErrMaximumRetryExceeded = errors.New("maximum retry has been exceeded") +) + const defaultDelayDuration = 1 * time.Second -func (rm *retryManger) parseParams(args ...interface{}) error { +func (rm *retryManager) parseParams(args ...interface{}) error { if len(args) > 0 { firstArgKind := reflect.TypeOf(args[0]).Kind() if reflect.TypeOf(args[0]).String() == "time.Duration" { @@ -30,68 +36,61 @@ func (rm *retryManger) parseParams(args ...interface{}) error { rm.retryUntil = maxDuration } else if isIntKind(firstArgKind) { rm.maxRetry = int64(math.Abs(float64(reflect.ValueOf(args[0]).Int()))) - } else if len(args) == 1 && reflect.TypeOf(args[0]).String() == "func(uint64) uint64" { - rm.delayerFn = args[0].(func(uint64) uint64) } else { - return errors.New("invalid argument type. maxRetry can be either integer or time.Duration") - } - - if len(args) == 1 && reflect.TypeOf(args[0]).String() == "func(uint64) uint64" { - rm.delayerFn = args[0].(func(uint64) uint64) + return ErrMaxRetryOrRetryUntilInvalidArg } - // delay in duration /backOff as func --> exponentialBackOff, randInt, customBackOffFn + // delay in time.Duration or backOffFunc as func(uint64) uint64 if len(args) > 1 { if reflect.TypeOf(args[1]).String() == "time.Duration" { rm.delay = args[1].(time.Duration) } else if reflect.TypeOf(args[1]).String() == "func(uint64) uint64" { - rm.delayerFn = args[1].(func(uint64) uint64) + rm.backoffFn = args[1].(func(uint64) uint64) } else { - return errors.New("invalid argument type for delay. delay can be either time.Duration or `func(uint64) uint64`") + return ErrDelayOrBackOffFuncInvalidArg } } } - if rm.delayerFn == nil && rm.delay == 0 { + if rm.backoffFn == nil && rm.delay == 0 { rm.delay = defaultDelayDuration } return nil } -func (rm *retryManger) addDelay() { +func (rm *retryManager) addDelay() { var delayInBetween time.Duration - if rm.lastBackOff == 0 { - rm.lastBackOff = 1 + if rm.lastBackoff == 0 { + rm.lastBackoff = 1 } - if rm.delayerFn != nil { - numberOfSeconds := rm.delayerFn(rm.lastBackOff) - rm.lastBackOff = numberOfSeconds + if rm.backoffFn != nil { + numberOfSeconds := rm.backoffFn(rm.lastBackoff) + rm.lastBackoff = numberOfSeconds delayInBetween = time.Duration(numberOfSeconds) * time.Second } else { delayInBetween = rm.delay } - fmt.Println("delayInBetween:", delayInBetween) withJitter := addJitter(delayInBetween) - fmt.Println("With Jitter:", withJitter) time.Sleep(withJitter) } -func (rm *retryManger) execute(fn Retryable) error { +func (rm *retryManager) execute(fn Retryable) error { shouldRetry := fn() for shouldRetry { if rm.retryUntil > 0 { + rm.addDelay() deadLineExceeded := time.Now().After(rm.startedAt.Add(rm.retryUntil)) if deadLineExceeded { - return errors.New("retry deadline has been exceeded") + return ErrDeadlineExceeded } - rm.addDelay() shouldRetry = fn() continue } - + // If maxRetry is set 5 then 5-1 time will be retried. Because initially function was already excuted once. + // Which means: 1 (Initial Call) + 4 retries == 5 maxRetry if rm.maxRetry > 0 { rm.maxRetry -= 1 if rm.maxRetry == 0 { - return errors.New("maximum retry has been exceeded") + return ErrMaximumRetryExceeded } } @@ -113,10 +112,9 @@ func (rm *retryManger) execute(fn Retryable) error { // Retry(retriableFn func() bool, retryUntil time.Duration, delay time.Duration) // Retry(retriableFn func() bool, retryUntil time.Duration, backOffFn func(uint64) uint64) // Retry(retriableFn func() bool, maxNumberOfRetry int) -// Retry(retriableFn, backOffFn func(uint64) uint64)) [If 2nd argument is function then it will be treated as backOffFn and maxNumberOfRetry will be considered Infinity] // Retry(retriableFn) func Retry(fn Retryable, args ...interface{}) error { - retryManager := &retryManger{ + retryManager := &retryManager{ startedAt: time.Now(), } err := retryManager.parseParams(args...) diff --git a/retry_test.go b/retry_test.go new file mode 100644 index 0000000..910f670 --- /dev/null +++ b/retry_test.go @@ -0,0 +1,193 @@ +package retry + +import ( + "testing" + "time" +) + +type callEntry struct { + calledAt time.Time +} + +type callRecorder struct { + fn func() bool + callStack []callEntry +} + +func NewCallRecorder(fn func() bool) *callRecorder { + recorder := &callRecorder{ + callStack: []callEntry{}, + } + logger := func() bool { + recorder.callStack = append(recorder.callStack, callEntry{ + calledAt: time.Now(), + }) + return fn() + } + recorder.fn = logger + + return recorder +} + +func TestRetryFailsWithWrongParameters(t *testing.T) { + err := Retry(func() bool { + return true + }, "wrong argument", false) + + if err != ErrMaxRetryOrRetryUntilInvalidArg { + t.Fatalf("Error did not match. Found %+v.\nexpected %+v ", err.Error(), ErrMaxRetryOrRetryUntilInvalidArg.Error()) + } +} + +func TestRetryFailsWithInvalidDelayParameter(t *testing.T) { + err := Retry(func() bool { + return true + }, 1, "Invalid argument") + + if err != ErrDelayOrBackOffFuncInvalidArg { + t.Fatalf("Error did not match. Found %+v.\nexpected %+v ", err.Error(), ErrDelayOrBackOffFuncInvalidArg.Error()) + } +} + +func TestRetryCallsExactNumberOfTimesWhichWasProvidedAsParamters(t *testing.T) { + recorder := NewCallRecorder(func() bool { + return true + }) + + Retry(recorder.fn, 3) + if len(recorder.callStack) != 3 { + t.Fatalf("Expected retriable function should be executed %d times but was executed %d times", 1, len(recorder.callStack)) + } +} + +func TestRetryWasExecutedWithExpectedDelay(t *testing.T) { + recorder := NewCallRecorder(func() bool { + return true + }) + + expectedDelay := 2 + + originalJitter := addJitter + + defer func() { + addJitter = originalJitter + }() + + // Doesn't apply any jitter + addJitter = func(delay time.Duration) time.Duration { + return delay + } + + Retry(recorder.fn, 5, time.Duration(expectedDelay)*time.Second) + + diffInSeconds := recorder.callStack[1].calledAt.Second() - recorder.callStack[0].calledAt.Second() + + if diffInSeconds != 2 { + t.Fatalf("Expected delay is %d but found %d", expectedDelay, diffInSeconds) + } +} + +func TestRetryDeadLineWasExeecedAfterSpecifiedDeadline(t *testing.T) { + recorder := NewCallRecorder(func() bool { + return true + }) + + startedAt := time.Now() + + expectedDuration := 5 * time.Second + deadLine := startedAt.Add(expectedDuration) + + err := Retry(recorder.fn, expectedDuration, 1*time.Second) + + lastCalledAt := recorder.callStack[len(recorder.callStack)-1].calledAt + if err != ErrDeadlineExceeded { + t.Fatalf("Expected error should be %s. But found: %s", ErrDeadlineExceeded.Error(), err.Error()) + } + if lastCalledAt.After(deadLine) { + t.Fatal("Retry should not call the function after the deadline is finished") + } +} + +func TestRetryHasADelaySpecifiedByBackOffFunction(t *testing.T) { + recorder := NewCallRecorder(func() bool { + return true + }) + + expectedDelay := 2 // seconds + + originalJitter := addJitter + + defer func() { + addJitter = originalJitter + }() + + // Doesn't apply any jitter + addJitter = func(delay time.Duration) time.Duration { + return delay + } + + err := Retry(recorder.fn, 5, func(_ uint64) uint64 { + return uint64(expectedDelay) + }) + + diffInSeconds := recorder.callStack[1].calledAt.Second() - recorder.callStack[0].calledAt.Second() + if err != ErrMaximumRetryExceeded { + t.Fatalf("Expected error should be %s. But found: %s", ErrMaximumRetryExceeded.Error(), err.Error()) + } + + if diffInSeconds != expectedDelay { + t.Fatalf("Expected delay is %d but found %d", expectedDelay, diffInSeconds) + } +} + +func TestRetryCallsFunctionExpectedNumberOfTimes(t *testing.T) { + expectedNumberOfCalls := 5 + i := 0 + recorder := NewCallRecorder(func() bool { + i++ + return i < expectedNumberOfCalls + }) + + err := Retry(recorder.fn) + + if err != nil { + t.Fatal("Should not return any error") + } + if len(recorder.callStack) != expectedNumberOfCalls { + t.Fatalf("Expected retriable function should be executed %d times but was executed %d times", expectedNumberOfCalls, len(recorder.callStack)) + } +} + +func TestRetryExitsWithErrorAfterSpecifiedNumberOfTryWithSpecifiedDelayInBetween(t *testing.T) { + expectedNumberOfCalls := 10 + expectedDelay := 3 + recorder := NewCallRecorder(func() bool { + return true + }) + + originalJitter := addJitter + defer func() { + addJitter = originalJitter + }() + + // Doesn't apply any jitter + addJitter = func(delay time.Duration) time.Duration { + return delay + } + + err := Retry(recorder.fn, expectedNumberOfCalls, time.Duration(expectedDelay)*time.Second) + + diffInSeconds := recorder.callStack[1].calledAt.Second() - recorder.callStack[0].calledAt.Second() + + if err != ErrMaximumRetryExceeded { + t.Fatalf("Expected error should be %s. But found: %s", ErrMaximumRetryExceeded.Error(), err.Error()) + } + + if diffInSeconds != expectedDelay { + t.Fatalf("Expected delay is %d but found %d", expectedDelay, diffInSeconds) + } + + if len(recorder.callStack) != expectedNumberOfCalls { + t.Fatalf("Expected retriable function should be executed %d times but was executed %d times", expectedNumberOfCalls, len(recorder.callStack)) + } +} diff --git a/utils.go b/utils.go index 9e6ad46..c541700 100644 --- a/utils.go +++ b/utils.go @@ -1,14 +1,12 @@ package retry import ( - "fmt" "math/rand" "reflect" "time" ) func isIntKind(kind reflect.Kind) bool { - fmt.Println("KIND:", kind) intKinds := []reflect.Kind{reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64} for _, kindItem := range intKinds { if kindItem == kind { @@ -23,11 +21,13 @@ func randomer() *rand.Rand { return rand.New(seed) } -func addJitter(delay time.Duration) time.Duration { +func applyJitter(delay time.Duration) time.Duration { // Added jitter between 10% to 30% return delay + time.Duration(float64(delay)*randomFloatWithinRange(.1, .3)) } +var addJitter = applyJitter + func randomIntWithinRange(lower, upper int) uint64 { return uint64(lower + randomer().Intn(upper-lower)) } @@ -36,28 +36,18 @@ func randomFloatWithinRange(lower, upper float64) float64 { return lower + rand.Float64()*(upper-lower) } -func RandInt(lower, upper int) func(uint64) uint64 { +func RandomBackoff(lower, upper int) func(uint64) uint64 { return func(_ uint64) uint64 { return randomIntWithinRange(lower, upper) } } -func ExponentialBackoff(maxBackOff int) func(uint64) uint64 { +func ExponentialBackoff(maxBackoff int) func(uint64) uint64 { return func(lastBackOff uint64) uint64 { exponentialbackOff := lastBackOff * 2 - if exponentialbackOff > uint64(maxBackOff) { - return uint64(maxBackOff) + if exponentialbackOff > uint64(maxBackoff) { + return uint64(maxBackoff) } return exponentialbackOff } } - -func Parcentage(percentage float32, maxBackOff int) func(uint64) uint64 { - return func(lastBackOff uint64) uint64 { - exponentialbackOff := float32(lastBackOff) + float32(lastBackOff)*percentage/100 - if uint64(exponentialbackOff) > uint64(maxBackOff) { - return uint64(maxBackOff) - } - return uint64(exponentialbackOff) - } -} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..5b683cf --- /dev/null +++ b/utils_test.go @@ -0,0 +1,47 @@ +package retry + +import ( + "reflect" + "testing" +) + +func TestExponentialBackoff(t *testing.T) { + backOffs := []uint64{} + backOff := uint64(1) + backOffs = append(backOffs, backOff) // backOffs = [1] + backOffFn := ExponentialBackoff(10) + // backOffs = [1] + backOff = backOffFn(backOff) + backOffs = append(backOffs, backOff) // backOffs = [1, 2] + + backOff = backOffFn(backOff) + backOffs = append(backOffs, backOff) // backOffs = [1, 2, 4] + + backOff = backOffFn(backOff) + backOffs = append(backOffs, backOff) // backOffs = [1, 2, 4, 8] + + backOff = backOffFn(backOff) + backOffs = append(backOffs, backOff) // backOffs = [1, 2, 4, 8, 10] Because maxBackOff expected is 10 + expectedBackOffs := []uint64{1, 2, 4, 8, 10} + if !reflect.DeepEqual(backOffs, expectedBackOffs) { + t.Fatalf("Expected BackOff should be %+v but found %+v", expectedBackOffs, backOffs) + } +} + +func TestRandomBackOff(t *testing.T) { + lower := 2 + upper := 7 + initialBackOff := uint64(1) + backoffFunc := RandomBackoff(2, 7) + firstBackOff := backoffFunc(initialBackOff) + secondBackOff := backoffFunc(firstBackOff) + thirdBackOff := backoffFunc(firstBackOff) + + backOffs := []uint64{firstBackOff, secondBackOff, thirdBackOff} + + for _, num := range backOffs { + if int(num) < lower || int(num) > upper { + t.Fatalf("Random number %d should be between %d and %d", num, lower, upper) + } + } +}