retry

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2026 License: MIT Imports: 4 Imported by: 0

README

retry

Run an operation again on failure — with exponential backoff, optional jitter, a bounded attempt budget, pluggable delay strategies, early stop on terminal errors, and a per-retry observation hook.

retry.Do wraps any context-aware function — an HTTP call, a SQL transaction, a broker publish, a queue job — so retry policy is composed at the call site instead of being reimplemented in every subsystem. retry.Backoff is the delay/budget policy, shared across the SDK (the HTTP client middleware, reliable workers, the queue), so there is one place that decides how retries are spaced.

Features

  • One entry point — Do(ctx, Config, fn) — for any func(ctx) error.
  • Exponential backoff with cap, factor, and jitter (Backoff).
  • Fixed delay (Fixed) or any custom cadence (Strategy).
  • Stop early on a critical/terminal error, or on any error (IsTerminal).
  • Observe every retry for logging/statistics (OnRetry).
  • Context-aware: cancels promptly during a wait; never loops unboundedly.
  • Deterministic in tests: inject Sleep and Rand.
  • No third-party dependencies; the same Backoff powers httpmw and outbox.

Install

import "github.com/assanoff/skit/retry"

Quick start — exponential backoff

Retry up to 4 times, doubling the delay from 100ms (capped at 2s), with ±20% jitter:

err := retry.Do(ctx, retry.Config{
    Backoff: retry.Backoff{
        Base: 100 * time.Millisecond, Factor: 2, Max: 2 * time.Second,
        MaxAttempts: 4, Jitter: 0.2,
    },
}, func(ctx context.Context) error {
    return callFlakyService(ctx)
})
if err != nil {
    return fmt.Errorf("service still failing after retries: %w", err)
}

Do returns nil on the first success, or the last error once the budget is spent. MaxAttempts <= 1 means a single attempt (no retry).

Strategies

The delay before each retry comes from Backoff by default. Set Strategy to override the cadence; the attempt budget still comes from Backoff.MaxAttempts.

Fixed delay
err := retry.Do(ctx, retry.Config{
    Backoff:  retry.Backoff{MaxAttempts: 5}, // budget only
    Strategy: retry.Fixed(time.Second),       // wait 1s between every attempt
}, func(ctx context.Context) error {
    return poll(ctx)
})
Custom strategy

Strategy receives the 1-based number of the attempt that just failed and returns the wait before the next one — implement any schedule:

// Read delays from a fixed schedule (1s, 5s, 30s, ...), clamping past the end.
schedule := []time.Duration{time.Second, 5 * time.Second, 30 * time.Second}
err := retry.Do(ctx, retry.Config{
    Backoff: retry.Backoff{MaxAttempts: len(schedule) + 1},
    Strategy: func(attempt int) time.Duration {
        if attempt > len(schedule) {
            return schedule[len(schedule)-1]
        }
        return schedule[attempt-1]
    },
}, func(ctx context.Context) error {
    return reconnect(ctx)
})

Stop conditions

Stop on a critical / terminal error

Some failures will never succeed on retry (bad input, a 4xx). Classify them with IsTerminal to stop immediately and return that error:

var ErrBadInput = errors.New("bad input")

err := retry.Do(ctx, retry.Config{
    Backoff:    retry.Backoff{Base: 200 * time.Millisecond, MaxAttempts: 5},
    IsTerminal: func(err error) bool { return errors.Is(err, ErrBadInput) },
}, func(ctx context.Context) error {
    return process(ctx) // returns ErrBadInput → no retry; anything else → retry
})
Stop on any error (try once, never retry)
err := retry.Do(ctx, retry.Config{
    Backoff:    retry.Backoff{MaxAttempts: 5},
    IsTerminal: func(error) bool { return true }, // every error is terminal
}, fn)

(Equivalently, leave MaxAttempts <= 1 for a single attempt.)

Observe retries (logging & statistics)

OnRetry fires once per retry that will actually happen — after an attempt fails and before the wait. It is not called for a success, a terminal error, or the final failed attempt:

var attempts atomic.Int64
err := retry.Do(ctx, retry.Config{
    Backoff: retry.Backoff{Base: 100 * time.Millisecond, MaxAttempts: 5},
    OnRetry: func(attempt int, err error) {
        attempts.Add(1)
        log.Warn("retrying", "attempt", attempt, "err", err)
    },
}, func(ctx context.Context) error {
    return callFlakyService(ctx)
})
// attempts.Load() == number of retries performed

Context cancellation

