Skip to content

Commit 6ce0c67

Browse files
authored
Merge pull request #42621 from hashicorp/b-prototype-new-retryer
Prototype of a new retry package
2 parents 148cd88 + efb0339 commit 6ce0c67

File tree

5 files changed

+365
-0
lines changed

5 files changed

+365
-0
lines changed

.teamcity/scripts/provider_tests/acceptance_tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ fi
3535
TF_ACC=1 go test \
3636
./internal/acctest/... \
3737
./internal/attrmap/... \
38+
./internal/backoff/... \
3839
./internal/conns/... \
3940
./internal/create/... \
4041
./internal/dns/... \

.teamcity/scripts/provider_tests/unit_tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ set -euo pipefail
77
go test \
88
./internal/acctest/... \
99
./internal/attrmap/... \
10+
./internal/backoff/... \
1011
./internal/conns/... \
1112
./internal/create/... \
1213
./internal/dns/... \

internal/backoff/backoff.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package backoff
5+
6+
import (
7+
"context"
8+
"math"
9+
"math/rand"
10+
"time"
11+
)
12+
13+
// Inspired by https://github.com/ServiceWeaver/weaver and https://github.com/avast/retry-go.
14+
15+
// Timer represents the timer used to track time for a retry.
16+
type Timer interface {
17+
After(time.Duration) <-chan time.Time
18+
}
19+
20+
// DelayFunc returns the duration to wait before the next retry attempt.
21+
type DelayFunc func(uint) time.Duration
22+
23+
// FixedDelay returns a delay. The first retry attempt has no delay (0), and subsequent attempts use the fixed delay.
24+
func FixedDelay(delay time.Duration) DelayFunc {
25+
return func(n uint) time.Duration {
26+
if n == 0 {
27+
return 0
28+
}
29+
30+
return delay
31+
}
32+
}
33+
34+
// Do not use the default RNG since we do not want different provider instances
35+
// to pick the same deterministic random sequence.
36+
var rng = rand.New(rand.NewSource(time.Now().UnixNano()))
37+
38+
// ExponentialJitterBackoff returns a duration of backoffMinDuration * backoffMultiplier**n, with added jitter.
39+
func ExponentialJitterBackoff(backoffMinDuration time.Duration, backoffMultiplier float64) DelayFunc {
40+
return func(n uint) time.Duration {
41+
if n == 0 {
42+
return 0
43+
}
44+
45+
mult := math.Pow(backoffMultiplier, float64(n))
46+
return applyJitter(time.Duration(float64(backoffMinDuration) * mult))
47+
}
48+
}
49+
50+
func applyJitter(base time.Duration) time.Duration {
51+
const jitterFactor = 0.4
52+
jitter := 1 - jitterFactor*rng.Float64() // Subtract up to 40%.
53+
return time.Duration(float64(base) * jitter)
54+
}
55+
56+
type sdkv2HelperRetryCompatibleDelay struct {
57+
minTimeout time.Duration
58+
pollInterval time.Duration
59+
wait time.Duration
60+
}
61+
62+
func (d *sdkv2HelperRetryCompatibleDelay) delay() time.Duration {
63+
wait := d.wait
64+
65+
// First round had no wait.
66+
if wait == 0 {
67+
wait = 100 * time.Millisecond
68+
}
69+
70+
wait *= 2
71+
72+
// If a poll interval has been specified, choose that interval.
73+
// Otherwise bound the default value.
74+
if d.pollInterval > 0 && d.pollInterval < 180*time.Second {
75+
wait = d.pollInterval
76+
} else {
77+
if wait < d.minTimeout {
78+
wait = d.minTimeout
79+
} else if wait > 10*time.Second {
80+
wait = 10 * time.Second
81+
}
82+
}
83+
84+
d.wait = wait
85+
86+
return wait
87+
}
88+
89+
// SDKv2HelperRetryCompatibleDelay returns a Terraform Plugin SDK v2 helper/retry-compatible delay.
90+
func SDKv2HelperRetryCompatibleDelay(initialDelay, pollInterval, minTimeout time.Duration) DelayFunc {
91+
delay := &sdkv2HelperRetryCompatibleDelay{
92+
minTimeout: minTimeout,
93+
pollInterval: pollInterval,
94+
}
95+
96+
return func(n uint) time.Duration {
97+
if n == 0 {
98+
return initialDelay
99+
}
100+
101+
return delay.delay()
102+
}
103+
}
104+
105+
// DefaultSDKv2HelperRetryCompatibleDelay returns a Terraform Plugin SDK v2 helper/retry-compatible delay
106+
// with default values (from the `RetryContext` function).
107+
func DefaultSDKv2HelperRetryCompatibleDelay() DelayFunc {
108+
return SDKv2HelperRetryCompatibleDelay(0, 0, 500*time.Millisecond) //nolint:mnd // 500ms is the Plugin SDKv2 default
109+
}
110+
111+
// RetryConfig configures a retry loop.
112+
type RetryConfig struct {
113+
delay DelayFunc
114+
gracePeriod time.Duration
115+
timer Timer
116+
}
117+
118+
// Option represents an option for retry.
119+
type Option func(*RetryConfig)
120+
121+
func emptyOption(c *RetryConfig) {}
122+
123+
func WithGracePeriod(d time.Duration) Option {
124+
return func(c *RetryConfig) {
125+
c.gracePeriod = d
126+
}
127+
}
128+
129+
func WithDelay(d DelayFunc) Option {
130+
if d == nil {
131+
return emptyOption
132+
}
133+
134+
return func(c *RetryConfig) {
135+
c.delay = d
136+
}
137+
}
138+
139+
// WithTimer provides a way to swap out timer module implementations.
140+
// This primarily is useful for mocking/testing, where you may not want to explicitly wait for a set duration
141+
// for retries.
142+
func WithTimer(t Timer) Option {
143+
return func(c *RetryConfig) {
144+
c.timer = t
145+
}
146+
}
147+
148+
// Default timer is a wrapper around time.After
149+
type timerImpl struct{}
150+
151+
func (t *timerImpl) After(d time.Duration) <-chan time.Time {
152+
return time.After(d)
153+
}
154+
155+
// The default RetryConfig is backwards compatible with github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry.
156+
func defaultRetryConfig() RetryConfig {
157+
return RetryConfig{
158+
delay: DefaultSDKv2HelperRetryCompatibleDelay(),
159+
gracePeriod: 30 * time.Second,
160+
timer: &timerImpl{},
161+
}
162+
}
163+
164+
// RetryLoop holds state for managing retry loops with a timeout.
165+
type RetryLoop struct {
166+
attempt uint
167+
config RetryConfig
168+
deadline deadline
169+
timedOut bool
170+
}
171+
172+
// NewRetryLoopWithOptions returns a new retry loop configured with the provided options.
173+
func NewRetryLoopWithOptions(timeout time.Duration, opts ...Option) *RetryLoop {
174+
config := defaultRetryConfig()
175+
for _, opt := range opts {
176+
opt(&config)
177+
}
178+
179+
return &RetryLoop{
180+
config: config,
181+
deadline: NewDeadline(timeout + config.gracePeriod),
182+
}
183+
}
184+
185+
// NewRetryLoop returns a new retry loop configured with the default options.
186+
func NewRetryLoop(timeout time.Duration) *RetryLoop {
187+
return NewRetryLoopWithOptions(timeout)
188+
}
189+
190+
// Continue sleeps between retry attempts.
191+
// It returns false if the timeout has been exceeded.
192+
// The deadline is not checked on the first call to Continue.
193+
func (r *RetryLoop) Continue(ctx context.Context) bool {
194+
if r.attempt != 0 && r.deadline.Remaining() == 0 {
195+
r.timedOut = true
196+
197+
return false
198+
}
199+
200+
r.sleep(ctx, r.config.delay(r.attempt))
201+
r.attempt++
202+
203+
return true
204+
}
205+
206+
// Reset resets a RetryLoop to its initial state.
207+
func (r *RetryLoop) Reset() {
208+
r.attempt = 0
209+
}
210+
211+
// TimedOut return whether the retry timed out.
212+
func (r *RetryLoop) TimedOut() bool {
213+
return r.timedOut
214+
}
215+
216+
// sleep sleeps for the specified duration or until the context is canceled, whichever occurs first.
217+
func (r *RetryLoop) sleep(ctx context.Context, d time.Duration) {
218+
if d == 0 {
219+
return
220+
}
221+
222+
select {
223+
case <-ctx.Done():
224+
return
225+
case <-r.config.timer.After(d):
226+
}
227+
}

