Skip to content

Commit 5aa569e

Browse files
authored
Merge branch 'master' into merge_129
2 parents f4ac2d9 + ea9d019 commit 5aa569e

File tree

3 files changed

+124
-3
lines changed

3 files changed

+124
-3
lines changed

options.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,35 @@ func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc {
158158
}
159159
}
160160

161+
// FullJitterBackoffDelay is a DelayTypeFunc that calculates delay using exponential backoff
162+
// with full jitter. The delay is a random value between 0 and the current backoff ceiling.
163+
// Formula: sleep = random_between(0, min(cap, base * 2^attempt))
164+
// It uses config.Delay as the base delay and config.MaxDelay as the cap.
165+
func FullJitterBackoffDelay(n uint, err error, config *Config) time.Duration {
166+
// Calculate the exponential backoff ceiling for the current attempt
167+
backoffCeiling := float64(config.delay) * math.Pow(2, float64(n))
168+
currentCap := float64(config.maxDelay)
169+
170+
// If MaxDelay is set and backoffCeiling exceeds it, cap at MaxDelay
171+
if currentCap > 0 && backoffCeiling > currentCap {
172+
backoffCeiling = currentCap
173+
}
174+
175+
// Ensure backoffCeiling is at least 0
176+
if backoffCeiling < 0 {
177+
backoffCeiling = 0
178+
}
179+
180+
// Add jitter: random value between 0 and backoffCeiling
181+
// rand.Int63n panics if argument is <= 0
182+
if backoffCeiling <= 0 {
183+
return 0 // No delay if ceiling is zero or negative
184+
}
185+
186+
jitter := rand.Int63n(int64(backoffCeiling)) // #nosec G404 -- Using math/rand is acceptable for non-security critical jitter.
187+
return time.Duration(jitter)
188+
}
189+
161190
// OnRetry function callback are called each retry
162191
//
163192
// log each retry example:

retry.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,6 @@ shouldRetry:
188188
break
189189
}
190190

