From cf2bd8bd7a4c32103ca8932a7a8e033bd2d4b6c2 Mon Sep 17 00:00:00 2001 From: Jan Seidl Date: Wed, 4 Nov 2026 16:06:13 +0100 Subject: [PATCH 1/2] fix(delay): clamp full-jitter ceiling to int64 range FullJitterBackoffDelay panics with "invalid argument to Int63n" once the exponential ceiling exceeds math.MaxInt64 (attempt 34 with Delay(1s) and no MaxDelay). Converting an out-of-range float64 to int64 is implementation- defined in Go; on amd64 it yields a negative value that Int63n rejects. arm64 saturates, so the bug is invisible there. Saturate the ceiling at math.MaxInt64 before the conversion. Builds on the approach from #159. Co-authored-by: dualfroz --- options.go | 9 ++++++++- retry_test.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/options.go b/options.go index d31b00d..8d16e0d 100644 --- a/options.go +++ b/options.go @@ -257,10 +257,17 @@ func FullJitterBackoffDelay(n uint, err error, config DelayContext) time.Duratio backoffCeiling = currentCap } - jitter := rand.Int63n(int64(backoffCeiling)) // #nosec G404 -- Using math/rand is acceptable for non-security critical jitter. + jitter := rand.Int63n(saturatingInt64(backoffCeiling)) // #nosec G404 -- Using math/rand is acceptable for non-security critical jitter. return time.Duration(jitter) } +func saturatingInt64(positive float64) int64 { + if positive >= float64(math.MaxInt64) { + return math.MaxInt64 + } + return int64(positive) +} + // OnRetry function callback are called each retry // // log each retry example: diff --git a/retry_test.go b/retry_test.go index 0481d24..de6dc3e 100644 --- a/retry_test.go +++ b/retry_test.go @@ -981,6 +981,26 @@ func TestFullJitterBackoffDelay(t *testing.T) { } } +func TestFullJitterBackoffDelayDoesNotPanicWhenCeilingExceedsInt64(t *testing.T) { + tests := []struct { + name string + config *Retrier + }{ + {"no max delay", New(Delay(time.Second))}, + {"max delay at int64 limit", New(Delay(time.Second), MaxDelay(time.Duration(math.MaxInt64)))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, n := range []uint{0, 33, 34, 63, 64, 1023, 1024, 2000} { + assert.NotPanics(t, func() { + assert.GreaterOrEqual(t, FullJitterBackoffDelay(n, errors.New("test"), tt.config), time.Duration(0), "attempt %d", n) + }, "attempt %d", n) + } + }) + } +} + func TestFullJitterZeroDelayAfterManyAttempts(t *testing.T) { for _, attempts := range []uint{0, 1100} { for _, maxDelay := range []time.Duration{0, time.Second} { From 3d0fc0e7e4dc0d1e3360aca10df5d5092266bd1e Mon Sep 17 00:00:00 2001 From: Jan Seidl Date: Wed, 4 Nov 2026 16:21:22 +0100 Subject: [PATCH 2/2] chore: drop unused gosec nosec directive --- options.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options.go b/options.go index 8d16e0d..634cf08 100644 --- a/options.go +++ b/options.go @@ -257,7 +257,7 @@ func FullJitterBackoffDelay(n uint, err error, config DelayContext) time.Duratio backoffCeiling = currentCap } - jitter := rand.Int63n(saturatingInt64(backoffCeiling)) // #nosec G404 -- Using math/rand is acceptable for non-security critical jitter. + jitter := rand.Int63n(saturatingInt64(backoffCeiling)) return time.Duration(jitter) }