File: retry.go

package info (click to toggle)
golang-github-olekukonko-errors 1.1.0-1
  • links: PTS, VCS
  • area: main
  • in suites: experimental, forky, sid
  • size: 448 kB
  • sloc: makefile: 2
file content (368 lines) | stat: -rw-r--r-- 10,977 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
// Package errors provides utilities for error handling, including a flexible retry mechanism.
package errors

import (
	"context"
	"math/rand"
	"time"
)

// BackoffStrategy defines the interface for calculating retry delays.
type BackoffStrategy interface {
	// Backoff returns the delay for a given attempt based on the base delay.
	Backoff(attempt int, baseDelay time.Duration) time.Duration
}

// ConstantBackoff provides a fixed delay for each retry attempt.
type ConstantBackoff struct{}

// Backoff returns the base delay regardless of the attempt number.
// Implements BackoffStrategy with a constant delay.
func (c ConstantBackoff) Backoff(_ int, baseDelay time.Duration) time.Duration {
	return baseDelay
}

// ExponentialBackoff provides an exponentially increasing delay for retry attempts.
type ExponentialBackoff struct{}

// Backoff returns a delay that doubles with each attempt, starting from the base delay.
// Uses bit shifting for efficient exponential growth (e.g., baseDelay * 2^(attempt-1)).
func (e ExponentialBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
	if attempt <= 1 {
		return baseDelay
	}
	return baseDelay * time.Duration(1<<uint(attempt-1))
}

// LinearBackoff provides a linearly increasing delay for retry attempts.
type LinearBackoff struct{}

// Backoff returns a delay that increases linearly with each attempt (e.g., baseDelay * attempt).
// Implements BackoffStrategy with linear progression.
func (l LinearBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
	return baseDelay * time.Duration(attempt)
}

// RetryOption configures a Retry instance.
// Defines a function type for setting retry parameters.
type RetryOption func(*Retry)

// Retry represents a retryable operation with configurable backoff and retry logic.
// Supports multiple attempts, delay strategies, jitter, and context-aware cancellation.
type Retry struct {
	maxAttempts int              // Maximum number of attempts (including initial try)
	delay       time.Duration    // Base delay for backoff calculations
	maxDelay    time.Duration    // Maximum delay cap to prevent excessive waits
	retryIf     func(error) bool // Condition to determine if retry should occur
	onRetry     func(int, error) // Callback executed after each failed attempt
	backoff     BackoffStrategy  // Strategy for calculating retry delays
	jitter      bool             // Whether to add random jitter to delays
	ctx         context.Context  // Context for cancellation and deadlines
}

// NewRetry creates a new Retry instance with the given options.
// Defaults: 3 attempts, 100ms base delay, 10s max delay, exponential backoff with jitter,
// and retrying on IsRetryable errors; ensures retryIf is never nil.
func NewRetry(options ...RetryOption) *Retry {
	r := &Retry{
		maxAttempts: 3,
		delay:       100 * time.Millisecond,
		maxDelay:    10 * time.Second,
		retryIf:     func(err error) bool { return IsRetryable(err) },
		onRetry:     nil,
		backoff:     ExponentialBackoff{},
		jitter:      true,
		ctx:         context.Background(),
	}
	for _, opt := range options {
		opt(r)
	}
	// Ensure retryIf is never nil, falling back to IsRetryable
	if r.retryIf == nil {
		r.retryIf = func(err error) bool { return IsRetryable(err) }
	}
	return r
}

// addJitter adds ±25% jitter to avoid thundering herd problems.
// Returns a duration adjusted by a random value between -25% and +25% of the input; not thread-safe.
func addJitter(d time.Duration) time.Duration {
	jitter := time.Duration(rand.Int63n(int64(d/2))) - (d / 4)
	return d + jitter
}

// Attempts returns the configured maximum number of retry attempts.
// Includes the initial attempt in the count.
func (r *Retry) Attempts() int {
	return r.maxAttempts
}

// Execute runs the provided function with the configured retry logic.
// Returns nil on success or the last error if all attempts fail; respects context cancellation.
func (r *Retry) Execute(fn func() error) error {
	var lastErr error

	for attempt := 1; attempt <= r.maxAttempts; attempt++ {
		// Check context before each attempt
		select {
		case <-r.ctx.Done():
			return r.ctx.Err()
		default:
		}

		err := fn()
		if err == nil {
			return nil
		}

		lastErr = err

		// Check if we should retry
		if r.retryIf != nil && !r.retryIf(err) {
			return err
		}

		if r.onRetry != nil {
			r.onRetry(attempt, err)
		}

		// Don't delay after last attempt
		if attempt == r.maxAttempts {
			break
		}

		// Calculate delay with backoff
		delay := r.backoff.Backoff(attempt, r.delay)
		if r.maxDelay > 0 && delay > r.maxDelay {
			delay = r.maxDelay
		}
		if r.jitter {
			delay = addJitter(delay)
		}

		// Wait with context
		select {
		case <-r.ctx.Done():
			return r.ctx.Err()
		case <-time.After(delay):
		}
	}

	return lastErr
}

