Skip to content

Commit d601269

Browse files
committed
Merge branch 'master' into merge_131
2 parents 74b99b5 + e08f658 commit d601269

File tree

3 files changed

+188
-11
lines changed

3 files changed

+188
-11
lines changed

options.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ func BackOffDelay(n uint, _ error, config *Config) time.Duration {
124124
config.maxBackOffN = max - uint(math.Floor(math.Log2(float64(config.delay))))
125125
}
126126

127+
n--
128+
127129
if n > config.maxBackOffN {
128130
n = config.maxBackOffN
129131
}
@@ -158,6 +160,35 @@ func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc {
158160
}
159161
}
160162

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

retry.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (
143143
attemptsForError[err] = attempts
144144
}
145145

146-
shouldRetry := true
147-
for shouldRetry {
146+
shouldRetry:
147+
for {
148148
t, err := retryableFunc()
149149
if err == nil {
150150
return t, nil
@@ -160,21 +160,24 @@ func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (
160160
break
161161
}
162162

163-
config.onRetry(n, err)
164-
165163
for errToCheck, attempts := range attemptsForError {
166164
if errors.Is(err, errToCheck) {
167165
attempts--
168166
attemptsForError[errToCheck] = attempts
169-
shouldRetry = shouldRetry && attempts > 0
167+
if attempts <= 0 {
168+
break shouldRetry
169+
}
170170
}
171171
}
172172

173173
// Setting attempts to 0 means we'll retry until we succeed
174174
// if this is last attempt - don't wait
175-
if config.attempts != 0 && n == config.attempts-1 {
176-
break
175+
if n == config.attempts-1 {
176+
break shouldRetry
177177
}
178+
179+
config.onRetry(n, err)
180+
178181
n++
179182
select {
180183
case <-config.timer.After(delay(config, n, err)):
@@ -185,7 +188,6 @@ func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (
185188

186189
return emptyT, append(errorLog, context.Cause(config.context))
187190
}
188-
189191
}
190192

191193
if config.lastErrorOnly {

retry_test.go

Lines changed: 147 additions & 3 deletions
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) {
@@ -294,7 +295,7 @@ func TestBackOffDelay(t *testing.T) {
294295
delay: -1,
295296
expectedMaxN: 62,
296297
n: 2,
297-
expectedDelay: 4,
298+
expectedDelay: 2,
298299
},
299300
{
300301
label: "zero-delay",
@@ -310,6 +311,13 @@ func TestBackOffDelay(t *testing.T) {
310311
n: 62,
311312
expectedDelay: time.Second << 33,
312313
},
314+
{
315+
label: "one-second-n",
316+
delay: time.Second,
317+
expectedMaxN: 33,
318+
n: 1,
319+
expectedDelay: time.Second,
320+
},
313321
} {
314322
t.Run(
315323
c.label,
@@ -490,7 +498,7 @@ func TestContext(t *testing.T) {
490498
})
491499

492500
t.Run("timed out on retry infinte attempts - wraps context error with last retried function error", func(t *testing.T) {
493-
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500)
501+
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200)
494502
defer cancel()
495503

496504
retrySum := 0
@@ -633,6 +641,74 @@ func BenchmarkDoWithDataNoErrors(b *testing.B) {
633641
}
634642
}
635643

644+
type attemptsForErrorTestError struct{}
645+
646+
func (attemptsForErrorTestError) Error() string { return "test error" }
647+
648+
func TestAttemptsForErrorNoDelayAfterFinalAttempt(t *testing.T) {
649+
var count uint64
650+
var timestamps []time.Time
651+
652+
startTime := time.Now()
653+
654+
err := Do(
655+
func() error {
656+
count++
657+
timestamps = append(timestamps, time.Now())
658+
return attemptsForErrorTestError{}
659+
},
660+
Attempts(3),
661+
Delay(200*time.Millisecond),
662+
DelayType(FixedDelay),
663+
AttemptsForError(2, attemptsForErrorTestError{}),
664+
LastErrorOnly(true),
665+
Context(context.Background()),
666+
)
667+
668+
endTime := time.Now()
669+
670+
assert.Error(t, err)
671+
assert.Equal(t, uint64(2), count, "should attempt exactly 2 times")
672+
assert.Len(t, timestamps, 2, "should have 2 timestamps")
673+
674+
// Verify timing: first attempt at ~0ms, second at ~200ms, end immediately after second attempt
675+
firstAttemptTime := timestamps[0].Sub(startTime)
676+
secondAttemptTime := timestamps[1].Sub(startTime)
677+
totalTime := endTime.Sub(startTime)
678+
679+
// First attempt should be immediate
680+
assert.Less(t, firstAttemptTime, 50*time.Millisecond, "first attempt should be immediate")
681+
682+
// Second attempt should be after delay
683+
assert.Greater(t, secondAttemptTime, 150*time.Millisecond, "second attempt should be after delay")
684+
assert.Less(t, secondAttemptTime, 250*time.Millisecond, "second attempt should not be too delayed")
685+
686+
// Total time should not include delay after final attempt
687+
assert.Less(t, totalTime, 300*time.Millisecond, "should not delay after final attempt")
688+
}
689+
690+
func TestOnRetryNotCalledOnLastAttempt(t *testing.T) {
691+
callCount := 0
692+
onRetryCalls := make([]uint, 0)
693+
694+
err := Do(
695+
func() error {
696+
callCount++
697+
return errors.New("test error")
698+
},
699+
Attempts(3),
700+
OnRetry(func(n uint, err error) {
701+
onRetryCalls = append(onRetryCalls, n)
702+
}),
703+
Delay(time.Nanosecond),
704+
)
705+
706+
assert.Error(t, err)
707+
assert.Equal(t, 3, callCount, "function should be called 3 times")
708+
assert.Equal(t, []uint{0, 1}, onRetryCalls, "onRetry should only be called for first 2 attempts, not the final one")
709+
assert.Len(t, onRetryCalls, 2, "onRetry should be called exactly 2 times (not on last attempt)")
710+
}
711+
636712
func TestIsRecoverable(t *testing.T) {
637713
err := errors.New("err")
638714
assert.True(t, IsRecoverable(err))
@@ -643,3 +719,71 @@ func TestIsRecoverable(t *testing.T) {
643719
err = fmt.Errorf("wrapping: %w", err)
644720
assert.False(t, IsRecoverable(err))
645721
}
722+
723+
func TestFullJitterBackoffDelay(t *testing.T) {
724+
// Seed for predictable randomness in tests
725+
// In real usage, math/rand is auto-seeded in Go 1.20+ or should be seeded once at program start.
726+
// For library test predictability, local seeding is fine.
727+
// However, retry-go's RandomDelay uses global math/rand without explicit seeding in tests.
728+
// Let's follow the existing pattern of not explicitly seeding in each test for now,
729+
// assuming test runs are isolated enough or that exact delay values aren't asserted,
730+
// but rather ranges or properties.
731+
732+
baseDelay := 50 * time.Millisecond
733+
maxDelay := 500 * time.Millisecond
734+
735+
config := &Config{
736+
delay: baseDelay,
737+
maxDelay: maxDelay,
738+
// other fields can be zero/default for this test
739+
}
740+
741+
attempts := []uint{0, 1, 2, 3, 4, 5, 6, 10}
742+
743+
for _, n := range attempts {
744+
delay := FullJitterBackoffDelay(n, errors.New("test error"), config)
745+
746+
expectedMaxCeiling := float64(baseDelay) * math.Pow(2, float64(n))
747+
if expectedMaxCeiling > float64(maxDelay) {
748+
expectedMaxCeiling = float64(maxDelay)
749+
}
750+
751+
assert.True(t, delay >= 0, "Delay should be non-negative. Got: %v for attempt %d", delay, n)
752+
assert.True(t, delay <= time.Duration(expectedMaxCeiling),
753+
"Delay %v should be less than or equal to current backoff ceiling %v for attempt %d", delay, time.Duration(expectedMaxCeiling), n)
754+
755+
t.Logf("Attempt %d: BaseDelay=%v, MaxDelay=%v, Calculated Ceiling=~%v, Actual Delay=%v",
756+
n, baseDelay, maxDelay, time.Duration(expectedMaxCeiling), delay)
757+
758+
// Test with MaxDelay disabled (0)
759+
configNoMax := &Config{delay: baseDelay, maxDelay: 0}
760+
delayNoMax := FullJitterBackoffDelay(n, errors.New("test error"), configNoMax)
761+
expectedCeilingNoMax := float64(baseDelay) * math.Pow(2, float64(n))
762+
if expectedCeilingNoMax > float64(10*time.Minute) { // Avoid overflow for very large N
763+
expectedCeilingNoMax = float64(10 * time.Minute)
764+
}
765+
assert.True(t, delayNoMax >= 0, "Delay (no max) should be non-negative. Got: %v for attempt %d", delayNoMax, n)
766+
assert.True(t, delayNoMax <= time.Duration(expectedCeilingNoMax),
767+
"Delay (no max) %v should be less than or equal to current backoff ceiling %v for attempt %d", delayNoMax, time.Duration(expectedCeilingNoMax), n)
768+
}
769+
770+
// Test case where baseDelay might be zero
771+
configZeroBase := &Config{delay: 0, maxDelay: maxDelay}
772+
delayZeroBase := FullJitterBackoffDelay(0, errors.New("test error"), configZeroBase)
773+
assert.Equal(t, time.Duration(0), delayZeroBase, "Delay with zero base delay should be 0")
774+
775+
delayZeroBaseAttempt1 := FullJitterBackoffDelay(1, errors.New("test error"), configZeroBase)
776+
assert.Equal(t, time.Duration(0), delayZeroBaseAttempt1, "Delay with zero base delay (attempt > 0) should be 0")
777+
778+
// Test with very small base delay
779+
smallBaseDelay := 1 * time.Nanosecond
780+
configSmallBase := &Config{delay: smallBaseDelay, maxDelay: 100 * time.Nanosecond}
781+
for i := uint(0); i < 5; i++ {
782+
d := FullJitterBackoffDelay(i, errors.New("test"), configSmallBase)
783+
ceil := float64(smallBaseDelay) * math.Pow(2, float64(i))
784+
if ceil > 100 {
785+
ceil = 100
786+
}
787+
assert.True(t, d <= time.Duration(ceil))
788+
}
789+
}

0 commit comments

Comments
 (0)