diff --git a/README.md b/README.md index 7ff9b2c..c3ecc5d 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,8 @@ func main() { - `ErrWait`: Returned when resources are not available after all retry attempts - `ErrInvalidArgument`: Returned for invalid input parameters +- `ErrResourceNotFound`: Returned when a resource type is not registered +- `ErrResourceAlreadyRegistered`: Returned when trying to register a resource type that already exists ### Error Handling Patterns diff --git a/errors.go b/errors.go index e946cec..3391daf 100644 --- a/errors.go +++ b/errors.go @@ -2,12 +2,16 @@ package waitfor import "errors" -// ErrWait is returned when resource availability testing fails. -// This error indicates that one or more resources did not become -// available within the configured timeout and retry parameters. var ( - ErrWait = errors.New("failed to wait for resource availability") + // ErrWait is returned when resource availability testing fails. + // This error indicates that one or more resources did not become + // available within the configured timeout and retry parameters. + ErrWait = errors.New("failed to wait for resource availability") // ErrInvalidArgument is returned when invalid arguments are passed // to functions, such as empty resource URLs or invalid configuration. ErrInvalidArgument = errors.New("invalid argument") + // ErrResourceAlreadyRegistered is returned when a resource factory is already registered for a scheme. + ErrResourceAlreadyRegistered = errors.New("resource is already registered with a given scheme") + // ErrResourceNotFound is returned when no resource factory is found for a scheme. + ErrResourceNotFound = errors.New("resource with a given scheme is not found") ) diff --git a/go.mod b/go.mod index 4ba2bba..b796cb1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 toolchain go1.24.6 require ( - github.com/cenkalti/backoff v2.2.1+incompatible + github.com/cenkalti/backoff/v5 v5.0.3 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index a9d32c9..766f71c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/options.go b/options.go index 61a6be4..2bc6bba 100644 --- a/options.go +++ b/options.go @@ -5,31 +5,37 @@ import ( ) type ( - // Options contains configuration parameters for resource testing behavior. + // options contains configuration parameters for resource testing behavior. // These options control retry intervals, maximum wait times, and the number // of attempts made when testing resource availability. - Options struct { - interval time.Duration // Initial retry interval between attempts - maxInterval time.Duration // Maximum interval for exponential backoff - attempts uint64 // Maximum number of retry attempts + options struct { + interval time.Duration // Initial retry interval between attempts + maxInterval time.Duration // Maximum interval for exponential backoff + attempts uint64 // Maximum number of retry attempts + multiplier float64 // Multiplier for exponential backoff + randomizationFactor float64 // Randomization factor for backoff intervals } - // Option is a function type used to configure Options through the functional + // Option is a function type used to configure options through the functional // options pattern. This allows flexible and extensible configuration of // resource testing behavior. - Option func(opts *Options) + Option func(opts *options) ) -// newOptions creates a new Options instance with default values and applies +// newOptions creates a new options instance with default values and applies // the provided option setters. Default values are: // - interval: 5 seconds -// - maxInterval: 60 seconds +// - maxInterval: 60 seconds // - attempts: 5. -func newOptions(setters []Option) *Options { - opts := &Options{ - interval: time.Duration(5) * time.Second, - maxInterval: time.Duration(60) * time.Second, - attempts: 5, +// - multiplier: 1.5 +// - randomizationFactor: 0.5 +func newOptions(setters []Option) *options { + opts := &options{ + interval: time.Duration(5) * time.Second, + maxInterval: time.Duration(60) * time.Second, + attempts: 5, + multiplier: 1.5, + randomizationFactor: 0.5, } for _, setter := range setters { @@ -47,7 +53,7 @@ func newOptions(setters []Option) *Options { // // runner.Test(ctx, resources, waitfor.WithInterval(2)) // Start with 2 second intervals func WithInterval(interval uint64) Option { - return func(opts *Options) { + return func(opts *options) { opts.interval = time.Duration(interval) * time.Second } } @@ -60,7 +66,7 @@ func WithInterval(interval uint64) Option { // // runner.Test(ctx, resources, waitfor.WithMaxInterval(30)) // Cap at 30 seconds func WithMaxInterval(interval uint64) Option { - return func(opts *Options) { + return func(opts *options) { opts.maxInterval = time.Duration(interval) * time.Second } } @@ -73,7 +79,34 @@ func WithMaxInterval(interval uint64) Option { // // runner.Test(ctx, resources, waitfor.WithAttempts(10)) // Try up to 10 times func WithAttempts(attempts uint64) Option { - return func(opts *Options) { + return func(opts *options) { opts.attempts = attempts } } + +// WithMultiplier creates an Option that sets the multiplier for exponential backoff. +// This value determines how quickly the retry interval increases after each attempt. +// A higher multiplier results in faster growth of the interval. +// +// Example: +// +// runner.Test(ctx, resources, waitfor.WithMultiplier(2.0)) // Double the interval each time +func WithMultiplier(multiplier float64) Option { + return func(opts *options) { + opts.multiplier = multiplier + } +} + +// WithRandomizationFactor creates an Option that sets the randomization factor for +// exponential backoff. This factor introduces jitter to the retry intervals, +// helping to prevent thundering herd problems when multiple clients are retrying +// simultaneously. +// +// Example: +// +// runner.Test(ctx, resources, waitfor.WithRandomizationFactor(0.5)) // 50% jitter +func WithRandomizationFactor(factor float64) Option { + return func(opts *options) { + opts.randomizationFactor = factor + } +} diff --git a/options_test.go b/options_test.go index cd5810f..13927c6 100644 --- a/options_test.go +++ b/options_test.go @@ -31,7 +31,7 @@ func TestNewOptions_WithSetters(t *testing.T) { func TestWithInterval(t *testing.T) { option := WithInterval(30) - opts := &Options{} + opts := &options{} option(opts) @@ -40,7 +40,7 @@ func TestWithInterval(t *testing.T) { func TestWithMaxInterval(t *testing.T) { option := WithMaxInterval(90) - opts := &Options{} + opts := &options{} option(opts) @@ -49,7 +49,7 @@ func TestWithMaxInterval(t *testing.T) { func TestWithAttempts(t *testing.T) { option := WithAttempts(20) - opts := &Options{} + opts := &options{} option(opts) @@ -67,3 +67,19 @@ func TestCombinedOptions(t *testing.T) { assert.Equal(t, time.Duration(30)*time.Second, opts.maxInterval) assert.Equal(t, uint64(8), opts.attempts) } + +func TestWithMultiplier(t *testing.T) { + opts := newOptions([]Option{ + WithMultiplier(2.5), + }) + + assert.Equal(t, 2.5, opts.multiplier) +} + +func TestWithRandomizationFactor(t *testing.T) { + opts := newOptions([]Option{ + WithRandomizationFactor(0.3), + }) + + assert.Equal(t, 0.3, opts.randomizationFactor) +} diff --git a/registry.go b/registry.go index ab562b5..2caf25a 100644 --- a/registry.go +++ b/registry.go @@ -2,7 +2,7 @@ package waitfor import ( "context" - "errors" + "fmt" "net/url" "strings" ) @@ -84,7 +84,7 @@ func (r *Registry) Register(scheme string, factory ResourceFactory) error { _, exists := r.resources[scheme] if exists { - return errors.New("resource is already registered with a given scheme:" + scheme) + return fmt.Errorf("%w: %s", ErrResourceAlreadyRegistered, scheme) } r.resources[scheme] = factory @@ -113,7 +113,7 @@ func (r *Registry) Resolve(location string) (Resource, error) { rf, found := r.resources[u.Scheme] if !found { - return nil, errors.New("resource with a given scheme is not found:" + u.Scheme) + return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, u.Scheme) } return rf(u) diff --git a/waitfor.go b/waitfor.go index 6db0a14..1bb9fd3 100644 --- a/waitfor.go +++ b/waitfor.go @@ -29,7 +29,7 @@ import ( "os/exec" "sync" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v5" ) type ( @@ -109,8 +109,10 @@ func (r *Runner) Run(ctx context.Context, program Program, setters ...Option) ([ // // The setters parameter allows customization of retry behavior including: // - Initial retry interval (WithInterval) -// - Maximum retry interval (WithMaxInterval) +// - Maximum retry interval (WithMaxInterval) // - Number of retry attempts (WithAttempts) +// - Multiplier for exponential backoff (WithMultiplier) +// - Randomization factor for backoff intervals (WithRandomizationFactor) // // Example: // @@ -142,7 +144,7 @@ func (r *Runner) Test(ctx context.Context, resources []string, setters ...Option // testAllInternal concurrently tests all provided resources and returns a channel // of errors. Each resource is tested in its own goroutine with the specified options. // The channel is closed when all tests complete. -func (r *Runner) testAllInternal(ctx context.Context, resources []string, opts Options) <-chan error { +func (r *Runner) testAllInternal(ctx context.Context, resources []string, opts options) <-chan error { var wg sync.WaitGroup wg.Add(len(resources)) @@ -169,7 +171,7 @@ func (r *Runner) testAllInternal(ctx context.Context, resources []string, opts O // testInternal tests a single resource with retry logic using exponential backoff. // It resolves the resource from the registry and applies the configured retry // strategy until the resource test passes or max attempts are reached. -func (r *Runner) testInternal(ctx context.Context, resource string, opts Options) error { +func (r *Runner) testInternal(ctx context.Context, resource string, opts options) error { rsc, err := r.registry.Resolve(resource) if err != nil { @@ -179,8 +181,13 @@ func (r *Runner) testInternal(ctx context.Context, resource string, opts Options b := backoff.NewExponentialBackOff() b.InitialInterval = opts.interval b.MaxInterval = opts.maxInterval + b.Multiplier = opts.multiplier + b.RandomizationFactor = opts.randomizationFactor - return backoff.Retry(func() error { - return rsc.Test(ctx) - }, backoff.WithContext(backoff.WithMaxRetries(b, opts.attempts), ctx)) + _, err = backoff.Retry(ctx, func() (bool, error) { + // The return value doesn't matter to us + return false, rsc.Test(ctx) + }, backoff.WithMaxTries(uint(opts.attempts))) + + return err } diff --git a/waitfor_test.go b/waitfor_test.go index 124b2e7..94cee21 100644 --- a/waitfor_test.go +++ b/waitfor_test.go @@ -212,7 +212,7 @@ func TestRunner_testInternal_ResolutionError(t *testing.T) { runner := New() // No resources registered ctx := context.Background() - opts := Options{ + opts := options{ interval: time.Second, maxInterval: time.Minute, attempts: 1, @@ -231,7 +231,7 @@ func TestRunner_testInternal_ResourceTestError(t *testing.T) { runner := New(config) ctx := context.Background() - opts := Options{ + opts := options{ interval: 1 * time.Millisecond, // Very short for testing maxInterval: 2 * time.Millisecond, attempts: 1, // Only one attempt to avoid long test time @@ -249,7 +249,7 @@ func TestRunner_testAllInternal(t *testing.T) { runner := New(config) ctx := context.Background() - opts := Options{ + opts := options{ interval: time.Millisecond, maxInterval: time.Millisecond * 10, attempts: 1, @@ -277,7 +277,7 @@ func TestRunner_testAllInternal_WithErrors(t *testing.T) { runner := New(config) ctx := context.Background() - opts := Options{ + opts := options{ interval: time.Millisecond, maxInterval: time.Millisecond * 10, attempts: 1,