// ExecuteContext runs the provided function with retry logic, respecting context cancellation.
// Returns nil on success or the last error if all attempts fail or context is cancelled.
func (r *Retry) ExecuteContext(ctx context.Context, fn func() error) error {
	var lastErr error

	// If the retry instance already has a context, use it. Otherwise, use the provided one.
	// If both are provided, maybe create a derived context? For now, prioritize the one from WithContext.
	execCtx := r.ctx
	if execCtx == context.Background() && ctx != nil { // Use provided ctx if retry ctx is default and provided one isn't nil
		execCtx = ctx
	} else if ctx == nil { // Ensure we always have a non-nil context
		execCtx = context.Background()
	}
	// Note: This logic might need refinement depending on how contexts should interact.
	// A safer approach might be: if r.ctx != background, use it. Else use provided ctx.

	for attempt := 1; attempt <= r.maxAttempts; attempt++ {
		// Check context before executing the function
		select {
		case <-execCtx.Done():
			return execCtx.Err() // Return context error immediately
		default:
			// Context is okay, proceed
		}

		err := fn()
		if err == nil {
			return nil // Success
		}

		// Check if retry is applicable based on the error
		if r.retryIf != nil && !r.retryIf(err) {
			return err // Not retryable, return the error
		}

		lastErr = err // Store the last encountered error

		// Execute the OnRetry callback if configured
		if r.onRetry != nil {
			r.onRetry(attempt, err)
		}

		// Exit loop if this was the last attempt
		if attempt == r.maxAttempts {
			break
		}

		// --- Calculate and apply delay ---
		currentDelay := r.backoff.Backoff(attempt, r.delay)
		if r.maxDelay > 0 && currentDelay > r.maxDelay { // Check maxDelay > 0 before capping
			currentDelay = r.maxDelay
		}
		if r.jitter {
			currentDelay = addJitter(currentDelay)
		}
		if currentDelay < 0 { // Ensure delay isn't negative after jitter
			currentDelay = 0
		}
		// --- Wait for the delay or context cancellation ---
		select {
		case <-execCtx.Done():
			// If context is cancelled during the wait, return the context error
			// Often more informative than returning the last application error.
			return execCtx.Err()
		case <-time.After(currentDelay):
			// Wait finished, continue to the next attempt
		}
	}

	// All attempts failed, return the last error encountered
	return lastErr
}

// Transform creates a new Retry instance with modified configuration.
// Copies all settings from the original Retry and applies the given options.
func (r *Retry) Transform(opts ...RetryOption) *Retry {
	newRetry := &Retry{
		maxAttempts: r.maxAttempts,
		delay:       r.delay,
		maxDelay:    r.maxDelay,
		retryIf:     r.retryIf,
		onRetry:     r.onRetry,
		backoff:     r.backoff,
		jitter:      r.jitter,
		ctx:         r.ctx,
	}
	for _, opt := range opts {
		opt(newRetry)
	}
	return newRetry
}

// WithBackoff sets the backoff strategy using the BackoffStrategy interface.
// Returns a RetryOption; no-op if strategy is nil, retaining the existing strategy.
func WithBackoff(strategy BackoffStrategy) RetryOption {
	return func(r *Retry) {
		if strategy != nil {
			r.backoff = strategy
		}
	}
}

// WithContext sets the context for cancellation and deadlines.
// Returns a RetryOption; retains context.Background if ctx is nil.
func WithContext(ctx context.Context) RetryOption {
	return func(r *Retry) {
		if ctx != nil {
			r.ctx = ctx
		}
	}
}

// WithDelay sets the initial delay between retries.
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
func WithDelay(delay time.Duration) RetryOption {
	return func(r *Retry) {
		if delay < 0 {
			delay = 0
		}
		r.delay = delay
	}
}

// WithJitter enables or disables jitter in the backoff delay.
// Returns a RetryOption; toggles random delay variation.
func WithJitter(jitter bool) RetryOption {
	return func(r *Retry) {
		r.jitter = jitter
	}
}

// WithMaxAttempts sets the maximum number of retry attempts.
// Returns a RetryOption; ensures at least 1 attempt by adjusting lower values.
func WithMaxAttempts(maxAttempts int) RetryOption {
	return func(r *Retry) {
		if maxAttempts < 1 {
			maxAttempts = 1
		}
		r.maxAttempts = maxAttempts
	}
}

// WithMaxDelay sets the maximum delay between retries.
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
func WithMaxDelay(maxDelay time.Duration) RetryOption {
	return func(r *Retry) {
		if maxDelay < 0 {
			maxDelay = 0
		}
		r.maxDelay = maxDelay
	}
}

// WithOnRetry sets a callback to execute after each failed attempt.
// Returns a RetryOption; callback receives attempt number and error.
func WithOnRetry(onRetry func(attempt int, err error)) RetryOption {
	return func(r *Retry) {
		r.onRetry = onRetry
	}
}

// WithRetryIf sets the condition under which to retry.
// Returns a RetryOption; retains IsRetryable default if retryIf is nil.
func WithRetryIf(retryIf func(error) bool) RetryOption {
	return func(r *Retry) {
		if retryIf != nil {
			r.retryIf = retryIf
		}
	}
}

// ExecuteReply runs the provided function with retry logic and returns its result.
// Returns the result and nil on success, or zero value and last error on failure; generic type T.
func ExecuteReply[T any](r *Retry, fn func() (T, error)) (T, error) {
	var lastErr error
	var zero T

	for attempt := 1; attempt <= r.maxAttempts; attempt++ {
		result, err := fn()
		if err == nil {
			return result, nil
		}

		// Check if retry is applicable; return immediately if not retryable
		if r.retryIf != nil && !r.retryIf(err) {
			return zero, err
		}

		lastErr = err
		if r.onRetry != nil {
			r.onRetry(attempt, err)
		}

		if attempt == r.maxAttempts {
			break
		}

		// Calculate delay with backoff, cap at maxDelay, and apply jitter if enabled
		currentDelay := r.backoff.Backoff(attempt, r.delay)
		if currentDelay > r.maxDelay {
			currentDelay = r.maxDelay
		}
		if r.jitter {
			currentDelay = addJitter(currentDelay)
		}

		// Wait with respect to context cancellation or timeout
		select {
		case <-r.ctx.Done():
			return zero, r.ctx.Err()
		case <-time.After(currentDelay):
		}
	}
	return zero, lastErr
}