Skip to content

Commit

Permalink
Add v2 implementation of repeater with strategies and tests
Browse files Browse the repository at this point in the history
- Implement retry strategies: FixedDelay and Backoff with types (Constant, Linear, Exponential) and options for max delay and jitter.
- Add Repeater struct to handle retry logic, providing NewBackoff and NewFixed constructor functions.
- Include error handling with immediate termination on specified critical errors, including ErrAny for stopping on any error.
- Integrate full testing suite to confirm correct behavior of retry mechanisms, including context cancellation respects.
- Update README with usage examples and detailed explanations of strategies and options.
- Configure GitHub Actions CI for building and testing Go v1.24 codebase with coverage reporting.
  • Loading branch information
umputun committed Feb 15, 2025
1 parent 390ed95 commit 1d8e763
Show file tree
Hide file tree
Showing 8 changed files with 776 additions and 0 deletions.
47 changes: 47 additions & 0 deletions .github/workflows/ci-v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: build-v2

on:
push:
branches:
tags:
paths:
- ".github/workflows/ci-v2.yml"
- "v2/**"
pull_request:
paths:
- ".github/workflows/ci-v2.yml"
- "v2/**"

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.24"
id: go

- name: checkout
uses: actions/checkout@v4

- name: build and test
run: |
go test -timeout=60s -v -race -p 1 -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov ./...
go build -race
working-directory: v2

- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
args: --config ../.golangci.yml
working-directory: v2

- name: submit coverage
run: |
go install github.com/mattn/goveralls@latest
goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
135 changes: 135 additions & 0 deletions v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Repeater

[![Build Status](https://github.com/go-pkgz/repeater/workflows/build/badge.svg)](https://github.com/go-pkgz/repeater/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/repeater)](https://goreportcard.com/report/github.com/go-pkgz/repeater) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/repeater/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/repeater?branch=master)

Package repeater implements a functional mechanism to repeat operations with different retry strategies.

## Install and update

`go get -u github.com/go-pkgz/repeater/v2`

## Usage

### Basic Example with Exponential Backoff

```go
// create repeater with exponential backoff
r := repeater.NewBackoff(5, time.Second) // 5 attempts starting with 1s delay

err := r.Do(ctx, func() error {
// do something that may fail
return nil
})
```

### Fixed Delay with Critical Error

```go
// create repeater with fixed delay
r := repeater.NewFixed(3, 100*time.Millisecond)

criticalErr := errors.New("critical error")

err := r.Do(ctx, func() error {
// do something that may fail
return fmt.Errorf("temp error")
}, criticalErr) // will stop immediately if criticalErr returned
```

### Custom Backoff Strategy

```go
r := repeater.NewBackoff(5, time.Second,
repeater.WithMaxDelay(10*time.Second),
repeater.WithBackoffType(repeater.BackoffLinear),
repeater.WithJitter(0.1),
)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := r.Do(ctx, func() error {
// do something that may fail
return nil
})
```

### Stop on Any Error

```go
r := repeater.NewFixed(3, time.Millisecond)

err := r.Do(ctx, func() error {
return errors.New("some error")
}, repeater.ErrAny) // will stop on any error
```

## Strategies

The package provides several retry strategies:

1. **Fixed Delay** - each retry happens after a fixed time interval
2. **Backoff** - delay between retries increases according to the chosen algorithm:
- Constant - same delay between attempts
- Linear - delay increases linearly
- Exponential - delay doubles with each attempt

Backoff strategy can be customized with:
- Maximum delay cap
- Jitter to prevent thundering herd
- Different backoff types (constant/linear/exponential)

### Custom Strategies

You can implement your own retry strategy by implementing the Strategy interface:

```go
type Strategy interface {
// NextDelay returns delay for the next attempt
// attempt starts from 1
NextDelay(attempt int) time.Duration
}
```

Example of a custom strategy that increases delay by a custom factor:

```go
// CustomStrategy implements Strategy with custom factor-based delays
type CustomStrategy struct {
Initial time.Duration
Factor float64
}

func (s CustomStrategy) NextDelay(attempt int) time.Duration {
if attempt <= 0 {
return 0
}
delay := time.Duration(float64(s.Initial) * math.Pow(s.Factor, float64(attempt-1)))
return delay
}

// Usage
strategy := &CustomStrategy{Initial: time.Second, Factor: 1.5}
r := repeater.NewWithStrategy(5, strategy)
err := r.Do(ctx, func() error {
// attempts will be delayed by: 1s, 1.5s, 2.25s, 3.37s, 5.06s
return nil
})
```

## Options

For backoff strategy, several options are available:

```go
WithMaxDelay(time.Duration) // set maximum delay between retries
WithBackoffType(BackoffType) // set backoff type (constant/linear/exponential)
WithJitter(float64) // add randomness to delays (0-1.0)
```

## Error Handling

- Stops on context cancellation
- Can stop on specific errors (pass them as additional parameters to Do)
- Special `ErrAny` to stop on any error
- Returns last error if all attempts fail
11 changes: 11 additions & 0 deletions v2/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module github.com/go-pkgz/repeater/v2

go 1.24

require github.com/stretchr/testify v1.10.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions v2/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
95 changes: 95 additions & 0 deletions v2/repeater.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package repeater

import (
"context"
"errors"
"time"
)

// ErrAny is a special sentinel error that, when passed as a critical error to Do,
// makes it fail on any error from the function
var ErrAny = errors.New("any error")

// Repeater holds configuration for retry operations
type Repeater struct {
strategy Strategy
attempts int
}

// NewWithStrategy creates a repeater with a custom retry strategy
func NewWithStrategy(attempts int, strategy Strategy) *Repeater {
if attempts <= 0 {
attempts = 1
}
if strategy == nil {
strategy = NewFixedDelay(time.Second)
}
return &Repeater{
attempts: attempts,
strategy: strategy,
}
}

// NewBackoff creates a repeater with backoff strategy
// Default settings (can be overridden with options):
// - 30s max delay
// - exponential backoff
// - 10% jitter
func NewBackoff(attempts int, initial time.Duration, opts ...backoffOption) *Repeater {
return NewWithStrategy(attempts, newBackoff(initial, opts...))
}

// NewFixed creates a repeater with fixed delay strategy
func NewFixed(attempts int, delay time.Duration) *Repeater {
return NewWithStrategy(attempts, NewFixedDelay(delay))
}

// Do repeats fun until it succeeds or max attempts reached
// terminates immediately on context cancellation or if err matches any in termErrs.
// if errs contains ErrAny, terminates on any error.
func (r *Repeater) Do(ctx context.Context, fun func() error, termErrs ...error) error {
var lastErr error

inErrors := func(err error) bool {
for _, e := range termErrs {
if errors.Is(e, ErrAny) {
return true
}
if errors.Is(err, e) {
return true
}
}
return false
}

for attempt := 0; attempt < r.attempts; attempt++ {
// check context before each attempt
if err := ctx.Err(); err != nil {
return err
}

var err error
if err = fun(); err == nil {
return nil
}

lastErr = err
if inErrors(err) {
return err
}

// don't sleep after the last attempt
if attempt < r.attempts-1 {
delay := r.strategy.NextDelay(attempt + 1)
if delay > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
}
}

return lastErr
}
Loading

0 comments on commit 1d8e763

Please sign in to comment.