From e6c775e66cac4da1d238e63d8b983724882ae220 Mon Sep 17 00:00:00 2001 From: Igor Serganov Date: Tue, 19 May 2026 21:02:28 -0700 Subject: [PATCH] Fix: race in NextBackoff 'Load' & 'Add' are two atomic ops, so concurrent callers can both pass the check before either increments. E.g., LimitRetry(.., 3) can occasionally allow 4+ attempts under high load. Switching to a single atomic Add fixes the race. --- retry.go | 3 +-- retry_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/retry.go b/retry.go index 0f548ef..73eeda8 100644 --- a/retry.go +++ b/retry.go @@ -39,10 +39,9 @@ func LimitRetry(s RetryStrategy, max int) RetryStrategy { } func (r *limitedRetry) NextBackoff() time.Duration { - if r.cnt.Load() >= r.max { + if r.cnt.Add(1) > r.max { return 0 } - r.cnt.Add(1) return r.s.NextBackoff() } diff --git a/retry_test.go b/retry_test.go index fc44fb4..f7a1675 100644 --- a/retry_test.go +++ b/retry_test.go @@ -1,6 +1,8 @@ package redislock_test import ( + "sync" + "sync/atomic" "testing" "time" @@ -60,3 +62,32 @@ func TestLimitRetry(t *testing.T) { } } } + +func TestLimitRetry_concurrent(t *testing.T) { + const ( + max = 100 + callers = 64 + ) + + var allowed atomic.Int64 + retry := LimitRetry(LinearBackoff(time.Millisecond), max) + + var wg sync.WaitGroup + start := make(chan struct{}) + for range callers { + wg.Go(func() { + <-start + for range max { + if retry.NextBackoff() > 0 { + allowed.Add(1) + } + } + }) + } + close(start) + wg.Wait() + + if got := allowed.Load(); got != max { + t.Fatalf("expected exactly %d allowed retries, got %d", max, got) + } +}