go 63 lines · 7 steps

How a retry budget caps retries in Go

A sliding-window rate limiter that ties allowed retries to recent traffic so retries can't overwhelm a struggling service.

Explained by highlit
1package retry
2 
3import (
4 "errors"
5 "sync"
6 "time"
7)
8 
9var ErrBudgetExhausted = errors.New("retry budget exhausted")
10 
11type Budget struct {
12 mu sync.Mutex
13 window time.Duration
14 max int
15 ratio float64
16 attempts []time.Time
17 retries []time.Time
18 now func() time.Time
19}
20 
21func NewBudget(window time.Duration, minRetries int, ratio float64) *Budget {
22 return &Budget{
23 window: window,
24 max: minRetries,
25 ratio: ratio,
26 now: time.Now,
27 }
28}
29 
30func (b *Budget) TryAcquire() error {
31 b.mu.Lock()
32 defer b.mu.Unlock()
33 
34 cutoff := b.now().Add(-b.window)
35 b.attempts = prune(b.attempts, cutoff)
36 b.retries = prune(b.retries, cutoff)
37 
38 limit := b.max
39 if scaled := int(float64(len(b.attempts)) * b.ratio); scaled > limit {
40 limit = scaled
41 }
42 
43 if len(b.retries) >= limit {
44 return ErrBudgetExhausted
45 }
46 
47 b.retries = append(b.retries, b.now())
48 return nil
49}
50 
51func (b *Budget) RecordAttempt() {
52 b.mu.Lock()
53 b.attempts = append(b.attempts, b.now())
54 b.mu.Unlock()
55}
56 
57func prune(ts []time.Time, cutoff time.Time) []time.Time {
58 i := 0
59 for i < len(ts) && ts[i].Before(cutoff) {
60 i++
61 }
62 return ts[i:]
63}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Scaling the retry limit to recent traffic keeps a floor while preventing retry storms under high load.
  2. 2A sliding window is cheap to maintain by pruning timestamps older than a cutoff on each check.
  3. 3Guarding all shared slices with one mutex keeps the budget's read-modify-write sequence atomic.

Related explainers

Share this explainer

Here's the card — post it anywhere.

How a retry budget caps retries in Go — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code