Skip to content

Commit 1bbb90c

Browse files
committed
feat(buckets): Introduce maxWait timeout to generalized RetrySettings
RetrySettings are generalized and further described, as they also apply to Create's wait for buckets to become active now. A generalized maxWaitDuration is introduced for controlling when retries time out if they take too long. Update's retry is modified to honor the context being canceled directly - previously an API call would be attempted and fail if the context times out.
1 parent 36e9114 commit 1bbb90c

File tree

2 files changed

+127
-30
lines changed

2 files changed

+127
-30
lines changed

api/clients/buckets/bucket.go

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ type listResponse struct {
5555
}
5656

5757
type retrySettings struct {
58-
maxRetries int
59-
waitDuration time.Duration
58+
maxRetries int
59+
durationBetweenTries time.Duration
60+
maxWaitDuration time.Duration
6061
}
6162

6263
type Client struct {
@@ -67,12 +68,20 @@ type Client struct {
6768
// Option represents a functional Option for the Client.
6869
type Option func(*Client)
6970

70-
// WithUpdateRetrySettings sets the maximum number of retries as well as duration between retries for Update and Upsert HTTP requests
71-
func WithUpdateRetrySettings(maxRetries int, waitDuration time.Duration) Option {
71+
// WithRetrySettings sets the maximum number of retries as well as duration between retries.
72+
// These settings are honored wherever retries are used in the Client - most notably in Client.Update and Client.Upsert,
73+
// as well as Client.Create when waiting for a bucket to become available after creation.
74+
//
75+
// Parameters:
76+
// - maxRetries: maximum amount actions may be retries. (Some actions may ignore this and only honor maxWaitDuration)
77+
// - durationBetweenTries: time.Duration to wait between tries.
78+
// - maxWaitDuration: maximum time.Duration to wait before retrying is cancelled. If you supply a context.Context with a timeout, the shorter of the two will be honored.
79+
func WithRetrySettings(maxRetries int, durationBetweenTries time.Duration, maxWaitDuration time.Duration) Option {
7280
return func(c *Client) {
7381
c.retrySettings = retrySettings{
74-
maxRetries: maxRetries,
75-
waitDuration: waitDuration,
82+
maxRetries: maxRetries,
83+
durationBetweenTries: durationBetweenTries,
84+
maxWaitDuration: maxWaitDuration,
7685
}
7786
}
7887
}
@@ -91,8 +100,9 @@ func NewClient(client *rest.Client, option ...Option) *Client {
91100
c := &Client{
92101
client: client,
93102
retrySettings: retrySettings{
94-
maxRetries: 5,
95-
waitDuration: time.Second,
103+
maxRetries: 5,
104+
durationBetweenTries: time.Second,
105+
maxWaitDuration: 2 * time.Minute,
96106
},
97107
}
98108

@@ -232,27 +242,34 @@ func (c Client) Update(ctx context.Context, bucketName string, data []byte) (Res
232242
}
233243

234244
// attempt update
245+
ctx, cancel := context.WithTimeout(ctx, c.retrySettings.maxWaitDuration)
246+
defer cancel()
235247
for i := 0; i < c.retrySettings.maxRetries; i++ {
236-
logger.V(1).Info(fmt.Sprintf("Trying to update bucket with bucket name %q (%d/%d retries)", bucketName, i+1, c.retrySettings.maxRetries))
248+
select {
249+
case <-ctx.Done():
250+
return Response{}, fmt.Errorf("context cancelled before bucket with bucktName %q became available", bucketName)
251+
default:
252+
logger.V(1).Info(fmt.Sprintf("Trying to update bucket with bucket name %q (%d/%d retries)", bucketName, i+1, c.retrySettings.maxRetries))
237253

238-
resp, err = c.getAndUpdate(ctx, bucketName, data)
239-
if err != nil {
240-
return Response{}, err
241-
}
254+
resp, err = c.getAndUpdate(ctx, bucketName, data)
255+
if err != nil {
256+
return Response{}, err
257+
}
242258

243-
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusBadRequest {
244-
return Response{api.Response{
245-
StatusCode: resp.StatusCode,
246-
Data: resp.Payload,
247-
Request: resp.RequestInfo,
248-
}}, nil
249-
}
259+
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusBadRequest {
260+
return Response{api.Response{
261+
StatusCode: resp.StatusCode,
262+
Data: resp.Payload,
263+
Request: resp.RequestInfo,
264+
}}, nil
265+
}
250266

251-
if resp.IsSuccess() {
252-
logger.Info(fmt.Sprintf("Updated bucket with bucket name %q", bucketName))
253-
return Response{api.Response{StatusCode: resp.StatusCode, Data: resp.Payload, Request: resp.RequestInfo}}, nil
267+
if resp.IsSuccess() {
268+
logger.Info(fmt.Sprintf("Updated bucket with bucket name %q", bucketName))
269+
return Response{api.Response{StatusCode: resp.StatusCode, Data: resp.Payload, Request: resp.RequestInfo}}, nil
270+
}
271+
time.Sleep(c.retrySettings.durationBetweenTries)
254272
}
255-
time.Sleep(c.retrySettings.waitDuration)
256273
}
257274
return Response{
258275
Response: api.Response{
@@ -358,7 +375,7 @@ func (c Client) create(ctx context.Context, bucketName string, data []byte) (res
358375
return r, nil
359376
}
360377

361-
timoutCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)
378+
timoutCtx, cancel := context.WithTimeout(ctx, c.retrySettings.maxWaitDuration)
362379
defer cancel() // cancel deadline if awaitBucketReady returns before deadline
363380
return c.awaitBucketReady(timoutCtx, bucketName)
364381
}
@@ -393,7 +410,7 @@ func (c Client) awaitBucketReady(ctx context.Context, bucketName string) (rest.R
393410
}
394411

395412
logger.V(1).Info("Waiting for bucket to become active after creation...")
396-
time.Sleep(c.retrySettings.waitDuration)
413+
time.Sleep(c.retrySettings.durationBetweenTries)
397414
}
398415
}
399416
}

api/clients/buckets/bucket_test.go

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package buckets_test
1616

1717
import (
18+
"context"
1819
"encoding/json"
1920
"fmt"
2021
"github.com/dynatrace/dynatrace-configuration-as-code-core/api"
@@ -26,7 +27,9 @@ import (
2627
"net/http"
2728
"net/http/httptest"
2829
"net/url"
30+
"strings"
2931
"testing"
32+
"time"
3033
)
3134

3235
func TestGet(t *testing.T) {
@@ -401,7 +404,7 @@ func TestUpsert(t *testing.T) {
401404

402405
client := buckets.NewClient(
403406
rest.NewClient(server.URL(), server.Client()),
404-
buckets.WithUpdateRetrySettings(5, 0))
407+
buckets.WithRetrySettings(5, 0, time.Minute))
405408
data := []byte("{}")
406409

407410
ctx := testutils.ContextWithLogger(t)
@@ -427,7 +430,7 @@ func TestUpsert(t *testing.T) {
427430

428431
client := buckets.NewClient(
429432
rest.NewClient(server.URL(), server.Client()),
430-
buckets.WithUpdateRetrySettings(5, 0))
433+
buckets.WithRetrySettings(5, 0, time.Minute))
431434
data := []byte("{}")
432435

433436
ctx := testutils.ContextWithLogger(t)
@@ -819,7 +822,7 @@ func TestUpdate(t *testing.T) {
819822

820823
client := buckets.NewClient(
821824
rest.NewClient(server.URL(), server.Client()),
822-
buckets.WithUpdateRetrySettings(5, 0))
825+
buckets.WithRetrySettings(5, 0, time.Minute))
823826
data := []byte("{}")
824827

825828
ctx := testutils.ContextWithLogger(t)
@@ -853,7 +856,7 @@ func TestUpdate(t *testing.T) {
853856

854857
})
855858

856-
t.Run("Update fails with conflict only once", func(t *testing.T) {
859+
t.Run("Update fails at first, but succeeds after retry", func(t *testing.T) {
857860
var firstTry = true
858861
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
859862
switch req.Method {
@@ -892,6 +895,83 @@ func TestUpdate(t *testing.T) {
892895

893896
assert.Equal(t, "bucket name", m["bucketName"])
894897
})
898+
899+
t.Run("Update honors context timeout", func(t *testing.T) {
900+
var firstTry = true
901+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
902+
switch req.Method {
903+
case http.MethodPost:
904+
rw.WriteHeader(http.StatusForbidden)
905+
rw.Write([]byte("no, this is an error"))
906+
case http.MethodGet:
907+
rw.Write([]byte(someBucketResponse))
908+
case http.MethodPut:
909+
if firstTry {
910+
rw.WriteHeader(http.StatusConflict)
911+
rw.Write([]byte("conflict"))
912+
firstTry = false
913+
} else {
914+
rw.WriteHeader(http.StatusOK)
915+
rw.Write([]byte(someBucketResponse))
916+
}
917+
default:
918+
assert.Failf(t, "unexpected method %q", req.Method)
919+
}
920+
}))
921+
defer server.Close()
922+
923+
u, _ := url.Parse(server.URL)
924+
client := buckets.NewClient(rest.NewClient(u, &http.Client{}),
925+
buckets.WithRetrySettings(5, 0, time.Minute)) // maxWaitDuration would allow 1 min
926+
data := []byte("{}")
927+
928+
ctx := testutils.ContextWithLogger(t)
929+
ctx, cancel := context.WithTimeout(ctx, 800*time.Microsecond) // context "should" time out after initial GET
930+
defer cancel()
931+
_, err := client.Update(ctx, "bucket name", data)
932+
assert.Error(t, err)
933+
934+
// if GET happens to be cancelled already we'll get a 'deadline exceeded' from the http client. That's ok too, no need to fail the test.
935+
if strings.Contains(err.Error(), "deadline exceeded") {
936+
t.Log("context timed out before our logic and http request returned error")
937+
return
938+
}
939+
assert.ErrorContains(t, err, "cancelled")
940+
})
941+
942+
t.Run("Update honors retrySettings maxWaitDuration", func(t *testing.T) {
943+
var firstTry = true
944+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
945+
switch req.Method {
946+
case http.MethodPost:
947+
rw.WriteHeader(http.StatusForbidden)
948+
rw.Write([]byte("no, this is an error"))
949+
case http.MethodGet:
950+
rw.Write([]byte(someBucketResponse))
951+
case http.MethodPut:
952+
if firstTry {
953+
rw.WriteHeader(http.StatusConflict)
954+
rw.Write([]byte("conflict"))
955+
firstTry = false
956+
} else {
957+
rw.WriteHeader(http.StatusOK)
958+
rw.Write([]byte(someBucketResponse))
959+
}
960+
default:
961+
assert.Failf(t, "unexpected method %q", req.Method)
962+
}
963+
}))
964+
defer server.Close()
965+
966+
u, _ := url.Parse(server.URL)
967+
client := buckets.NewClient(rest.NewClient(u, &http.Client{}),
968+
buckets.WithRetrySettings(5, 0, 0)) // maxWaitDuration should time out immediatly
969+
data := []byte("{}")
970+
971+
ctx := testutils.ContextWithLogger(t)
972+
_, err := client.Update(ctx, "bucket name", data)
973+
assert.ErrorContains(t, err, "cancelled")
974+
})
895975
}
896976

897977
func TestDecodingBucketResponses(t *testing.T) {

0 commit comments

Comments
 (0)