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. Delays only get that large after many consecutive failures, so the wrap fires in the middle of your worst outage.
nurago’s backoff package is a small pure calculator that handles 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
}
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.
Three jitter strategies
All three come from the AWS “Exponential Backoff And Jitter” analysis, and Config.Strategy picks between them.
JitterAdditive is the default: a fixed random amount in [0, Jitter) added on top of the exponential delay. The ceiling is fixed, so its desynchronising effect shrinks in relative terms as the delay grows.
JitterFull throws the computed delay away and returns a uniform random value in [0, delay). The spread scales with the delay itself, so it decorrelates concurrent clients better than the other two. That is usually what you want when many clients back off against the same dependency.
JitterEqual keeps half the delay and randomises the other half, for a wait in [delay/2, delay). It buys a floor on how eagerly you retry and gives up some of the decorrelation to get it.
Full and equal scale their randomness from the delay, so both ignore the Jitter field.
AddJitter exposes the jitter step on its own, 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
The usual implementation: 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. It 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)
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 so the overflow branch is 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.
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. The package documents it as a limitation; at any realistic sub-minute retry delay it makes no difference.
Small on purpose
backoff owns one calculation: turning an attempt number into a non-negative, jittered, bounded delay, however many attempts there have been. That is easy to get subtly wrong, so nurago keeps one tested copy of it and not one per retry package. No timers, no retry loop, no policy about what to retry: that belongs in the caller, or in the higher-level retrier and HTTP retrier packages that build on it.
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.