New: reading flame graphs without guessingRead the post

A retry helper with full jitter, in about 40 lines of Go

Exponential backoff without jitter turns one outage into synchronized waves of retries. Here is a small Go helper that gets backoff, jitter and cancellation right.

( )$ test
Illustration for “A retry helper with full jitter, in about 40 lines of Go”.
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.N from math/rand/v2 works directly on time.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 << attempt wraps around to zero or a negative value after enough attempts, so we clamp to Max in 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.

Filed under
Portrait of Marek Dvořák (illustration)

Written by

Marek Dvořák

Site reliability engineer · Brno, Czechia

Marek writes Go for the infrastructure side: proxies, workers and the retry logic in between. His posts cover profiling, backoff and what happens to a system when a dependency goes slow instead of down.

One email when a new post ships

Roughly twice a month: the post, the code and one link we found useful. Plain text, no tracking pixels.

Unsubscribe with one click. We never share the list.

Read by 6,800 developers

Live demo · Exit Zero Journal— View template →