Documentation
¶
Overview ¶
Package retry provides flexible, composable retry logic with dependency injection support.
retry is a retry package that provides:
- Dependency Injection: Inject policies at wire-up, customize behavior at call sites
- Composable Backoff: Chain strategies like Exponential, WithCap, and WithJitter
- Injectable Clock: Control time in tests without real sleeps
- Lifecycle Hooks: OnRetry, OnSuccess, OnExhausted for observability
- Error Aggregation: Collect all errors or just the last one
- Zero Dependencies: Only the Go standard library
Quick Start ¶
Using the global Do function for one-off retries:
err := retry.Do(ctx, func(ctx context.Context) error {
return client.Call(ctx)
})
Creating a reusable policy for dependency injection:
// At wire-up time (e.g., in main or a DI container)
policy := retry.New(
retry.WithMaxAttempts(5),
retry.WithBackoff(retry.Exponential(100*time.Millisecond)),
)
// At call site
err := policy.Do(ctx, func(ctx context.Context) error {
return client.Call(ctx)
},
retry.If(isTransient),
retry.OnRetry(func(ctx context.Context, attempt int, err error, delay time.Duration) {
log.Warn("retrying", "attempt", attempt, "error", err, "delay", delay)
}),
)
Design Philosophy ¶
The package separates configuration into two categories:
Policy-Level (set at wire-up, injected via DI):
- MaxAttempts: How many times to try
- MaxDuration: Total time budget across all attempts
- Backoff: Delay strategy between attempts
- Clock: Time abstraction for testing
Call-Level (set at each call site):
- If: Condition to determine if an error should be retried
- OnRetry: Hook called before each retry sleep
- OnSuccess: Hook called when the function succeeds
- OnExhausted: Hook called when all attempts are exhausted
- WithAllErrors: Collect all errors instead of just the last
This separation allows:
- Infrastructure to control retry budgets (how many, how fast)
- Application code to control retry behavior (which errors, what to log)
- Clean dependency injection without coupling to configuration
Terminal Errors ¶
Use Stop to signal that an error should not be retried:
func fetchUser(ctx context.Context, id string) (*User, error) {
user, err := db.Get(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, retry.Stop(ErrNotFound) // Don't retry "not found"
}
return user, err // Other errors will be retried
}
Backoff Strategies ¶
The package provides three base strategies:
retry.Constant(100*time.Millisecond) // Always 100ms retry.Linear(100*time.Millisecond) // 100ms, 200ms, 300ms, ... retry.Exponential(100*time.Millisecond) // 100ms, 200ms, 400ms, 800ms, ...
Strategies can be composed with wrappers:
// Exponential backoff, capped at 10s, with ±20% jitter
backoff := retry.WithJitter(0.2,
retry.WithCap(10*time.Second,
retry.Exponential(100*time.Millisecond),
),
)
Available wrappers:
- WithCap(max, b): Caps delay at max duration
- WithMin(min, b): Ensures delay is at least min duration
- WithJitter(factor, b): Adds random jitter (±factor * delay)
Custom backoff strategies can be created using BackoffFunc:
custom := retry.BackoffFunc(func(attempt int) time.Duration {
return time.Duration(attempt*attempt) * 100 * time.Millisecond
})
Time Budgets ¶
Use both MaxAttempts and MaxDuration for precise control:
policy := retry.New(
retry.WithMaxAttempts(10), // Stop after 10 attempts
retry.WithMaxDuration(30*time.Second), // OR stop after 30s total
)
The retry loop stops when either limit is reached first.
Lifecycle Hooks ¶
Hooks provide observability without coupling to a specific logger or metrics system:
err := policy.Do(ctx, fn,
retry.OnRetry(func(ctx context.Context, attempt int, err error, delay time.Duration) {
logger.Warn("retrying", "attempt", attempt, "delay", delay)
metrics.Increment("retries")
}),
retry.OnSuccess(func(ctx context.Context, attempts int) {
if attempts > 1 {
logger.Info("recovered", "attempts", attempts)
}
}),
retry.OnExhausted(func(ctx context.Context, attempts int, err error) {
logger.Error("gave up", "attempts", attempts, "error", err)
alerting.Notify("retry exhausted")
}),
)
Error Aggregation ¶
By default, only the last error is returned. Use WithAllErrors to collect all:
err := retry.Do(ctx, fn, retry.WithAllErrors()) // err contains all attempt errors via errors.Join // errors.Is/As work through the chain
Testing ¶
Inject a fake clock to control time in tests:
type fakeClock struct {
now time.Time
sleeps []time.Duration
}
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Sleep(ctx context.Context, d time.Duration) error {
c.sleeps = append(c.sleeps, d)
c.now = c.now.Add(d)
return ctx.Err()
}
func TestRetry(t *testing.T) {
clock := &fakeClock{now: time.Now()}
policy := retry.New(
retry.WithMaxAttempts(3),
retry.WithClock(clock),
)
attempts := 0
_ = policy.Do(ctx, func(ctx context.Context) error {
attempts++
return errors.New("fail")
})
assert.Equal(t, 3, attempts)
assert.Len(t, clock.sleeps, 2) // 2 sleeps between 3 attempts
}
Pre-Built Policies ¶
The package provides convenience functions for common configurations:
retry.Never() // No retries, just run once retry.Default() // Sensible defaults (3 attempts, exponential backoff with jitter)
Best Practices ¶
1. Inject policies, customize at call sites:
// Wire-up policy := retry.New(retry.WithMaxAttempts(5)) // Call site err := policy.Do(ctx, fn, retry.If(isTransient))
2. Use Stop for non-retryable errors:
if errors.Is(err, ErrNotFound) {
return retry.Stop(err)
}
3. Add jitter to prevent thundering herd:
retry.WithJitter(0.2, retry.Exponential(100*time.Millisecond))
4. Cap exponential backoff to prevent excessive delays:
retry.WithCap(30*time.Second, retry.Exponential(100*time.Millisecond))
5. Use hooks for observability instead of wrapping:
retry.OnRetry(func(...) { logger.Warn(...) })
Example (ComposedBackoff) ¶
Example_composedBackoff demonstrates composing multiple backoff wrappers.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
// Exponential backoff, capped at 1s, with minimum 50ms
b := retry.WithMin(50*time.Millisecond,
retry.WithCap(1*time.Second,
retry.Exponential(10*time.Millisecond),
),
)
fmt.Println("Attempt 1:", b.Delay(1)) // 10ms -> 50ms (min)
fmt.Println("Attempt 2:", b.Delay(2)) // 20ms -> 50ms (min)
fmt.Println("Attempt 5:", b.Delay(5)) // 160ms
fmt.Println("Attempt 10:", b.Delay(10)) // 5.12s -> 1s (cap)
}
Output: Attempt 1: 50ms Attempt 2: 50ms Attempt 5: 160ms Attempt 10: 1s
Example (DependencyInjection) ¶
Example_dependencyInjection demonstrates the recommended DI pattern.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
// === Wire-up time (e.g., in main or DI container) ===
policy := retry.New(
retry.WithMaxAttempts(5),
retry.WithBackoff(retry.Constant(time.Millisecond)),
)
// === Call site (in application code) ===
// The caller doesn't know or care about the retry budget.
// It only controls which errors to retry and what to log.
attempts := 0
var retried bool
err := policy.Do(context.Background(), func(ctx context.Context) error {
attempts++
if attempts < 2 {
return errors.New("transient")
}
return nil
},
retry.If(func(err error) bool {
return err.Error() == "transient"
}),
retry.OnRetry(func(ctx context.Context, attempt int, err error, delay time.Duration) {
retried = true
}),
)
fmt.Println("Error:", err)
fmt.Println("Retried:", retried)
}
Output: Error: <nil> Retried: true
Index ¶
- Constants
- func Do(ctx context.Context, fn Func, opts ...Option) error
- func Stop(err error) error
- type Backoff
- type BackoffFunc
- type Clock
- type Condition
- type Func
- type OnExhaustedFunc
- type OnRetryFunc
- type OnSuccessFunc
- type Option
- func If(cond Condition) Option
- func IfNot(cond Condition) Option
- func OnExhausted(fn OnExhaustedFunc) Option
- func OnRetry(fn OnRetryFunc) Option
- func OnSuccess(fn OnSuccessFunc) Option
- func WithAllErrors() Option
- func WithBackoff(b Backoff) Option
- func WithClock(clock Clock) Option
- func WithMaxAttempts(n int) Option
- func WithMaxDuration(d time.Duration) Option
- type Policy
Examples ¶
Constants ¶
const (
DefaultMaxAttempts = 3
)
Default values.
Variables ¶
This section is empty.
Functions ¶
func Do ¶
Do executes fn with retry using the default policy.
Example ¶
ExampleDo demonstrates the simplest usage with the global Do function.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
attempts := 0
err := retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
if attempts < 3 {
return errors.New("temporary failure")
}
return nil
},
retry.WithMaxAttempts(5),
retry.WithBackoff(retry.Constant(time.Millisecond)),
)
fmt.Println("Error:", err)
fmt.Println("Attempts:", attempts)
}
Output: Error: <nil> Attempts: 3
func Stop ¶
Stop wraps an error to signal that it should not be retried. The retry loop will immediately return the unwrapped error.
Example ¶
ExampleStop demonstrates signaling a non-retryable error.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
notFound := errors.New("not found")
attempts := 0
err := retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
return retry.Stop(notFound)
},
retry.WithMaxAttempts(5),
retry.WithBackoff(retry.Constant(time.Millisecond)),
)
fmt.Println("Error:", err)
fmt.Println("Attempts:", attempts)
}
Output: Error: not found Attempts: 1
Types ¶
type Backoff ¶
Backoff calculates the delay between retry attempts.
func Constant ¶
Constant returns a backoff that always waits the same duration.
Example ¶
ExampleConstant demonstrates constant backoff.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
b := retry.Constant(100 * time.Millisecond)
fmt.Println("Attempt 1:", b.Delay(1))
fmt.Println("Attempt 2:", b.Delay(2))
fmt.Println("Attempt 5:", b.Delay(5))
}
Output: Attempt 1: 100ms Attempt 2: 100ms Attempt 5: 100ms
func Exponential ¶
Exponential returns a backoff that doubles with each attempt. delay = base * 2^(attempt-1)
Example ¶
ExampleExponential demonstrates exponential backoff.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
b := retry.Exponential(100 * time.Millisecond)
fmt.Println("Attempt 1:", b.Delay(1))
fmt.Println("Attempt 2:", b.Delay(2))
fmt.Println("Attempt 3:", b.Delay(3))
fmt.Println("Attempt 4:", b.Delay(4))
}
Output: Attempt 1: 100ms Attempt 2: 200ms Attempt 3: 400ms Attempt 4: 800ms
func Linear ¶
Linear returns a backoff that increases linearly with each attempt. delay = base * attempt
Example ¶
ExampleLinear demonstrates linear backoff.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
b := retry.Linear(100 * time.Millisecond)
fmt.Println("Attempt 1:", b.Delay(1))
fmt.Println("Attempt 2:", b.Delay(2))
fmt.Println("Attempt 5:", b.Delay(5))
}
Output: Attempt 1: 100ms Attempt 2: 200ms Attempt 5: 500ms
func WithCap ¶
WithCap wraps a backoff and caps the delay at a maximum value.
Example ¶
ExampleWithCap demonstrates capping backoff delays.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
b := retry.WithCap(500*time.Millisecond, retry.Exponential(100*time.Millisecond))
fmt.Println("Attempt 1:", b.Delay(1))
fmt.Println("Attempt 2:", b.Delay(2))
fmt.Println("Attempt 3:", b.Delay(3))
fmt.Println("Attempt 4:", b.Delay(4)) // Would be 800ms, capped to 500ms
fmt.Println("Attempt 5:", b.Delay(5)) // Would be 1.6s, capped to 500ms
}
Output: Attempt 1: 100ms Attempt 2: 200ms Attempt 3: 400ms Attempt 4: 500ms Attempt 5: 500ms
func WithJitter ¶
WithJitter wraps a backoff and adds random jitter to the delay. The jitter is a factor between 0 and 1, where 0.2 means ±20%.
func WithMin ¶
WithMin wraps a backoff and ensures the delay is at least a minimum value.
Example ¶
ExampleWithMin demonstrates minimum backoff delays.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
b := retry.WithMin(150*time.Millisecond, retry.Linear(50*time.Millisecond))
fmt.Println("Attempt 1:", b.Delay(1)) // 50ms -> 150ms (min)
fmt.Println("Attempt 2:", b.Delay(2)) // 100ms -> 150ms (min)
fmt.Println("Attempt 3:", b.Delay(3)) // 150ms (at min)
fmt.Println("Attempt 4:", b.Delay(4)) // 200ms (above min)
}
Output: Attempt 1: 150ms Attempt 2: 150ms Attempt 3: 150ms Attempt 4: 200ms
type BackoffFunc ¶
BackoffFunc is an adapter that allows a function to be used as a Backoff.
Example ¶
ExampleBackoffFunc demonstrates creating a custom backoff strategy.
package main
import (
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
// Quadratic backoff: delay = base * attempt^2
b := retry.BackoffFunc(func(attempt int) time.Duration {
return time.Duration(attempt*attempt) * 10 * time.Millisecond
})
fmt.Println("Attempt 1:", b.Delay(1))
fmt.Println("Attempt 2:", b.Delay(2))
fmt.Println("Attempt 3:", b.Delay(3))
fmt.Println("Attempt 4:", b.Delay(4))
}
Output: Attempt 1: 10ms Attempt 2: 40ms Attempt 3: 90ms Attempt 4: 160ms
type Condition ¶
Condition determines whether an error should be retried.
func Not ¶
Not inverts a condition.
Example ¶
ExampleNot demonstrates inverting a condition.
package main
import (
"errors"
"fmt"
"github.com/bjaus/retry"
)
func main() {
isTimeout := func(err error) bool {
return err.Error() == "timeout"
}
// Retry everything EXCEPT timeouts
notTimeout := retry.Not(isTimeout)
fmt.Println("timeout matches:", isTimeout(errors.New("timeout")))
fmt.Println("timeout matches Not:", notTimeout(errors.New("timeout")))
fmt.Println("other matches Not:", notTimeout(errors.New("other")))
}
Output: timeout matches: true timeout matches Not: false other matches Not: true
type OnExhaustedFunc ¶
OnExhaustedFunc is called when all retry attempts are exhausted.
type OnRetryFunc ¶
OnRetryFunc is called before each retry sleep.
type OnSuccessFunc ¶
OnSuccessFunc is called when the function succeeds.
type Option ¶
type Option func(*config)
Option configures retry behavior.
func If ¶
If sets the condition that determines whether an error should be retried. If the condition returns false, the retry loop stops immediately.
Example ¶
ExampleIf demonstrates conditional retry based on error type.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
transient := errors.New("transient error")
permanent := errors.New("permanent error")
attempts := 0
err := retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
if attempts < 3 {
return transient
}
return permanent
},
retry.WithMaxAttempts(10),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.If(func(err error) bool {
return errors.Is(err, transient)
}),
)
fmt.Println("Error:", err)
fmt.Println("Attempts:", attempts)
}
Output: Error: permanent error Attempts: 3
func IfNot ¶
IfNot sets a condition where matching errors are NOT retried. This is equivalent to If(Not(cond)).
Example ¶
ExampleIfNot demonstrates skipping specific errors from retry.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
validationErr := errors.New("validation error")
attempts := 0
err := retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
if attempts == 2 {
return validationErr // Don't retry this
}
return errors.New("transient")
},
retry.WithMaxAttempts(10),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.IfNot(func(err error) bool {
return errors.Is(err, validationErr)
}),
)
fmt.Println("Error:", err)
fmt.Println("Attempts:", attempts)
}
Output: Error: validation error Attempts: 2
func OnExhausted ¶
func OnExhausted(fn OnExhaustedFunc) Option
OnExhausted sets a hook that is called when all retry attempts are exhausted.
Example ¶
ExampleOnExhausted demonstrates the exhausted hook.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
_ = retry.Do(context.Background(), func(ctx context.Context) error {
return errors.New("always fails")
},
retry.WithMaxAttempts(3),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.OnExhausted(func(ctx context.Context, attempts int, err error) {
fmt.Printf("Exhausted after %d attempts: %v\n", attempts, err)
}),
)
}
Output: Exhausted after 3 attempts: always fails
func OnRetry ¶
func OnRetry(fn OnRetryFunc) Option
OnRetry sets a hook that is called before each retry sleep.
Example ¶
ExampleOnRetry demonstrates the retry hook for logging.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
attempts := 0
retryCount := 0
_ = retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
return errors.New("fail")
},
retry.WithMaxAttempts(3),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.OnRetry(func(ctx context.Context, attempt int, err error, delay time.Duration) {
retryCount++
fmt.Printf("Retry %d: %v\n", attempt, err)
}),
)
fmt.Println("Total retries:", retryCount)
}
Output: Retry 1: fail Retry 2: fail Total retries: 2
func OnSuccess ¶
func OnSuccess(fn OnSuccessFunc) Option
OnSuccess sets a hook that is called when the function succeeds.
Example ¶
ExampleOnSuccess demonstrates the success hook.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
attempts := 0
_ = retry.Do(context.Background(), func(ctx context.Context) error {
attempts++
if attempts < 3 {
return errors.New("not yet")
}
return nil
},
retry.WithMaxAttempts(5),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.OnSuccess(func(ctx context.Context, attempts int) {
fmt.Printf("Succeeded on attempt %d\n", attempts)
}),
)
}
Output: Succeeded on attempt 3
func WithAllErrors ¶
func WithAllErrors() Option
WithAllErrors configures the retry to collect all errors from each attempt. When enabled, the final error is an errors.Join of all attempt errors. By default, only the last error is returned.
Example ¶
ExampleWithAllErrors demonstrates collecting all errors.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
attempt := 0
err := retry.Do(context.Background(), func(ctx context.Context) error {
attempt++
return fmt.Errorf("error %d", attempt)
},
retry.WithMaxAttempts(3),
retry.WithBackoff(retry.Constant(time.Millisecond)),
retry.WithAllErrors(),
)
fmt.Println("Contains error 1:", errors.Is(err, fmt.Errorf("error 1")))
fmt.Println("Error string contains all:", err != nil)
}
Output: Contains error 1: false Error string contains all: true
func WithMaxAttempts ¶
WithMaxAttempts sets the maximum number of attempts.
func WithMaxDuration ¶
WithMaxDuration sets the maximum total duration for all attempts. Retries stop when this duration is exceeded, even if attempts remain.
type Policy ¶
type Policy struct {
// contains filtered or unexported fields
}
Policy defines retry behavior. Safe for concurrent use.
func Never ¶
func Never() *Policy
Never returns a policy that does not retry.
Example ¶
ExampleNever demonstrates a policy that does not retry.
package main
import (
"context"
"errors"
"fmt"
"github.com/bjaus/retry"
)
func main() {
policy := retry.Never()
attempts := 0
_ = policy.Do(context.Background(), func(ctx context.Context) error {
attempts++
return errors.New("fail")
})
fmt.Println("Attempts:", attempts)
}
Output: Attempts: 1
func New ¶
New creates a Policy with the given options.
Example ¶
ExampleNew demonstrates creating a reusable policy.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/bjaus/retry"
)
func main() {
policy := retry.New(
retry.WithMaxAttempts(3),
retry.WithBackoff(retry.Constant(time.Millisecond)),
)
attempts := 0
err := policy.Do(context.Background(), func(ctx context.Context) error {
attempts++
return errors.New("always fails")
})
fmt.Println("Error:", err)
fmt.Println("Attempts:", attempts)
}
Output: Error: always fails Attempts: 3