Skip to content

Commit 0def635

Browse files
authored
feat(core): add new waiter helper function (#5939)
relates to STACKITSDK-366
1 parent 675d135 commit 0def635

File tree

18 files changed

+372
-136
lines changed

18 files changed

+372
-136
lines changed

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
## Release (2026-DD-MM)
2-
3-
- `core`: [v0.22.0](core/CHANGELOG.md#v0220)
4-
- **Feature:** Support Azure DevOps OIDC adapter
2+
- `core`:
3+
- [v0.23.0](core/CHANGELOG.md#v0230)
4+
- **New:** Add new `WaiterHelper` struct, which creates an `AsyncActionCheck` function based on the configuration
5+
- [v0.22.0](core/CHANGELOG.md#v0220)
6+
- **Feature:** Support Azure DevOps OIDC adapter
57
- `alb`:
68
- [v0.10.0](services/alb/CHANGELOG.md#v0100)
79
- **Feature:** Add new field `AltPort` to `ActiveHealthCheck`

CONTRIBUTION.md

Lines changed: 55 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -70,67 +70,58 @@ type APIClientInterface interface {
7070
}
7171

7272
// CreateBarWaitHandler will wait for Bar creation
73-
func CreateBarWaitHandler(ctx context.Context, a APIClientInterface, BarId, projectId string) *wait.AsyncActionHandler[foo.GetBarResponse] {
74-
handler := wait.New(func() (waitFinished bool, response *foo.GetBarResponse, err error) {
75-
s, err := a.GetBarExecute(ctx, BarId, projectId)
76-
if err != nil {
77-
return false, nil, err
78-
}
79-
if s.Id == nil || s.Status == nil {
80-
return false, nil, fmt.Errorf("could not get Bar id or status from response for project %s and Bar %s", projectId, BarId)
81-
}
82-
if *s.Id == BarId && *s.Status == CreateSuccess {
83-
return true, s, nil
84-
}
85-
if *s.Id == BarId && *s.Status == CreateFail {
86-
return true, s, fmt.Errorf("create failed for Bar with id %s", BarId)
87-
}
88-
return false, nil, nil
89-
})
73+
func CreateBarWaitHandler(ctx context.Context, a APIClientInterface, projectId, barId string) *wait.AsyncActionHandler[foo.GetBarResponse] {
74+
waitConfig := wait.WaiterHelper[foo.GetBarResponse, string]{
75+
FetchInstance: a.GetBar(ctx, projectId, barId).Execute,
76+
GetState: func(d *foo.GetBarResponse) (string, error) {
77+
if d == nil {
78+
return "", errors.New("could not get bar status from response")
79+
}
80+
return d.Status, nil
81+
},
82+
ActiveState: []string{CreateSuccess},
83+
ErrorState: []string{CreateFail},
84+
}
85+
86+
handler := wait.New(waitConfig.Wait())
9087
handler.SetTimeout(45 * time.Minute)
9188
return handler
9289
}
9390

9491
// UpdateBarWaitHandler will wait for Bar update
9592
func UpdateBarWaitHandler(ctx context.Context, a APIClientInterface, BarId, projectId string) *wait.AsyncActionHandler[foo.GetBarResponse] {
96-
handler := wait.New(func() (waitFinished bool, response *foo.GetBarResponse, err error) {
97-
s, err := a.GetBarExecute(ctx, BarId, projectId)
98-
if err != nil {
99-
return false, nil, err
100-
}
101-
if s.Id == nil || s.Status == nil {
102-
return false, nil, fmt.Errorf("could not get Bar id or status from response for project %s and Bar %s", projectId, BarId)
103-
}
104-
if *s.Id == BarId && (*s.Status == UpdateSuccess) {
105-
return true, s, nil
106-
}
107-
if *s.Id == BarId && (*s.Status == UpdateFail) {
108-
return true, s, fmt.Errorf("update failed for Bar with id %s", BarId)
109-
}
110-
return false, nil, nil
111-
})
93+
waitConfig := wait.WaiterHelper[foo.GetBarResponse, string]{
94+
FetchInstance: a.GetBar(ctx, projectId, barId).Execute,
95+
GetState: func(d *foo.GetBarResponse) (string, error) {
96+
if d == nil {
97+
return "", errors.New("could not get bar status from response")
98+
}
99+
return d.Status, nil
100+
},
101+
ActiveState: []string{UpdateSuccess},
102+
ErrorState: []string{UpdateFail},
103+
}
104+
105+
handler := wait.New(waitConfig.Wait())
112106
handler.SetTimeout(30 * time.Minute)
113107
return handler
114108
}
115109

116110
// DeleteBarWaitHandler will wait for Bar deletion
117111
func DeleteBarWaitHandler(ctx context.Context, a APIClientInterface, BarId, projectId string) *wait.AsyncActionHandler[foo.GetBarResponse] {
118-
handler := wait.New(func() (waitFinished bool, response *foo.GetBarResponse, err error) {
119-
s, err := a.GetBarExecute(ctx, BarId, projectId)
120-
if err != nil {
121-
return false, nil, err
122-
}
123-
if s.Id == nil || s.Status == nil {
124-
return false, nil, fmt.Errorf("could not get Bar id or status from response for project %s and Bar %s", projectId, BarId)
125-
}
126-
if *s.Id == BarId && *s.Status == DeleteSuccess {
127-
return true, s, nil
128-
}
129-
if *s.Id == BarId && *s.Status == DeleteFail {
130-
return true, s, fmt.Errorf("delete failed for Bar with id %s", BarId)
131-
}
132-
return false, nil, nil
133-
})
112+
waitConfig := wait.WaiterHelper[foo.GetBarResponse, string]{
113+
FetchInstance: a.GetBar(ctx, projectId, barId).Execute,
114+
GetState: func(d *foo.GetBarResponse) (string, error) {
115+
if d == nil {
116+
return "", errors.New("could not get bar status from response")
117+
}
118+
return d.Status, nil
119+
},
120+
ActiveState: []string{DeleteSuccess},
121+
ErrorState: []string{DeleteFail},
122+
}
123+
124+
handler := wait.New(waitConfig.Wait())
134125
handler.SetTimeout(20 * time.Minute)
135126
return handler
136127
}
@@ -141,26 +132,29 @@ func DeleteBarWaitHandler(ctx context.Context, a APIClientInterface, BarId, proj
141132
- The success condition may vary from service to service. In the example above we wait for the field `Status` to match a successful or failed message, but other services may have different fields and/or values to represent the state of the create, update or delete operations
142133
- The `id` and the `state` might not be present on the root level of the API response, this also varies from service to service. You must always match the resource `id` and the resource `state` to what is expected
143134
- The timeout values included above are just for reference, each resource takes different amounts of time to finish the create, update or delete operations. You should account for some buffer, e.g. 15 minutes, on top of normal execution times
144-
- For some resources, after a successful delete operation the resource can't be found anymore, so a call to the `Get` method would result in an error. In those cases, the waiter can be implemented by calling the `List` method and check that the resource is not present, like in this example:
135+
- For some resources, after a successful delete operation the resource can't be found anymore, so a call to the `Get` method would result in an error. In those cases, the WaiterHelper should have no ActiveStates configured, like in this example:
145136

146137
```go
147138
// DeleteBarWaitHandler will wait for Bar deletion
148139
func DeleteBarWaitHandler(ctx context.Context, a APIClientInterface, barId, projectId string) *wait.AsyncActionHandler[foo.ListBarsResponse] {
149-
handler := wait.New(func() (waitFinished bool, response *foo.ListBarsResponse, err error) {
150-
s, err := a.ListBarsExecute(ctx, barId, projectId)
151-
if err != nil {
152-
return false, nil, err
140+
waitConfig := wait.WaiterHelper[foo.GetBarResponse, string]{
141+
FetchInstance: a.GetBar(ctx, projectId, barId).Execute,
142+
GetState: func(d *foo.GetBarResponse) (string, error) {
143+
if d == nil {
144+
return "", errors.New("could not get bar status from response")
153145
}
154-
for i := range s {
155-
if *s[i].Id == barId {
156-
return false, nil, nil
157-
}
158-
}
159-
return true, s, nil
160-
})
146+
return d.Status, nil
147+
},
148+
ActiveState: nil,
149+
ErrorState: []string{DeleteFail},
150+
DeleteHttpErrorStatusCodes: []int{http.StatusNotFound},
151+
}
152+
153+
handler := wait.New(waitConfig.Wait())
161154
handler.SetTimeout(10 * time.Minute)
162155
return handler
163156
}
157+
164158
```
165159

166160
- The main objective of the waiter functions is to make sure that the operation was successful, which means any other special cases such as intermediate error states should also be handled

core/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## v0.23.0
2+
- **New:** Add new `WaiterHelper` struct, which creates an `AsyncActionCheck` function based on the configuration
3+
14
## v0.22.0
25
- **Feature:** Support Azure DevOps OIDC adapter
36

core/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.22.0
1+
v0.23.0

core/wait/wait.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ func (h *AsyncActionHandler[T]) WaitWithContext(ctx context.Context) (res *T, er
7474
defer cancel()
7575

7676
// Wait some seconds for the API to process the request
77-
time.Sleep(h.sleepBeforeWait)
77+
if h.sleepBeforeWait > 0 {
78+
select {
79+
case <-ctx.Done():
80+
return nil, fmt.Errorf("context canceled during initial sleep: %w", ctx.Err())
81+
case <-time.After(h.sleepBeforeWait):
82+
// continue within the WaitForContext() function
83+
}
84+
}
7885

7986
ticker := time.NewTicker(h.throttle)
8087
defer ticker.Stop()

core/wait/wait_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ func TestWaitWithContext(t *testing.T) {
270270
handlerTimeout: 100 * time.Millisecond,
271271
handlerTempErrRetryLimit: 0,
272272
contextTimeout: 1000 * time.Millisecond,
273-
wantCheckFnNumberCalls: 1,
273+
wantCheckFnNumberCalls: 0,
274274
wantErr: true,
275275
},
276276
{
@@ -283,7 +283,7 @@ func TestWaitWithContext(t *testing.T) {
283283
handlerTimeout: 1000 * time.Millisecond,
284284
handlerTempErrRetryLimit: 0,
285285
contextTimeout: 100 * time.Millisecond,
286-
wantCheckFnNumberCalls: 1,
286+
wantCheckFnNumberCalls: 0,
287287
wantErr: true,
288288
},
289289
{

core/wait/waiterhelper.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package wait
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"slices"
8+
9+
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
10+
)
11+
12+
// WaiterHelper is a helper struct, which creates based on the configured attributes the AsyncActionCheck for wait.New
13+
type WaiterHelper[T any, S comparable] struct {
14+
// FetchInstance is called periodically to get the latest resource data.
15+
FetchInstance func() (*T, error)
16+
17+
// GetState extracts the status string from the API response.
18+
GetState func(*T) (S, error)
19+
20+
// ActiveState represents the terminal "Happy Path" (e.g. ACTIVE, READY, CREATED).
21+
ActiveState []S
22+
23+
// ErrorState represents the terminal "Error Path" (e.g. FAILED, ERROR).
24+
ErrorState []S
25+
26+
// DeleteHttpErrorStatusCodes defines codes treated as a successful deletion (default: 403, 404, 410).
27+
DeleteHttpErrorStatusCodes []int
28+
}
29+
30+
var defaultHttpErrorStatusCodes = []int{http.StatusForbidden, http.StatusNotFound, http.StatusGone}
31+
32+
func (w *WaiterHelper[T, S]) Wait() AsyncActionCheck[T] {
33+
if len(w.DeleteHttpErrorStatusCodes) == 0 {
34+
w.DeleteHttpErrorStatusCodes = append(w.DeleteHttpErrorStatusCodes, defaultHttpErrorStatusCodes...)
35+
}
36+
37+
return func() (waitFinished bool, response *T, err error) {
38+
instance, err := w.FetchInstance()
39+
if err != nil {
40+
var oapiErr *oapierror.GenericOpenAPIError
41+
if errors.As(err, &oapiErr) {
42+
// If no active states are defined and one of the "Delete HTTP Status codes" is returned, finish wait without an error
43+
if len(w.ActiveState) == 0 && slices.Contains(w.DeleteHttpErrorStatusCodes, oapiErr.StatusCode) {
44+
return true, nil, nil
45+
}
46+
}
47+
return true, nil, err
48+
}
49+
50+
state, err := w.GetState(instance)
51+
if err != nil {
52+
return true, nil, err
53+
}
54+
55+
// 1. Check if the operation succeeded
56+
if slices.Contains(w.ActiveState, state) {
57+
return true, instance, nil
58+
}
59+
60+
// 2. Check if the operation failed
61+
if slices.Contains(w.ErrorState, state) {
62+
return true, instance, fmt.Errorf("waiting failed. state is %v", state)
63+
}
64+
65+
// 3. Default: Operation is pending
66+
return false, nil, nil
67+
}
68+
}

0 commit comments

Comments
 (0)