Documentation
¶
Overview ¶
Package jitterx adds randomness to retries, backoff intervals, timeouts and scheduled work, so independent callers stop lining up on the same instant.
Three pieces, each usable on its own:
A Strategy randomises a single duration. Full, Equal, Proportional and Decorrelated cover the usual choices.
A Backoff turns a strategy into a sequence: geometric growth from a base, clamped to a max, jittered on the way out.
Do runs a function against a Backoff until it succeeds, is told to stop, or the context ends.
Wait is the primitive under both: a jittered, cancellable sleep.
Ticker and Every put the same jitter on periodic work — cache refreshes, heartbeats, lease renewals, reconcile loops — which is where a fleet on a shared schedule hurts most.
Transport wraps an net/http.RoundTripper so every client using it retries, with no change at the call sites.
Picking a strategy:
- Full spreads a thundering herd best, but can retry almost instantly.
- Equal keeps half the delay guaranteed and randomises the rest.
- Decorrelated climbs and drops without a fixed schedule; good for long outages where a strict ceiling would keep every client synchronised.
- Proportional keeps an interval roughly intact, for cron ticks, cache TTLs and heartbeats.
- Early never exceeds the duration given, for delays protecting a deadline: lease renewals, token refreshes, cache expiry.
The package has no dependencies beyond the standard library. A Backoff is not safe for concurrent use; give each retry loop its own.
Index ¶
- Constants
- func DefaultShouldRetry(req *http.Request, resp *http.Response, err error) bool
- func Do(ctx context.Context, b *Backoff, fn func(context.Context) error) error
- func DoValue[T any](ctx context.Context, b *Backoff, fn func(context.Context) (T, error)) (T, error)
- func Every(ctx context.Context, interval time.Duration, s Strategy, ...) error
- func Permanent(err error) error
- func RetryAfter(d time.Duration, err error) error
- func Wait(ctx context.Context, d time.Duration, s Strategy) error
- type Backoff
- type Option
- func WithBase(d time.Duration) Option
- func WithMax(d time.Duration) Option
- func WithMaxElapsed(d time.Duration) Option
- func WithMaxRetries(n int) Option
- func WithMaxRetryAfter(d time.Duration) Option
- func WithMultiplier(f float64) Option
- func WithOnRetry(fn func(attempt int, delay time.Duration, err error)) Option
- func WithStrategy(s Strategy) Option
- type Source
- type SourceFunc
- type Strategy
- type StrategyFunc
- type Ticker
- type Transport
Examples ¶
Constants ¶
const ( DefaultBase = 100 * time.Millisecond DefaultMax = 30 * time.Second DefaultMultiplier = 2.0 )
Defaults applied by New when an option is missing or out of range.
const DefaultTickerSpread = 0.1
DefaultTickerSpread is the +/- fraction NewTicker jitters by when no strategy is given. Ten percent spreads a fleet without meaningfully moving the average interval.
const Stop = time.Duration(-1)
Stop is returned by Next once the configured attempt limit is spent.
Variables ¶
This section is empty.
Functions ¶
func DefaultShouldRetry ¶
DefaultShouldRetry retries idempotent requests that failed in transport or came back 429, 500, 502, 503 or 504.
Non-idempotent methods are never retried: a POST may already have had its effect before the error was written, and sending it again would apply that effect twice. If yours is safe to repeat — it carries an idempotency key, or the endpoint deduplicates — say so with your own Transport.ShouldRetry.
func Do ¶
Do calls fn until it succeeds, returns a Permanent error, exhausts b, or ctx ends. It resets b on entry, so the same Backoff can be reused across calls.
On failure it returns the last error from fn; if ctx ended first, the returned error joins ctx.Err() with that last error, so errors.Is matches either.
Example ¶
Retry an HTTP request, treating 4xx as permanent.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
b := jitterx.New(
jitterx.WithBase(200*time.Millisecond),
jitterx.WithMax(10*time.Second),
jitterx.WithMaxRetries(4),
jitterx.WithStrategy(jitterx.Equal(nil)),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var body int
err := jitterx.Do(ctx, b, func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
if err != nil {
return jitterx.Permanent(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // transient: retry
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return jitterx.Permanent(fmt.Errorf("status %d", resp.StatusCode))
}
if resp.StatusCode >= 500 {
return fmt.Errorf("status %d", resp.StatusCode)
}
body = resp.StatusCode
return nil
})
_ = body
_ = errors.Is(err, context.DeadlineExceeded)
}
Output:
func DoValue ¶
func DoValue[T any](ctx context.Context, b *Backoff, fn func(context.Context) (T, error)) (T, error)
DoValue is Do for a function that produces a value. On success it returns the value from the attempt that succeeded; on failure, the zero value and the same error Do would return.
Example ¶
DoValue retries a call that produces a value, and honours a Retry-After header when the server sends one.
package main
import (
"context"
"errors"
"log"
"net/http"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
b := jitterx.New(
jitterx.WithBase(200*time.Millisecond),
jitterx.WithMaxElapsed(30*time.Second),
jitterx.WithMaxRetryAfter(5*time.Minute),
jitterx.WithOnRetry(func(attempt int, d time.Duration, err error) {
log.Printf("attempt %d failed (%v), retrying in %v", attempt, err, d)
}),
)
user, err := jitterx.DoValue(context.Background(), b, func(ctx context.Context) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/me", nil)
if err != nil {
return nil, jitterx.Permanent(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
after, _ := time.ParseDuration(resp.Header.Get("Retry-After") + "s")
return nil, jitterx.RetryAfter(after, errors.New("rate limited"))
}
return decodeUser(resp)
})
_, _ = user, err
}
type User struct{}
func decodeUser(*http.Response) (*User, error) { return &User{}, nil }
Output:
func Every ¶
func Every(ctx context.Context, interval time.Duration, s Strategy, fn func(context.Context) error) error
Every calls fn on a jittered interval until fn returns an error or ctx ends, then returns that error or ctx.Err(). A nil strategy means Proportional with DefaultTickerSpread.
fn is not called immediately: the first call comes after the first interval.
Example ¶
Every is the same loop without the ticker bookkeeping, and it stops on context cancellation.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := jitterx.Every(ctx, time.Minute, nil, sendHeartbeat)
fmt.Println(errors.Is(err, context.Canceled))
}
func sendHeartbeat(context.Context) error { return nil }
Output:
func Permanent ¶
Permanent wraps err so Do stops immediately and returns the cause. Use it for failures no amount of waiting will fix: a 400, a validation error, a missing record. Permanent(nil) is nil.
func RetryAfter ¶
RetryAfter wraps err with a delay the server asked for, overriding whatever the Backoff would have chosen for that one wait. Use it for a 429 or 503 carrying a Retry-After header: the server knows when it will be ready and the client does not.
The delay is honoured as given unless WithMaxRetryAfter caps it. The attempt still counts against WithMaxRetries, and the wait still respects the context. RetryAfter(d, nil) is nil.
Types ¶
type Backoff ¶
type Backoff struct {
// contains filtered or unexported fields
}
Backoff produces a sequence of jittered delays: the raw delay grows geometrically from base, is clamped to max, and is then handed to the Strategy.
A Backoff is not safe for concurrent use. Give each retry loop its own.
func New ¶
New builds a Backoff. Without options it uses DefaultBase, DefaultMax, DefaultMultiplier, full jitter and no attempt limit.
func (*Backoff) Attempt ¶
Attempt reports how many delays have been handed out since the last Reset.
func (*Backoff) Next ¶
Next advances the attempt counter and returns the next delay, or Stop once a limit is reached: the retry count from WithMaxRetries, or the time budget from WithMaxElapsed.
The budget is checked against the delay about to be handed out, so Next stops rather than returning a delay that would sleep past the budget.
Example ¶
Drive the delays yourself instead of handing control to Do.
package main
import (
"fmt"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
b := jitterx.New(
jitterx.WithBase(time.Second),
jitterx.WithMax(time.Minute),
jitterx.WithStrategy(jitterx.None()), // deterministic, for the output below
)
for {
d := b.Next()
if d == jitterx.Stop {
break
}
fmt.Println(d)
if b.Attempt() == 4 {
break
}
}
}
Output: 1s 2s 4s 8s
type Option ¶
type Option func(*Backoff)
Option configures a Backoff. Invalid values are ignored rather than returning an error, so New never fails and misconfiguration degrades to the documented default.
func WithMaxElapsed ¶
WithMaxElapsed gives the whole retry sequence a time budget. Next returns Stop once the budget is spent, or once the delay it is about to hand out would sleep past it. Values <= 0 mean unlimited, which is the default.
The clock starts in New and restarts on Reset, which Do calls on entry.
func WithMaxRetries ¶
WithMaxRetries limits the Backoff to n delays, after which Next returns Stop. Under Do that means fn is called at most n+1 times: once, then once per retry. Values <= 0 mean unlimited, which is the default.
func WithMaxRetryAfter ¶
WithMaxRetryAfter caps the delay a server can ask for through RetryAfter. Without it a server's value is honoured as given, which is correct but leaves how long you wait in someone else's hands. Values <= 0 mean no cap, which is the default.
func WithMultiplier ¶
WithMultiplier sets the geometric growth factor. Values < 1 are ignored, since they would shrink the delay as attempts pile up.
func WithOnRetry ¶
WithOnRetry registers a hook called once per retry, before the wait, with the 1-based attempt number, the delay about to be slept, and the error that caused it. It is not called for the final failure, where nothing is retried.
The hook runs on the calling goroutine and blocks the retry, so keep it to logging or a metric.
func WithStrategy ¶
WithStrategy sets the jitter strategy. A nil strategy is ignored.
Stateful strategies (Decorrelated) must not be shared between Backoff instances or goroutines.
type Source ¶
type Source interface {
Float64() float64
}
Source supplies the randomness a Strategy draws on. Implementations must return a value in [0, 1). The default source is safe for concurrent use; a custom one only needs to be safe if the Backoff using it is shared.
type Strategy ¶
Strategy turns an intended delay into a randomised one. Every strategy returns a non-negative duration, and returns 0 for a non-positive input.
func Decorrelated ¶
Decorrelated returns AWS' decorrelated jitter strategy, seeded at base.
Unlike the other strategies it ignores its argument: the next delay is drawn from [base, prev*3]. Pair it with a Backoff for the upper clamp — nothing in the strategy itself caps growth.
A nil Source means the default process-wide source.
func Early ¶
Early picks uniformly from (d-d*f, d]: the result never exceeds d. Use it wherever the duration is protecting a deadline rather than pacing a retry — a cache TTL that must not serve past its contract, a lease renewal that must fire before expiry, a token refresh that must beat the clock. Spreading those in both directions, as Proportional does, pushes half the fleet past the very deadline the delay exists to respect.
A factor <= 0 leaves d unchanged; a factor >= 1 can reach zero. A nil Source means the default process-wide source.
Example ¶
Early keeps a jittered delay under the deadline it protects, so a lease is always renewed before it expires.
package main
import (
"fmt"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
lease := time.Minute
renewIn := jitterx.Early(0.2, nil).Jitter(lease) // (48s, 60s]
fmt.Println(renewIn <= lease)
}
Output: true
func Equal ¶
Equal picks uniformly from [d/2, d). Half the delay is guaranteed, so a retry never fires immediately, and half is randomised.
A nil Source means the default process-wide source.
func Full ¶
Full picks uniformly from [0, d). This is AWS' "full jitter": the best choice for spreading a thundering herd, at the cost of occasionally retrying almost immediately.
A nil Source means the default process-wide source.
func None ¶
func None() Strategy
None returns the delay unchanged. Useful as a default in tests and for disabling jitter without branching at the call site.
func Proportional ¶
Proportional picks uniformly from [d-d*f, d+d*f]. Use it for scheduled work (cron ticks, cache TTLs, heartbeats) where the interval should stay roughly intact but callers must not line up. A factor above 1 is allowed; the result is still clamped at 0.
A nil Source means the default process-wide source.
Example ¶
Spread a periodic task so many instances do not tick together.
package main
import (
"fmt"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
interval := 30 * time.Second
spread := jitterx.Proportional(0.1, nil) // +/- 10%
next := spread.Jitter(interval)
fmt.Println(next >= 27*time.Second && next <= 33*time.Second)
}
Output: true
type StrategyFunc ¶
StrategyFunc adapts a plain function into a Strategy.
type Ticker ¶
type Ticker struct {
// C delivers the ticks.
C <-chan time.Time
// contains filtered or unexported fields
}
Ticker delivers ticks at a jittered interval. It is the drop-in shape of time.Ticker — a receive-only C, Stop and Reset — for periodic work that many processes run on the same schedule: cache refreshes, heartbeats, lease renewals, reconcile loops.
Each interval is jittered independently, so the ticker drifts relative to a fixed schedule; that is the point. If you need ticks anchored to absolute wall-clock times, this is the wrong tool.
Like time.Ticker, C has room for one tick and sends that would block are dropped, so a slow receiver cannot build a backlog. Stop releases the ticker's goroutine but does not close C.
func NewTicker ¶
NewTicker returns a Ticker that fires about every interval, jittered by s. A nil strategy means Proportional with DefaultTickerSpread.
It panics if interval is not positive, matching time.NewTicker.
Strategies that can return zero, such as Full, will make the ticker fire almost immediately every so often. That is usually not what a scheduler wants; Proportional or Equal are the fitting choices here.
Example ¶
Refresh a cache on a jittered schedule, so many instances do not all stampede the origin at the same second.
package main
import (
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
t := jitterx.NewTicker(30*time.Second, nil) // +/- 10% by default
defer t.Stop()
for range t.C {
refreshCache()
}
}
func refreshCache() {}
Output:
type Transport ¶
type Transport struct {
// Base sends the requests. Nil means [http.DefaultTransport].
Base http.RoundTripper
// NewBackoff builds a Backoff for one request. It is called per request
// because a Backoff is not safe for concurrent use, and a transport is
// shared by every goroutine using the client. Nil means New().
NewBackoff func() *Backoff
// ShouldRetry decides whether a result is worth another attempt. Exactly
// one of resp and err is non-nil. Nil means [DefaultShouldRetry].
ShouldRetry func(req *http.Request, resp *http.Response, err error) bool
}
Transport is an http.RoundTripper that retries failed requests with jittered backoff. Wrap it around another transport and every client using it retries, with no change at the call sites:
client := &http.Client{Transport: &jitterx.Transport{
NewBackoff: func() *jitterx.Backoff {
return jitterx.New(jitterx.WithMaxRetries(3))
},
}}
When the retries run out, Transport returns the last response rather than an error: a caller needs the status and body to decide what to do. It returns an error only when the final attempt did, or when the context ended.
A response that will not be returned is drained and closed first, so the connection goes back to the pool instead of being dropped.
Example ¶
Transport puts retries underneath an http.Client, so call sites do not change.
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/bakhod1r/jitterx"
)
func main() {
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &jitterx.Transport{
NewBackoff: func() *jitterx.Backoff {
return jitterx.New(
jitterx.WithBase(200*time.Millisecond),
jitterx.WithMaxRetries(3),
jitterx.WithMaxRetryAfter(time.Minute),
)
},
},
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com", nil)
if err != nil {
return
}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
// Retries are spent by now: this is the final status, whatever it is.
fmt.Println(resp.StatusCode >= 200)
}
Output: