Skip to content

Commit a70a78a

Browse files
committed
Add backoff strategy
1 parent 6362445 commit a70a78a

File tree

2 files changed

+130
-0
lines changed

2 files changed

+130
-0
lines changed

internal/actionwait/wait.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"errors"
1212
"slices"
1313
"time"
14+
15+
"github.com/hashicorp/terraform-provider-aws/internal/backoff"
1416
)
1517

1618
// DefaultPollInterval is the default fixed polling interval used when no custom IntervalStrategy is provided.
@@ -41,6 +43,32 @@ type FixedInterval time.Duration
4143
// NextPoll returns the fixed duration.
4244
func (fi FixedInterval) NextPoll(uint) time.Duration { return time.Duration(fi) }
4345

46+
// BackoffInterval implements IntervalStrategy using a backoff.Delay strategy.
47+
// This allows actionwait to leverage sophisticated backoff algorithms while
48+
// maintaining the declarative status-based polling approach.
49+
type BackoffInterval struct {
50+
delay backoff.Delay
51+
}
52+
53+
// NextPoll returns the next polling interval using the wrapped backoff delay strategy.
54+
func (bi BackoffInterval) NextPoll(attempt uint) time.Duration {
55+
return bi.delay.Next(attempt)
56+
}
57+
58+
// WithBackoffDelay creates an IntervalStrategy that uses the provided backoff.Delay.
59+
// This bridges actionwait's IntervalStrategy interface with the backoff package's
60+
// delay strategies (fixed, exponential, SDK-compatible, etc.).
61+
//
62+
// Example usage:
63+
//
64+
// opts := actionwait.Options[MyType]{
65+
// Interval: actionwait.WithBackoffDelay(backoff.FixedDelay(time.Second)),
66+
// // ... other options
67+
// }
68+
func WithBackoffDelay(delay backoff.Delay) IntervalStrategy {
69+
return BackoffInterval{delay: delay}
70+
}
71+
4472
// Options configure the WaitForStatus loop.
4573
type Options[T any] struct {
4674
Timeout time.Duration // Required total timeout.

internal/actionwait/wait_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"sync/atomic"
1111
"testing"
1212
"time"
13+
14+
"github.com/hashicorp/terraform-provider-aws/internal/backoff"
1315
)
1416

1517
// fastFixedInterval returns a very small fixed interval to speed tests.
@@ -322,3 +324,103 @@ func TestWaitForStatus_UnexpectedStateErrorMessage(t *testing.T) {
322324
t.Errorf("error message should contain allowed state 'PENDING', got: %s", errMsg)
323325
}
324326
}
327+
328+
func TestBackoffInterval(t *testing.T) {
329+
t.Parallel()
330+
331+
tests := []struct {
332+
name string
333+
delay backoff.Delay
334+
attempts []uint
335+
expectedDurations []time.Duration
336+
}{
337+
{
338+
name: "fixed delay",
339+
delay: backoff.FixedDelay(100 * time.Millisecond),
340+
attempts: []uint{0, 1, 2, 3},
341+
expectedDurations: []time.Duration{0, 100 * time.Millisecond, 100 * time.Millisecond, 100 * time.Millisecond},
342+
},
343+
{
344+
name: "zero delay",
345+
delay: backoff.ZeroDelay,
346+
attempts: []uint{0, 1, 2},
347+
expectedDurations: []time.Duration{0, 0, 0},
348+
},
349+
}
350+
351+
for _, tt := range tests {
352+
t.Run(tt.name, func(t *testing.T) {
353+
t.Parallel()
354+
355+
interval := BackoffInterval{delay: tt.delay}
356+
357+
for i, attempt := range tt.attempts {
358+
got := interval.NextPoll(attempt)
359+
want := tt.expectedDurations[i]
360+
if got != want {
361+
t.Errorf("NextPoll(%d) = %v, want %v", attempt, got, want)
362+
}
363+
}
364+
})
365+
}
366+
}
367+
368+
func TestWithBackoffDelay(t *testing.T) {
369+
t.Parallel()
370+
371+
delay := backoff.FixedDelay(50 * time.Millisecond)
372+
interval := WithBackoffDelay(delay)
373+
374+
// Verify it implements IntervalStrategy
375+
var _ IntervalStrategy = interval
376+
377+
// Test that it wraps the delay correctly
378+
if got := interval.NextPoll(0); got != 0 {
379+
t.Errorf("NextPoll(0) = %v, want 0", got)
380+
}
381+
if got := interval.NextPoll(1); got != 50*time.Millisecond {
382+
t.Errorf("NextPoll(1) = %v, want 50ms", got)
383+
}
384+
}
385+
386+
func TestBackoffIntegration(t *testing.T) {
387+
t.Parallel()
388+
389+
ctx := makeCtx(t)
390+
391+
var callCount atomic.Int32
392+
fetch := func(context.Context) (FetchResult[string], error) {
393+
count := callCount.Add(1)
394+
switch count {
395+
case 1:
396+
return FetchResult[string]{Status: "CREATING", Value: "attempt1"}, nil
397+
case 2:
398+
return FetchResult[string]{Status: "AVAILABLE", Value: "success"}, nil
399+
default:
400+
t.Errorf("unexpected call count: %d", count)
401+
return FetchResult[string]{}, errors.New("too many calls")
402+
}
403+
}
404+
405+
opts := Options[string]{
406+
Timeout: 2 * time.Second,
407+
Interval: WithBackoffDelay(backoff.FixedDelay(fastFixedInterval)),
408+
SuccessStates: []Status{"AVAILABLE"},
409+
TransitionalStates: []Status{"CREATING"},
410+
}
411+
412+
result, err := WaitForStatus(ctx, fetch, opts)
413+
if err != nil {
414+
t.Fatalf("WaitForStatus() error = %v", err)
415+
}
416+
417+
if result.Status != "AVAILABLE" {
418+
t.Errorf("result.Status = %q, want %q", result.Status, "AVAILABLE")
419+
}
420+
if result.Value != "success" {
421+
t.Errorf("result.Value = %q, want %q", result.Value, "success")
422+
}
423+
if callCount.Load() != 2 {
424+
t.Errorf("expected 2 fetch calls, got %d", callCount.Load())
425+
}
426+
}

0 commit comments

Comments
 (0)