Skip to content

Commit 327f818

Browse files
committed
feat(objectstorage): retry on HTTP 429 rate limits
relates to #1764
1 parent 0c8ee62 commit 327f818

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

stackit/internal/services/objectstorage/utils/util.go

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package utils
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
7+
"io"
8+
"math/rand/v2"
69
"net/http"
10+
"strconv"
711
"time"
812

913
objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api"
@@ -35,14 +39,124 @@ func EnableProject(ctx context.Context, projectId, region string, client objects
3539
return nil
3640
}
3741

42+
// RetryTransport wraps an underlying RoundTripper to retry on HTTP 429 rate limit errors with jitter.
43+
type RetryTransport struct {
44+
Base http.RoundTripper
45+
MaxRetries int
46+
BaseBackoff time.Duration
47+
MaxJitter time.Duration
48+
}
49+
50+
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
51+
base := t.Base
52+
if base == nil {
53+
base = http.DefaultTransport
54+
}
55+
56+
// Preserve request body for retries if present
57+
var bodyBytes []byte
58+
if req.Body != nil && req.Body != http.NoBody {
59+
var err error
60+
bodyBytes, err = io.ReadAll(req.Body)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
err = req.Body.Close()
66+
if err != nil {
67+
return nil, err
68+
}
69+
}
70+
71+
var resp *http.Response
72+
var err error
73+
74+
for attempt := 0; attempt <= t.MaxRetries; attempt++ {
75+
// Re-hydrate the request body on each attempt
76+
if bodyBytes != nil {
77+
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
78+
}
79+
80+
resp, err = base.RoundTrip(req)
81+
82+
// If success or non-429 error, return immediately
83+
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
84+
return resp, err
85+
}
86+
87+
// Stop if max retries reached
88+
if attempt == t.MaxRetries {
89+
break
90+
}
91+
92+
// Calculate base sleep duration (value of Retry-After Header, if header isn't present falls back to exponential backoff)
93+
wait := t.getWaitDuration(resp, attempt)
94+
95+
// Always add random jitter regardless of Retry-After header presence. Else all resource / datasource
96+
// goroutines would try again in parallel after exactly the same interval.
97+
jitter := time.Duration(rand.Int64N(int64(t.MaxJitter))) //nolint:gosec // only used for jitter
98+
totalWait := wait + jitter
99+
100+
// Drain and close response body before retrying to reuse TCP connections
101+
_, err = io.Copy(io.Discard, resp.Body)
102+
if err != nil {
103+
return nil, err
104+
}
105+
106+
err = resp.Body.Close()
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
select {
112+
case <-req.Context().Done():
113+
return nil, req.Context().Err()
114+
case <-time.After(totalWait):
115+
}
116+
}
117+
118+
return resp, err
119+
}
120+
121+
func (t *RetryTransport) getWaitDuration(resp *http.Response, attempt int) time.Duration {
122+
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
123+
// Try parsing as integer seconds
124+
if seconds, err := strconv.Atoi(retryAfter); err == nil {
125+
return time.Duration(seconds) * time.Second
126+
}
127+
// Try parsing as HTTP-Date string
128+
if date, err := http.ParseTime(retryAfter); err == nil {
129+
if d := time.Until(date); d > 0 {
130+
return d
131+
}
132+
}
133+
}
134+
135+
// Fallback to exponential backoff
136+
return t.BaseBackoff * (1 << attempt)
137+
}
138+
38139
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *objectstorage.APIClient {
140+
// Add middleware to retry on HTTP 429 rate limits.
141+
// This solution is **not** intended to be copied to each and every service (!!).
142+
// This should be rolled out centrally instead, should be easily doable after
143+
// this refactoring: https://github.com/stackitcloud/terraform-provider-stackit/pull/1663
144+
retryRoundTripper := &RetryTransport{
145+
Base: providerData.RoundTripper,
146+
MaxRetries: 3,
147+
BaseBackoff: 1 * time.Second,
148+
MaxJitter: 500 * time.Millisecond, // Always added to wait time
149+
}
150+
39151
apiClientConfigOptions := []config.ConfigurationOption{
40-
config.WithCustomAuth(providerData.RoundTripper),
152+
config.WithCustomAuth(retryRoundTripper),
41153
utils.UserAgentConfigOption(providerData.Version),
42154
}
155+
43156
if providerData.ObjectStorageCustomEndpoint != "" {
44157
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ObjectStorageCustomEndpoint))
45158
}
159+
46160
apiClient, err := objectstorage.NewAPIClient(apiClientConfigOptions...)
47161
if err != nil {
48162
core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))

stackit/internal/services/objectstorage/utils/util_test.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package utils
22

33
import (
44
"context"
5+
"crypto/tls"
56
"fmt"
67
"net/http"
78
"os"
@@ -33,6 +34,10 @@ func TestConfigureClient(t *testing.T) {
3334
t.Errorf("error setting env variable: %v", err)
3435
}
3536

37+
var roundTripper http.RoundTripper = &http.Transport{
38+
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13},
39+
}
40+
3641
type args struct {
3742
providerData *core.ProviderData
3843
}
@@ -46,13 +51,21 @@ func TestConfigureClient(t *testing.T) {
4651
name: "default endpoint",
4752
args: args{
4853
providerData: &core.ProviderData{
49-
Version: testVersion,
54+
Version: testVersion,
55+
RoundTripper: roundTripper,
5056
},
5157
},
5258
expected: func() *objectstorage.APIClient {
5359
apiClient, err := objectstorage.NewAPIClient(
5460
utils.UserAgentConfigOption(testVersion),
61+
config.WithCustomAuth(&RetryTransport{
62+
Base: roundTripper,
63+
MaxRetries: 3,
64+
BaseBackoff: 1 * time.Second,
65+
MaxJitter: 500 * time.Millisecond,
66+
}),
5567
)
68+
5669
if err != nil {
5770
t.Errorf("error configuring client: %v", err)
5871
}
@@ -65,13 +78,20 @@ func TestConfigureClient(t *testing.T) {
6578
args: args{
6679
providerData: &core.ProviderData{
6780
Version: testVersion,
81+
RoundTripper: roundTripper,
6882
ObjectStorageCustomEndpoint: testCustomEndpoint,
6983
},
7084
},
7185
expected: func() *objectstorage.APIClient {
7286
apiClient, err := objectstorage.NewAPIClient(
7387
utils.UserAgentConfigOption(testVersion),
7488
config.WithEndpoint(testCustomEndpoint),
89+
config.WithCustomAuth(&RetryTransport{
90+
Base: roundTripper,
91+
MaxRetries: 3,
92+
BaseBackoff: 1 * time.Second,
93+
MaxJitter: 500 * time.Millisecond,
94+
}),
7595
)
7696
if err != nil {
7797
t.Errorf("error configuring client: %v", err)
@@ -91,6 +111,7 @@ func TestConfigureClient(t *testing.T) {
91111
t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr)
92112
}
93113

114+
// fails because reflect.DeepEqual can't compare functions properly -> false
94115
if !reflect.DeepEqual(actual, tt.expected) {
95116
t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected)
96117
}

0 commit comments

Comments
 (0)