A simple, lightweight, interface-driven exponential backoff library for Go 1.26.3+ designed for retrying operations with random jitter.
- 100% Interface-Driven: Clean API exposing interfaces (
Backoff,Option) allowing simple mocking and stubbing in consumer tests. - Modern Go: Leverages
"math/rand/v2"for fast, auto-seeded, and thread-safe random jitter without memory allocations. - Resource Safe: Protects against memory leaks by explicitly stopping timers when contexts are cancelled.
- Generics Support: Native helper
backoff.Do[T]to execute operations that return both a value and an error in a type-safe way. - Permanent Errors: Define errors that immediately halt the retry loop using
backoff.Permanent(err). - First-Class Testing Utilities: Dedicated
backofftestpackage with customizable mocks, zero-delay executors, and error simulators.
go get github.com/ciceroverneck/backoffpackage main
import (
"context"
"log"
"time"
"github.com/ciceroverneck/backoff"
)
func main() {
ctx := context.Background()
// Configure a backoff mechanism
boff := backoff.New(
backoff.Exponential(),
backoff.MaxAttempts(5),
backoff.Interval(200 * time.Millisecond),
backoff.Notify(func(err error, next time.Duration, count uint) {
log.Printf("Attempt %d failed: %v. Retrying in %v...", count, err, next)
}),
)
// Execute operation
err := boff.Do(ctx, func(ctx context.Context) error {
// Your logic here (e.g. database query, HTTP request)
return nil
})
if err != nil {
log.Fatalf("Operation failed: %v", err)
}
}val, err := backoff.Do(ctx, boff, func(ctx context.Context) (string, error) {
// Execute logic that returns a result
return "success data", nil
})Use backoff.Permanent to wrap an error and signal to the retry loop that it should stop retrying immediately.
err := boff.Do(ctx, func(ctx context.Context) error {
err := performAction()
if isUnrecoverable(err) {
return backoff.Permanent(err) // Stops retrying immediately
}
return err // Retries if count/time limits are not reached
})The backofftest package simplifies unit testing for consumers of this library.
Use backofftest.NewZero() to run the operation exactly once without any delays, retries, or timers, allowing tests to run instantly:
func TestMyService(t *testing.T) {
// Injects a zero backoff to bypass delays in tests
service := NewService(backofftest.NewZero())
err := service.PerformAction()
// Assertions...
}Use backofftest.NewMock to inspect called operations or mock custom error/retry scenarios:
func TestMyService_WithMock(t *testing.T) {
// Create a mock that returns a custom error
mockBoff := backofftest.NewMock(func(ctx context.Context, fn func(context.Context) error) error {
_ = fn(ctx) // execute inner function
return errors.New("mocked error")
})
service := NewService(mockBoff)
_ = service.PerformAction()
// Assertions on recorded calls
if len(mockBoff.Calls()) != 1 {
t.Errorf("expected 1 call, got %d", len(mockBoff.Calls()))
}
}Easily verify how your code handles backoff limit errors (such as ErrMaxRetries or ErrMaxElapsedTime):
// Simulate reaching max retries limit
mockBoff := backofftest.NewMockMaxRetries(errors.New("db error"))
err := mockBoff.Do(ctx, func(ctx context.Context) error {
return errors.New("db error")
})
// err wraps backoff.ErrMaxRetries and the original db error
if errors.Is(err, backoff.ErrMaxRetries) {
// Handle max retries path...
}This project is licensed under the MIT License - see the LICENSE file for details.