Retry is a context-aware retry loop driven by a composable Strategy. A Strategy is a plain function that controls the wait before the next attempt. Returning a negative duration stops retrying. Custom strategies are straightforward to write inline, and built-in strategies compose cleanly.
- Composable strategies —
Exponential,Constant,Linear, wrapped byMaxAttempts - Sentinel errors — signal hard stops without consulting the strategy
- Context-aware — cancellation is checked before every attempt and during sleep
- Zero dependencies
go get lowbit.dev/retryA Strategy is a plain function:
type Strategy func(attempt int) time.Durationattempt is 1-indexed: 1 after the first failure, 2 after the second. Return a positive duration to sleep before the next attempt, zero to retry immediately, or a negative value to stop. That's the whole contract.
err := retry.Do(ctx,
retry.MaxAttempts(5, retry.Exponential(100*time.Millisecond, 30*time.Second)),
func() error {
return db.Ping(ctx)
},
)Or store the strategy and call it as a method:
strategy := retry.MaxAttempts(5, retry.Exponential(100*time.Millisecond, 30*time.Second))
err := strategy.Do(ctx, func() error {
return db.Ping(ctx)
})Custom strategies are just functions — no interface to implement:
err := retry.Do(ctx, func(attempt int) time.Duration {
if attempt >= 3 {
return -1 // stop
}
return time.Duration(attempt) * 200 * time.Millisecond
}, fn)retry ships with 5 basic strategies which should cover most regular usecases.
| Strategy | Behaviour |
|---|---|
Exponential(base, cap) |
Full-jitter exponential backoff, uniform in [0, min(cap, base<<n)] |
Constant(delay) |
Fixed wait before every retry |
Linear(base) |
Wait grows as base × attempt (no jitter) |
MaxAttempts(n, p) |
Wraps any strategy; stops after n total calls |
Never |
Stops after the first failure; no retries |
Always |
Retries immediately with no delay, indefinitely |
All infinite strategies (Exponential, Constant, Linear, Always) are meant to be composed with MaxAttempts. The limit belongs there, not buried inside the strategy.
| Error | Effect |
|---|---|
ErrDoNotRetry |
Stops immediately; error returned as-is with sentinel in chain. |
ErrRetryLimitExceeded |
Wrapped around the last error when the strategy returns negative. |
Both are detectable with errors.Is:
err := retry.Do(ctx, strategy, fn)
switch {
case errors.Is(err, context.Canceled):
// context was cancelled
case errors.Is(err, retry.ErrDoNotRetry):
// fn signalled a hard stop
case errors.Is(err, retry.ErrRetryLimitExceeded):
// strategy exhausted all attempts
}To signal a hard stop from inside fn, wrap ErrDoNotRetry in the returned error:
func fetchUser(ctx context.Context, id int) error {
resp, err := http.Get(...)
if err != nil {
return err // transient, will be retried
}
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("user %d not found: %w", id, retry.ErrDoNotRetry)
}
return nil
}