From 08f757f959358eeccdeff56d5ef41f31638c01e0 Mon Sep 17 00:00:00 2001 From: Daniel Bae <157205701+MrBeldum@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:18:00 -0700 Subject: [PATCH] fix: keep EqualJitter delays within [cap/2, cap) --- try.go | 21 +++++++++++++-------- try_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/try.go b/try.go index a2c0ba1..82e4ee2 100644 --- a/try.go +++ b/try.go @@ -396,13 +396,18 @@ func calculateNextDelay(cfg *Config, attempt int, err error) time.Duration { // 4. Compute the jitter window — distinct from the backoff cap. // MaxJitter, if set, caps only the random spread while leaving the // deterministic base delay intact (Option A semantics): - // FullJitter: rand[0, jitterWindow) — base = 0 - // EqualJitter: cap/2 + rand[0, jitterWindow) — base = cap/2 - var jitterWindow time.Duration - if cfg.MaxJitter > 0 && cfg.MaxJitter < cap { + // FullJitter: rand[0, jitterWindow) — base = 0, window <= cap + // EqualJitter: cap/2 + rand[0, jitterWindow) — base = cap/2, window <= cap - cap/2 + // + // For EqualJitter the default window is the remaining half of the cap, so + // the delay stays within [cap/2, cap) as documented instead of reaching + // up to 1.5x the exponential cap. + jitterWindow := cap + if cfg.Jitter == EqualJitter { + jitterWindow = cap - cap/2 + } + if cfg.MaxJitter > 0 && cfg.MaxJitter < jitterWindow { jitterWindow = cfg.MaxJitter - } else { - jitterWindow = cap // default: full backoff cap is the jitter window } // 5. Enforce the 1ms floor on the jitter window *before* passing it to @@ -435,8 +440,8 @@ func calculateNextDelay(cfg *Config, attempt int, err error) time.Duration { } // 7. Enforce MaxDelay as the hard ceiling on the final delay. - // EqualJitter's base + jitter can slightly exceed MaxDelay when cap is - // close to MaxDelay. Cap here rather than constraining the components. + // The 1ms floors above can push a tiny delay past a sub-millisecond + // MaxDelay. Cap here rather than constraining the components. if cfg.MaxDelay > 0 && d > cfg.MaxDelay { d = cfg.MaxDelay } diff --git a/try_test.go b/try_test.go index 66a5fc5..7d1bdf8 100644 --- a/try_test.go +++ b/try_test.go @@ -1268,3 +1268,37 @@ func TestAppendErrHistory_RingEviction(t *testing.T) { t.Error("e1 should have been evicted") } } + +func TestDo_EqualJitter_StaysWithinCap(t *testing.T) { + // EqualJitter is documented as cap/2 + rand[0, cap/2): every delay must be + // at least half the exponential cap and strictly below the cap itself, + // even while the cap is still below MaxDelay. + const initial = 100 * time.Millisecond + const attempts = 4 + + for run := 0; run < 200; run++ { + clk := &testClock{afterChan: make(chan time.Time, attempts)} + for i := 0; i < attempts; i++ { + clk.afterChan <- time.Now() + } + + var infos []RetryInfo + _, _ = Do(context.Background(), func(ctx context.Context) (int, error) { + return 0, errors.New("fail") + }, + WithAttempts(attempts), + WithInitialDelay(initial), + WithMaxDelay(time.Hour), + WithJitter(EqualJitter), + WithClock(clk), + WithOnRetry(func(info RetryInfo) { infos = append(infos, info) }), + ) + + for _, info := range infos { + capDelay := initial << (info.Attempt - 1) + if info.Delay < capDelay/2 || info.Delay >= capDelay { + t.Fatalf("attempt %d: delay %v outside [%v, %v)", info.Attempt, info.Delay, capDelay/2, capDelay) + } + } + } +}