Skip to content

Commit a73675d

Browse files
authored
Merge pull request #44465 from hashicorp/f-polling-library-for-actions
actionwait: add polling library for actions
2 parents 40fc494 + a9c99d8 commit a73675d

File tree

9 files changed

+1174
-190
lines changed

9 files changed

+1174
-190
lines changed

.teamcity/scripts/provider_tests/acceptance_tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ fi
3434

3535
TF_ACC=1 go test \
3636
./internal/acctest/... \
37+
./internal/actionwait/... \
3738
./internal/attrmap/... \
3839
./internal/backoff/... \
3940
./internal/conns/... \

.teamcity/scripts/provider_tests/unit_tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ set -euo pipefail
66

77
go test \
88
./internal/acctest/... \
9+
./internal/actionwait/... \
910
./internal/attrmap/... \
1011
./internal/backoff/... \
1112
./internal/conns/... \

internal/actionwait/errors.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package actionwait
5+
6+
import (
7+
"errors"
8+
"strings"
9+
"time"
10+
)
11+
12+
// TimeoutError is returned when the operation does not reach a success state within Timeout.
13+
type TimeoutError struct {
14+
LastStatus Status
15+
Timeout time.Duration
16+
}
17+
18+
func (e *TimeoutError) Error() string {
19+
return "timeout waiting for target status after " + e.Timeout.String()
20+
}
21+
22+
// FailureStateError indicates the operation entered a declared failure state.
23+
type FailureStateError struct {
24+
Status Status
25+
}
26+
27+
func (e *FailureStateError) Error() string {
28+
return "operation entered failure state: " + string(e.Status)
29+
}
30+
31+
// UnexpectedStateError indicates the operation entered a state outside success/transitional/failure sets.
32+
type UnexpectedStateError struct {
33+
Status Status
34+
Allowed []Status
35+
}
36+
37+
func (e *UnexpectedStateError) Error() string {
38+
if len(e.Allowed) == 0 {
39+
return "operation entered unexpected state: " + string(e.Status)
40+
}
41+
allowedStr := make([]string, len(e.Allowed))
42+
for i, s := range e.Allowed {
43+
allowedStr[i] = string(s)
44+
}
45+
return "operation entered unexpected state: " + string(e.Status) + " (allowed: " +
46+
strings.Join(allowedStr, ", ") + ")"
47+
}
48+
49+
// Error type assertions for compile-time verification
50+
var (
51+
_ error = (*TimeoutError)(nil)
52+
_ error = (*FailureStateError)(nil)
53+
_ error = (*UnexpectedStateError)(nil)
54+
)
55+
56+
// Helper functions for error type checking
57+
func IsTimeout(err error) bool {
58+
var timeoutErr *TimeoutError
59+
return errors.As(err, &timeoutErr)
60+
}
61+
62+
func IsFailureState(err error) bool {
63+
var failureErr *FailureStateError
64+
return errors.As(err, &failureErr)
65+
}
66+
67+
func IsUnexpectedState(err error) bool {
68+
var unexpectedErr *UnexpectedStateError
69+
return errors.As(err, &unexpectedErr)
70+
}

