tenseleyflow/shithub / e1c2fa0

Browse files

S33: exponential backoff with jitter (24h cap)

Authored by espadonne
SHA
e1c2fa0632302c189e272465996bf0afd377385e
Parents
706426a
Tree
cda908e

2 changed files

StatusFile+-
A internal/webhook/backoff.go 39 0
A internal/webhook/backoff_test.go 55 0
internal/webhook/backoff.goadded
@@ -0,0 +1,39 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package webhook
4
+
5
+import "time"
6
+
7
+// BackoffBase is the smallest retry delay; doubles per attempt.
8
+const BackoffBase = 30 * time.Second
9
+
10
+// BackoffMax caps the delay so a stale subscriber doesn't disappear
11
+// for days at a time. The spec calls for 24 hours.
12
+const BackoffMax = 24 * time.Hour
13
+
14
+// Backoff returns the delay before retrying delivery `attempt` (1-
15
+// indexed: the just-failed attempt counts as 1). Applies ±20% jitter
16
+// so a fleet of webhooks pointed at one outage doesn't synchronize
17
+// retries. `jitter` returns a value in [0, 1); pass `nil` for no jitter
18
+// (deterministic schedule, useful in tests).
19
+func Backoff(attempt int, jitter func() float64) time.Duration {
20
+	if attempt < 1 {
21
+		attempt = 1
22
+	}
23
+	d := BackoffBase
24
+	for i := 1; i < attempt; i++ {
25
+		d *= 2
26
+		if d >= BackoffMax {
27
+			d = BackoffMax
28
+			break
29
+		}
30
+	}
31
+	if d > BackoffMax {
32
+		d = BackoffMax
33
+	}
34
+	if jitter != nil {
35
+		mult := 0.8 + 0.4*jitter()
36
+		d = time.Duration(float64(d) * mult)
37
+	}
38
+	return d
39
+}
internal/webhook/backoff_test.goadded
@@ -0,0 +1,55 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package webhook
4
+
5
+import (
6
+	"testing"
7
+	"time"
8
+)
9
+
10
+func TestBackoffSchedule(t *testing.T) {
11
+	cases := []struct {
12
+		attempt int
13
+		want    time.Duration
14
+	}{
15
+		{1, 30 * time.Second},
16
+		{2, 1 * time.Minute},
17
+		{3, 2 * time.Minute},
18
+		{4, 4 * time.Minute},
19
+		{5, 8 * time.Minute},
20
+		{8, 64 * time.Minute},
21
+	}
22
+	for _, tc := range cases {
23
+		got := Backoff(tc.attempt, nil)
24
+		if got != tc.want {
25
+			t.Errorf("Backoff(%d) = %v; want %v", tc.attempt, got, tc.want)
26
+		}
27
+	}
28
+}
29
+
30
+func TestBackoffCapsAt24Hours(t *testing.T) {
31
+	got := Backoff(50, nil)
32
+	if got != BackoffMax {
33
+		t.Fatalf("Backoff(50) = %v; want %v", got, BackoffMax)
34
+	}
35
+}
36
+
37
+func TestBackoffJitterStaysWithinTwentyPercent(t *testing.T) {
38
+	base := Backoff(3, nil) // 2 minutes
39
+	for j := 0.0; j < 1.0; j += 0.05 {
40
+		jit := j
41
+		got := Backoff(3, func() float64 { return jit })
42
+		min := time.Duration(float64(base) * 0.79)
43
+		max := time.Duration(float64(base) * 1.21)
44
+		if got < min || got > max {
45
+			t.Errorf("Backoff(3, jitter=%.2f) = %v; want in [%v, %v]", jit, got, min, max)
46
+		}
47
+	}
48
+}
49
+
50
+func TestBackoffClampsZeroAttempt(t *testing.T) {
51
+	got := Backoff(0, nil)
52
+	if got != BackoffBase {
53
+		t.Fatalf("Backoff(0) = %v; want %v", got, BackoffBase)
54
+	}
55
+}