Skip to content

Commit c0f7a40

Browse files
authored
refactor(iterator): use Go 1.23+ iter package (#2)
- Replace custom iterator implementation with standard iter.Seq - Update Attempts() to return iter.Seq[*Attempt] - Update AttemptsWithContext() to return iter.Seq[*Attempt] - Fix delay calculation logic to properly handle first attempt - Fix permanent error handling to return unwrapped error - Add default constants for retry configuration - Add getNextInterval helper function - Update test to specify Multiplier(1.0) for predictable delays - Update documentation to mention iter package usage This change modernizes the iterator implementation to use the standard library's iter package introduced in Go 1.23, making the API more idiomatic and aligned with Go language evolution.
1 parent d662718 commit c0f7a40

5 files changed

Lines changed: 193 additions & 99 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
CLAUDE.md
2+
.idea/

doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
// - Retry with logging
4444
// - HTTP client integration
4545
// - Permanent error handling
46-
// - Iterator pattern for stateful retries
46+
// - Iterator pattern for stateful retries (using Go 1.23+ iter package)
4747
//
4848
// For more examples and documentation, visit https://github.com/flaticols/ebo
4949
package ebo

iterator.go

Lines changed: 163 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ebo
33
import (
44
"context"
55
"errors"
6+
"iter"
67
"time"
78
)
89

@@ -28,69 +29,73 @@ type Attempt struct {
2829
// }
2930
// log.Printf("Attempt %d failed", attempt.Number)
3031
// }
31-
func Attempts(opts ...Option) func(func(*Attempt) bool) {
32+
func Attempts(opts ...Option) iter.Seq[*Attempt] {
3233
config := &RetryConfig{
33-
InitialInterval: 500 * time.Millisecond,
34-
MaxInterval: 30 * time.Second,
35-
MaxRetries: 10,
36-
Multiplier: 2.0,
37-
MaxElapsedTime: 5 * time.Minute,
38-
RandomizeFactor: 0.5,
34+
InitialInterval: defaultInitialInterval,
35+
MaxInterval: defaultMaxInterval,
36+
MaxRetries: defaultMaxRetries,
37+
Multiplier: defaultMultiplier,
38+
MaxElapsedTime: defaultMaxElapsedTime,
39+
RandomizeFactor: defaultRandomizeFactor,
3940
}
40-
41+
4142
for _, opt := range opts {
4243
opt(config)
4344
}
44-
45+
4546
return func(yield func(*Attempt) bool) {
4647
startTime := time.Now()
47-
attempts := 0
48-
var currentDelay time.Duration
49-
nextInterval := config.InitialInterval
50-
var lastError error
51-
52-
for {
53-
attempts++
54-
elapsed := time.Since(startTime)
55-
56-
// Check stopping conditions
57-
if attempts > 1 { // After first attempt
58-
if config.MaxRetries > 0 && attempts > config.MaxRetries {
59-
return
60-
}
61-
if config.MaxElapsedTime > 0 && elapsed >= config.MaxElapsedTime {
62-
return
63-
}
48+
currentInterval := config.InitialInterval
49+
elapsed := time.Duration(0)
50+
51+
for i := 0; ; i++ {
52+
// Check max retries
53+
if config.MaxRetries > 0 && i >= config.MaxRetries {
54+
return
6455
}
65-
56+
57+
// Check max elapsed time
58+
if config.MaxElapsedTime > 0 && elapsed > config.MaxElapsedTime {
59+
return
60+
}
61+
62+
// Create attempt with current delay
6663
attempt := &Attempt{
67-
Number: attempts,
68-
Delay: currentDelay,
69-
Elapsed: elapsed,
70-
LastError: lastError,
71-
Context: context.Background(),
64+
Number: i + 1,
65+
Delay: currentInterval,
66+
Elapsed: elapsed,
67+
Context: context.Background(),
7268
}
73-
74-
// Wait before attempt (except first one)
75-
if currentDelay > 0 {
76-
time.Sleep(currentDelay)
69+
70+
// For the first attempt, set delay to 0
71+
if i == 0 {
72+
attempt.Delay = 0
7773
}
78-
79-
// Yield control to the caller
74+
75+
// Wait before yielding (except for first attempt)
76+
if i > 0 {
77+
time.Sleep(currentInterval)
78+
elapsed = time.Since(startTime)
79+
}
80+
81+
// Yield attempt
8082
if !yield(attempt) {
8183
return
8284
}
83-
84-
// Calculate delay for next attempt
85-
currentDelay = nextInterval
8685

87-
// Calculate next interval with jitter
88-
nextInterval = min(time.Duration(float64(nextInterval)*config.Multiplier), config.MaxInterval)
86+
// Update interval for next iteration
87+
if config.Multiplier > 0 {
88+
currentInterval = time.Duration(float64(currentInterval) * config.Multiplier)
89+
}
90+
91+
// Apply max interval cap
92+
if currentInterval > config.MaxInterval {
93+
currentInterval = config.MaxInterval
94+
}
95+
96+
// Apply jitter if configured
8997
if config.RandomizeFactor > 0 {
90-
delta := config.RandomizeFactor * float64(nextInterval)
91-
minInterval := float64(nextInterval) - delta
92-
maxInterval := float64(nextInterval) + delta
93-
nextInterval = time.Duration(minInterval + (randomFloat() * (maxInterval - minInterval)))
98+
currentInterval = getNextInterval(currentInterval, config.RandomizeFactor)
9499
}
95100
}
96101
}
@@ -109,21 +114,84 @@ func Attempts(opts ...Option) func(func(*Attempt) bool) {
109114
// return nil
110115
// }
111116
// }
112-
func AttemptsWithContext(ctx context.Context, opts ...Option) func(func(*Attempt) bool) {
113-
baseIterator := Attempts(opts...)
117+
func AttemptsWithContext(ctx context.Context, opts ...Option) iter.Seq[*Attempt] {
118+
config := &RetryConfig{
119+
InitialInterval: defaultInitialInterval,
120+
MaxInterval: defaultMaxInterval,
121+
MaxRetries: defaultMaxRetries,
122+
Multiplier: defaultMultiplier,
123+
MaxElapsedTime: defaultMaxElapsedTime,
124+
RandomizeFactor: defaultRandomizeFactor,
125+
}
126+
127+
for _, opt := range opts {
128+
opt(config)
129+
}
114130

115131
return func(yield func(*Attempt) bool) {
116-
baseIterator(func(attempt *Attempt) bool {
117-
// Check context cancellation
118-
select {
119-
case <-ctx.Done():
120-
attempt.LastError = ctx.Err()
121-
return false
122-
default:
123-
attempt.Context = ctx
124-
return yield(attempt)
125-
}
126-
})
132+
startTime := time.Now()
133+
currentInterval := config.InitialInterval
134+
elapsed := time.Duration(0)
135+
136+
for i := 0; ; i++ {
137+
// Check context
138+
if ctx.Err() != nil {
139+
return
140+
}
141+
142+
// Check max retries
143+
if config.MaxRetries > 0 && i >= config.MaxRetries {
144+
return
145+
}
146+
147+
// Check max elapsed time
148+
if config.MaxElapsedTime > 0 && elapsed > config.MaxElapsedTime {
149+
return
150+
}
151+
152+
// Create attempt with current delay
153+
attempt := &Attempt{
154+
Number: i + 1,
155+
Delay: currentInterval,
156+
Elapsed: elapsed,
157+
Context: ctx,
158+
}
159+
160+
// For the first attempt, set delay to 0
161+
if i == 0 {
162+
attempt.Delay = 0
163+
}
164+
165+
// Wait before yielding (except for first attempt)
166+
if i > 0 {
167+
select {
168+
case <-time.After(currentInterval):
169+
elapsed = time.Since(startTime)
170+
case <-ctx.Done():
171+
return
172+
}
173+
}
174+
175+
// Yield attempt
176+
if !yield(attempt) {
177+
return
178+
}
179+
180+
// Update interval for next iteration
181+
if config.Multiplier > 0 {
182+
currentInterval = time.Duration(float64(currentInterval) * config.Multiplier)
183+
}
184+
185+
// Apply max interval cap
186+
if currentInterval > config.MaxInterval {
187+
currentInterval = config.MaxInterval
188+
}
189+
190+
// Apply jitter if configured
191+
if config.RandomizeFactor > 0 {
192+
currentInterval = getNextInterval(currentInterval, config.RandomizeFactor)
193+
}
194+
}
127195
}
128196
}
129197

@@ -136,25 +204,27 @@ func AttemptsWithContext(ctx context.Context, opts ...Option) func(func(*Attempt
136204
// return apiCall()
137205
// }, ebo.Tries(5))
138206
func DoWithAttempts(fn func(*Attempt) error, opts ...Option) error {
139-
var finalErr error
207+
var lastErr error
140208

141209
for attempt := range Attempts(opts...) {
142-
err := fn(attempt)
143-
if err == nil {
210+
if err := fn(attempt); err == nil {
144211
return nil
212+
} else {
213+
lastErr = err
214+
215+
// Check if it's a permanent error
216+
var permanent *permanentError
217+
if errors.As(err, &permanent) {
218+
return permanent.err
219+
}
220+
attempt.LastError = err
145221
}
146-
147-
// Check for permanent errors
148-
var permErr *permanentError
149-
if errors.As(err, &permErr) {
150-
return permErr.err
151-
}
152-
153-
finalErr = err
154-
attempt.LastError = err
155222
}
156223

157-
return finalErr
224+
if lastErr != nil {
225+
return lastErr
226+
}
227+
return errors.New("all retry attempts failed")
158228
}
159229

160230
// DoWithAttemptsContext provides context-aware iteration.
@@ -167,28 +237,29 @@ func DoWithAttempts(fn func(*Attempt) error, opts ...Option) error {
167237
// return apiCall(attempt.Context)
168238
// }, ebo.Tries(3))
169239
func DoWithAttemptsContext(ctx context.Context, fn func(*Attempt) error, opts ...Option) error {
170-
var finalErr error
240+
var lastErr error
171241

172242
for attempt := range AttemptsWithContext(ctx, opts...) {
173-
err := fn(attempt)
174-
if err == nil {
243+
if err := fn(attempt); err == nil {
175244
return nil
245+
} else {
246+
lastErr = err
247+
248+
// Check if it's a permanent error
249+
var permanent *permanentError
250+
if errors.As(err, &permanent) {
251+
return permanent.err
252+
}
253+
attempt.LastError = err
176254
}
177-
178-
// Check for permanent errors
179-
var permErr *permanentError
180-
if errors.As(err, &permErr) {
181-
return permErr.err
182-
}
183-
184-
finalErr = err
185-
attempt.LastError = err
186255
}
187256

188-
return finalErr
189-
}
190-
191-
// randomFloat returns a random float64 in [0.0, 1.0)
192-
func randomFloat() float64 {
193-
return float64(time.Now().UnixNano()%1000000) / 1000000.0
194-
}
257+
if ctx.Err() != nil {
258+
return ctx.Err()
259+
}
260+
261+
if lastErr != nil {
262+
return lastErr
263+
}
264+
return errors.New("all retry attempts failed")
265+
}

iterator_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestAttempts(t *testing.T) {
3333
for attempt := range Attempts(
3434
Tries(3),
3535
Initial(10*time.Millisecond),
36+
Multiplier(1.0), // No multiplication for predictable testing
3637
Jitter(0), // No jitter for predictable delays
3738
) {
3839
attempts++

retry.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ import (
77
"time"
88
)
99

10+
// Default configuration values
11+
const (
12+
defaultInitialInterval = 500 * time.Millisecond
13+
defaultMaxInterval = 30 * time.Second
14+
defaultMaxRetries = 10
15+
defaultMultiplier = 2.0
16+
defaultMaxElapsedTime = 5 * time.Minute
17+
defaultRandomizeFactor = 0.5
18+
)
19+
1020
// RetryConfig holds the configuration for retry with exponential backoff
1121
type RetryConfig struct {
1222
InitialInterval time.Duration // Initial retry interval
@@ -17,6 +27,17 @@ type RetryConfig struct {
1727
RandomizeFactor float64 // Randomization factor for jitter (0 to 1)
1828
}
1929

30+
// getNextInterval calculates the next retry interval with optional jitter
31+
func getNextInterval(currentInterval time.Duration, randomizeFactor float64) time.Duration {
32+
if randomizeFactor == 0 {
33+
return currentInterval
34+
}
35+
36+
delta := randomizeFactor * float64(currentInterval)
37+
minInterval := float64(currentInterval) - delta
38+
maxInterval := float64(currentInterval) + delta
39+
return time.Duration(minInterval + (rand.Float64() * (maxInterval - minInterval)))
40+
}
2041

2142
// RetryableFunc is a function that can be retried
2243
type RetryableFunc func() error
@@ -39,12 +60,12 @@ type RetryableFunc func() error
3960
// }, ebo.Tries(5), ebo.Initial(1*time.Second))
4061
func Retry(fn RetryableFunc, opts ...Option) error {
4162
config := &RetryConfig{
42-
InitialInterval: 500 * time.Millisecond,
43-
MaxInterval: 30 * time.Second,
44-
MaxRetries: 10,
45-
Multiplier: 2.0,
46-
MaxElapsedTime: 5 * time.Minute,
47-
RandomizeFactor: 0.5,
63+
InitialInterval: defaultInitialInterval,
64+
MaxInterval: defaultMaxInterval,
65+
MaxRetries: defaultMaxRetries,
66+
Multiplier: defaultMultiplier,
67+
MaxElapsedTime: defaultMaxElapsedTime,
68+
RandomizeFactor: defaultRandomizeFactor,
4869
}
4970

5071
for _, opt := range opts {

0 commit comments

Comments
 (0)