Skip to content

Commit f7a2441

Browse files
committed
add retries for objectstorage, add more tests
1 parent 20f28ba commit f7a2441

4 files changed

Lines changed: 334 additions & 182 deletions

File tree

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/google/go-cmp v0.7.0
77
github.com/google/uuid v1.6.0
88
github.com/gorilla/mux v1.8.1
9+
github.com/hashicorp/go-retryablehttp v0.7.8
910
github.com/hashicorp/terraform-plugin-framework v1.19.0
1011
github.com/hashicorp/terraform-plugin-framework-timeouts v0.7.0
1112
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0
@@ -53,10 +54,10 @@ require (
5354
github.com/teambition/rrule-go v1.8.2
5455
go.uber.org/mock v0.6.0
5556
golang.org/x/mod v0.39.0
57+
golang.org/x/time v0.16.0
5658
)
5759

5860
require (
59-
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
6061
github.com/kr/text v0.2.0 // indirect
6162
github.com/stretchr/testify v1.11.1 // indirect
6263
)
@@ -102,7 +103,7 @@ require (
102103
github.com/zclconf/go-cty v1.18.1 // indirect
103104
golang.org/x/crypto v0.54.0 // indirect
104105
golang.org/x/net v0.57.0 // indirect
105-
golang.org/x/sync v0.22.0 // indirect
106+
golang.org/x/sync v0.22.0
106107
golang.org/x/sys v0.47.0 // indirect
107108
golang.org/x/text v0.40.0 // indirect
108109
golang.org/x/tools v0.48.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
306306
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
307307
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
308308
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
309+
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
310+
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
309311
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
310312
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
311313
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

stackit/internal/core/clientutils.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package core
22

33
import (
4+
"bytes"
45
"fmt"
6+
"io"
7+
"math/rand/v2"
58
"net/http"
9+
"strconv"
10+
"time"
611

712
"github.com/stackitcloud/stackit-sdk-go/core/config"
813
alb "github.com/stackitcloud/stackit-sdk-go/services/alb/v2api"
@@ -313,9 +318,116 @@ func (f *DefaultClientFactory) newMongoDbFlexV2Client() (mongodbflex.DefaultAPI,
313318
return apiClient.DefaultAPI, nil
314319
}
315320

321+
// RetryTransport wraps an underlying RoundTripper to handle HTTP 429s with jitter.
322+
type RetryTransport struct {
323+
Base http.RoundTripper
324+
MaxRetries int
325+
BaseBackoff time.Duration
326+
MaxJitter time.Duration
327+
}
328+
329+
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
330+
base := t.Base
331+
if base == nil {
332+
base = http.DefaultTransport
333+
}
334+
335+
// Preserve request body for retries if present
336+
var bodyBytes []byte
337+
if req.Body != nil && req.Body != http.NoBody {
338+
var err error
339+
bodyBytes, err = io.ReadAll(req.Body)
340+
if err != nil {
341+
return nil, err
342+
}
343+
344+
err = req.Body.Close()
345+
if err != nil {
346+
return nil, err
347+
}
348+
}
349+
350+
var resp *http.Response
351+
var err error
352+
353+
for attempt := 0; attempt <= t.MaxRetries; attempt++ {
354+
// Re-hydrate the request body on each attempt
355+
if bodyBytes != nil {
356+
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
357+
}
358+
359+
resp, err = base.RoundTrip(req)
360+
361+
// If success or non-429 error, return immediately
362+
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
363+
return resp, err
364+
}
365+
366+
// Stop if max retries reached
367+
if attempt == t.MaxRetries {
368+
break
369+
}
370+
371+
// Calculate base sleep duration (Retry-After or Exponential Backoff)
372+
wait := t.getWaitDuration(resp, attempt)
373+
374+
// Always add random jitter regardless of Retry-After header presence
375+
jitter := time.Duration(rand.Int64N(int64(t.MaxJitter))) //nolint:gosec // only used for jitter
376+
totalWait := wait + jitter
377+
378+
// Drain and close response body before retrying to reuse TCP connections
379+
_, err = io.Copy(io.Discard, resp.Body)
380+
if err != nil {
381+
return nil, err
382+
}
383+
384+
err = resp.Body.Close()
385+
if err != nil {
386+
return nil, err
387+
}
388+
389+
select {
390+
case <-req.Context().Done():
391+
return nil, req.Context().Err()
392+
case <-time.After(totalWait):
393+
}
394+
}
395+
396+
return resp, err
397+
}
398+
399+
func (t *RetryTransport) getWaitDuration(resp *http.Response, attempt int) time.Duration {
400+
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
401+
// Try parsing as integer seconds
402+
if seconds, err := strconv.Atoi(retryAfter); err == nil {
403+
return time.Duration(seconds) * time.Second
404+
}
405+
// Try parsing as HTTP-Date string
406+
if date, err := http.ParseTime(retryAfter); err == nil {
407+
if d := time.Until(date); d > 0 {
408+
return d
409+
}
410+
}
411+
}
412+
413+
// Fallback to exponential backoff
414+
return t.BaseBackoff * (1 << attempt)
415+
}
416+
316417
func (f *DefaultClientFactory) newObjectStorageV2Client() (objectstorage.DefaultAPI, error) {
317418
apiClientConfigOptions := f.defaultConfigOptions(f.CustomEndpoints.ObjectStorageCustomEndpoint)
318419

420+
mdlw := func(rt http.RoundTripper) http.RoundTripper {
421+
return &RetryTransport{
422+
Base: rt,
423+
MaxRetries: 3,
424+
BaseBackoff: 1 * time.Second,
425+
MaxJitter: 500 * time.Millisecond, // Always added to wait time
426+
}
427+
}
428+
429+
apiClientConfigOptions = append(apiClientConfigOptions, config.WithMiddleware(mdlw))
430+
319431
apiClient, err := objectstorage.NewAPIClient(apiClientConfigOptions...)
320432
if err != nil {
321433
return nil, fmt.Errorf("configuring client: %w. This is an error related to the provider configuration, not to the resource configuration", err)

0 commit comments

Comments
 (0)