internal/backoff/backoff_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package backoff
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/google/go-cmp/cmp"
12+
)
13+
14+
func TestDefaultSDKv2HelperRetryCompatibleDelay(t *testing.T) {
15+
t.Parallel()
16+
17+
delay := DefaultSDKv2HelperRetryCompatibleDelay()
18+
want := []time.Duration{
19+
0,
20+
500 * time.Millisecond,
21+
1 * time.Second,
22+
2 * time.Second,
23+
4 * time.Second,
24+
8 * time.Second,
25+
10 * time.Second,
26+
10 * time.Second,
27+
10 * time.Second,
28+
10 * time.Second,
29+
}
30+
var got []time.Duration
31+
for i := range len(want) {
32+
got = append(got, delay(uint(i)))
33+
}
34+
35+
if diff := cmp.Diff(got, want); diff != "" {
36+
t.Errorf("unexpected diff (+wanted, -got): %s", diff)
37+
}
38+
}
39+
40+
func TestSDKv2HelperRetryCompatibleDelay(t *testing.T) {
41+
t.Parallel()
42+
43+
delay := SDKv2HelperRetryCompatibleDelay(200*time.Millisecond, 0, 3*time.Second)
44+
want := []time.Duration{
45+
200 * time.Millisecond,
46+
3 * time.Second,
47+
6 * time.Second,
48+
10 * time.Second,
49+
10 * time.Second,
50+
}
51+
var got []time.Duration
52+
for i := range len(want) {
53+
got = append(got, delay(uint(i)))
54+
}
55+
56+
if diff := cmp.Diff(got, want); diff != "" {
57+
t.Errorf("unexpected diff (+wanted, -got): %s", diff)
58+
}
59+
}
60+
61+
func TestSDKv2HelperRetryCompatibleDelayWithPollTimeout(t *testing.T) {
62+
t.Parallel()
63+
64+
delay := SDKv2HelperRetryCompatibleDelay(200*time.Millisecond, 20*time.Second, 3*time.Second)
65+
want := []time.Duration{
66+
200 * time.Millisecond,
67+
20 * time.Second,
68+
20 * time.Second,
69+
20 * time.Second,
70+
20 * time.Second,
71+
}
72+
var got []time.Duration
73+
for i := range len(want) {
74+
got = append(got, delay(uint(i)))
75+
}
76+
77+
if diff := cmp.Diff(got, want); diff != "" {
78+
t.Errorf("unexpected diff (+wanted, -got): %s", diff)
79+
}
80+
}
81+
82+
func TestRetryWithTimeoutDefaultGracePeriod(t *testing.T) {
83+
t.Parallel()
84+
85+
ctx := context.Background()
86+
87+
var n int
88+
for r := NewRetryLoopWithOptions(1*time.Minute, WithDelay(FixedDelay(1*time.Second))); r.Continue(ctx); {
89+
time.Sleep(35 * time.Second)
90+
n++
91+
}
92+
93+
// Want = 3 because of default 30s grace period.
94+
if got, want := n, 3; got != want {
95+
t.Errorf("Iterations = %v, want %v", got, want)
96+
}
97+
}
98+
99+
func TestRetryWithTimeoutNoGracePeriod(t *testing.T) {
100+
t.Parallel()
101+
102+
ctx := context.Background()
103+
104+
var n int
105+
for r := NewRetryLoopWithOptions(1*time.Minute, WithDelay(FixedDelay(1*time.Second)), WithGracePeriod(0)); r.Continue(ctx); {
106+
time.Sleep(35 * time.Second)
107+
n++
108+
}
109+
110+
// Want = 2 because of no grace period.
111+
if got, want := n, 2; got != want {
112+
t.Errorf("Iterations = %v, want %v", got, want)
113+
}
114+
}

internal/backoff/deadline.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package backoff
5+
6+
import (
7+
"time"
8+
)
9+
10+
type deadline time.Time
11+
12+
func NewDeadline(duration time.Duration) deadline {
13+
return deadline(time.Now().Add(duration))
14+
}
15+
16+
func (d deadline) Remaining() time.Duration {
17+
if v := time.Until(time.Time(d)); v < 0 {
18+
return 0
19+
} else {
20+
return v
21+
}
22+
}

0 commit comments

Comments
 (0)