S33: exponential backoff with jitter (24h cap)
- SHA
e1c2fa0632302c189e272465996bf0afd377385e- Parents
-
706426a - Tree
cda908e
e1c2fa0
e1c2fa0632302c189e272465996bf0afd377385e706426a
cda908e| Status | File | + | - |
|---|---|---|---|
| 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 | +} | |