Table of contents
Exponential backoff is the usual first response when a downstream call starts failing: wait a bit, then wait longer, then longer still. It looks trivial. It hides two real bugs.
The first is the thundering herd. Without jitter, a fleet of clients that all failed at the same instant will all retry at the same instant, hammering the recovering service in synchronised waves.
The second is that the delay arithmetic can overflow. Signed integer arithmetic in Go wraps around, so a time.Duration that grows past math.MaxInt64 nanoseconds does not saturate. It comes back negative. The timing is what makes it nasty: delays only get that large after many consecutive failures, so the overflow fires in the middle of your worst outage, at the exact moment backoff was supposed to be doing its job.
The backoff
package in nurago
is a small pure calculator built to handle both.
A pure per-call calculator
backoff does no timing, no sleeping, no goroutines, and no I/O. It computes the next delay and advances its state. You own the loop and the timer.
import "github.com/tecnickcom/nurago/pkg/backoff"
s := backoff.New(backoff.Config{
Base: 100 * time.Millisecond,
Factor: 2,
Jitter: 50 * time.Millisecond,
MaxDelay: 30 * time.Second,
})
for {
// ... attempt the work ...
time.Sleep(s.Next()) // 100ms, 200ms, 400ms, ... capped at 30s, each with jitter
}
Each call to Next returns a delay and advances the progression. Inside nurago this is the shared numeric core. The generic retrier and the HTTP retrier both build their delay math on a Schedule, and the HTTP retrier routes server-supplied Retry-After waits through the same jitter helper. Every retry path in the library runs on one tested piece of arithmetic, instead of several slightly different re-derivations of it.
Three jitter strategies
Jitter is the desynchronisation mechanism, and there is more than one way to apply it. backoff implements the taxonomy from the AWS “Exponential Backoff And Jitter”
analysis, selectable through Config.Strategy.
JitterAdditive is the default. It adds a fixed random amount in [0, Jitter) on top of the exponential delay. Simple, but the random ceiling is fixed, so its desynchronising effect shrinks in relative terms as the delay grows.
JitterFull replaces the delay entirely with a uniform random value in [0, delay). It decorrelates concurrent clients best, because the spread scales with the delay itself.
JitterEqual keeps half the delay and randomises the other half, giving a wait in [delay/2, delay). Some of full jitter’s decorrelation, traded for a fixed minimum wait.
Full and equal derive their randomness from the delay, so both ignore the Jitter field. Full jitter spreads retries the widest, which is usually what you want when many clients back off together. Equal jitter is the one to reach for when you also need a floor on how eagerly you retry.
The jitter step is also exposed on its own as AddJitter, for code that paces work at a fixed interval rather than backing off. The periodic scheduler in nurago uses it exactly this way: an optional random first delay in [0, jitter) to spread start-up across a fleet, then interval + [0, jitter) for every tick after that.
The overflow footgun
Here is the implementation most of us have written at least once: a time.Duration accumulator, doubled after each attempt, with the returned value capped at some MaxDelay. It looks safe. Every delay you ever observe is at most MaxDelay.
Look at what is actually growing, though. Not the returned delay, the internal exponential state. The output cap does nothing to the accumulator, which keeps multiplying past the cap on every call. Starting from a 100 ms base with a factor of 2, the accumulator goes negative on the 37th doubling, because Go’s int64 arithmetic wraps two’s complement style.
From there the usual guard is useless. if delay > maxDelay { delay = maxDelay } waves the negative value straight through, since a negative number is not greater than anything positive. time.Sleep treats a negative duration as “return immediately”. Mid-incident, the backoff loop degenerates into a tight retry loop against a service that was already on its knees.
Capping the output is not the same as capping the state, and only the state can overflow. backoff puts the clamp on the state. The internal progression is a float64, multiplied by the factor after every Next call and immediately re-capped at a safety bound sitting far below the int64 ceiling:
// maxSafeDelay caps the internal exponential state well below math.MaxInt64
// nanoseconds (~146 years). Keeping the progression at or below this bound
// guarantees the float64-to-int64 conversion in [Schedule.Next] and the jitter
// addition can never overflow into a negative duration at high attempt counts.
const maxSafeDelay = time.Duration(math.MaxInt64 / 2)
So the state cannot run away, however many times Next is called.
A second clamp sits on the per-call path, and it matters on the very first call. A caller-supplied MaxDelay larger than the safety cap is pulled down to it before the float64 to int64 conversion. That conversion is its own hazard: converting an out-of-range float64 to int64 is implementation-dependent in Go, and float64(math.MaxInt64) rounds up to 2^63, which is already out of range. On x86-64 the conversion yields math.MinInt64. A config with Base and MaxDelay both set to math.MaxInt64 would produce a negative delay on attempt one in a naive implementation. A test asserts that exact configuration stays strictly positive on every call.
Jitter gets its own guard. Adding a random amount to a delay already near the ceiling could wrap the sum, so the addition saturates: if base + jitter would exceed math.MaxInt64, the result is pinned at math.MaxInt64 instead of wrapping negative. The saturation check lives in its own tiny function, which makes the overflow branch testable without involving the random draw.
Out-of-contract inputs get handled rather than trusted. New accepts any configuration instead of failing, so a negative Base, a negative Factor, or a NaN (not a number) Factor all degrade gracefully: the per-call path floors a negative or NaN pre-jitter delay to zero before converting.
NaN is why that check has to live per call rather than in the state cap. Every comparison against NaN is false, so a NaN progression sails past any > bound, and only an explicit math.IsNaN test catches it. The delay sequence for such inputs is unspecified, since a negative factor makes the state oscillate in sign, but the flooring keeps each returned value non-negative. The package documentation states the property plainly: “no delay can overflow into a negative duration, regardless of factor, attempt count, or configured maximum”.
The obvious objection is that a disciplined caller bounds its retries anyway, so the ceiling is never approached; nurago’s own retriers default to four attempts. But the package owns no retry policy. Nothing in its API limits how many times Next may be called, or how large a Base and MaxDelay a caller may pass, and the first-call example above shows the dangerous configurations do not need many attempts at all.
One documented limitation: delays are computed in float64, so integer-nanosecond precision is exact only up to about 2^53 nanoseconds, roughly 104 days. Beyond that a result may differ by a few nanoseconds. At any realistic sub-minute retry delay it makes no difference.
Small on purpose
backoff is intentionally tiny. No timers, no retry loop, no policy about what to retry. All of that belongs in the caller, or in the higher-level retrier
and HTTP retrier packages that build on it.
It owns one calculation: turning an attempt number into a non-negative, jittered, bounded delay, however many attempts there have been. That calculation is easy to get subtly wrong, which is the argument for keeping exactly one tested copy of it.
On concurrency, a Schedule is stateful, since each Next advances it, so construct one per retry sequence rather than sharing it across goroutines. AddJitter is stateless and safe to call concurrently.