Table of contents
Part of nurago, a collection of independent Go packages for backend services.
import "github.com/tecnickcom/nurago/pkg/retrier"
Runs a function until it succeeds, gives up, or the context is cancelled, waiting a growing jittered delay between attempts.
r, err := retrier.New(retrier.WithAttempts(5))
err = r.Run(ctx, func(ctx context.Context) error {
return doSomething(ctx)
})
The loop
- Run the task with a per-attempt timeout context.
- Evaluate the result with the retry predicate.
- Stop when attempts are exhausted or the predicate says no.
- Otherwise wait the delay plus jitter.
- Multiply the delay by the factor and go again.
Parent context cancellation is honoured throughout, including while waiting between attempts. A cancelled request does not sit out a five-second backoff before noticing.
Defaults
| Setting | Default |
|---|---|
| attempts | 4 |
| initial delay | 1s |
| delay factor | 2 |
| jitter | 1ms |
| per-attempt timeout | 1s |
| maximum delay | unbounded |
| retry condition | any non-nil error |
Two of these deserve attention before use.
Maximum delay is unbounded unless you set WithMaxDelay. With the default factor of 2, the tenth attempt waits about 512 seconds. Over four attempts that never arises; raise WithAttempts without also setting WithMaxDelay and it does.
The default predicate retries on any error. That includes errors no number of retries will fix: a validation failure, a malformed request, a permission denial. Retrying those wastes the attempt budget and multiplies load on a service that already said no.
WithRetryIfFn narrows it:
retrier.WithRetryIfFn(func(err error) bool {
return !errors.Is(err, ErrInvalidInput)
})
Two timeouts, and the difference
The per-attempt timeout bounds one attempt. The parent context bounds the whole operation.
Both are needed. Per-attempt alone means four attempts at one second each, plus backoff, can exceed any overall deadline the caller had in mind. Parent-only means a single hung attempt consumes the entire budget and leaves no room for the retry that would have succeeded.
The arithmetic is worth doing before deploying: with the defaults, a worst case is four attempts of 1s plus delays of roughly 1s, 2s and 4s, so about 11 seconds. If the inbound request has a 5-second deadline, the retry policy cannot complete and the later attempts are wasted work.
The retry that is not idempotent
Run re-executes the task function. If the task has already had an effect before failing, a charge taken, a message published, a row inserted, the retry repeats it.
Nothing in the package can detect this. The task function has to be safe to run more than once, which usually means carrying an idempotency key. stringkey produces a stable one from the request fields.
Observing retries
WithOnRetry takes a callback invoked before each retry, which is where a counter or a log line belongs. Without it, a service that succeeds on the fourth attempt looks identical to one that succeeded on the first, and a dependency degrading steadily is invisible until it fails outright.
Dependencies
This package reaches no external module: it uses only the Go standard library, plus backoff for the delay schedule.
Links
- Reference documentation
- Source code
- The delay schedule: backoff
- HTTP-aware retries: httpretrier
- The resilience guide: /docs/resilience/
- All nurago packages