From 3f19c3b5bd88814d89a77a2d2385c33d3f9ce9db Mon Sep 17 00:00:00 2001 From: Ruben Hoenle Date: Tue, 20 Oct 2026 18:51:47 +0200 Subject: [PATCH] feat(examples): add example for automatic HTTP retries --- examples/retries/go.mod | 14 +++ examples/retries/go.sum | 8 ++ examples/retries/retries.go | 184 ++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 examples/retries/go.mod create mode 100644 examples/retries/go.sum create mode 100644 examples/retries/retries.go diff --git a/examples/retries/go.mod b/examples/retries/go.mod new file mode 100644 index 0000000000..158518b24d --- /dev/null +++ b/examples/retries/go.mod @@ -0,0 +1,14 @@ +module github.com/stackitcloud/stackit-sdk-go/examples/retries + +go 1.25 + +// This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub. +replace github.com/stackitcloud/stackit-sdk-go/services/objectstorage => ../../services/objectstorage + +require github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.12.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/stackitcloud/stackit-sdk-go/core v0.27.1 // indirect +) diff --git a/examples/retries/go.sum b/examples/retries/go.sum new file mode 100644 index 0000000000..5bae1d1250 --- /dev/null +++ b/examples/retries/go.sum @@ -0,0 +1,8 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/stackitcloud/stackit-sdk-go/core v0.27.1 h1:k7SrqqMoHjkvHbx6n6nV3Lt51itp8nNplht6vGHkPhw= +github.com/stackitcloud/stackit-sdk-go/core v0.27.1/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= diff --git a/examples/retries/retries.go b/examples/retries/retries.go new file mode 100644 index 0000000000..9999d8f443 --- /dev/null +++ b/examples/retries/retries.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "math/rand/v2" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/config" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +/* This example shows how a middleware can be used to enable automatic + * retries for HTTP 429 rate limits within the STACKIT Go SDK. To show + * off that it works an amount of 100 goroutines is spawned which all try + * to do the same SDK call in parallel. + */ + +// RetryTransport wraps an underlying RoundTripper to retry on HTTP 429 rate limit errors with jitter. +type RetryTransport struct { + Base http.RoundTripper + MaxRetries int + BaseBackoff time.Duration + MaxJitter time.Duration +} + +func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + + // Preserve request body for retries if present without mutating the original request + getBody := req.GetBody + if getBody == nil && req.Body != nil && req.Body != http.NoBody { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + err = req.Body.Close() + if err != nil { + return nil, err + } + + getBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(bodyBytes)), nil + } + } + + var resp *http.Response + var err error + + for attempt := 0; attempt <= t.MaxRetries; attempt++ { + reqClone := req.Clone(req.Context()) + if getBody != nil { + var bodyErr error + reqClone.Body, bodyErr = getBody() + if bodyErr != nil { + return nil, bodyErr + } + } + + resp, err = base.RoundTrip(reqClone) + + // If success or non-429 error, return immediately + if err != nil || resp.StatusCode != http.StatusTooManyRequests { + return resp, err + } + + // Stop if max retries reached + if attempt == t.MaxRetries { + break + } + + // Calculate base sleep duration (value of Retry-After Header, if header isn't present falls back to exponential backoff) + wait := t.getWaitDuration(resp, attempt) + + // Always add random jitter regardless of Retry-After header presence. Else all + // goroutines would try again in parallel after exactly the same interval. + jitter := time.Duration(rand.Int64N(int64(t.MaxJitter))) //nolint:gosec // only used for jitter + totalWait := wait + jitter + + // Drain and close response body before retrying to reuse TCP connections + _, err = io.Copy(io.Discard, resp.Body) + if err != nil { + return nil, err + } + + err = resp.Body.Close() + if err != nil { + return nil, err + } + + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(totalWait): + } + } + + return resp, err +} + +func (t *RetryTransport) getWaitDuration(resp *http.Response, attempt int) time.Duration { + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + // Try parsing as integer seconds + if seconds, err := strconv.Atoi(retryAfter); err == nil { + return time.Duration(seconds) * time.Second + } + // Try parsing as HTTP-Date string + if date, err := http.ParseTime(retryAfter); err == nil { + if d := time.Until(date); d > 0 { + return d + } + } + } + + // Fallback to exponential backoff + return t.BaseBackoff * (1 << attempt) +} + +func NewRetryMiddleware(rt http.RoundTripper) http.RoundTripper { + return &RetryTransport{ + Base: rt, + MaxRetries: 3, + BaseBackoff: 10 * time.Second, + MaxJitter: 500 * time.Millisecond, // Always added to wait time + } +} + +func main() { + const ( + projectId = "PROJECT_ID" // the uuid of your STACKIT project + region = "eu01" + ) + + ctx := context.Background() + + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithMiddleware(NewRetryMiddleware), + } + + // Create a new API client, that uses default authentication, add the retry implementation as a middleware. + client, err := objectstorage.NewAPIClient(apiClientConfigOptions...) + if err != nil { + fmt.Fprintf(os.Stderr, "Creating API client: %v\n", err) + os.Exit(1) + } + + // do an initial API call to make sure not all goroutines will try to authenticate in parallel + _, err = client.DefaultAPI.ListBuckets(ctx, projectId, region).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Initial API call failed: %v\n", err) + return + } + + var wg sync.WaitGroup + + for i := range 100 { + wg.Add(1) + + go func(goroutineId int) { + defer wg.Done() + + resp, err := client.DefaultAPI.ListBuckets(ctx, projectId, region).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Goroutine #%d failed: %v\n", goroutineId, err) + return + } + + fmt.Fprintf(os.Stderr, "Goroutine #%d completed (Amount of buckets: %d)\n", goroutineId, len(resp.Buckets)) + }(i) + } + + wg.Wait() + fmt.Fprintln(os.Stderr, "All goroutines finished.") +}