Skip to content

Commit 2afb96e

Browse files
authored
fix(zambda): Retry Zambda fetch for up to 2 minutes; roll back create (#69)
1 parent b4b01e3 commit 2afb96e

File tree

5 files changed

+164
-13
lines changed

5 files changed

+164
-13
lines changed

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ require (
77
github.com/hashicorp/terraform-plugin-framework v1.16.1
88
github.com/hashicorp/terraform-plugin-go v0.29.0
99
github.com/hashicorp/terraform-plugin-log v0.10.0
10+
github.com/stretchr/testify v1.10.0
1011
)
1112

1213
require (
14+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
1315
github.com/fatih/color v1.15.0 // indirect
1416
github.com/golang/protobuf v1.5.4 // indirect
1517
github.com/hashicorp/go-hclog v1.6.3 // indirect
@@ -22,6 +24,7 @@ require (
2224
github.com/mattn/go-isatty v0.0.19 // indirect
2325
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
2426
github.com/oklog/run v1.1.0 // indirect
27+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
2528
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
2629
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
2730
golang.org/x/net v0.43.0 // indirect
@@ -30,4 +33,5 @@ require (
3033
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
3134
google.golang.org/grpc v1.75.1 // indirect
3235
google.golang.org/protobuf v1.36.9 // indirect
36+
gopkg.in/yaml.v3 v3.0.1 // indirect
3337
)

go.sum

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9
2727
github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y=
2828
github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU=
2929
github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM=
30-
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
31-
github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow=
3230
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
3331
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
3432
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
@@ -96,6 +94,7 @@ google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
9694
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
9795
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
9896
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
97+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
9998
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
10099
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
101100
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/provider/zambda_resource.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"strings"
7+
"time"
78

89
"github.com/hashicorp/terraform-plugin-framework/attr"
910
"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -250,10 +251,22 @@ func (r *ZambdaResource) Create(ctx context.Context, req resource.CreateRequest,
250251
retrievedZambda, err := r.getZambdaAfterMutation(ctx, &resp.Diagnostics, *createdZambda.ID, plan.SourceChecksum.ValueString())
251252
if err != nil {
252253
resp.Diagnostics.AddError("Error Retrieving Created Zambda", err.Error())
254+
// Roll back Zambda create
255+
err := r.client.Zambda.DeleteZambda(ctx, *createdZambda.ID)
256+
if err != nil {
257+
resp.Diagnostics.AddError("Error Deleting Zambda", err.Error())
258+
return
259+
}
253260
return
254261
}
255262
if retrievedZambda == nil {
256263
// Error already added to diagnostics in getZambdaAfterMutation
264+
// Roll back Zambda create
265+
err := r.client.Zambda.DeleteZambda(ctx, *createdZambda.ID)
266+
if err != nil {
267+
resp.Diagnostics.AddError("Error Deleting Zambda", err.Error())
268+
return
269+
}
257270
return
258271
}
259272

@@ -409,8 +422,9 @@ func (r *ZambdaResource) getZambdaAfterMutation(ctx context.Context, diags *diag
409422
return retrievedZambda, nil
410423
}, retry.RetryConfig{
411424
BaseBackoff: retry.BaseBackoffDefault,
412-
MaxBackoff: 16000,
413-
MaxAttempts: 10,
425+
MaxBackoff: 8 * time.Second,
426+
MaxDuration: 2 * time.Minute,
427+
MaxAttempts: retry.Disabled, // disable max attempts
414428
})
415429
}
416430

internal/retry/retry.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,62 @@ package retry
22

33
import (
44
"context"
5+
"math"
56
"math/rand"
67
"time"
78

89
"github.com/hashicorp/terraform-plugin-log/tflog"
910
)
1011

1112
const (
12-
BaseBackoffDefault time.Duration = 500
13-
MaxBackoffDefault time.Duration = 8000
13+
BaseBackoffDefault time.Duration = 500 * time.Millisecond
14+
MaxBackoffDefault time.Duration = 8000 * time.Millisecond
15+
MaxDurationDefault time.Duration = 30 * time.Second
1416
MaxAttemptsDefault = 3
17+
Disabled = 0
1518
)
1619

1720
var (
1821
// DefaultRetryConfig defines the default configuration for retrying operations.
1922
DefaultRetryConfig = RetryConfig{
20-
BaseBackoff: BaseBackoffDefault,
21-
MaxBackoff: MaxBackoffDefault,
22-
MaxAttempts: MaxAttemptsDefault,
23+
BaseBackoff: BaseBackoffDefault,
24+
MaxBackoff: MaxBackoffDefault,
25+
MaxDuration: MaxDurationDefault,
26+
MaxAttempts: MaxAttemptsDefault,
27+
DisableJitter: false,
2328
}
2429
)
2530

2631
type RetryConfig struct {
2732
BaseBackoff time.Duration
2833
MaxBackoff time.Duration
34+
// Set to retry.Disable to disable max duration
35+
MaxDuration time.Duration
36+
// Set to retry.Disable to disable max attempts
2937
MaxAttempts int
38+
// Set to true to wait the full backoff
39+
DisableJitter bool
3040
}
3141

3242
func RetryWithBackoff[T any](ctx context.Context, operation func() (T, error), config RetryConfig) (T, error) {
3343
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
34-
baseBackoff := time.Duration(config.BaseBackoff) * time.Millisecond
35-
maxBackoff := time.Duration(config.MaxBackoff) * time.Millisecond
44+
baseBackoff := config.BaseBackoff
45+
maxBackoff := config.MaxBackoff
46+
maxDuration := config.MaxDuration
47+
start := time.Now()
3648

37-
for attempt := range config.MaxAttempts {
49+
for attempt := range int(math.Max(float64(config.MaxAttempts), 1000)) {
3850
res, err := operation()
3951
if err == nil {
4052
return res, nil
4153
}
4254
// If this was the last attempt, return the error
43-
if attempt == config.MaxAttempts-1 {
55+
if config.MaxAttempts > Disabled && attempt == config.MaxAttempts-1 {
56+
var zero T
57+
return zero, err
58+
}
59+
// If we have elapsed max duration, return the error
60+
if config.MaxDuration > Disabled && time.Since(start) > maxDuration {
4461
var zero T
4562
return zero, err
4663
}
@@ -50,6 +67,9 @@ func RetryWithBackoff[T any](ctx context.Context, operation func() (T, error), c
5067
backoff = maxBackoff
5168
}
5269
jitter := time.Duration(rng.Int63n(int64(backoff)))
70+
if config.DisableJitter {
71+
jitter = backoff
72+
}
5373
tflog.Debug(ctx, "Retrying operation after backoff", map[string]any{
5474
"attempt": attempt + 1,
5575
"max_attempts": config.MaxAttempts,

internal/retry/retry_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package retry
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestRetry(t *testing.T) {
13+
tt := []struct {
14+
name string
15+
numFailures int
16+
baseBackoff time.Duration
17+
maxBackoff time.Duration
18+
maxDuration time.Duration
19+
maxAttempts int
20+
disableJitter bool
21+
expectedRes int
22+
expectedErr error
23+
}{
24+
{
25+
name: "defaults success",
26+
numFailures: 1,
27+
baseBackoff: BaseBackoffDefault,
28+
maxBackoff: MaxBackoffDefault,
29+
maxDuration: MaxDurationDefault,
30+
maxAttempts: MaxAttemptsDefault,
31+
expectedRes: 42,
32+
expectedErr: nil,
33+
},
34+
{
35+
name: "defaults error",
36+
numFailures: MaxAttemptsDefault + 1, // 1 more than our attempts
37+
baseBackoff: BaseBackoffDefault,
38+
maxBackoff: MaxBackoffDefault,
39+
maxDuration: MaxDurationDefault,
40+
maxAttempts: MaxAttemptsDefault,
41+
expectedRes: 0,
42+
expectedErr: fmt.Errorf("some error"),
43+
},
44+
{
45+
name: "no max duration success",
46+
numFailures: 5, // 1 less than our attempts
47+
baseBackoff: MaxBackoffDefault, // always 8 seconds wait
48+
maxBackoff: MaxBackoffDefault,
49+
maxDuration: Disabled,
50+
maxAttempts: 6, // 6 tries, 5 waits; wait 5 times is longer than 30 seconds and has a check > 30 seconds
51+
disableJitter: true, // wait full time to prove we don't use duration
52+
expectedRes: 42,
53+
expectedErr: nil,
54+
},
55+
{
56+
name: "no max duration error",
57+
numFailures: 7, // 1 more than our attempts
58+
baseBackoff: MaxBackoffDefault, // always 8 seconds wait
59+
maxBackoff: MaxBackoffDefault,
60+
maxDuration: Disabled,
61+
maxAttempts: 6, // 6 tries, 5 waits; wait 5 times is longer than 30 seconds and has a check > 30 seconds
62+
disableJitter: true, // wait full time to prove we don't use duration
63+
expectedRes: 0,
64+
expectedErr: fmt.Errorf("some error"),
65+
},
66+
{
67+
name: "no max attempts success",
68+
numFailures: 3, // (3 - 1) * 8 = 16 seconds
69+
baseBackoff: MaxBackoffDefault, // always 8 seconds wait
70+
maxBackoff: MaxBackoffDefault,
71+
maxDuration: 20 * time.Second,
72+
maxAttempts: Disabled,
73+
disableJitter: true, // wait full time to prove we don't use attempts
74+
expectedRes: 42,
75+
expectedErr: nil,
76+
},
77+
{
78+
name: "no max attempts error",
79+
numFailures: 4, // (4 - 1) * 8 = 24 seconds
80+
baseBackoff: MaxBackoffDefault, // always 8 seconds wait
81+
maxBackoff: MaxBackoffDefault,
82+
maxDuration: 20 * time.Second,
83+
maxAttempts: Disabled,
84+
disableJitter: true, // wait full time to prove we don't use attempts
85+
expectedRes: 0,
86+
expectedErr: fmt.Errorf("some error"),
87+
},
88+
}
89+
wg := sync.WaitGroup{}
90+
for _, tc := range tt {
91+
wg.Add(1)
92+
go t.Run(tc.name, func(t *testing.T) {
93+
var i int
94+
op := func() (int, error) {
95+
if i > tc.numFailures-1 {
96+
return 42, nil
97+
}
98+
i++
99+
return 0, fmt.Errorf("some error")
100+
}
101+
res, err := RetryWithBackoff(t.Context(), op, RetryConfig{
102+
BaseBackoff: tc.baseBackoff,
103+
MaxBackoff: tc.maxBackoff,
104+
MaxDuration: tc.maxDuration,
105+
MaxAttempts: tc.maxAttempts,
106+
DisableJitter: tc.disableJitter,
107+
})
108+
assert.Equal(t, tc.expectedRes, res, "unexpected result")
109+
assert.Equal(t, tc.expectedErr, err, "unexpected error")
110+
wg.Done()
111+
})
112+
}
113+
wg.Wait()
114+
}

0 commit comments

Comments
 (0)