Skip to content

Commit 2501569

Browse files
authored
Merge pull request #4039 from crazy-max/kubernetes-dial-backoff-jitter
kubernetes: jitter transient dial retries
2 parents bf5ccd4 + d61e36d commit 2501569

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

driver/kubernetes/driver.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
stderrors "errors"
66
"fmt"
7+
"math/rand/v2"
78
"net"
89
"strings"
910
"syscall"
@@ -389,9 +390,17 @@ func isTransientConnectionError(err error) bool {
389390
return false
390391
}
391392

392-
// calculateBackoff calculates the delay for the given attempt with exponential backoff.
393+
// calculateBackoff returns a randomized exponential backoff delay for attempt,
394+
// never exceeding maxDelay.
393395
func calculateBackoff(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
394-
return min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay)
396+
delay := min(baseDelay<<attempt, maxDelay)
397+
// Jitter is additive and clipped to the headroom left under maxDelay, so a
398+
// retry never fires sooner than the exponential schedule alone would allow.
399+
jitter := min(delay, maxDelay-delay)
400+
if jitter <= 0 {
401+
return delay
402+
}
403+
return delay + rand.N(jitter) // #nosec G404 -- no strong randomness required for retry jitter
395404
}
396405

397406
func (d *Driver) Client(ctx context.Context, opts ...client.ClientOpt) (*client.Client, error) {

driver/kubernetes/driver_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package kubernetes
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestCalculateBackoff(t *testing.T) {
11+
const (
12+
baseDelay = 500 * time.Millisecond
13+
maxDelay = 10 * time.Second
14+
)
15+
tests := []struct {
16+
attempt int
17+
floor, cap time.Duration
18+
}{
19+
{0, 500 * time.Millisecond, time.Second},
20+
{1, time.Second, 2 * time.Second},
21+
{2, 2 * time.Second, 4 * time.Second},
22+
{3, 4 * time.Second, 8 * time.Second},
23+
{4, 8 * time.Second, maxDelay},
24+
{5, maxDelay, maxDelay},
25+
{20, maxDelay, maxDelay},
26+
}
27+
for _, tt := range tests {
28+
seen := make(map[time.Duration]struct{})
29+
for range 500 {
30+
got := calculateBackoff(tt.attempt, baseDelay, maxDelay)
31+
require.GreaterOrEqual(t, got, tt.floor, "attempt %d must never wait less than the exponential schedule", tt.attempt)
32+
require.LessOrEqual(t, got, tt.cap, "attempt %d must not exceed twice the schedule, nor maxDelay", tt.attempt)
33+
seen[got] = struct{}{}
34+
}
35+
if tt.floor < tt.cap {
36+
// The narrowest jittered range is 500ms wide, so a single distinct
37+
// value across 500 draws would not be chance.
38+
require.Greater(t, len(seen), 1, "attempt %d must vary so concurrent builders do not retry in lockstep", tt.attempt)
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)