191-
config.onRetry(n, err)
192-
193191
for errToCheck, attempts := range attemptsForError {
194192
if errors.Is(err, errToCheck) {
195193
attempts--
@@ -204,6 +202,9 @@ shouldRetry:
204202
if n == config.attempts-1 {
205203
break shouldRetry
206204
}
205+
206+
config.onRetry(n, err)
207+
207208
n++
208209
select {
209210
case <-config.timer.After(delay(config, n, err)):

retry_test.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"math"
78
"os"
89
"testing"
910
"time"
@@ -35,7 +36,7 @@ func TestDoWithDataAllFailed(t *testing.T) {
3536
assert.Len(t, err, 10)
3637
fmt.Println(err.Error())
3738
assert.Equal(t, expectedErrorFormat, err.Error(), "retry error format")
38-
assert.Equal(t, uint(45), retrySum, "right count of retry")
39+
assert.Equal(t, uint(36), retrySum, "right count of retry")
3940
}
4041

4142
func TestDoFirstOk(t *testing.T) {
@@ -678,6 +679,28 @@ func TestAttemptsForErrorNoDelayAfterFinalAttempt(t *testing.T) {
678679
assert.Less(t, totalTime, 300*time.Millisecond, "should not delay after final attempt")
679680
}
680681

682+
func TestOnRetryNotCalledOnLastAttempt(t *testing.T) {
683+
callCount := 0
684+
onRetryCalls := make([]uint, 0)
685+
686+
err := Do(
687+
func() error {
688+
callCount++
689+
return errors.New("test error")
690+
},
691+
Attempts(3),
692+
OnRetry(func(n uint, err error) {
693+
onRetryCalls = append(onRetryCalls, n)
694+
}),
695+
Delay(time.Nanosecond),
696+
)
697+
698+
assert.Error(t, err)
699+
assert.Equal(t, 3, callCount, "function should be called 3 times")
700+
assert.Equal(t, []uint{0, 1}, onRetryCalls, "onRetry should only be called for first 2 attempts, not the final one")
701+
assert.Len(t, onRetryCalls, 2, "onRetry should be called exactly 2 times (not on last attempt)")
702+
}
703+
681704
func TestIsRecoverable(t *testing.T) {
682705
err := errors.New("err")
683706
assert.True(t, IsRecoverable(err))
@@ -688,3 +711,71 @@ func TestIsRecoverable(t *testing.T) {
688711
err = fmt.Errorf("wrapping: %w", err)
689712
assert.False(t, IsRecoverable(err))
690713
}
714+
715+
func TestFullJitterBackoffDelay(t *testing.T) {
716+
// Seed for predictable randomness in tests
717+
// In real usage, math/rand is auto-seeded in Go 1.20+ or should be seeded once at program start.
718+
// For library test predictability, local seeding is fine.
719+
// However, retry-go's RandomDelay uses global math/rand without explicit seeding in tests.
720+
// Let's follow the existing pattern of not explicitly seeding in each test for now,
721+
// assuming test runs are isolated enough or that exact delay values aren't asserted,
722+
// but rather ranges or properties.
723+
724+
baseDelay := 50 * time.Millisecond
725+
maxDelay := 500 * time.Millisecond
726+
727+
config := &Config{
728+
delay: baseDelay,
729+
maxDelay: maxDelay,
730+
// other fields can be zero/default for this test
731+
}
732+
733+
attempts := []uint{0, 1, 2, 3, 4, 5, 6, 10}
734+
735+
for _, n := range attempts {
736+
delay := FullJitterBackoffDelay(n, errors.New("test error"), config)
737+
738+
expectedMaxCeiling := float64(baseDelay) * math.Pow(2, float64(n))
739+
if expectedMaxCeiling > float64(maxDelay) {
740+
expectedMaxCeiling = float64(maxDelay)
741+
}
742+
743+
assert.True(t, delay >= 0, "Delay should be non-negative. Got: %v for attempt %d", delay, n)
744+
assert.True(t, delay <= time.Duration(expectedMaxCeiling),
745+
"Delay %v should be less than or equal to current backoff ceiling %v for attempt %d", delay, time.Duration(expectedMaxCeiling), n)
746+
747+
t.Logf("Attempt %d: BaseDelay=%v, MaxDelay=%v, Calculated Ceiling=~%v, Actual Delay=%v",
748+
n, baseDelay, maxDelay, time.Duration(expectedMaxCeiling), delay)
749+
750+
// Test with MaxDelay disabled (0)
751+
configNoMax := &Config{delay: baseDelay, maxDelay: 0}
752+
delayNoMax := FullJitterBackoffDelay(n, errors.New("test error"), configNoMax)
753+
expectedCeilingNoMax := float64(baseDelay) * math.Pow(2, float64(n))
754+
if expectedCeilingNoMax > float64(10*time.Minute) { // Avoid overflow for very large N
755+
expectedCeilingNoMax = float64(10 * time.Minute)
756+
}
757+
assert.True(t, delayNoMax >= 0, "Delay (no max) should be non-negative. Got: %v for attempt %d", delayNoMax, n)
758+
assert.True(t, delayNoMax <= time.Duration(expectedCeilingNoMax),
759+
"Delay (no max) %v should be less than or equal to current backoff ceiling %v for attempt %d", delayNoMax, time.Duration(expectedCeilingNoMax), n)
760+
}
761+
762+
// Test case where baseDelay might be zero
763+
configZeroBase := &Config{delay: 0, maxDelay: maxDelay}
764+
delayZeroBase := FullJitterBackoffDelay(0, errors.New("test error"), configZeroBase)
765+
assert.Equal(t, time.Duration(0), delayZeroBase, "Delay with zero base delay should be 0")
766+
767+
delayZeroBaseAttempt1 := FullJitterBackoffDelay(1, errors.New("test error"), configZeroBase)
768+
assert.Equal(t, time.Duration(0), delayZeroBaseAttempt1, "Delay with zero base delay (attempt > 0) should be 0")
769+
770+
// Test with very small base delay
771+
smallBaseDelay := 1 * time.Nanosecond
772+
configSmallBase := &Config{delay: smallBaseDelay, maxDelay: 100 * time.Nanosecond}
773+
for i := uint(0); i < 5; i++ {
774+
d := FullJitterBackoffDelay(i, errors.New("test"), configSmallBase)
775+
ceil := float64(smallBaseDelay) * math.Pow(2, float64(i))
776+
if ceil > 100 {
777+
ceil = 100
778+
}
779+
assert.True(t, d <= time.Duration(ceil))
780+
}
781+
}

0 commit comments

Comments
 (0)