From b477c28daec118d7eb2da55c3ed194891931381a Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Thu, 4 Jun 2026 13:11:17 -0400 Subject: [PATCH 1/3] feat: aborted-transaction retry policy, runner, and client integration Add RetryPolicy / DefaultRetryPolicy and a runner that re-executes a function on aborted Dgraph transactions with exponential backoff (retry.go), exposed on the client via a WithRetry method. --- client.go | 3 + retry.go | 96 ++++++++++++++++++++ retry_internal_test.go | 68 ++++++++++++++ retry_test.go | 197 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 retry.go create mode 100644 retry_internal_test.go create mode 100644 retry_test.go diff --git a/client.go b/client.go index be9813b..e4bb263 100644 --- a/client.go +++ b/client.go @@ -87,6 +87,9 @@ type Client interface { // DgraphClient returns a gRPC Dgraph client from the connection pool and a cleanup function. // The cleanup function must be called when finished with the client to return it to the pool. DgraphClient() (*dgo.Dgraph, func(), error) + + // WithRetry executes fn, retrying on aborted transactions per policy. + WithRetry(ctx context.Context, policy RetryPolicy, fn func() error) error } const ( diff --git a/retry.go b/retry.go new file mode 100644 index 0000000..9b49fda --- /dev/null +++ b/retry.go @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph + +import ( + "context" + "errors" + "math/rand/v2" + "time" + + "github.com/dgraph-io/dgo/v250" +) + +// RetryPolicy controls how WithRetry handles aborted transactions. +// Modeled after dgraph4j's RetryPolicy: exponential backoff with jitter. +type RetryPolicy struct { + // MaxRetries is the maximum number of retry attempts after the initial try. + MaxRetries int + + // BaseDelay is the initial delay before the first retry. + // Subsequent delays grow exponentially: BaseDelay * 2^attempt. + BaseDelay time.Duration + + // MaxDelay caps the backoff duration. No single delay exceeds this. + MaxDelay time.Duration + + // Jitter adds randomness to each delay to prevent thundering herd. + // Expressed as a fraction of the computed delay (e.g. 0.1 = 10%). + Jitter float64 +} + +// DefaultRetryPolicy mirrors dgraph4j's defaults: +// 5 retries, 100ms base delay, 5s max delay, 10% jitter. +var DefaultRetryPolicy = RetryPolicy{ + MaxRetries: 10, + BaseDelay: 100 * time.Millisecond, + MaxDelay: 5 * time.Second, + Jitter: 0.1, +} + +// delay computes the backoff duration for a given attempt (0-indexed). +// Formula: min(BaseDelay * 2^attempt, MaxDelay) + random(0, delay * Jitter) +func (p RetryPolicy) delay(attempt int) time.Duration { + d := p.BaseDelay * time.Duration(1< p.MaxDelay { + d = p.MaxDelay + } + if p.Jitter > 0 { + d += time.Duration(float64(d) * p.Jitter * rand.Float64()) + } + return d +} + +// WithRetry executes fn, retrying on aborted transactions according to policy. +// +// This is an opt-in mechanism modeled after dgraph4j's client.withRetry(). +// The caller wraps their mutation logic in fn; WithRetry handles creating +// fresh attempts with exponential backoff when Dgraph returns a transaction +// abort due to concurrent conflicts. +// +// fn is called at least once. On each aborted-transaction error, WithRetry +// waits according to the policy's backoff schedule and calls fn again, up to +// policy.MaxRetries additional times. Non-abort errors are returned immediately. +// +// The context is checked between retries; if cancelled during a backoff sleep, +// the context error is returned. +// +// Usage: +// +// err := client.WithRetry(ctx, modusgraph.DefaultRetryPolicy, func() error { +// return client.Insert(ctx, &entity) +// }) +func (c client) WithRetry(ctx context.Context, policy RetryPolicy, fn func() error) error { + for attempt := range policy.MaxRetries + 1 { + err := fn() + if err == nil { + return nil + } + if !errors.Is(err, dgo.ErrAborted) || attempt >= policy.MaxRetries { + return err + } + d := policy.delay(attempt) + c.logger.V(1).Info("Transaction aborted, retrying", + "attempt", attempt+1, "maxRetries", policy.MaxRetries, "delay", d) + select { + case <-time.After(d): + case <-ctx.Done(): + return ctx.Err() + } + } + // Unreachable: the loop runs MaxRetries+1 times and returns on every path. + panic("unreachable") +} diff --git a/retry_internal_test.go b/retry_internal_test.go new file mode 100644 index 0000000..ce6bd2b --- /dev/null +++ b/retry_internal_test.go @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRetryPolicyDelayExponentialGrowth(t *testing.T) { + p := RetryPolicy{ + BaseDelay: 100 * time.Millisecond, + MaxDelay: 10 * time.Second, + Jitter: 0, + } + + assert.Equal(t, 100*time.Millisecond, p.delay(0)) + assert.Equal(t, 200*time.Millisecond, p.delay(1)) + assert.Equal(t, 400*time.Millisecond, p.delay(2)) + assert.Equal(t, 800*time.Millisecond, p.delay(3)) + assert.Equal(t, 1600*time.Millisecond, p.delay(4)) +} + +func TestRetryPolicyDelayMaxCap(t *testing.T) { + p := RetryPolicy{ + BaseDelay: 1 * time.Second, + MaxDelay: 3 * time.Second, + Jitter: 0, + } + + assert.Equal(t, 1*time.Second, p.delay(0)) + assert.Equal(t, 2*time.Second, p.delay(1)) + assert.Equal(t, 3*time.Second, p.delay(2)) + assert.Equal(t, 3*time.Second, p.delay(3)) + assert.Equal(t, 3*time.Second, p.delay(10)) +} + +func TestRetryPolicyDelayWithJitter(t *testing.T) { + p := RetryPolicy{ + BaseDelay: 100 * time.Millisecond, + MaxDelay: 10 * time.Second, + Jitter: 0.5, + } + + for range 100 { + d := p.delay(0) + assert.GreaterOrEqual(t, d, 100*time.Millisecond, "delay should be at least base") + assert.LessOrEqual(t, d, 150*time.Millisecond, "delay should not exceed base + 50% jitter") + } +} + +func TestRetryPolicyDelayZeroJitter(t *testing.T) { + p := RetryPolicy{ + BaseDelay: 100 * time.Millisecond, + MaxDelay: 10 * time.Second, + Jitter: 0, + } + + for range 10 { + assert.Equal(t, 100*time.Millisecond, p.delay(0)) + assert.Equal(t, 200*time.Millisecond, p.delay(1)) + } +} diff --git a/retry_test.go b/retry_test.go new file mode 100644 index 0000000..4cb0d86 --- /dev/null +++ b/retry_test.go @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph_test + +import ( + "context" + "fmt" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/matthewmcneely/modusgraph" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RetryEntity is a test struct with a unique index to provoke transaction conflicts. +type RetryEntity struct { + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` + Name string `json:"name,omitempty" dgraph:"index=term,exact upsert"` + Value int `json:"value,omitempty"` +} + +// TestConcurrentInsertsWithRetry verifies that WithRetry handles aborted +// transactions from concurrent inserts. Without WithRetry, concurrent inserts +// on the same predicate index would fail with dgo.ErrAborted. +func TestConcurrentInsertsWithRetry(t *testing.T) { + testCases := []struct { + name string + uri string + skip bool + }{ + { + name: "FileURI", + uri: "file://" + GetTempDir(t), + }, + { + name: "DgraphURI", + uri: "dgraph://" + os.Getenv("MODUSGRAPH_TEST_ADDR"), + skip: os.Getenv("MODUSGRAPH_TEST_ADDR") == "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skipf("Skipping %s: MODUSGRAPH_TEST_ADDR not set", tc.name) + return + } + + client, cleanup := CreateTestClient(t, tc.uri) + defer cleanup() + + ctx := context.Background() + const numWorkers = 8 + const entitiesPerWorker = 10 + + var succeeded atomic.Int64 + var wg sync.WaitGroup + + for w := range numWorkers { + wg.Add(1) + go func() { + defer wg.Done() + for i := range entitiesPerWorker { + entity := &RetryEntity{ + Name: fmt.Sprintf("entity-%d-%d", w, i), + Value: w*entitiesPerWorker + i, + } + err := client.WithRetry(ctx, modusgraph.DefaultRetryPolicy, func() error { + return client.Insert(ctx, entity) + }) + if err != nil { + t.Errorf("worker %d entity %d: %v", w, i, err) + return + } + succeeded.Add(1) + } + }() + } + wg.Wait() + + total := int64(numWorkers * entitiesPerWorker) + require.Equal(t, total, succeeded.Load(), + "all concurrent inserts should succeed with retry") + }) + } +} + +// TestWithRetryContextCancellation verifies that WithRetry respects context +// cancellation during backoff sleeps. +func TestWithRetryContextCancellation(t *testing.T) { + uri := "file://" + GetTempDir(t) + client, cleanup := CreateTestClient(t, uri) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Use a policy with a long delay so the context expires during backoff. + slowPolicy := modusgraph.RetryPolicy{ + MaxRetries: 10, + BaseDelay: 1 * time.Second, + MaxDelay: 5 * time.Second, + Jitter: 0, + } + + callCount := 0 + err := client.WithRetry(ctx, slowPolicy, func() error { + callCount++ + // Always return an error that looks like an abort to trigger retry. + // We simulate this by inserting a duplicate to get a UniqueError, + // but that won't be retried. Instead, use a real insert to a fresh + // entity so the first call succeeds. + // Actually, to test the cancellation path we need the fn to always + // fail with an aborted error. Since we can't easily manufacture + // dgo.ErrAborted, test that context cancellation returns ctx.Err() + // by having fn block until context is done. + <-ctx.Done() + return ctx.Err() + }) + + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Equal(t, 1, callCount, "fn should be called once before context expires") +} + +// TestRetryPolicyDelay verifies the exponential backoff calculation. +func TestRetryPolicyDelay(t *testing.T) { + // Use the public struct fields to verify delay behavior indirectly + // by checking that DefaultRetryPolicy has the expected values. + p := modusgraph.DefaultRetryPolicy + assert.Equal(t, 10, p.MaxRetries) + assert.Equal(t, 100*time.Millisecond, p.BaseDelay) + assert.Equal(t, 5*time.Second, p.MaxDelay) + assert.InDelta(t, 0.1, p.Jitter, 0.001) +} + +// TestWithRetryNonAbortError verifies that non-abort errors are returned +// immediately without any retry. +func TestWithRetryNonAbortError(t *testing.T) { + uri := "file://" + GetTempDir(t) + client, cleanup := CreateTestClient(t, uri) + defer cleanup() + + callCount := 0 + expectedErr := fmt.Errorf("not an abort error") + + err := client.WithRetry(context.Background(), modusgraph.DefaultRetryPolicy, func() error { + callCount++ + return expectedErr + }) + + assert.ErrorIs(t, err, expectedErr) + assert.Equal(t, 1, callCount, "non-abort errors should not trigger retry") +} + +// TestWithRetrySucceedsFirstTry verifies that WithRetry returns nil +// when fn succeeds on the first call. +func TestWithRetrySucceedsFirstTry(t *testing.T) { + uri := "file://" + GetTempDir(t) + client, cleanup := CreateTestClient(t, uri) + defer cleanup() + + callCount := 0 + err := client.WithRetry(context.Background(), modusgraph.DefaultRetryPolicy, func() error { + callCount++ + return nil + }) + + assert.NoError(t, err) + assert.Equal(t, 1, callCount) +} + +// TestWithRetryMaxRetriesZero verifies that MaxRetries=0 calls fn exactly once +// and returns any error without retrying. +func TestWithRetryMaxRetriesZero(t *testing.T) { + uri := "file://" + GetTempDir(t) + client, cleanup := CreateTestClient(t, uri) + defer cleanup() + + policy := modusgraph.RetryPolicy{MaxRetries: 0} + callCount := 0 + + err := client.WithRetry(context.Background(), policy, func() error { + callCount++ + return fmt.Errorf("always fails") + }) + + assert.Error(t, err) + assert.Equal(t, 1, callCount, "MaxRetries=0 should call fn exactly once") +} From febcd5cf12d8fab5d50a753d9b73cdd28d43ec02 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Wed, 17 Jun 2026 17:23:28 -0400 Subject: [PATCH 2/3] fix(retry): keep jitter under MaxDelay; never skip fn on negative MaxRetries Addresses review feedback on the retry policy: - delay() now adds jitter before the final MaxDelay clamp, so the documented invariant "no single delay exceeds MaxDelay" holds. Previously the cap was applied before jitter, letting the delay reach MaxDelay*(1+Jitter). The exponential is also capped before the shift to avoid overflow at large attempt counts. - WithRetry clamps a negative MaxRetries to zero so fn always runs at least once, as documented, instead of skipping the loop and hitting the unreachable panic. Tests: - TestRetryPolicyDelayJitterNeverExceedsMaxCap asserts the cap invariant across attempts with large jitter. - TestWithRetryNegativeMaxRetries asserts fn runs once with MaxRetries=-1. - TestWithRetryContextCancellation now returns dgo.ErrAborted from fn so it actually enters the backoff sleep and exercises the ctx.Done() path it claims to cover (previously it returned ctx.Err() and bailed immediately). Docs: add runnable examples for WithRetry and a custom RetryPolicy. --- retry.go | 34 ++++++++++++++++++++------- retry_example_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++ retry_internal_test.go | 20 ++++++++++++++++ retry_test.go | 39 ++++++++++++++++++++++--------- 4 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 retry_example_test.go diff --git a/retry.go b/retry.go index 9b49fda..691d4ad 100644 --- a/retry.go +++ b/retry.go @@ -41,15 +41,25 @@ var DefaultRetryPolicy = RetryPolicy{ Jitter: 0.1, } -// delay computes the backoff duration for a given attempt (0-indexed). -// Formula: min(BaseDelay * 2^attempt, MaxDelay) + random(0, delay * Jitter) +// delay computes the backoff duration for a given attempt (0-indexed): +// the exponential BaseDelay*2^attempt, plus up to Jitter of itself, clamped +// so the result never exceeds MaxDelay. Clamping last keeps the documented +// invariant that no single delay exceeds MaxDelay — adding jitter after the +// cap would let the delay overshoot it. func (p RetryPolicy) delay(attempt int) time.Duration { - d := p.BaseDelay * time.Duration(1< p.MaxDelay { - d = p.MaxDelay + // Cap the exponential before jitter so a large attempt cannot overflow the + // shift. exp <= 0 means the shift overflowed; treat that as the cap too. + d := p.MaxDelay + if attempt < 63 { + if exp := p.BaseDelay << uint(attempt); exp > 0 && exp < p.MaxDelay { + d = exp + } } if p.Jitter > 0 { d += time.Duration(float64(d) * p.Jitter * rand.Float64()) + if d > p.MaxDelay { + d = p.MaxDelay + } } return d } @@ -74,23 +84,29 @@ func (p RetryPolicy) delay(attempt int) time.Duration { // return client.Insert(ctx, &entity) // }) func (c client) WithRetry(ctx context.Context, policy RetryPolicy, fn func() error) error { - for attempt := range policy.MaxRetries + 1 { + // A negative MaxRetries would make the loop run zero times and never call + // fn; clamp to zero so fn always runs at least once, as documented. + maxRetries := policy.MaxRetries + if maxRetries < 0 { + maxRetries = 0 + } + for attempt := range maxRetries + 1 { err := fn() if err == nil { return nil } - if !errors.Is(err, dgo.ErrAborted) || attempt >= policy.MaxRetries { + if !errors.Is(err, dgo.ErrAborted) || attempt >= maxRetries { return err } d := policy.delay(attempt) c.logger.V(1).Info("Transaction aborted, retrying", - "attempt", attempt+1, "maxRetries", policy.MaxRetries, "delay", d) + "attempt", attempt+1, "maxRetries", maxRetries, "delay", d) select { case <-time.After(d): case <-ctx.Done(): return ctx.Err() } } - // Unreachable: the loop runs MaxRetries+1 times and returns on every path. + // Unreachable: the loop runs at least once and returns on every path. panic("unreachable") } diff --git a/retry_example_test.go b/retry_example_test.go new file mode 100644 index 0000000..c95e9ef --- /dev/null +++ b/retry_example_test.go @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package modusgraph_test + +import ( + "context" + "time" + + "github.com/matthewmcneely/modusgraph" +) + +// ExampleClient_WithRetry wraps a mutation so an aborted transaction — the +// error Dgraph returns when concurrent writers conflict on an indexed +// predicate — is retried with exponential backoff instead of surfacing to the +// caller. Non-abort errors return immediately; the context bounds the total +// wait. +func ExampleClient_withRetry() { + client, _ := modusgraph.NewClient("dgraph://localhost:9080") + defer client.Close() + + ctx := context.Background() + entity := &RetryEntity{Name: "alice", Value: 1} + + err := client.WithRetry(ctx, modusgraph.DefaultRetryPolicy, func() error { + return client.Insert(ctx, entity) + }) + if err != nil { + panic(err) + } +} + +// ExampleRetryPolicy shows a custom backoff schedule: three retries, 50ms base +// delay doubling each attempt, capped at 2s, with 20% jitter to spread +// concurrent retriers apart. +func ExampleRetryPolicy() { + client, _ := modusgraph.NewClient("dgraph://localhost:9080") + defer client.Close() + + policy := modusgraph.RetryPolicy{ + MaxRetries: 3, + BaseDelay: 50 * time.Millisecond, + MaxDelay: 2 * time.Second, + Jitter: 0.2, + } + + ctx := context.Background() + _ = client.WithRetry(ctx, policy, func() error { + return client.Insert(ctx, &RetryEntity{Name: "bob", Value: 2}) + }) +} diff --git a/retry_internal_test.go b/retry_internal_test.go index ce6bd2b..4eadccf 100644 --- a/retry_internal_test.go +++ b/retry_internal_test.go @@ -54,6 +54,26 @@ func TestRetryPolicyDelayWithJitter(t *testing.T) { } } +// TestRetryPolicyDelayJitterNeverExceedsMaxCap pins the documented invariant: +// even when the exponential delay sits at or above MaxDelay and jitter is +// large, no single delay exceeds MaxDelay. Jitter is added before the final +// clamp, so the result stays within the cap. +func TestRetryPolicyDelayJitterNeverExceedsMaxCap(t *testing.T) { + p := RetryPolicy{ + BaseDelay: 8 * time.Second, + MaxDelay: 10 * time.Second, + Jitter: 0.5, // up to +50% would overshoot MaxDelay without the clamp + } + for attempt := range 6 { + for range 100 { + d := p.delay(attempt) + assert.LessOrEqual(t, d, p.MaxDelay, + "attempt %d: delay %v exceeded MaxDelay %v", attempt, d, p.MaxDelay) + assert.Positive(t, d, "attempt %d: delay should be positive", attempt) + } + } +} + func TestRetryPolicyDelayZeroJitter(t *testing.T) { p := RetryPolicy{ BaseDelay: 100 * time.Millisecond, diff --git a/retry_test.go b/retry_test.go index 4cb0d86..a2a5e63 100644 --- a/retry_test.go +++ b/retry_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/dgraph-io/dgo/v250" "github.com/matthewmcneely/modusgraph" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -114,20 +115,15 @@ func TestWithRetryContextCancellation(t *testing.T) { callCount := 0 err := client.WithRetry(ctx, slowPolicy, func() error { callCount++ - // Always return an error that looks like an abort to trigger retry. - // We simulate this by inserting a duplicate to get a UniqueError, - // but that won't be retried. Instead, use a real insert to a fresh - // entity so the first call succeeds. - // Actually, to test the cancellation path we need the fn to always - // fail with an aborted error. Since we can't easily manufacture - // dgo.ErrAborted, test that context cancellation returns ctx.Err() - // by having fn block until context is done. - <-ctx.Done() - return ctx.Err() + // Return a real abort so WithRetry enters the retry path and sleeps for + // the 1s backoff. The 50ms context deadline fires during that sleep, so + // WithRetry must return from the ctx.Done() branch of the backoff select + // — the path this test exists to cover. + return dgo.ErrAborted }) assert.ErrorIs(t, err, context.DeadlineExceeded) - assert.Equal(t, 1, callCount, "fn should be called once before context expires") + assert.Equal(t, 1, callCount, "fn runs once, then the context expires during backoff") } // TestRetryPolicyDelay verifies the exponential backoff calculation. @@ -195,3 +191,24 @@ func TestWithRetryMaxRetriesZero(t *testing.T) { assert.Error(t, err) assert.Equal(t, 1, callCount, "MaxRetries=0 should call fn exactly once") } + +// TestWithRetryNegativeMaxRetries verifies that a negative MaxRetries still +// calls fn exactly once and returns its error, rather than skipping the loop +// and panicking. +func TestWithRetryNegativeMaxRetries(t *testing.T) { + uri := "file://" + GetTempDir(t) + client, cleanup := CreateTestClient(t, uri) + defer cleanup() + + policy := modusgraph.RetryPolicy{MaxRetries: -1} + callCount := 0 + expectedErr := fmt.Errorf("boom") + + err := client.WithRetry(context.Background(), policy, func() error { + callCount++ + return expectedErr + }) + + assert.ErrorIs(t, err, expectedErr) + assert.Equal(t, 1, callCount, "negative MaxRetries should still call fn once") +} From bbf99b955c751730688d9ce7a5936738ee714225 Mon Sep 17 00:00:00 2001 From: Michael Welles Date: Wed, 17 Jun 2026 18:10:40 -0400 Subject: [PATCH 3/3] style(retry): clear gosec G115 and G404 in delay() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the int->uint shift conversion (Go allows a signed shift count, and attempt is bounded to [0,63)), clearing gosec G115. Annotate the jitter RNG with nolint:gosec — backoff jitter is not security-sensitive, so math/rand/v2 is appropriate. No behavior change. --- retry.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/retry.go b/retry.go index 691d4ad..113c7fd 100644 --- a/retry.go +++ b/retry.go @@ -48,15 +48,18 @@ var DefaultRetryPolicy = RetryPolicy{ // cap would let the delay overshoot it. func (p RetryPolicy) delay(attempt int) time.Duration { // Cap the exponential before jitter so a large attempt cannot overflow the - // shift. exp <= 0 means the shift overflowed; treat that as the cap too. + // shift. attempt comes from a range loop (>= 0) and is bounded below 63; + // exp <= 0 means the shift overflowed anyway, which we treat as the cap. d := p.MaxDelay - if attempt < 63 { - if exp := p.BaseDelay << uint(attempt); exp > 0 && exp < p.MaxDelay { + if attempt >= 0 && attempt < 63 { + if exp := p.BaseDelay << attempt; exp > 0 && exp < p.MaxDelay { d = exp } } if p.Jitter > 0 { - d += time.Duration(float64(d) * p.Jitter * rand.Float64()) + // Backoff jitter spreads retriers apart; it does not need a + // cryptographic RNG, so math/rand/v2 is appropriate here. + d += time.Duration(float64(d) * p.Jitter * rand.Float64()) //nolint:gosec // G404: jitter is not security-sensitive if d > p.MaxDelay { d = p.MaxDelay }