From 3d4af7551040e7c3988ed211f5fdcef14a075ad6 Mon Sep 17 00:00:00 2001 From: Vlad Lavrishko Date: Mon, 23 Jun 2025 00:05:15 +0300 Subject: [PATCH] feat(yabackoff): implemented own `backoff`mechanic --- go.mod | 7 +++ go.sum | 1 + yabackoff/exponential.go | 119 ++++++++++++++++++++++++++++++++++++ yabackoff/yabackoff.go | 72 ++++++++++++++++++++++ yabackoff/yabackoff_test.go | 88 ++++++++++++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 yabackoff/exponential.go create mode 100644 yabackoff/yabackoff.go create mode 100644 yabackoff/yabackoff_test.go diff --git a/go.mod b/go.mod index 192ac1d..1f3371f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,13 @@ go 1.24.1 require ( github.com/joho/godotenv v1.5.1 github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) require ( diff --git a/go.sum b/go.sum index 36953a3..8a9f549 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/yabackoff/exponential.go b/yabackoff/exponential.go new file mode 100644 index 0000000..e85381a --- /dev/null +++ b/yabackoff/exponential.go @@ -0,0 +1,119 @@ +package yabackoff + +import "time" + +// Exponential is a back‑off that multiplies the delay by a constant factor +// each time Next() is called, capping at maxInterval. +// +// Example: +// +// backoff := yabackoff.NewExponential(100*time.Millisecond, 2, time.Second) +// fmt.Println(backoff.Next()) // 200 ms +// fmt.Println(backoff.Next()) // 400 ms +// fmt.Println(backoff.Next()) // 800 ms +// fmt.Println(backoff.Next()) // 1 s (capped) +// fmt.Println(backoff.Next()) // 1 s (stays capped) +// +// The zero value of Exponential is usable: on first use the package defaults +// are substituted. +type Exponential struct { + initialInterval time.Duration + multiplier float64 + maxInterval time.Duration + currentInterval time.Duration +} + +// NewExponential creates a new exponential back‑off. Any zero argument is +// replaced by the corresponding package default. +// +// Example: +// +// backoff := yabackoff.NewExponential(0, 0, 0) // uses all defaults +// fmt.Println(backoff.Current()) // 500 ms (default) +func NewExponential( + initialInterval time.Duration, + multiplier float64, + maxInterval time.Duration, +) Exponential { + return Exponential{ + initialInterval: initialInterval, + multiplier: multiplier, + maxInterval: maxInterval, + currentInterval: initialInterval, + } +} + +// Reset sets currentInterval back to the initial value. +// +// Example: +// +// backoff := yabackoff.NewExponential(250*time.Millisecond, 2, time.Second) +// _ = backoff.Next() // 500 ms +// backoff.Reset() +// fmt.Println(backoff.Current()) // 250 ms +func (e *Exponential) Reset() { + e.currentInterval = e.initialInterval +} + +// Next returns the next delay and advances the internal state. +// +// Example: +// +// backoff := yabackoff.NewExponential(100*time.Millisecond, 2, time.Second) +// delay := backoff.Next() // 200 ms +// doSomethingAfter(delay) +func (e *Exponential) Next() time.Duration { + e.safety() + + e.incrementCurrentInterval() + + return e.currentInterval +} + +// Current reports the delay that would be (or was) returned by the most recent +// call to Next(). Calling Current() never mutates state. +// +// Example: +// +// fmt.Println("current delay:", backoff.Current()) +func (e *Exponential) Current() time.Duration { + return e.currentInterval +} + +// Wait sleeps for Next(). It is shorthand for `time.Sleep(b.Next())`. +// +// Example: +// +// start := time.Now() +// backoff.Wait() +// fmt.Println("slept for", time.Since(start)) +func (e *Exponential) Wait() { + time.Sleep(e.Next()) +} + +// incrementCurrentInterval multiplies currentInterval by multiplier, clamping +// at maxInterval. +func (e *Exponential) incrementCurrentInterval() { + if float64(e.currentInterval) >= float64(e.maxInterval) { + e.currentInterval = e.maxInterval + } else { + e.currentInterval = min(time.Duration(float64(e.currentInterval)*e.multiplier), e.maxInterval) + } +} + +// safety lazily substitutes defaults the first time the struct is used, so a +// zero value Exponential is fully functional. +func (e *Exponential) safety() { + if e.initialInterval == 0 { + e.initialInterval = DefaultInitialInterval + e.currentInterval = DefaultInitialInterval + } + + if e.maxInterval == 0 { + e.maxInterval = DefaultMaxInterval + } + + if e.multiplier == 0 { + e.multiplier = DefaultMultiplier + } +} diff --git a/yabackoff/yabackoff.go b/yabackoff/yabackoff.go new file mode 100644 index 0000000..736743a --- /dev/null +++ b/yabackoff/yabackoff.go @@ -0,0 +1,72 @@ +// Package yabackoff provides simple, self-contained back-off strategies for +// retry loops. A back-off progressively increases the time you wait between +// attempts of an operation that might fail (for example, an HTTP request). +// +// # Quick start +// +// backoff := yabackoff.NewExponential(500*time.Millisecond, 1.5, 60*time.Second) +// for { +// if err := doWork(); err == nil { +// break // success – stop retrying +// } +// backoff.Wait() // progressively longer sleeps +// } +// +// The package is dependency-free and can be safely vendored. +package yabackoff + +import ( + "time" +) + +// Default* constants are applied when the caller provides zero +// values to NewExponential, or when an Exponential is declared +// as a zero value and used without initialisation. +const ( + // DefaultInitialInterval is used when initialInterval == 0. + DefaultInitialInterval = 500 * time.Millisecond + + // DefaultMultiplier is applied when multiplier == 0. + DefaultMultiplier = 1.5 + + // DefaultMaxInterval is used when maxInterval == 0. + DefaultMaxInterval = 60 * time.Second +) + +// Backoff is the behaviour shared by all back‑off strategies in this package. +// Implementations are *not* safe for concurrent use – surround them with your +// own synchronisation if you share one instance between goroutines. +// +// Example: +// +// backoff := yabackoff.NewExponential(100*time.Millisecond, 2, time.Second) +// _ = b.Next() // 200 ms +// _ = b.Next() // 400 ms +// b.Reset() // back to 100 ms +// +// The concrete type behind the interface decides how the delays grow. +type Backoff interface { + // Next advances the strategy and returns the delay for *this* attempt. + Next() time.Duration + + // Current returns the delay that was (or will be) produced by the most + // recent (or next) call to Next(). It never mutates internal state. + Current() time.Duration + + // Wait is a convenience wrapper that simply does: + // + // time.Sleep(b.Next()) + // + // Example: + // + // backoff.Wait() // sleeps for the next back‑off interval and updates state + Wait() + + // Reset puts the strategy back to its initial state so that the very next + // call to Next() will return the initial interval again. + // + // Example: + // + // backoff.Reset() + Reset() +} diff --git a/yabackoff/yabackoff_test.go b/yabackoff/yabackoff_test.go new file mode 100644 index 0000000..14d525b --- /dev/null +++ b/yabackoff/yabackoff_test.go @@ -0,0 +1,88 @@ +package yabackoff_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yabackoff" +) + +func TestEmptySafety_Works(t *testing.T) { + exp := yabackoff.Exponential{} + got := exp.Next() + + expected := yabackoff.NewExponential( + yabackoff.DefaultInitialInterval, + yabackoff.DefaultMultiplier, + yabackoff.DefaultMaxInterval, + ) + want := expected.Next() + + assert.Equal(t, want, got) +} + +func TestNext_Works(t *testing.T) { + start := 500 * time.Millisecond + multiplier := 1.5 + maxInterval := 10 * time.Second + + backoff := yabackoff.NewExponential(start, multiplier, maxInterval) + + expected := []time.Duration{start} + + for { + last := expected[len(expected)-1] + + next := min(time.Duration(float64(last)*multiplier), maxInterval) + + expected = append(expected, next) + + if next == maxInterval { + break + } + } + + for i, want := range expected[1:] { + got := backoff.Next() + + assert.Equal(t, want, got, "mismatch at step %d", i) + } +} + +func TestReset_Works(t *testing.T) { + start := time.Second + + b := yabackoff.NewExponential(start, 2.0, 10*time.Second) + + b.Next() + b.Next() + + b.Reset() + + assert.Equal(t, start, b.Current()) +} + +func TestMaxIntervalIsRespected(t *testing.T) { + maxInterval := 5 * time.Second + + backoff := yabackoff.NewExponential(2*time.Second, 10, maxInterval) + + backoff.Next() + + assert.Equal(t, maxInterval, backoff.Current()) +} + +func TestWaitDoesSleep(t *testing.T) { + start := 100 * time.Millisecond + backoff := yabackoff.NewExponential(start, 1.0, time.Second) + + startWaiting := time.Now() + + backoff.Wait() + + elapsed := time.Since(startWaiting) + + assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond) +}