Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ cd utils
| **templates** | Template rendering utilities | [README](templates/README.md) | [EXAMPLES](templates/EXAMPLES.md) |
| **timeutils** | Time and date manipulation utilities | [README](time/README.md) | [EXAMPLES](time/EXAMPLES.md) |
| **url** | URL parsing and manipulation utilities | [README](url/README.md) | [EXAMPLES](url/EXAMPLES.md) |
| **retry** | Retry fallible operations | [README](retry/README.md) | [EXAMPLES](retry/EXAMPLES.md) |

## Contributions

Expand Down
172 changes: 172 additions & 0 deletions retry/EXAMPLES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
## Retry Examples

### Basic usage
```go
package main

import (
"context"
"fmt"
"time"

"github.com/kashifkhan0771/utils/retry"
)

func main() {
opts := retry.Options{
MaxAttempts: 3,
TotalTimeout: 10 * time.Second,
Backoff: retry.FixedBackoff(500 * time.Millisecond),
ShouldRetry: func(err error) bool { return true },
}

result, err := retry.Do(context.Background(), opts, func(ctx context.Context) (string, error) {
return callAPI(ctx)
})
fmt.Println(result, err)
}
```
#### Output:
```
"response" <nil>
Comment thread
danilovict2 marked this conversation as resolved.
Outdated
```

---

### Aborting on non-retryable errors
```go
package main

import (
"context"
"errors"
"fmt"
"time"

"github.com/kashifkhan0771/utils/retry"
)

var ErrNotFound = errors.New("not found")

func main() {
opts := retry.Options{
MaxAttempts: 5,
TotalTimeout: 10 * time.Second,
Backoff: retry.FixedBackoff(500 * time.Millisecond),
ShouldRetry: func(err error) bool {
return !errors.Is(err, ErrNotFound)
},
}

_, err := retry.Do(context.Background(), opts, func(ctx context.Context) (string, error) {
return "", ErrNotFound
})
fmt.Println(err)
}
```
#### Output:
```
not found
```

---

### Using DoVoid for side-effecting operations
```go
package main

import (
"context"
"fmt"
"time"

"github.com/kashifkhan0771/utils/retry"
)

func main() {
opts := retry.Options{
MaxAttempts: 3,
TotalTimeout: 10 * time.Second,
Backoff: retry.FixedBackoff(200 * time.Millisecond),
ShouldRetry: func(err error) bool { return true },
}

err := retry.DoVoid(context.Background(), opts, func(ctx context.Context) error {
return sendNotification(ctx)
})
fmt.Println(err)
}
```
#### Output:
```
<nil>
```

---

### Backoff strategies compared
```go
package main

import (
"fmt"
"time"

"github.com/kashifkhan0771/utils/retry"
)

func main() {
fixed := retry.FixedBackoff(1 * time.Second)
linear := retry.LinearBackoff(1 * time.Second)
exponential := retry.ExponentialBackoff(1 * time.Second)

for attempt := range 4 {
fmt.Printf("attempt %d fixed=%-6s linear=%-6s exponential=%s\n",
attempt,
fixed(attempt),
linear(attempt),
exponential(attempt),
)
}
}
```
#### Output:
```
attempt 0 fixed=1s linear=0s exponential=1s
attempt 1 fixed=1s linear=1s exponential=2s
attempt 2 fixed=1s linear=2s exponential=4s
attempt 3 fixed=1s linear=3s exponential=8s
```

---

### Respecting context cancellation
```go
package main

import (
"context"
"fmt"
"time"

"github.com/kashifkhan0771/utils/retry"
)

func main() {
opts := retry.Options{
MaxAttempts: 10,
TotalTimeout: 2 * time.Second,
Backoff: retry.ExponentialBackoff(1 * time.Second),
ShouldRetry: func(err error) bool { return true },
}

_, err := retry.Do(context.Background(), opts, func(ctx context.Context) (string, error) {
return "", fmt.Errorf("service unavailable")
})
fmt.Println(err)
}
```
#### Output:
```
context deadline exceeded
```
Comment thread
danilovict2 marked this conversation as resolved.
Outdated
39 changes: 39 additions & 0 deletions retry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
### Retry

The `retry` package provides a generic, context-aware way to retry fallible operations with configurable backoff strategies and timeout handling.

#### **Options**

