On this page 4 sections
Every codebase I have worked on had at least three retry loops, written by three people, with three different bugs. One slept a fixed second between attempts. One doubled the delay but ignored context cancellation, so a shutdown waited a full minute. One retried a 400 Bad Request twelve times, very politely.
This is the helper we now copy into new Go services. It is small enough to read in one sitting, which matters more to me than features.
Why backoff alone is not enough
Exponential backoff spaces attempts out: 100 ms, 200 ms, 400 ms, and so on. That protects a struggling dependency from a single client. It does not protect it from a thousand clients that all failed at the same moment, because they all back off by the same amounts and retry in lockstep. The dependency recovers, gets hit by a synchronized wave, falls over again, and the cycle repeats.
Jitter breaks the lockstep. With “full jitter”, each client sleeps a random duration between zero and the current backoff ceiling. The total work is the same, but it arrives as a smooth spread instead of spikes. In our load tests against a fake slow dependency, full jitter cut the time to recover from an induced outage from about 90 seconds to 20.
The helper
package retry
// imports: context, errors, math/rand/v2, time
type Policy struct {
Attempts int // total calls, including the first
Base time.Duration // ceiling for the first backoff
Max time.Duration // upper bound for any single sleep
}
// Permanent marks an error that must not be retried, such as a 4xx response.
type Permanent struct{ Err error }
func (p Permanent) Error() string { return p.Err.Error() }
func (p Permanent) Unwrap() error { return p.Err }
func Do(ctx context.Context, p Policy, fn func(context.Context) error) error {
var err error
for attempt := 0; attempt < p.Attempts; attempt++ {
if err = fn(ctx); err == nil {
return nil
}
var perm Permanent
if errors.As(err, &perm) {
return perm.Err
}
if attempt == p.Attempts-1 {
break
}
ceiling := p.Base << attempt
if ceiling <= 0 || ceiling > p.Max {
ceiling = p.Max
}
timer := time.NewTimer(rand.N(ceiling + 1)) // full jitter: uniform in [0, ceiling]
select {
case <-ctx.Done():
timer.Stop()
return errors.Join(ctx.Err(), err)
case <-timer.C:
}
}
return err
}A few choices worth explaining:
rand.Nfrommath/rand/v2works directly ontime.Duration, so there is no float conversion and no seeding to forget. It needs Go 1.22 or later.- The ceiling check catches overflow.
p.Base << attemptwraps around to zero or a negative value after enough attempts, so we clamp toMaxin that case too. - Cancellation wins. If the context ends while we are sleeping, we return immediately with both errors joined, so callers can see that the deadline expired and what the last failure was.
- No sleep after the last attempt. It sounds obvious. Two of our three old loops did it anyway.
Testing it without sleeping
The tests set Base to a millisecond so they finish quickly, and they check behaviour rather than timing. The most important one asserts that a permanent error stops the loop on the first call:
func TestPermanentErrorStopsRetrying(t *testing.T) {
calls := 0
p := Policy{Attempts: 5, Base: time.Millisecond, Max: 10 * time.Millisecond}
err := Do(context.Background(), p, func(context.Context) error {
calls++
return Permanent{Err: errors.New("400 bad request")}
})
if calls != 1 {
t.Fatalf("calls = %d, want 1", calls)
}
if err == nil || err.Error() != "400 bad request" {
t.Fatalf("err = %v, want the unwrapped permanent error", err)
}
}The other tests cover success on the third attempt, giving up after Attempts calls, and returning promptly when the context is cancelled mid-sleep. That last one uses a context with a 5 ms timeout and a policy whose Max is a full minute, then asserts the call returns in under 100 ms.
What it deliberately leaves out
There is no retry budget, no circuit breaker and no metrics hook. Those are real needs, but they belong one layer up, where you know which dependency you are calling. Keeping this helper boring means people actually read it before they use it, and nobody has written a fourth retry loop since.