Skip to content

Commit d9aac5c

Browse files
committed
resource retry win
1 parent 50d1ce1 commit d9aac5c

9 files changed

Lines changed: 260 additions & 97 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package common
2+
3+
import (
4+
"bytes"
5+
"runtime"
6+
"strconv"
7+
"sync"
8+
)
9+
10+
// skipProviderHTTPRetry is a per-worker "resource retry is active" flag.
11+
// Keyed by goroutine id so parallel Terraform resource workers stay isolated:
12+
// if resource A has retry {}, only A's HTTP calls skip provider retry; resource B
13+
// (no resource retry) still uses provider HTTP retry. A single global bool would
14+
// incorrectly disable provider retry for every concurrent worker.
15+
var skipProviderHTTPRetry sync.Map // goroutineID -> struct{}
16+
17+
// SetSkipProviderHTTPRetry marks (or clears) "skip provider HTTP retry" for the
18+
// current worker only. Used while a resource-level retry {} attempt is in flight.
19+
func SetSkipProviderHTTPRetry(skip bool) {
20+
id := goroutineID()
21+
if skip {
22+
skipProviderHTTPRetry.Store(id, struct{}{})
23+
return
24+
}
25+
skipProviderHTTPRetry.Delete(id)
26+
}
27+
28+
// SkipProviderHTTPRetry is true when the current worker has resource retry active
29+
// (see SetSkipProviderHTTPRetry). Checked from the HTTP transport.
30+
func SkipProviderHTTPRetry() bool {
31+
_, ok := skipProviderHTTPRetry.Load(goroutineID())
32+
return ok
33+
}
34+
35+
func goroutineID() uint64 {
36+
b := make([]byte, 64)
37+
b = b[:runtime.Stack(b, false)]
38+
// "goroutine 123 [running]:..."
39+
b = bytes.TrimPrefix(b, []byte("goroutine "))
40+
i := bytes.IndexByte(b, ' ')
41+
if i <= 0 {
42+
return 0
43+
}
44+
id, _ := strconv.ParseUint(string(b[:i]), 10, 64)
45+
return id
46+
}

akeyless/common/retry.go

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,44 @@ import (
99
"time"
1010

1111
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
12+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
1213
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1314
)
1415

16+
// sdkRetryTimeout is large on purpose: we stop with max_retries inside RetryFunc,
17+
// not with the SDK helper's timeout clock.
18+
const sdkRetryTimeout = 24 * time.Hour
19+
20+
// default config values
1521
const (
1622
resourceRetryDefaultIntervalSeconds = 10.0
1723
resourceRetryDefaultMaxIntervalSeconds = 180.0
1824
resourceRetryDefaultMultiplier = 1.5
1925
)
2026

