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 is also one of those things that looks trivial and hides two real bugs. The first is that without jitter, a fleet of clients that all failed at the same instant will all retry at the same instant, the classic thundering herd, 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. And the cruel part is the timing: the delays only get that large after many consecutive failures, which means 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. This post is about the jitter strategies and, especially, the overflow handling.
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. That separation keeps it simple to test and to reason about.
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 even routes server-supplied Retry-After waits through the same jitter helper, so 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 well-known AWS “Exponential Backoff And Jitter”
analysis, selectable via Config.Strategy. The default, JitterAdditive, adds a fixed random amount in [0, Jitter) on top of the exponential delay; it is simple, but because the random ceiling is fixed, its desynchronising effect shrinks in relative terms as the delay grows. JitterFull replaces the delay entirely with a uniform random value in [0, delay), which 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. The full and equal strategies derive their randomness from the delay, so they ignore the Jitter field.
Choosing between them is a real decision. Full jitter spreads retries out the most, which is usually what you want when many clients are backing off together; equal jitter is the compromise 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
This is the part worth internalising, because the trap is invisible until it fires, and it fires precisely when backoff matters most.
Picture 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, because every delay you ever observe is at most MaxDelay. But notice what is actually growing: 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 100ms 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 that point the usual guard, 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”, and your backoff loop, mid-incident, 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 closes this off structurally, and it is instructive to see exactly where the clamp lives. The internal progression is a float64, and after every Next call it is multiplied by the factor 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)
The state itself does not run away, no matter how many times Next is called. There is a second clamp on the per-call path, and it matters even 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 therefore produce a negative delay on attempt one in a naive implementation, and the package has a test asserting 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 is split into its own tiny function so the overflow branch is testable without involving the random draw.
Finally, out-of-contract inputs are handled rather than trusted. New is written to accept any configuration rather than fail: a negative Base, a negative Factor, or a NaN (not a number) Factor all degrade gracefully, because the per-call path floors a negative or NaN pre-jitter delay to zero before converting. The NaN case 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 can only be caught by an explicit math.IsNaN test. The delay sequence for such inputs is unspecified (a negative factor makes the state oscillate in sign, for instance), but the flooring keeps each returned value non-negative. The package documentation states the resulting property plainly: “no delay can overflow into a negative duration, regardless of factor, attempt count, or configured maximum”.
You might object that a disciplined caller bounds its retries anyway, so the ceiling is never approached in practice; nurago’s own retriers default to four attempts. But this package deliberately 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 as the first-call example above shows, the dangerous configurations do not even need many attempts. A calculator that accepts arbitrary input and can be advanced an unbounded number of times cannot outsource its overflow safety to the caller’s discipline.
One limitation is documented as a fact rather than hidden: 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 this is immaterial.
Small on purpose
backoff is intentionally tiny. It has 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. What it owns is the one thing that is genuinely easy to get subtly wrong: turning an attempt number into a non-negative, jittered, bounded delay, however many attempts there have been, without overflowing. Getting that one calculation right in a single tested place, and reusing it everywhere, is the whole point.
One note on concurrency: a Schedule is stateful (each Next advances it), so construct one per retry sequence rather than sharing it across goroutines. AddJitter, being stateless, is safe to call concurrently.