Skip to content

Commit 706fc7b

Browse files
feat(objectstorage): retry on HTTP 429 rate limits (#1771)
relates to #1764 Co-authored-by: Marcel Jacek <Marcel.Jacek@digits.schwarz>
1 parent 84d56f5 commit 706fc7b

3 files changed

Lines changed: 222 additions & 6 deletions

File tree

stackit/internal/services/objectstorage/objectstorage_acc_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,8 @@ func TestAccObjectStorageResourceMin(t *testing.T) {
143143
}
144144
145145
data "stackit_objectstorage_default_retention" "retention" {
146-
bucket_name = stackit_objectstorage_bucket.bucket_object_lock.name
147-
project_id = var.project_id
148-
days = var.retention_days
149-
mode = var.retention_mode
146+
bucket_name = stackit_objectstorage_bucket.bucket_object_lock.name
147+
project_id = var.project_id
150148
}
151149
`,
152150
testutil.NewConfigBuilder().BuildProviderConfig()+resourceMinConfig,

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

Lines changed: 122 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,131 @@ 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 without mutating the original request
57+
getBody := req.GetBody
58+
if getBody == nil && req.Body != nil && req.Body != http.NoBody {
59+
bodyBytes, err := io.ReadAll(req.Body)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
err = req.Body.Close()
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
getBody = func() (io.ReadCloser, error) {
70+
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
71+
}
72+
}
73+
74+
var resp *http.Response
75+
var err error
76+
77+
for attempt := 0; attempt <= t.MaxRetries; attempt++ {
78+
reqClone := req.Clone(req.Context())
79+
if getBody != nil {
80+
var bodyErr error
81+
reqClone.Body, bodyErr = getBody()
82+
if bodyErr != nil {
83+
return nil, bodyErr
84+
}
85+
}
86+
87+
resp, err = base.RoundTrip(reqClone)
88+
89+
// If success or non-429 error, return immediately
90+
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
91+
return resp, err
92+
}
93+
94+
// Stop if max retries reached
95+
if attempt == t.MaxRetries {
96+
break
97+
}
98+
99+
// Calculate base sleep duration (value of Retry-After Header, if header isn't present falls back to exponential backoff)
100+
wait := t.getWaitDuration(resp, attempt)
101+
102+
// Always add random jitter regardless of Retry-After header presence. Else all resource / datasource
103+
// goroutines would try again in parallel after exactly the same interval.
104+
jitter := time.Duration(rand.Int64N(int64(t.MaxJitter))) //nolint:gosec // only used for jitter
105+
totalWait := wait + jitter
106+
107+
// Drain and close response body before retrying to reuse TCP connections
108+
_, err = io.Copy(io.Discard, resp.Body)
109+
if err != nil {
110+
return nil, err
111+
}
112+
113+
err = resp.Body.Close()
114+
if err != nil {
115+
return nil, err
116+
}
117+
118+
select {
119+
case <-req.Context().Done():
120+
return nil, req.Context().Err()
121+
case <-time.After(totalWait):
122+
}
123+
}
124+
125+
return resp, err
126+
}
127+
128+
func (t *RetryTransport) getWaitDuration(resp *http.Response, attempt int) time.Duration {
129+
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
130+
// Try parsing as integer seconds
131+
if seconds, err := strconv.Atoi(retryAfter); err == nil {
132+
return time.Duration(seconds) * time.Second
133+
}
134+
// Try parsing as HTTP-Date string
135+
if date, err := http.ParseTime(retryAfter); err == nil {
136+
if d := time.Until(date); d > 0 {
137+
return d
138+
}
139+
}
140+
}
141+
142+
// Fallback to exponential backoff
143+
return t.BaseBackoff * (1 << attempt)
144+
}
145+
38146
func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *objectstorage.APIClient {
147+
// Add middleware to retry on HTTP 429 rate limits.
148+
// This solution is **not** intended to be copied to each and every service (!!).
149+
// This should be rolled out centrally instead, should be easily doable after
150+
// this refactoring: https://github.com/stackitcloud/terraform-provider-stackit/pull/1663
151+
retryRoundTripper := &RetryTransport{
152+
Base: providerData.RoundTripper,
153+
MaxRetries: 3,
154+
BaseBackoff: 10 * time.Second,
155+
MaxJitter: 500 * time.Millisecond, // Always added to wait time
156+
}
157+
39158
apiClientConfigOptions := []config.ConfigurationOption{
40-
config.WithCustomAuth(providerData.RoundTripper),
159+
config.WithCustomAuth(retryRoundTripper),
41160
utils.UserAgentConfigOption(providerData.Version),
42161
}
162+
43163
if providerData.ObjectStorageCustomEndpoint != "" {
44164
apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ObjectStorageCustomEndpoint))
45165
}
166+
46167
apiClient, err := objectstorage.NewAPIClient(apiClientConfigOptions...)
47168
if err != nil {
48169
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: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ package utils
22

33
import (
44
"context"
5+
"crypto/tls"
56
"fmt"
67
"net/http"
8+
"net/http/httptest"
79
"os"
810
"reflect"
911
"testing"
1012
"testing/synctest"
1113
"time"
1214

15+
"github.com/google/uuid"
1316
"github.com/hashicorp/terraform-plugin-framework/diag"
1417
sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients"
1518
"github.com/stackitcloud/stackit-sdk-go/core/config"
@@ -33,6 +36,10 @@ func TestConfigureClient(t *testing.T) {
3336
t.Errorf("error setting env variable: %v", err)
3437
}
3538

39+
var roundTripper http.RoundTripper = &http.Transport{
40+
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13},
41+
}
42+
3643
type args struct {
3744
providerData *core.ProviderData
3845
}
@@ -46,13 +53,21 @@ func TestConfigureClient(t *testing.T) {
4653
name: "default endpoint",
4754
args: args{
4855
providerData: &core.ProviderData{
49-
Version: testVersion,
56+
Version: testVersion,
57+
RoundTripper: roundTripper,
5058
},
5159
},
5260
expected: func() *objectstorage.APIClient {
5361
apiClient, err := objectstorage.NewAPIClient(
5462
utils.UserAgentConfigOption(testVersion),
63+
config.WithCustomAuth(&RetryTransport{
64+
Base: roundTripper,
65+
MaxRetries: 3,
66+
BaseBackoff: 10 * time.Second,
67+
MaxJitter: 500 * time.Millisecond,
68+
}),
5569
)
70+
5671
if err != nil {
5772
t.Errorf("error configuring client: %v", err)
5873
}
@@ -65,13 +80,20 @@ func TestConfigureClient(t *testing.T) {
6580
args: args{
6681
providerData: &core.ProviderData{
6782
Version: testVersion,
83+
RoundTripper: roundTripper,
6884
ObjectStorageCustomEndpoint: testCustomEndpoint,
6985
},
7086
},
7187
expected: func() *objectstorage.APIClient {
7288
apiClient, err := objectstorage.NewAPIClient(
7389
utils.UserAgentConfigOption(testVersion),
7490
config.WithEndpoint(testCustomEndpoint),
91+
config.WithCustomAuth(&RetryTransport{
92+
Base: roundTripper,
93+
MaxRetries: 3,
94+
BaseBackoff: 10 * time.Second,
95+
MaxJitter: 500 * time.Millisecond,
96+
}),
7597
)
7698
if err != nil {
7799
t.Errorf("error configuring client: %v", err)
@@ -98,6 +120,81 @@ func TestConfigureClient(t *testing.T) {
98120
}
99121
}
100122

123+
func TestClientRetry(t *testing.T) {
124+
ctx := context.Background()
125+
diags := diag.Diagnostics{}
126+
127+
testProjectId := uuid.New().String()
128+
const testRegion = "eu01"
129+
const testBucketName = "karl-otto"
130+
131+
attempts := 0
132+
133+
// Create mock server returning HTTP 429 on first & second call, HTTP 200 on final retry
134+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+
attempts++
136+
137+
if r.URL.Path != fmt.Sprintf("/v2/project/%s/regions/%s/bucket/%s", testProjectId, testRegion, testBucketName) {
138+
t.Fatalf("invalid endpoint called")
139+
}
140+
141+
// first request: HTTP 429 *with* Retry-After header
142+
if attempts == 1 {
143+
w.Header().Set("Retry-After", "1")
144+
w.Header().Set("Content-Type", "application/json")
145+
w.WriteHeader(http.StatusTooManyRequests)
146+
_, err := w.Write([]byte(`{"error": "rate_limit_exceeded"}`))
147+
if err != nil {
148+
t.Fatalf("error writing response: %v", err)
149+
}
150+
return
151+
}
152+
153+
// second request: HTTP 429 *without* Retry-After header (we expect base backoff to be used now)
154+
if attempts == 2 {
155+
w.Header().Set("Content-Type", "application/json")
156+
w.WriteHeader(http.StatusTooManyRequests)
157+
_, err := w.Write([]byte(`{"error": "rate_limit_exceeded"}`))
158+
if err != nil {
159+
t.Fatalf("error writing response: %v", err)
160+
}
161+
return
162+
}
163+
164+
w.Header().Set("Content-Type", "application/json")
165+
w.WriteHeader(http.StatusOK)
166+
_, err := w.Write([]byte(`{
167+
"bucket": {
168+
"name": "bucket-1",
169+
"objectLockEnabled": false,
170+
"region": "eu01",
171+
"urlPathStyle": "https://object.storage.eu01.onstackit.cloud/bucket-1",
172+
"urlVirtualHostedStyle": "https://bucket-1.object.storage.eu01.onstackit.cloud"
173+
},
174+
"project": "` + testProjectId + `"}`))
175+
if err != nil {
176+
t.Fatalf("error writing response: %v", err)
177+
}
178+
}))
179+
defer server.Close()
180+
181+
client := ConfigureClient(ctx, &core.ProviderData{
182+
ObjectStorageCustomEndpoint: server.URL,
183+
}, &diags)
184+
if diags.HasError() {
185+
t.Fatalf("error configuring client: %v", diags)
186+
}
187+
188+
_, err := client.DefaultAPI.GetBucket(ctx, testProjectId, testRegion, testBucketName).Execute()
189+
if err != nil {
190+
t.Fatalf("unexpected request error: %v", err)
191+
}
192+
193+
if attempts != 3 {
194+
t.Fatalf("expected 3 attempts, got %d", attempts)
195+
}
196+
}
197+
101198
func TestEnableProject(t *testing.T) {
102199
tests := []struct {
103200
description string

0 commit comments

Comments
 (0)