21-
// ResourceRetrySchema is the optional resource-level retry block (after provider HTTP retry).
27+
// defaultResourceRetryErrorPatterns mirrors provider-level transient/rate-limit text defaults.
28+
var defaultResourceRetryErrorPatterns = []string{
29+
"Too Many Requests",
30+
"will be released in",
31+
"EOF",
32+
"connection reset by peer",
33+
"connection refused",
34+
}
35+
36+
// ResourceRetrySchema is the optional resource-level retry block.
37+
// When set, it overrides provider HTTP retry for that resource's CRUD calls.
2238
func ResourceRetrySchema() *schema.Schema {
2339
return &schema.Schema{
2440
Type: schema.TypeList,
2541
Optional: true,
2642
MaxItems: 1,
27-
Description: "Optional resource-level retry. Runs after provider HTTP retries; matches error messages.",
43+
Description: "Optional resource-level retry. When set, overrides provider HTTP retry for this resource.",
2844
Elem: &schema.Resource{
2945
Schema: map[string]*schema.Schema{
30-
"error_message_regex": {
46+
"retry_on_messages": {
3147
Type: schema.TypeList,
3248
Optional: true,
33-
Description: "Regexes matched against error messages. If any match, the operation is retried.",
49+
Description: "Error message texts that trigger a retry. Defaults to provider transient/rate-limit messages when omitted.",
3450
Elem: &schema.Schema{Type: schema.TypeString},
3551
},
3652
"interval_seconds": {
@@ -157,26 +173,27 @@ func resourceRetryConfigFromData(d *schema.ResourceData) (*resourceRetryConfig,
157173
cfg.Multiplier = v
158174
}
159175

160-
patterns, _ := m["error_message_regex"].([]interface{})
176+
patterns, _ := m["retry_on_messages"].([]interface{})
161177
for _, p := range patterns {
162178
s, ok := p.(string)
163179
if !ok || s == "" {
164180
continue
165181
}
166182
re, err := regexp.Compile(s)
167183
if err != nil {
168-
return nil, fmt.Errorf("invalid retry.error_message_regex %q: %w", s, err)
184+
return nil, fmt.Errorf("invalid retry.retry_on_messages %q: %w", s, err)
169185
}
170186
cfg.ErrorMessageRegex = append(cfg.ErrorMessageRegex, re)
171187
}
172188
if len(cfg.ErrorMessageRegex) == 0 {
173-
// Without message matchers, resource-level retry would retry every error — refuse that.
174-
return nil, fmt.Errorf("retry.error_message_regex must contain at least one pattern")
189+
for _, s := range defaultResourceRetryErrorPatterns {
190+
cfg.ErrorMessageRegex = append(cfg.ErrorMessageRegex, regexp.MustCompile(s))
191+
}
175192
}
176193
return cfg, nil
177194
}
178195

179-
// RetryResourceOp runs fn, retrying when a resource retry block matches the error message.
196+
// RetryResourceOp wraps older CRUD hooks that return error (Create/Update/Delete).
180197
func RetryResourceOp(d *schema.ResourceData, fn func() error) error {
181198
cfg, err := resourceRetryConfigFromData(d)
182199
if err != nil {
@@ -185,24 +202,11 @@ func RetryResourceOp(d *schema.ResourceData, fn func() error) error {
185202
if cfg == nil {
186203
return fn()
187204
}
188-
189-
var lastErr error
190-
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
191-
lastErr = fn()
192-
if lastErr == nil {
193-
return nil
194-
}
195-
if attempt == cfg.MaxRetries || !cfg.matches(lastErr.Error()) {
196-
return lastErr
197-
}
198-
wait := cfg.backoff(attempt)
199-
log.Printf("[DEBUG] akeyless: resource retry attempt %d/%d after %s: %v", attempt+1, cfg.MaxRetries, wait, lastErr)
200-
time.Sleep(wait)
201-
}
202-
return lastErr
205+
return cfg.run(fn)
203206
}
204207

205-
// RetryResourceOpDiag is the Context-CRUD equivalent of RetryResourceOp.
208+
// RetryResourceOpDiag wraps Context CRUD hooks that return diag.Diagnostics
209+
// (CreateContext/UpdateContext/DeleteContext). Same retry policy as RetryResourceOp.
206210
func RetryResourceOpDiag(d *schema.ResourceData, fn func() diag.Diagnostics) diag.Diagnostics {
207211
cfg, err := resourceRetryConfigFromData(d)
208212
if err != nil {
@@ -213,23 +217,45 @@ func RetryResourceOpDiag(d *schema.ResourceData, fn func() diag.Diagnostics) dia
213217
}
214218

215219
var last diag.Diagnostics
216-
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
220+
_ = cfg.run(func() error {
217221
last = fn()
218222
if !last.HasError() {
219-
return last
223+
return nil
220224
}
221-
msg := diagnosticsMessage(last)
222-
if attempt == cfg.MaxRetries || !cfg.matches(msg) {
223-
return last
225+
return fmt.Errorf("%s", diagnosticsMessage(last))
226+
})
227+
return last
228+
}
229+
230+
// run is the shared resource-retry loop. Set skip inside RetryFunc: SDK runs it on a
231+
// worker goroutine (same one as HTTP calls).
232+
func (cfg *resourceRetryConfig) run(fn func() error) error {
233+
attempt := 0
234+
var lastErr error
235+
err := retry.RetryContext(context.Background(), sdkRetryTimeout, func() *retry.RetryError {
236+
SetSkipProviderHTTPRetry(true)
237+
defer SetSkipProviderHTTPRetry(false)
238+
239+
lastErr = fn()
240+
if lastErr == nil {
241+
return nil
242+
}
243+
if attempt >= cfg.MaxRetries || !cfg.matches(lastErr.Error()) {
244+
return retry.NonRetryableError(lastErr)
224245
}
225246
wait := cfg.backoff(attempt)
226-
log.Printf("[DEBUG] akeyless: resource retry attempt %d/%d after %s: %s", attempt+1, cfg.MaxRetries, wait, msg)
247+
log.Printf("[DEBUG] akeyless: resource retry attempt %d/%d after %s: %v", attempt+1, cfg.MaxRetries, wait, lastErr)
227248
time.Sleep(wait)
249+
attempt++
250+
return retry.RetryableError(lastErr)
251+
})
252+
if lastErr != nil {
253+
return lastErr
228254
}
229-
return last
255+
return err
230256
}
231257

232-
// diagnosticsMessage returns the first error text so resource retry can match error_message_regex.
258+
// diagnosticsMessage returns the first error text so resource retry can match retry_on_messages.
233259
func diagnosticsMessage(diags diag.Diagnostics) string {
234260
for _, d := range diags {
235261
if d.Severity != diag.Error {

akeyless/common/retry_test.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestRetryResourceOp_MatchesAndRetries(t *testing.T) {
3232
d := schema.TestResourceDataRaw(t, r.Schema, map[string]interface{}{
3333
"retry": []interface{}{
3434
map[string]interface{}{
35-
"error_message_regex": []interface{}{"Too Many Requests"},
35+
"retry_on_messages": []interface{}{"Too Many Requests"},
3636
"interval_seconds": 0.01,
3737
"max_interval_seconds": 0.05,
3838
"max_retries": 2,
@@ -63,7 +63,7 @@ func TestRetryResourceOp_NoMatch(t *testing.T) {
6363
d := schema.TestResourceDataRaw(t, r.Schema, map[string]interface{}{
6464
"retry": []interface{}{
6565
map[string]interface{}{
66-
"error_message_regex": []interface{}{"will be released in"},
66+
"retry_on_messages": []interface{}{"will be released in"},
6767
"interval_seconds": 0.01,
6868
"max_retries": 3,
6969
},
@@ -83,19 +83,60 @@ func TestRetryResourceOp_NoMatch(t *testing.T) {
8383
}
8484
}
8585

86-
func TestRetryResourceOp_RequiresRegex(t *testing.T) {
86+
func TestRetryResourceOp_DefaultRegexWhenOmitted(t *testing.T) {
8787
r := &schema.Resource{Schema: map[string]*schema.Schema{}}
8888
AddResourceRetrySchema(r.Schema)
8989
d := schema.TestResourceDataRaw(t, r.Schema, map[string]interface{}{
9090
"retry": []interface{}{
9191
map[string]interface{}{
92-
"max_retries": 1,
92+
"interval_seconds": 0.01,
93+
"max_retries": 2,
94+
"multiplier": 1.0,
9395
},
9496
},
9597
})
9698

97-
err := RetryResourceOp(d, func() error { return nil })
98-
if err == nil {
99-
t.Fatal("expected error for missing error_message_regex")
99+
var calls int32
100+
err := RetryResourceOp(d, func() error {
101+
n := atomic.AddInt32(&calls, 1)
102+
if n < 3 {
103+
return errors.New("429 Too Many Requests")
104+
}
105+
return nil
106+
})
107+
if err != nil {
108+
t.Fatalf("unexpected err: %v", err)
109+
}
110+
if calls != 3 {
111+
t.Fatalf("calls=%d want 3", calls)
112+
}
113+
}
114+
115+
func TestRetryResourceOp_SkipsProviderHTTPRetry(t *testing.T) {
116+
r := &schema.Resource{Schema: map[string]*schema.Schema{}}
117+
AddResourceRetrySchema(r.Schema)
118+
d := schema.TestResourceDataRaw(t, r.Schema, map[string]interface{}{
119+
"retry": []interface{}{
120+
map[string]interface{}{
121+
"retry_on_messages": []interface{}{"x"},
122+
"max_retries": 0,
123+
},
124+
},
125+
})
126+
127+
if SkipProviderHTTPRetry() {
128+
t.Fatal("skip should be off before RetryResourceOp")
129+
}
130+
err := RetryResourceOp(d, func() error {
131+
if !SkipProviderHTTPRetry() {
132+
t.Fatal("skip should be on during resource retry")
133+
}
134+
return nil
135+
})
136+
if err != nil {
137+
t.Fatalf("unexpected err: %v", err)
138+
}
139+
if SkipProviderHTTPRetry() {
140+
t.Fatal("skip should be off after RetryResourceOp")
100141
}
101142
}

akeyless/retry_config.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ func providerRetrySchema() *schema.Schema {
8686
Default: defaultMultiplier,
8787
Description: "Exponential backoff multiplier. Env: AKEYLESS_RETRY_MULTIPLIER.",
8888
},
89-
"error_message_regex": {
89+
"retry_on_messages": {
9090
Type: schema.TypeList,
9191
Optional: true,
92-
Description: "Additional regexes matched against response bodies to trigger a retry.",
92+
Description: "Additional response-body message texts that trigger a retry.",
9393
Elem: &schema.Schema{Type: schema.TypeString},
9494
},
9595
},
@@ -123,7 +123,7 @@ func retryConfigFromProviderData(d *schema.ResourceData) (retryConfig, error) {
123123
}
124124
cfg.RetryOnStatusCodes = statusCodeSet(parsed)
125125
}
126-
if patterns, ok := m["error_message_regex"].([]interface{}); ok {
126+
if patterns, ok := m["retry_on_messages"].([]interface{}); ok {
127127
regexes := make([]*regexp.Regexp, 0, len(patterns))
128128
for _, p := range patterns {
129129
s, ok := p.(string)
@@ -132,7 +132,7 @@ func retryConfigFromProviderData(d *schema.ResourceData) (retryConfig, error) {
132132
}
133133
re, err := regexp.Compile(s)
134134
if err != nil {
135-
return cfg, fmt.Errorf("invalid error_message_regex %q: %w", s, err)
135+
return cfg, fmt.Errorf("invalid retry_on_messages %q: %w", s, err)
136136
}
137137
regexes = append(regexes, re)
138138
}

0 commit comments

Comments
 (0)