Do aborts promptly when ctx is cancelled during a backoff wait and returns the operation's last error. Give the whole retry a deadline with a timeout context:

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := retry.Do(ctx, cfg, fn)

Backoff as a delay calculator (distributed / persistent retry)

Do is for in-process retry. For retry that must survive a crash and not block a worker — the transactional outbox, a durable queue — you don't run an in-process loop; you store when to try next and let a background worker pick it up on a later tick. There, use Backoff.Next(attempt) as a plain value:

b := retry.Backoff{Base: 2 * time.Second, Factor: 2, Max: 5 * time.Minute, MaxAttempts: 10}

// On failure, schedule the next attempt instead of looping in memory:
nextAttemptAt := now.Add(b.Next(attempts))
if b.Exhausted(attempts) {
    // give up: mark the row failed / dead-letter it
}

This is exactly how outbox computes next_attempt_at and how httpmw spaces its HTTP retries — the same Backoff, used as math rather than as a loop.

Integration with the SDK

Wrap a worker Handler

worker.Retry adds in-process retry to a worker.Handler[T] (e.g. a queue Mux), absorbing transient errors before the Processor records the outcome:

handler := worker.Retry[queue.Task](retry.Config{
    Backoff:    retry.Backoff{Base: 50 * time.Millisecond, MaxAttempts: 3},
    IsTerminal: func(err error) bool { return errors.Is(err, ErrPermanent) },
}, mux)

proc := worker.NewProcessor[queue.Task](log, q, handler, q, worker.ProcessorConfig{Name: "queue"})
Wrap a queue JobFunc
mux := queue.NewMux()
if err := mux.Register("email.welcome", queue.Retry(retry.Config{
    Backoff: retry.Backoff{Base: 100 * time.Millisecond, MaxAttempts: 3},
}, sendWelcome)); err != nil {
    return err
}
HTTP client middleware

httpmw.RetryTransport takes a retry.Backoff and additionally honors a server's Retry-After header — see the httpmw package.

Caveats

  • In-process, blocking. Do runs retries in the calling goroutine and sleeps on the delay. When the caller holds a resource for the duration — a queue lease, a DB transaction — keep the budget and delays well under that resource's timeout, or use the distributed pattern above instead.
  • Budget lives in Backoff.MaxAttempts, even when a custom Strategy overrides the delay. MaxAttempts <= 1 ⇒ a single attempt; 0 is treated as a single attempt too (Do never loops unboundedly).
  • Rand is only used by the default Backoff cadence. A custom Strategy computes its own (deterministic) delay.
  • Backoff.Max is a hard ceiling. The returned delay never exceeds Max, even with jitter — full upward jitter (Jitter: 1) is clamped back to Max rather than overshooting it. Jitter: 1 also means the delay can drop to 0, so use a smaller jitter (e.g. 0.10.3) if a retry should always wait.

Documentation

Overview

Package retry runs an operation again on failure, with exponential backoff, optional jitter, a bounded attempt budget, and an early stop on terminal errors.

Do is the universal entry point: it wraps any context-aware function — an HTTP call, a SQL transaction, a broker publish, a queue job — so retry policy is composed at the call site instead of being reimplemented in each subsystem. Backoff is the delay/budget policy and is shared across the SDK (the HTTP client middleware, reliable workers, this package), so there is one place that decides how retries are spaced.

Usage

err := retry.Do(ctx, retry.Config{
    Backoff: retry.Backoff{
        Base: 100 * time.Millisecond, Factor: 2, Max: 2 * time.Second, MaxAttempts: 4,
    },
    IsTerminal: func(err error) bool { return errors.Is(err, ErrBadInput) },
}, func(ctx context.Context) error {
    return callFlakyService(ctx)
})

Semantics

Do calls fn, and on a non-nil error waits Backoff.Next(attempt) and tries again, until fn succeeds, the attempt budget is spent, IsTerminal matches, or ctx is cancelled. It returns nil on the first success or the last error otherwise. Retries run in the calling goroutine and block on the backoff delay, so keep the budget and delays modest when the caller holds a resource for the duration (for example a queue lease, which another consumer may reclaim if the total retry time exceeds the lease timeout).

Config

  • Backoff: inter-attempt delay (Base/Factor/Max/Jitter) and the attempt budget (MaxAttempts). MaxAttempts <= 1 means a single attempt (no retry); Do never loops unboundedly even when MaxAttempts is 0.
  • Strategy: override the delay cadence per attempt (e.g. Fixed for a constant delay, or any custom schedule); the budget still comes from Backoff.MaxAttempts.
  • IsTerminal: classify an error as permanent to stop early (nil = every error is retryable until the budget is spent; return true unconditionally to stop on any error).
  • OnRetry: observe each retry (attempt number + error) for logging or statistics; called only when a retry will actually happen.
  • Sleep, Rand: seams overridden in tests for deterministic timing and jitter; both default to real implementations.

