From 99f584ad9eacc676886b5023de992e1a51e71582 Mon Sep 17 00:00:00 2001 From: Risky Feryansyah Date: Mon, 11 Dec 2023 13:26:53 +0700 Subject: [PATCH] chore: add initial retry mechanism (#2) * chore: add initial retry mechanism * refactor: use defaultValkyrietry in defaultValkyrietryWithContext * refactor: use time.timer directly instead of abstract it * refactor: simplify the retry mechanism runner function into two --- README.md | 13 ++-- cons.go | 10 +++ error.go | 7 ++ example/main.go | 46 +++++++++++++ go.mod | 3 + option.go | 51 ++++++++++++++ option_test.go | 50 ++++++++++++++ timer.go | 38 +++++++++++ timer_test.go | 50 ++++++++++++++ valkyrietry.go | 159 ++++++++++++++++++++++++++++++++++++++++++++ valkyrietry_test.go | 151 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 cons.go create mode 100644 error.go create mode 100644 example/main.go create mode 100644 go.mod create mode 100644 option.go create mode 100644 option_test.go create mode 100644 timer.go create mode 100644 timer_test.go create mode 100644 valkyrietry.go create mode 100644 valkyrietry_test.go diff --git a/README.md b/README.md index 5ddd626..6e8a6ba 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ To get started with Valkyrietry, simply install the library in your GoLang proje package main import ( + "context" "errors" "fmt" "net/http" @@ -31,11 +32,13 @@ import ( ) func main() { + ctx := context.Background() + options := []valkyrietry.Option{ - valkyrietry.WithMaxRetryAttempts(5), - valkyrietry.WithRetryDelay(0.5 * time.Second) // Google use 0.5 as initial retry, - valkyrietry.WithRetryBackoffMultiplier(1.5) // Google also use 1.5 for default multiplier, - valkyrietry.WithJitter(0.5), + valkyrietry.WithMaxRetryAttempts(1), + valkyrietry.WithRetryDelay(2 * time.Second), + valkyrietry.WithRetryBackoffMultiplier(2), + valkyrietry.WithJitter(0.2), } retryFunc := func() error { @@ -55,7 +58,7 @@ func main() { } // Use Valkyrietry to handle the retry logic - if err := valkyrietry.Do(retryFunc, options...); err != nil { + if err := valkyrietry.Do(ctx, retryFunc, options...); err != nil { fmt.Println("Operation failed after retries:", err) return } diff --git a/cons.go b/cons.go new file mode 100644 index 0000000..1404582 --- /dev/null +++ b/cons.go @@ -0,0 +1,10 @@ +package valkyrietry + +import "time" + +const ( + DefaultMaxRetryAttempt = 5 + DefaultRetryDelay = time.Duration(0.5 * float64(time.Second)) + DefaultRetryBackoffMultiplier = 1.5 + DefaultJitter = 0.5 +) diff --git a/error.go b/error.go new file mode 100644 index 0000000..da6eb47 --- /dev/null +++ b/error.go @@ -0,0 +1,7 @@ +package valkyrietry + +import "fmt" + +var ( + ErrMaxRetryAttemptsExceeded = fmt.Errorf("function is failed to retry after max attemps retries") +) diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..a34e02e --- /dev/null +++ b/example/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/ruang-guru/valkyrietry" +) + +func main() { + ctx := context.Background() + + options := []valkyrietry.Option{ + valkyrietry.WithMaxRetryAttempts(1), + valkyrietry.WithRetryDelay(2 * time.Second), + valkyrietry.WithRetryBackoffMultiplier(2), + valkyrietry.WithJitter(0.2), + } + + retryFunc := func() error { + resp, err := http.Get("http://testingexample.com") + if err != nil { + fmt.Println("Request failed, will retry:", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + // Simulate server-side error + return errors.New("server error, retrying") + } + fmt.Println("Request succeeded") + return nil + } + + // Use Valkyrietry to handle the retry logic + if err := valkyrietry.Do(ctx, retryFunc, options...); err != nil { + fmt.Println("Operation failed after retries:", err) + return + } + + fmt.Println("Operation successful") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5214e4d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ruang-guru/valkyrietry + +go 1.20 diff --git a/option.go b/option.go new file mode 100644 index 0000000..a0a99a8 --- /dev/null +++ b/option.go @@ -0,0 +1,51 @@ +package valkyrietry + +import ( + "time" +) + +type Configuration struct { + MaxRetryAttempts uint + InitialRetryDelay time.Duration + RetryBackoffMultiplier float32 + JitterPercentage float32 +} + +// option is a function option used to configure a Valkyrietry. +type Option func(c *Configuration) + +// WithMaxRetryAttempts +// Set the maximum number of retry attempts for the retry mechanism. +// +// if you set the attempt to 0, it means it will run until the process succed +func WithMaxRetryAttempts(attempt uint) Option { + return func(c *Configuration) { + c.MaxRetryAttempts = attempt + } +} + +// WithRetryDelay +// Set the initial duration value for the first retry. +func WithRetryDelay(delay time.Duration) Option { + return func(c *Configuration) { + c.InitialRetryDelay = delay + } +} + +// WithRetryBackoffMultiplier +// Set the multiplier for each failed retry attempt. +// Formula: initial retry delay * multiplier. +func WithRetryBackoffMultiplier(multiplier float32) Option { + return func(c *Configuration) { + c.RetryBackoffMultiplier = multiplier + } +} + +// WithJitter +// Set the percentage of jitter value to determine the lowest and highest +// random values. +func WithJitter(percentage float32) Option { + return func(c *Configuration) { + c.JitterPercentage = percentage + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..4e1fc77 --- /dev/null +++ b/option_test.go @@ -0,0 +1,50 @@ +package valkyrietry + +import ( + "testing" + "time" +) + +func TestWithMaxRetryAttempts(t *testing.T) { + expectedAttempts := uint(5) + config := &Configuration{} + option := WithMaxRetryAttempts(expectedAttempts) + option(config) + + if config.MaxRetryAttempts != expectedAttempts { + t.Errorf("WithMaxRetryAttempts() = %v, want %v", config.MaxRetryAttempts, expectedAttempts) + } +} + +func TestWithRetryDelay(t *testing.T) { + expectedDelay := 100 * time.Millisecond + config := &Configuration{} + option := WithRetryDelay(expectedDelay) + option(config) + + if config.InitialRetryDelay != expectedDelay { + t.Errorf("WithRetryDelay() = %v, want %v", config.InitialRetryDelay, expectedDelay) + } +} + +func TestWithRetryBackoffMultiplier(t *testing.T) { + expectedMultiplier := float32(2.0) + config := &Configuration{} + option := WithRetryBackoffMultiplier(expectedMultiplier) + option(config) + + if config.RetryBackoffMultiplier != expectedMultiplier { + t.Errorf("WithRetryBackoffMultiplier() = %v, want %v", config.RetryBackoffMultiplier, expectedMultiplier) + } +} + +func TestWithJitter(t *testing.T) { + expectedJitter := float32(0.25) + config := &Configuration{} + option := WithJitter(expectedJitter) + option(config) + + if config.JitterPercentage != expectedJitter { + t.Errorf("WithJitter() = %v, want %v", config.JitterPercentage, expectedJitter) + } +} diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..be8b692 --- /dev/null +++ b/timer.go @@ -0,0 +1,38 @@ +package valkyrietry + +import "time" + +type Timer struct { + timer *time.Timer +} + +func NewTimer() *Timer { + return &Timer{} +} + +// Start +// Set the timer for the specified duration. +// If the current timer is nil, initialize a new one; +// otherwise, reset it to the new duration. +func (t *Timer) Start(duration time.Duration) { + if t.timer == nil { + t.timer = time.NewTimer(duration) + return + } + + t.timer.Reset(duration) +} + +// Stop +// Stop the current timer. +func (t *Timer) Stop() { + if t.timer != nil { + t.timer.Stop() + } +} + +// C +// Retrieve the channel when either the timer stops or the timer completes. +func (t *Timer) C() <-chan time.Time { + return t.timer.C +} diff --git a/timer_test.go b/timer_test.go new file mode 100644 index 0000000..352e5fa --- /dev/null +++ b/timer_test.go @@ -0,0 +1,50 @@ +package valkyrietry + +import ( + "testing" + "time" +) + +func TestTimerStart(t *testing.T) { + timer := NewTimer() + duration := 100 * time.Millisecond + + start := time.Now() + timer.Start(duration) + <-timer.C() + + if time.Since(start) < duration { + t.Errorf("Timer fired before the expected duration") + } +} + +func TestTimerReset(t *testing.T) { + timer := NewTimer() + firstDuration := 50 * time.Millisecond + secondDuration := 100 * time.Millisecond + + timer.Start(firstDuration) + time.Sleep(30 * time.Millisecond) + timer.Start(secondDuration) + + start := time.Now() + <-timer.C() + + if elapsed := time.Since(start); elapsed < secondDuration { + t.Errorf("Timer fired before the expected reset duration, elapsed: %v", elapsed) + } +} + +func TestTimerStop(t *testing.T) { + timer := NewTimer() + duration := 100 * time.Millisecond + + timer.Start(duration) + timer.Stop() + + select { + case <-timer.C(): + t.Errorf("Timer channel should not receive after being stopped") + case <-time.After(150 * time.Millisecond): + } +} diff --git a/valkyrietry.go b/valkyrietry.go new file mode 100644 index 0000000..24383b6 --- /dev/null +++ b/valkyrietry.go @@ -0,0 +1,159 @@ +package valkyrietry + +import ( + "context" + "math/rand" + "time" +) + +// RetryFunc +// Implement this function for retryable actions within the `Do` or `DoWithContext` functions. +// It is necessary to define this function if you want to utilize the +// retry mechanism inside `Do` or `DoWithContext`. +type RetryFunc func() error + +// RetryFuncWithData +// Implement this function for retryable actions within the `DoWithData` or `DoWithDataAndContext` functions. +// It is necessary to define this function if you intend to use +// the retry mechanism inside `DoWithData` or `DoWithDataAndContext`. +type RetryFuncWithData[T any] func() (T, error) + +type Valkyrietry struct { + Configuration *Configuration + + ctx context.Context +} + +func defaultValkyrietry(options ...Option) *Valkyrietry { + defaultConfiguration := &Configuration{ + MaxRetryAttempts: DefaultMaxRetryAttempt, + InitialRetryDelay: DefaultRetryDelay, + RetryBackoffMultiplier: DefaultRetryBackoffMultiplier, + JitterPercentage: DefaultJitter, + } + + defaultValue := &Valkyrietry{ + Configuration: defaultConfiguration, + ctx: context.Background(), + } + + for _, opt := range options { + opt(defaultConfiguration) + } + + return defaultValue +} + +func defaultValkyrietryWithContext(ctx context.Context, options ...Option) *Valkyrietry { + defaultValue := defaultValkyrietry(options...) + defaultValue.ctx = ctx + + return defaultValue +} + +// Do +// Start the retry mechanism using the given context and continue running the process until the `RetryFunc` +// returns successfully (without error) or until the maximum number of retry attempts is exceeded. +// +// This function ensures that the given `RetryFunc` will run at least once. +func Do(ctx context.Context, f RetryFunc, options ...Option) error { + valkyrietry := defaultValkyrietryWithContext(ctx, options...) + + currentAttempt := 0 + currentInterval := valkyrietry.Configuration.InitialRetryDelay + + // Initialize the timer to a zero value for + // the first initialization. + timer := time.NewTimer(0) + + defer func() { + timer.Stop() + }() + + for { + if currentAttempt > int(valkyrietry.Configuration.MaxRetryAttempts) { + return ErrMaxRetryAttemptsExceeded + } + + err := f() + + if err == nil { + return nil + } + + retryInterval := valkyrietry.getRetryIntervalValue(currentInterval) + currentInterval = time.Duration(float32(currentInterval) * valkyrietry.Configuration.RetryBackoffMultiplier) + + currentAttempt++ + + // Reset the duration to match the retry interval duration. + // Thus, we will adjust the timer interval for each retry. + timer.Reset(retryInterval) + + select { + case <-valkyrietry.ctx.Done(): + return valkyrietry.ctx.Err() + case <-timer.C: + } + } +} + +// DoWithData +// Start the retry mechanism with any given data to receive and a context, and continue running the process until the `RetryFunc` +// successfully returns with the data (without error) or until the maximum number of retry attempts is exceeded. +// +// This function ensures that the given `RetryFunc` will run at least once. +func DoWithData[T any](ctx context.Context, f RetryFuncWithData[T], options ...Option) (T, error) { + valkyrietry := defaultValkyrietryWithContext(ctx, options...) + + currentAttempt := 0 + currentInterval := valkyrietry.Configuration.InitialRetryDelay + + // Initialize the timer to a zero value for + // the first initialization. + timer := time.NewTimer(0) + + defer func() { + timer.Stop() + }() + + var response T + + for { + if currentAttempt > int(valkyrietry.Configuration.MaxRetryAttempts) { + return response, ErrMaxRetryAttemptsExceeded + } + + response, err := f() + + if err == nil { + return response, nil + } + + retryInterval := valkyrietry.getRetryIntervalValue(currentInterval) + currentInterval = time.Duration(float32(currentInterval) * valkyrietry.Configuration.RetryBackoffMultiplier) + + currentAttempt++ + + // Reset the duration to match the retry interval duration. + // Thus, we will adjust the timer interval for each retry. + timer.Reset(retryInterval) + + select { + case <-valkyrietry.ctx.Done(): + return response, valkyrietry.ctx.Err() + case <-timer.C: + } + } +} + +func (v *Valkyrietry) getRetryIntervalValue(currentInterval time.Duration) time.Duration { + jitterInterval := v.Configuration.JitterPercentage * float32(currentInterval) + + maxRetryInterval := float32(currentInterval) + jitterInterval + minRetryInterval := float32(currentInterval) - jitterInterval + + randomValue := rand.Float32() + + return time.Duration(minRetryInterval + (randomValue * (maxRetryInterval - minRetryInterval + 1))) +} diff --git a/valkyrietry_test.go b/valkyrietry_test.go new file mode 100644 index 0000000..54efb78 --- /dev/null +++ b/valkyrietry_test.go @@ -0,0 +1,151 @@ +package valkyrietry + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestBasicRetrySuccess(t *testing.T) { + ctx := context.Background() + failureCount := 0 + maxFailures := 3 + retryFunc := func() error { + if failureCount < maxFailures { + failureCount++ + return errors.New("temporary error") + } + return nil + } + + err := Do(ctx, retryFunc, WithMaxRetryAttempts(5)) + + if err != nil { + t.Errorf("Expected function to succeed, but it failed: %v", err) + } + if failureCount != maxFailures { + t.Errorf("Expected %d failures, got %d", maxFailures, failureCount) + } +} + +func TestMaxRetryAttemptsExceeded(t *testing.T) { + ctx := context.Background() + retryFunc := func() error { + return errors.New("permanent error") + } + + err := Do( + ctx, + retryFunc, + WithMaxRetryAttempts(2), + ) + + if err == nil || err != ErrMaxRetryAttemptsExceeded { + t.Errorf("Expected ErrMaxRetryAttemptsExceeded, got %v", err) + } +} + +func TestRetryWithContextCancellation(t *testing.T) { + ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + retryFunc := func() error { + time.Sleep(500 * time.Millisecond) + return errors.New("error after delay") + } + + err := Do( + ctx, + retryFunc, + ) + + if err == nil || err != context.DeadlineExceeded { + t.Errorf("Expected context.DeadlineExceeded, got %v", err) + } +} + +func TestJitterAndBackoff(t *testing.T) { + ctx := context.Background() + var retryDurations []time.Duration + var lastRetryTime time.Time + + retryFunc := func() error { + if !lastRetryTime.IsZero() { + retryDurations = append(retryDurations, time.Since(lastRetryTime)) + } + lastRetryTime = time.Now() + return errors.New("retry error") + } + + initialDelay := 500 * time.Millisecond + maxAttempts := uint(3) + backoffMultiplier := float32(1.5) + jitterPercentage := float32(0.5) // 50% jitter + + _ = Do( + ctx, + retryFunc, + WithMaxRetryAttempts(maxAttempts), + WithRetryDelay(initialDelay), + WithRetryBackoffMultiplier(backoffMultiplier), + WithJitter(jitterPercentage), + ) + + baseDuration := initialDelay + + for i, duration := range retryDurations { + jitter := time.Duration(float64(baseDuration) * float64(jitterPercentage)) + minDuration := baseDuration - jitter + maxDuration := baseDuration + jitter + + if duration < minDuration || duration > maxDuration { + t.Errorf("Retry interval %d is out of expected range: got %v, want between %v and %v", i, duration, minDuration, maxDuration) + } + + baseDuration = time.Duration(float32(baseDuration) * backoffMultiplier) + } +} + +func TestRetryIntervalProgression(t *testing.T) { + ctx := context.Background() + var retryDurations []time.Duration + var lastRetryTime time.Time + + retryFunc := func() error { + if !lastRetryTime.IsZero() { + retryDurations = append(retryDurations, time.Since(lastRetryTime)) + } + lastRetryTime = time.Now() + return errors.New("retry error") + } + + initialDelay := 500 * time.Millisecond + maxAttempts := uint(3) + backoffMultiplier := float32(1.5) + jitterPercentage := float32(0.5) // 50% jitter + + _ = Do( + ctx, + retryFunc, + WithMaxRetryAttempts(maxAttempts), + WithRetryDelay(initialDelay), + WithRetryBackoffMultiplier(backoffMultiplier), + WithJitter(jitterPercentage), + ) + + baseDuration := initialDelay + + for i, duration := range retryDurations { + jitter := time.Duration(float64(baseDuration) * float64(jitterPercentage)) + minDuration := baseDuration - jitter + maxDuration := baseDuration + jitter + + if duration < minDuration || duration > maxDuration { + t.Errorf("Retry interval %d is out of expected range: got %v, want between %v and %v", i, duration, minDuration, maxDuration) + } + + baseDuration = time.Duration(float32(baseDuration) * backoffMultiplier) + } +}