retrier

retrier executes a task function with backoff, jitter and per-attempt timeouts, sharing its delay schedule with the HTTP retrier so one policy covers both.

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

  1. Run the task with a per-attempt timeout context.
  2. Evaluate the result with the retry predicate.
  3. Stop when attempts are exhausted or the predicate says no.
  4. Otherwise wait the delay plus jitter.
  5. 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

SettingDefault
attempts4
initial delay1s
delay factor2
jitter1ms
per-attempt timeout1s
maximum delayunbounded
retry conditionany 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.