internal/actionwait/errors_test.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package actionwait
5+
6+
import (
7+
"errors"
8+
"strings"
9+
"testing"
10+
"time"
11+
)
12+
13+
func TestTimeoutError(t *testing.T) {
14+
t.Parallel()
15+
16+
tests := []struct {
17+
name string
18+
err *TimeoutError
19+
wantMsg string
20+
wantType string
21+
}{
22+
{
23+
name: "with last status",
24+
err: &TimeoutError{
25+
LastStatus: "CREATING",
26+
Timeout: 5 * time.Minute,
27+
},
28+
wantMsg: "timeout waiting for target status after 5m0s",
29+
wantType: "*actionwait.TimeoutError",
30+
},
31+
{
32+
name: "with empty status",
33+
err: &TimeoutError{
34+
LastStatus: "",
35+
Timeout: 30 * time.Second,
36+
},
37+
wantMsg: "timeout waiting for target status after 30s",
38+
wantType: "*actionwait.TimeoutError",
39+
},
40+
}
41+
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
t.Parallel()
45+
46+
if got := tt.err.Error(); got != tt.wantMsg {
47+
t.Errorf("TimeoutError.Error() = %q, want %q", got, tt.wantMsg)
48+
}
49+
50+
// Verify it implements error interface
51+
var err error = tt.err
52+
if got := err.Error(); got != tt.wantMsg {
53+
t.Errorf("TimeoutError as error.Error() = %q, want %q", got, tt.wantMsg)
54+
}
55+
})
56+
}
57+
}
58+
59+
func TestFailureStateError(t *testing.T) {
60+
t.Parallel()
61+
62+
tests := []struct {
63+
name string
64+
err *FailureStateError
65+
wantMsg string
66+
}{
67+
{
68+
name: "with status",
69+
err: &FailureStateError{
70+
Status: "FAILED",
71+
},
72+
wantMsg: "operation entered failure state: FAILED",
73+
},
74+
{
75+
name: "with empty status",
76+
err: &FailureStateError{
77+
Status: "",
78+
},
79+
wantMsg: "operation entered failure state: ",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
if got := tt.err.Error(); got != tt.wantMsg {
88+
t.Errorf("FailureStateError.Error() = %q, want %q", got, tt.wantMsg)
89+
}
90+
})
91+
}
92+
}
93+
94+
func TestUnexpectedStateError(t *testing.T) {
95+
t.Parallel()
96+
97+
tests := []struct {
98+
name string
99+
err *UnexpectedStateError
100+
wantMsg string
101+
}{
102+
{
103+
name: "no allowed states",
104+
err: &UnexpectedStateError{
105+
Status: "UNKNOWN",
106+
Allowed: nil,
107+
},
108+
wantMsg: "operation entered unexpected state: UNKNOWN",
109+
},
110+
{
111+
name: "empty allowed states",
112+
err: &UnexpectedStateError{
113+
Status: "UNKNOWN",
114+
Allowed: []Status{},
115+
},
116+
wantMsg: "operation entered unexpected state: UNKNOWN",
117+
},
118+
{
119+
name: "single allowed state",
120+
err: &UnexpectedStateError{
121+
Status: "UNKNOWN",
122+
Allowed: []Status{"AVAILABLE"},
123+
},
124+
wantMsg: "operation entered unexpected state: UNKNOWN (allowed: AVAILABLE)",
125+
},
126+
{
127+
name: "multiple allowed states",
128+
err: &UnexpectedStateError{
129+
Status: "UNKNOWN",
130+
Allowed: []Status{"CREATING", "AVAILABLE", "UPDATING"},
131+
},
132+
wantMsg: "operation entered unexpected state: UNKNOWN (allowed: CREATING, AVAILABLE, UPDATING)",
133+
},
134+
}
135+
136+
for _, tt := range tests {
137+
t.Run(tt.name, func(t *testing.T) {
138+
t.Parallel()
139+
140+
if got := tt.err.Error(); got != tt.wantMsg {
141+
t.Errorf("UnexpectedStateError.Error() = %q, want %q", got, tt.wantMsg)
142+
}
143+
})
144+
}
145+
}
146+
147+
func TestErrorTypeChecking(t *testing.T) {
148+
t.Parallel()
149+
150+
// Create instances of each error type
151+
timeoutErr := &TimeoutError{LastStatus: "CREATING", Timeout: time.Minute}
152+
failureErr := &FailureStateError{Status: "FAILED"}
153+
unexpectedErr := &UnexpectedStateError{Status: "UNKNOWN", Allowed: []Status{"AVAILABLE"}}
154+
genericErr := errors.New("generic error")
155+
156+
tests := []struct {
157+
name string
158+
err error
159+
wantIsTimeout bool
160+
wantIsFailure bool
161+
wantIsUnexpected bool
162+
}{
163+
{
164+
name: "TimeoutError",
165+
err: timeoutErr,
166+
wantIsTimeout: true,
167+
wantIsFailure: false,
168+
wantIsUnexpected: false,
169+
},
170+
{
171+
name: "FailureStateError",
172+
err: failureErr,
173+
wantIsTimeout: false,
174+
wantIsFailure: true,
175+
wantIsUnexpected: false,
176+
},
177+
{
178+
name: "UnexpectedStateError",
179+
err: unexpectedErr,
180+
wantIsTimeout: false,
181+
wantIsFailure: false,
182+
wantIsUnexpected: true,
183+
},
184+
{
185+
name: "generic error",
186+
err: genericErr,
187+
wantIsTimeout: false,
188+
wantIsFailure: false,
189+
wantIsUnexpected: false,
190+
},
191+
{
192+
name: "nil error",
193+
err: nil,
194+
wantIsTimeout: false,
195+
wantIsFailure: false,
196+
wantIsUnexpected: false,
197+
},
198+
}
199+
200+
for _, tt := range tests {
201+
t.Run(tt.name, func(t *testing.T) {
202+
t.Parallel()
203+
204+
if got := IsTimeout(tt.err); got != tt.wantIsTimeout {
205+
t.Errorf("IsTimeout(%v) = %v, want %v", tt.err, got, tt.wantIsTimeout)
206+
}
207+
208+
if got := IsFailureState(tt.err); got != tt.wantIsFailure {
209+
t.Errorf("IsFailureState(%v) = %v, want %v", tt.err, got, tt.wantIsFailure)
210+
}
211+
212+
if got := IsUnexpectedState(tt.err); got != tt.wantIsUnexpected {
213+
t.Errorf("IsUnexpectedState(%v) = %v, want %v", tt.err, got, tt.wantIsUnexpected)
214+
}
215+
})
216+
}
217+
}
218+
219+
func TestWrappedErrors(t *testing.T) {
220+
t.Parallel()
221+
222+
// Test that error type checking works with wrapped errors
223+
baseErr := &TimeoutError{LastStatus: "CREATING", Timeout: time.Minute}
224+
wrappedErr := errors.New("wrapped: " + baseErr.Error())
225+
226+
// Direct error should be detected
227+
if !IsTimeout(baseErr) {
228+
t.Errorf("IsTimeout should detect direct TimeoutError")
229+
}
230+
231+
// Wrapped string error should NOT be detected (this is expected behavior)
232+
if IsTimeout(wrappedErr) {
233+
t.Errorf("IsTimeout should not detect string-wrapped error")
234+
}
235+
236+
// But wrapped with errors.Join should work
237+
joinedErr := errors.Join(baseErr, errors.New("additional context"))
238+
if !IsTimeout(joinedErr) {
239+
t.Errorf("IsTimeout should detect error in errors.Join")
240+
}
241+
}
242+
243+
func TestErrorMessages(t *testing.T) {
244+
t.Parallel()
245+
246+
// Verify error messages contain expected components for debugging
247+
timeoutErr := &TimeoutError{
248+
LastStatus: "PENDING",
249+
Timeout: 2 * time.Minute,
250+
}
251+
252+
msg := timeoutErr.Error()
253+
if !strings.Contains(msg, "timeout") {
254+
t.Errorf("TimeoutError message should contain 'timeout', got: %q", msg)
255+
}
256+
if !strings.Contains(msg, "2m0s") {
257+
t.Errorf("TimeoutError message should contain timeout duration, got: %q", msg)
258+
}
259+
260+
failureErr := &FailureStateError{Status: "ERROR"}
261+
msg = failureErr.Error()
262+
if !strings.Contains(msg, "failure state") {
263+
t.Errorf("FailureStateError message should contain 'failure state', got: %q", msg)
264+
}
265+
if !strings.Contains(msg, "ERROR") {
266+
t.Errorf("FailureStateError message should contain status, got: %q", msg)
267+
}
268+
269+
unexpectedErr := &UnexpectedStateError{
270+
Status: "WEIRD",
271+
Allowed: []Status{"GOOD", "BETTER"},
272+
}
273+
msg = unexpectedErr.Error()
274+
if !strings.Contains(msg, "unexpected state") {
275+
t.Errorf("UnexpectedStateError message should contain 'unexpected state', got: %q", msg)
276+
}
277+
if !strings.Contains(msg, "WEIRD") {
278+
t.Errorf("UnexpectedStateError message should contain actual status, got: %q", msg)
279+
}
280+
if !strings.Contains(msg, "GOOD, BETTER") {
281+
t.Errorf("UnexpectedStateError message should contain allowed states, got: %q", msg)
282+
}
283+
}

0 commit comments

Comments
 (0)