- **`MaxAttempts int`**: Total number of times the operation will be tried before giving up.
- **`TotalTimeout time.Duration`**: Maximum time allowed across all attempts, including backoff delays.
- **`Backoff func(attempt int) time.Duration`**: Returns how long to wait before the next attempt. `attempt` is zero-indexed.
- **`ShouldRetry func(err error) bool`**: Reports whether the given error is retryable. Return `false` to abort immediately.

#### **Functions**

- **`Do[T any](ctx context.Context, opts Options, fn RetryFunc[T]) (T, error)`**:
Calls `fn` repeatedly until it succeeds, `ShouldRetry` returns `false`, `MaxAttempts` is reached, or `TotalTimeout` elapses.

- **`DoVoid(ctx context.Context, opts Options, fn func(ctx context.Context) error) error`**:
Convenience wrapper around `Do` for operations that return no value.

#### **Backoff Strategies**

- **`FixedBackoff(d time.Duration) func(attempt int) time.Duration`**:
Waits exactly `d` between every attempt.

- **`LinearBackoff(d time.Duration) func(attempt int) time.Duration`**:
Waits `d * attempt`. Grows linearly: `0, d, 2d, 3d, …`

- **`ExponentialBackoff(d time.Duration) func(attempt int) time.Duration`**:
Waits `d * 2^attempt`. Doubles on each failure: `d, 2d, 4d, 8d, …`

#### **Notes**
- `TotalTimeout` is enforced via a derived context passed to every attempt. If the deadline is exceeded mid-backoff, `Do` returns `context.DeadlineExceeded` immediately.
- A non-retryable error returned from `ShouldRetry` is returned as-is, without wrapping.
- `LinearBackoff` produces a zero-length first pause (`attempt 0`). Prefer `FixedBackoff` if an immediate first retry is undesirable.

## Examples:
For examples of each function, please check out [EXAMPLES.md](/retry/EXAMPLES.md)

---
79 changes: 79 additions & 0 deletions retry/retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package retry

import (
"context"
"fmt"
"time"
)

type Options struct {
MaxAttempts int
TotalTimeout time.Duration
// Backoff returns how long to wait before the next attempt.
// attempt is zero-indexed (0 = pause after the first failure).
Backoff func(attempt int) time.Duration

// ShouldRetry reports whether the given error is retryable.
// Return false to abort immediately.
ShouldRetry func(err error) bool
}

type RetryFunc[T any] func(ctx context.Context) (T, error)

// Do calls fn repeatedly until fn succeeds, ShouldRetry returns false,
// MaxAttempts is reached, or TotalTimeout elapses.
func Do[T any](ctx context.Context, opts Options, fn RetryFunc[T]) (T, error) {
ctx, cls := context.WithTimeout(ctx, opts.TotalTimeout)
defer cls()

var zero T
Comment thread
danilovict2 marked this conversation as resolved.
Outdated
for attempt := range opts.MaxAttempts {
ret, err := fn(ctx)
if err == nil {
return ret, nil
}

if !opts.ShouldRetry(err) {
return zero, err
}

select {
case <-time.After(opts.Backoff(attempt)):
case <-ctx.Done():
return zero, ctx.Err()
}

}

return zero, fmt.Errorf("max attempts reached")
Comment thread
danilovict2 marked this conversation as resolved.
}

// DoVoid is a convenience wrapper around [Do] for operations that return no value.
func DoVoid(ctx context.Context, opts Options, fn func(ctx context.Context) error) error {
_, err := Do(ctx, opts, func(ctx context.Context) (struct{}, error) {
return struct{}{}, fn(ctx)
})

return err
}

// FixedBackoff returns a backoff that always waits exactly d.
func FixedBackoff(d time.Duration) func(attempt int) time.Duration {
return func(attempt int) time.Duration {
return d
}
}

// LinearBackoff returns a backoff that waits d * attempt.
func LinearBackoff(d time.Duration) func(attempt int) time.Duration {
return func(attempt int) time.Duration {
return d * time.Duration(attempt)
}
}

// ExponentialBackoff returns a backoff that waits d * 2^attempt.
func ExponentialBackoff(d time.Duration) func(attempt int) time.Duration {
return func(attempt int) time.Duration {
return d * time.Duration(1<<attempt)
}
Comment thread
danilovict2 marked this conversation as resolved.
Outdated
}
Loading
Loading