Skip to content

Commit d49aaeb

Browse files
committed
Improve code quality
1 parent f45dfa7 commit d49aaeb

File tree

9 files changed

+82
-129
lines changed

9 files changed

+82
-129
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ cover: test ## Run all the tests and opens the coverage report
22
go tool cover -html=coverage.txt
33

44
test:
5-
go test ./...
5+
go test -race ./...
66

77
fmt: ## gofmt and goimports all go files
88
find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done

README.md

Lines changed: 54 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,130 +5,102 @@
55
[![Go Report Card](https://goreportcard.com/badge/github.com/codeGROOVE-dev/retry-go?style=flat-square)](https://goreportcard.com/report/github.com/codeGROOVE-dev/retry-go)
66
[![Go Reference](https://pkg.go.dev/badge/github.com/codeGROOVE-dev/retry-go.svg)](https://pkg.go.dev/github.com/codeGROOVE-dev/retry-go)
77

8-
**Zero dependencies. Memory-bounded. Uptime-focused.**
8+
**Zero dependencies. Memory-bounded. No goroutine leaks. No panics.**
99

10-
*Because 99.99% uptime means your retry logic can't be the failure point.*
10+
*Hard guarantees: Bounded memory (1000 errors max). No allocations in hot path. Context-aware cancellation.*
1111

1212
Actively maintained fork of [avast/retry-go](https://github.com/avast/retry-go) focused on correctness and resource efficiency. 100% API compatible drop-in replacement.
1313

14-
**Key improvements:**
15-
- ⚡ Zero external dependencies
16-
- 🔒 Memory-bounded error accumulation
17-
- 🛡️ Integer overflow protection in backoff
18-
- 🎯 Enhanced readability and debuggability
19-
- 📊 Predictable behavior under load
14+
**Production guarantees:**
15+
- Memory bounded: Max 1000 errors stored (configurable via maxErrors constant)
16+
- No goroutine leaks: Uses caller's goroutine exclusively
17+
- Integer overflow safe: Backoff capped at 2^62 to prevent wraparound
18+
- Context-aware: Cancellation checked before each attempt
19+
- No panics: All edge cases return errors
20+
- Predictable jitter: Uses math/rand/v2 for consistent performance
21+
- Zero allocations after init in success path
2022

2123
## Quick Start
2224

23-
### Basic Retry with Error Handling
25+
### Simple Retry
2426

2527
```go
26-
import (
27-
"net/http"
28-
"github.com/codeGROOVE-dev/retry-go"
29-
)
28+
// Retry a flaky operation up to 10 times (default)
29+
err := retry.Do(func() error {
30+
return doSomethingFlaky()
31+
})
32+
```
3033

31-
// Retry API call with exponential backoff + jitter
34+
### Retry with Custom Attempts
35+
36+
```go
37+
// Retry up to 5 times with exponential backoff
3238
err := retry.Do(
3339
func() error {
34-
resp, err := http.Get("https://api.stripe.com/v1/charges")
40+
resp, err := http.Get("https://api.example.com/data")
3541
if err != nil {
3642
return err
3743
}
3844
defer resp.Body.Close()
39-
40-
if resp.StatusCode >= 500 {
41-
return fmt.Errorf("server error: %d", resp.StatusCode)
42-
}
4345
return nil
4446
},
4547
retry.Attempts(5),
46-
retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)),
4748
)
4849
```
4950

50-
### Retry with Data Return (Generics)
51+
### Overly-complicated production configuration
5152

5253
```go
53-
import (
54-
"context"
55-
"encoding/json"
56-
"github.com/codeGROOVE-dev/retry-go"
57-
)
58-
59-
// Database query with timeout and retry
60-
users, err := retry.DoWithData(
61-
func() ([]User, error) {
62-
return db.FindActiveUsers(ctx)
63-
},
64-
retry.Attempts(3),
65-
retry.Context(ctx),
66-
retry.DelayType(retry.BackOffDelay),
67-
)
68-
```
69-
70-
### Production Configuration
7154

72-
```go
73-
// Payment processing with comprehensive retry logic
55+
// Overly-complex production pattern: bounded retries with circuit breaking
7456
err := retry.Do(
75-
func() error { return paymentGateway.Charge(ctx, amount) },
76-
retry.Attempts(5),
77-
retry.Context(ctx),
78-
retry.DelayType(retry.FullJitterBackoffDelay),
79-
retry.MaxDelay(30*time.Second),
57+
func() error {
58+
return processPayment(ctx, req)
59+
},
60+
retry.Attempts(3), // Hard limit
61+
retry.Context(ctx), // Respect cancellation
62+
retry.MaxDelay(10*time.Second), // Cap backoff
63+
retry.AttemptsForError(0, ErrRateLimit), // Stop on rate limit
8064
retry.OnRetry(func(n uint, err error) {
81-
log.Warn("Payment retry", "attempt", n+1, "error", err)
65+
log.Printf("retry attempt %d: %v", n, err)
8266
}),
8367
retry.RetryIf(func(err error) bool {
84-
return !isAuthError(err) // Don't retry 4xx errors
68+
// Only retry on network errors
69+
var netErr net.Error
70+
return errors.As(err, &netErr) && netErr.Temporary()
8571
}),
8672
)
8773
```
8874

89-
## Key Features
75+
### Preventing Cascading Failures
9076

91-
**Unrecoverable Errors** - Stop immediately for certain errors:
9277
```go
93-
if resp.StatusCode == 401 {
94-
return retry.Unrecoverable(errors.New("auth failed"))
78+
// Stop retry storms with Unrecoverable
79+
if errors.Is(err, context.DeadlineExceeded) {
80+
return retry.Unrecoverable(err) // Don't retry timeouts
9581
}
96-
```
9782

98-
**Context Integration** - Timeout and cancellation:
99-
```go
100-
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
101-
retry.Do(dbQuery, retry.Context(ctx))
83+
// Per-error type limits prevent thundering herd
84+
retry.AttemptsForError(0, ErrCircuitOpen) // Fail fast on circuit breaker
85+
retry.AttemptsForError(1, sql.ErrTxDone) // One retry for tx errors
86+
retry.AttemptsForError(5, ErrServiceUnavailable) // More retries for 503s
10287
```
10388

104-
**Error-Specific Limits** - Different retry counts per error type:
105-
```go
106-
retry.AttemptsForError(1, sql.ErrTxDone) // Don't retry transaction errors
107-
```
108-
109-
## Performance & Scale
110-
111-
| Metric | Value |
112-
|--------|-------|
113-
| Memory overhead | ~200 bytes per operation |
114-
| Error accumulation | Bounded at 1000 errors (DoS protection) |
115-
| Goroutines | Uses calling goroutine only |
116-
| High-throughput safe | No hidden allocations or locks |
89+
## Failure Modes & Limits
11790

118-
## Library Comparison
119-
120-
**[cenkalti/backoff](https://github.com/cenkalti/backoff)** - Complex interface, requires manual retry loops, no error accumulation.
121-
122-
**[sethgrid/pester](https://github.com/sethgrid/pester)** - HTTP-only, lacks general-purpose retry logic.
123-
124-
**[matryer/try](https://github.com/matryer/try)** - Popular but non-standard API, missing production features.
125-
126-
**[rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go)** - Similar design but lacks error-specific limits and comprehensive context handling.
127-
128-
**This fork** builds on avast/retry-go's solid foundation with correctness fixes and resource optimizations.
91+
| Scenario | Behavior | Limit |
92+
|----------|----------|-------|
93+
| Error accumulation | Old errors dropped after limit | 1000 errors |
94+
| Attempt overflow | Stops retrying | uint max (~4B) |
95+
| Backoff overflow | Capped at max duration | 2^62 ns |
96+
| Context cancelled | Returns immediately | No retries |
97+
| Timer returns nil | Returns error | Fail safe |
98+
| Panic in retryable func | Propagates panic | No swallowing |
12999

130100
## Installation
131101

102+
Requires Go 1.22 or higher.
103+
132104
```bash
133105
go get github.com/codeGROOVE-dev/retry-go
134106
```
@@ -141,4 +113,4 @@ go get github.com/codeGROOVE-dev/retry-go
141113

142114
---
143115

144-
*Production retry logic that just works.*
116+
*Production retry logic that just works.*

examples/custom_retry_function_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ import (
1313
"github.com/codeGROOVE-dev/retry-go"
1414
)
1515

16-
// RetriableError is a custom error that contains a positive duration for the next retry
16+
// RetriableError is a custom error that contains a positive duration for the next retry.
1717
type RetriableError struct {
1818
Err error
1919
RetryAfter time.Duration
2020
}
2121

22-
// Error returns error message and a Retry-After duration
22+
// Error returns error message and a Retry-After duration.
2323
func (e *RetriableError) Error() string {
2424
return fmt.Sprintf("%s (retry after %v)", e.Err.Error(), e.RetryAfter)
2525
}
2626

2727
var _ error = (*RetriableError)(nil)
2828

29-
// TestCustomRetryFunction shows how to use a custom retry function
29+
// TestCustomRetryFunction shows how to use a custom retry function.
3030
func TestCustomRetryFunction(t *testing.T) {
3131
attempts := 5 // server succeeds after 5 attempts
3232
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -90,7 +90,6 @@ func TestCustomRetryFunction(t *testing.T) {
9090
return retry.BackOffDelay(n, err, config)
9191
}),
9292
)
93-
9493
// Server responds with: <body content>
9594
if err != nil {
9695
t.Fatalf("unexpected error: %v", err)

examples/delay_based_on_error_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,8 @@ func TestCustomRetryFunctionBasedOnKindOfError(t *testing.T) {
6262
retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration {
6363
var retryAfterErr RetryAfterError
6464
if errors.As(err, &retryAfterErr) {
65-
if t, err := parseRetryAfter(retryAfterErr.response.Header.Get("Retry-After")); err == nil {
66-
return time.Until(t)
67-
}
65+
t := parseRetryAfter(retryAfterErr.response.Header.Get("Retry-After"))
66+
return time.Until(t)
6867
}
6968
var someOtherErr SomeOtherError
7069
if errors.As(err, &someOtherErr) {
@@ -83,7 +82,7 @@ func TestCustomRetryFunctionBasedOnKindOfError(t *testing.T) {
8382
}
8483
}
8584

86-
// use https://github.com/aereal/go-httpretryafter instead
87-
func parseRetryAfter(_ string) (time.Time, error) {
88-
return time.Now().Add(1 * time.Second), nil //nolint:unparam // error is always nil for test simplicity
85+
// use https://github.com/aereal/go-httpretryafter instead.
86+
func parseRetryAfter(_ string) time.Time {
87+
return time.Now().Add(1 * time.Second)
8988
}

examples/errors_history_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
)
1111

1212
// TestErrorHistory shows an example of how to get all the previous errors when
13-
// retry.Do ends in success
13+
// retry.Do ends in success.
1414
func TestErrorHistory(t *testing.T) {
1515
attempts := 3 // server succeeds after 3 attempts
1616
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module github.com/codeGROOVE-dev/retry-go
22

3-
go 1.20
3+
go 1.22

options.go

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ package retry //nolint:revive // More than 5 public structs are necessary for AP
22

33
import (
44
"context"
5-
cryptorand "crypto/rand"
6-
"encoding/binary"
75
"errors"
86
"fmt"
97
"math"
8+
"math/rand/v2"
109
"time"
1110
)
1211

@@ -217,29 +216,7 @@ func RandomDelay(_ uint, _ error, config *Config) time.Duration {
217216
if config.maxJitter <= 0 {
218217
return 0
219218
}
220-
return time.Duration(secureRandomInt63n(int64(config.maxJitter)))
221-
}
222-
223-
// secureRandomInt63n returns a non-negative pseudo-random number in [0,n) using crypto/rand.
224-
func secureRandomInt63n(n int64) int64 {
225-
if n <= 0 {
226-
return 0
227-
}
228-
var b [8]byte
229-
for {
230-
if _, err := cryptorand.Read(b[:]); err != nil {
231-
// Fall back to 0 on error rather than panic
232-
return 0
233-
}
234-
// Clear sign bit to ensure non-negative
235-
val := int64(binary.BigEndian.Uint64(b[:]) & 0x7FFFFFFFFFFFFFFF) //nolint:gosec // Bitwise AND to clear sign bit is safe
236-
// Rejection sampling to avoid modulo bias
237-
const maxInt63 = 0x7FFFFFFFFFFFFFFF // max int63
238-
maxVal := int64(maxInt63)
239-
if val < maxVal-(maxVal%n) {
240-
return val % n
241-
}
242-
}
219+
return time.Duration(rand.Int64N(int64(config.maxJitter))) //nolint:gosec // Cryptographic randomness not needed for retry jitter
243220
}
244221

245222
// CombineDelay creates a DelayTypeFunc that sums the delays from multiple strategies.
@@ -293,7 +270,7 @@ func FullJitterBackoffDelay(attempt uint, _ error, config *Config) time.Duration
293270
if ceiling <= 0 {
294271
return 0
295272
}
296-
return time.Duration(secureRandomInt63n(int64(ceiling)))
273+
return time.Duration(rand.Int64N(int64(ceiling))) //nolint:gosec // Cryptographic randomness not needed for retry jitter
297274
}
298275

299276
// OnRetry sets a callback function that is called after each failed attempt.

retry.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (
239239
}
240240

241241
if config.lastErrorOnly {
242-
return emptyT, errorLog.Unwrap()
242+
if len(errorLog) > 0 {
243+
return emptyT, errorLog.Unwrap()
244+
}
245+
return emptyT, nil
243246
}
244247
return emptyT, errorLog
245248
}
@@ -256,7 +259,10 @@ func (e Error) Error() string {
256259
return "retry: all attempts failed"
257260
}
258261

262+
// Pre-size builder for better performance
263+
// Estimate: prefix (30) + each error (~50 chars avg)
259264
var b strings.Builder
265+
b.Grow(30 + len(e)*50)
260266
b.WriteString("retry: all attempts failed:")
261267

262268
for i, err := range e {

0 commit comments

Comments
 (0)