See the package README for worked examples of each case.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Do

func Do(ctx context.Context, cfg Config, fn func(ctx context.Context) error) error

Do calls fn and retries it on error according to cfg, until fn succeeds, the attempt budget is spent, cfg.IsTerminal matches, or ctx is cancelled. It returns nil on the first success, or the last error otherwise.

Do wraps any context-aware operation — an HTTP call, a DB transaction, a broker publish, a queue job — so retry policy is composed at the call site and not baked into each subsystem. Because retries run in the calling goroutine (blocking on the delay), keep the budget and delays modest when the caller holds a resource for the duration (for example a queue lease, which another consumer may reclaim if the total retry time exceeds the lease timeout).

func Fixed

func Fixed(d time.Duration) func(attempt int) time.Duration

Fixed returns a Strategy that always waits d, regardless of the attempt number — a constant inter-attempt delay.

Types

type Backoff

type Backoff struct {
	// Base is the delay for the first retry (attempt 1).
	Base time.Duration
	// Max caps the delay (0 = uncapped). It is a hard ceiling on the final
	// value: the returned delay never exceeds Max, even after jitter.
	Max time.Duration
	// Factor is the multiplier per attempt (defaults to 2 when <= 1).
	Factor float64
	// MaxAttempts is the retry budget; Exhausted reports when it is spent.
	MaxAttempts int
	// Jitter in [0,1] randomizes the delay by ±(jitter*delay). The caller
	// supplies the random fraction to NextWithRand to keep Backoff deterministic
	// and testable.
	Jitter float64
}

Backoff computes retry delays with exponential growth and optional jitter, capped at Max, and exposes a max-attempts budget. It is shared by retrying HTTP clients, reliable processors, and the Do helper so backoff policy lives in one place.

func (Backoff) Exhausted

func (b Backoff) Exhausted(attempts int) bool

Exhausted reports whether attempts has reached the MaxAttempts budget. A non-positive MaxAttempts means "never exhausted".

func (Backoff) Next

func (b Backoff) Next(attempt int) time.Duration

Next returns the delay before the given attempt (1-based), without jitter.

func (Backoff) NextWithRand

func (b Backoff) NextWithRand(attempt int, randFraction float64) time.Duration

NextWithRand is Next with an explicit random fraction in [0,1) used to apply jitter. Pass rand.Float64() in production; pass a fixed value in tests.

type Config

type Config struct {
	// Backoff sets the default delay schedule and — always — the attempt budget
	// via its MaxAttempts field. MaxAttempts <= 1 means a single attempt (no
	// retry); set it to >= 2 to retry. Do never loops unboundedly even when
	// MaxAttempts is 0.
	Backoff Backoff
	// Strategy, when set, overrides the delay before each retry: it is called
	// with the 1-based number of the attempt that just failed and returns the
	// wait before the next attempt. Use it for a fixed delay (see Fixed) or any
	// custom cadence. The attempt budget still comes from Backoff.MaxAttempts.
	Strategy func(attempt int) time.Duration
	// IsTerminal, when it returns true for an error, stops retrying and returns
	// that error at once (a critical/permanent error). nil means every error is
	// retryable until the budget is spent; return true unconditionally to stop on
	// any error.
	IsTerminal func(err error) bool
	// OnRetry, when set, is called after an attempt fails and before Do waits to
	// retry — once per retry that actually happens. It is not called for a
	// success, a terminal error, or the final failed attempt (no retry follows
	// those). Use it for logging or retry statistics. attempt is the 1-based
	// number of the attempt that just failed.
	OnRetry func(attempt int, err error)
	// Sleep waits d or returns early with ctx.Err() if ctx is cancelled first.
	// Defaults to a context-aware timer; override it in tests for determinism.
	Sleep func(ctx context.Context, d time.Duration) error
	// Rand returns the jitter fraction in [0,1) for each delay. Defaults to
	// math/rand/v2; override it in tests. Consulted only by the default Backoff
	// cadence (a custom Strategy computes its own delay).
	Rand func() float64
}

Config tunes Do: how long to wait between attempts, how many attempts to make, which errors stop early, what to observe on each retry, and (for tests) how to sleep and how to draw jitter.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL