From 4adda508b0a51717c17a2226bf48ca79caff98bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20=C3=96zelo=C4=9Flu?= <33967642+gozeloglu@users.noreply.github.com> Date: Fri, 19 Aug 2022 09:20:14 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20v3=20(feature):=20add=20retry=20mec?= =?UTF-8?q?hanism=20(#1972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v3-retry-mechanism: Add retry mechanism * General logic is implemented. * Unit tests are added. Signed-off-by: Gökhan Özeloğlu * Refactor test assertion * Replaced testify/assert with fiber's assert. Signed-off-by: Gökhan Özeloğlu * Add test for next method * currentInterval bug is fixed in Retry. * If condition is fixed in next. * struct definition refactored and if condtion is removed in TestExponentialBackoff_Retry. Signed-off-by: Gökhan Özeloğlu * Add config for retry. * Constant variables are removed. * Helper function is added for default. * Helper function is used in New function. Signed-off-by: Gökhan Özeloğlu * Replace math/rand with crypto/rand * Random number generation package has been replaced with more secure one, crypto/rand. Signed-off-by: Gökhan Özeloğlu * Add a README for retry middleware * Explanation and examples are added. Signed-off-by: Gökhan Özeloğlu * Add comment line for documentation * Comment lines are added for ExponentialBackoff variables. Signed-off-by: Gökhan Özeloğlu * Run go mod tidy * Unused package(s) removed. Signed-off-by: Gökhan Özeloğlu * move middleware -> addon Signed-off-by: Gökhan Özeloğlu Co-authored-by: Muhammed Efe Çetin --- addon/retry/README.md | 97 ++++++++++++++++++ addon/retry/config.go | 66 +++++++++++++ addon/retry/exponential_backoff.go | 73 ++++++++++++++ addon/retry/exponential_backoff_test.go | 124 ++++++++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 addon/retry/README.md create mode 100644 addon/retry/config.go create mode 100644 addon/retry/exponential_backoff.go create mode 100644 addon/retry/exponential_backoff_test.go diff --git a/addon/retry/README.md b/addon/retry/README.md new file mode 100644 index 00000000..a07c7637 --- /dev/null +++ b/addon/retry/README.md @@ -0,0 +1,97 @@ +# Retry Addon + +Retry addon for [Fiber](https://github.com/gofiber/fiber) designed to apply retry mechanism for unsuccessful network +operations. This addon uses exponential backoff algorithm with jitter. It calls the function multiple times and tries +to make it successful. If all calls are failed, then, it returns error. It adds a jitter at each retry step because adding +a jitter is a way to break synchronization across the client and avoid collision. + +## Table of Contents + +- [Retry Addon](#retry-addon) + - [Table of Contents](#table-of-contents) + - [Signatures](#signatures) + - [Examples](#examples) + - [Default Config](#default-config) + - [Custom Config](#custom-config) + - [Config](#config) + - [Default Config Example](#default-config-example) + +## Signatures + +```go +func NewExponentialBackoff(config ...Config) *ExponentialBackoff +``` + +## Examples + +Firstly, import the addon from Fiber, + +```go +import ( + "github.com/gofiber/fiber/v3/addon/retry" +) +``` + +## Default Config + +```go +retry.NewExponentialBackoff() +``` + +## Custom Config + +```go +retry.NewExponentialBackoff(retry.Config{ + InitialInterval: 2 * time.Second, + MaxBackoffTime: 64 * time.Second, + Multiplier: 2.0, + MaxRetryCount: 15, +}) +``` + +## Config + +```go +// Config defines the config for addon. +type Config struct { + // InitialInterval defines the initial time interval for backoff algorithm. + // + // Optional. Default: 1 * time.Second + InitialInterval time.Duration + + // MaxBackoffTime defines maximum time duration for backoff algorithm. When + // the algorithm is reached this time, rest of the retries will be maximum + // 32 seconds. + // + // Optional. Default: 32 * time.Second + MaxBackoffTime time.Duration + + // Multiplier defines multiplier number of the backoff algorithm. + // + // Optional. Default: 2.0 + Multiplier float64 + + // MaxRetryCount defines maximum retry count for the backoff algorithm. + // + // Optional. Default: 10 + MaxRetryCount int + + // currentInterval tracks the current waiting time. + // + // Optional. Default: 1 * time.Second + currentInterval time.Duration +} +``` + +## Default Config Example + +```go +// DefaultConfig is the default config for retry. +var DefaultConfig = Config{ + InitialInterval: 1 * time.Second, + MaxBackoffTime: 32 * time.Second, + Multiplier: 2.0, + MaxRetryCount: 10, + currentInterval: 1 * time.Second, +} +``` \ No newline at end of file diff --git a/addon/retry/config.go b/addon/retry/config.go new file mode 100644 index 00000000..a2dcd271 --- /dev/null +++ b/addon/retry/config.go @@ -0,0 +1,66 @@ +package retry + +import "time" + +// Config defines the config for addon. +type Config struct { + // InitialInterval defines the initial time interval for backoff algorithm. + // + // Optional. Default: 1 * time.Second + InitialInterval time.Duration + + // MaxBackoffTime defines maximum time duration for backoff algorithm. When + // the algorithm is reached this time, rest of the retries will be maximum + // 32 seconds. + // + // Optional. Default: 32 * time.Second + MaxBackoffTime time.Duration + + // Multiplier defines multiplier number of the backoff algorithm. + // + // Optional. Default: 2.0 + Multiplier float64 + + // MaxRetryCount defines maximum retry count for the backoff algorithm. + // + // Optional. Default: 10 + MaxRetryCount int + + // currentInterval tracks the current waiting time. + // + // Optional. Default: 1 * time.Second + currentInterval time.Duration +} + +// DefaultConfig is the default config for retry. +var DefaultConfig = Config{ + InitialInterval: 1 * time.Second, + MaxBackoffTime: 32 * time.Second, + Multiplier: 2.0, + MaxRetryCount: 10, + currentInterval: 1 * time.Second, +} + +// configDefault sets the config values if they are not set. +func configDefault(config ...Config) Config { + if len(config) == 0 { + return DefaultConfig + } + cfg := config[0] + if cfg.InitialInterval == 0 { + cfg.InitialInterval = DefaultConfig.InitialInterval + } + if cfg.MaxBackoffTime == 0 { + cfg.MaxBackoffTime = DefaultConfig.MaxBackoffTime + } + if cfg.Multiplier <= 0 { + cfg.Multiplier = DefaultConfig.Multiplier + } + if cfg.MaxRetryCount <= 0 { + cfg.MaxRetryCount = DefaultConfig.MaxRetryCount + } + if cfg.currentInterval != cfg.InitialInterval { + cfg.currentInterval = DefaultConfig.currentInterval + } + return cfg +} diff --git a/addon/retry/exponential_backoff.go b/addon/retry/exponential_backoff.go new file mode 100644 index 00000000..6740c359 --- /dev/null +++ b/addon/retry/exponential_backoff.go @@ -0,0 +1,73 @@ +package retry + +import ( + "crypto/rand" + "math/big" + "time" +) + +// ExponentialBackoff is a retry mechanism for retrying some calls. +type ExponentialBackoff struct { + // InitialInterval is the initial time interval for backoff algorithm. + InitialInterval time.Duration + + // MaxBackoffTime is the maximum time duration for backoff algorithm. It limits + // the maximum sleep time. + MaxBackoffTime time.Duration + + // Multiplier is a multiplier number of the backoff algorithm. + Multiplier float64 + + // MaxRetryCount is the maximum number of retry count. + MaxRetryCount int + + // currentInterval tracks the current sleep time. + currentInterval time.Duration +} + +// NewExponentialBackoff creates a ExponentialBackoff with default values. +func NewExponentialBackoff(config ...Config) *ExponentialBackoff { + cfg := configDefault(config...) + return &ExponentialBackoff{ + InitialInterval: cfg.InitialInterval, + MaxBackoffTime: cfg.MaxBackoffTime, + Multiplier: cfg.Multiplier, + MaxRetryCount: cfg.MaxRetryCount, + currentInterval: cfg.currentInterval, + } +} + +// Retry is the core logic of the retry mechanism. If the calling function returns +// nil as an error, then the Retry method is terminated with returning nil. Otherwise, +// if all function calls are returned error, then the method returns this error. +func (e *ExponentialBackoff) Retry(f func() error) error { + if e.currentInterval <= 0 { + e.currentInterval = e.InitialInterval + } + var err error + for i := 0; i < e.MaxRetryCount; i++ { + err = f() + if err == nil { + return nil + } + next := e.next() + time.Sleep(next) + } + return err +} + +// next calculates the next sleeping time interval. +func (e *ExponentialBackoff) next() time.Duration { + // generate a random value between [0, 1000) + n, err := rand.Int(rand.Reader, big.NewInt(1000)) + if err != nil { + return e.MaxBackoffTime + } + t := e.currentInterval + (time.Duration(n.Int64()) * time.Millisecond) + e.currentInterval = time.Duration(float64(e.currentInterval) * e.Multiplier) + if t >= e.MaxBackoffTime { + e.currentInterval = e.MaxBackoffTime + return e.MaxBackoffTime + } + return t +} diff --git a/addon/retry/exponential_backoff_test.go b/addon/retry/exponential_backoff_test.go new file mode 100644 index 00000000..aa8fd9df --- /dev/null +++ b/addon/retry/exponential_backoff_test.go @@ -0,0 +1,124 @@ +package retry + +import ( + "fmt" + "github.com/gofiber/fiber/v3/utils" + "testing" + "time" +) + +func TestExponentialBackoff_Retry(t *testing.T) { + tests := []struct { + name string + expBackoff *ExponentialBackoff + f func() error + expErr error + }{ + { + name: "With default values - successful", + expBackoff: NewExponentialBackoff(), + f: func() error { + return nil + }, + }, + { + name: "With default values - unsuccessful", + expBackoff: NewExponentialBackoff(), + f: func() error { + return fmt.Errorf("failed function") + }, + expErr: fmt.Errorf("failed function"), + }, + { + name: "Successful function", + expBackoff: &ExponentialBackoff{ + InitialInterval: 1 * time.Millisecond, + MaxBackoffTime: 100 * time.Millisecond, + Multiplier: 2.0, + MaxRetryCount: 5, + }, + f: func() error { + return nil + }, + }, + { + name: "Unsuccessful function", + expBackoff: &ExponentialBackoff{ + InitialInterval: 2 * time.Millisecond, + MaxBackoffTime: 100 * time.Millisecond, + Multiplier: 2.0, + MaxRetryCount: 5, + }, + f: func() error { + return fmt.Errorf("failed function") + }, + expErr: fmt.Errorf("failed function"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.expBackoff.Retry(tt.f) + utils.AssertEqual(t, tt.expErr, err) + }) + } +} + +func TestExponentialBackoff_Next(t *testing.T) { + tests := []struct { + name string + expBackoff *ExponentialBackoff + expNextTimeIntervals []time.Duration + }{ + { + name: "With default values", + expBackoff: NewExponentialBackoff(), + expNextTimeIntervals: []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 16 * time.Second, + 32 * time.Second, + 32 * time.Second, + 32 * time.Second, + 32 * time.Second, + 32 * time.Second, + }, + }, + { + name: "Custom values", + expBackoff: &ExponentialBackoff{ + InitialInterval: 2.0 * time.Second, + MaxBackoffTime: 64 * time.Second, + Multiplier: 3.0, + MaxRetryCount: 8, + currentInterval: 2.0 * time.Second, + }, + expNextTimeIntervals: []time.Duration{ + 2 * time.Second, + 6 * time.Second, + 18 * time.Second, + 54 * time.Second, + 64 * time.Second, + 64 * time.Second, + 64 * time.Second, + 64 * time.Second, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i := 0; i < tt.expBackoff.MaxRetryCount; i++ { + next := tt.expBackoff.next() + if next < tt.expNextTimeIntervals[i] || next > tt.expNextTimeIntervals[i]+1*time.Second { + t.Errorf("wrong next time:\n"+ + "actual:%v\n"+ + "expected range:%v-%v\n", + next, tt.expNextTimeIntervals[i], tt.expNextTimeIntervals[i]+1*time.Second) + } + } + }) + } +}