From e07fdb568a0d004ba7bbe6d390c2c85db94c54d7 Mon Sep 17 00:00:00 2001 From: Roberto Losanno Date: Thu, 18 Sep 2025 12:59:37 +0100 Subject: [PATCH 1/2] Add rate limiting and retry logic to handle 429 errors - Add configurable rate limiting (RateLimit, RateBurst) - Add automatic retry logic with exponential backoff for 429 responses - Add MaxRetries configuration option - Implement proper 429 error handling in processResponse - Use golang.org/x/time/rate for token bucket rate limiting - Respect server Retry-After headers when provided - Default to conservative 10 req/sec rate limit to comply with API limits - Default to 3 retry attempts with 1s, 2s, 4s, 8s, 16s backoff (capped at 30s) This resolves issues with Terraform and other automation tools that make concurrent requests and frequently hit Contentstack's API rate limits. --- go.mod | 6 ++- go.sum | 2 + management/client.go | 99 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 go.sum diff --git a/go.mod b/go.mod index 5bf8c4a..c2df321 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module github.com/labd/contentstack-go-sdk -go 1.20 +go 1.24.0 + +toolchain go1.24.3 + +require golang.org/x/time v0.13.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa686d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= +golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/management/client.go b/management/client.go index b908bb9..f1b1e8a 100644 --- a/management/client.go +++ b/management/client.go @@ -8,6 +8,10 @@ import ( "io/ioutil" "net/http" "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" ) type Auth struct { @@ -19,6 +23,9 @@ type ClientConfig struct { HTTPClient *http.Client AuthToken string OrganizationUID string + RateLimit float64 + RateBurst int + MaxRetries int } type UserCredentials struct { @@ -27,9 +34,11 @@ type UserCredentials struct { } type Client struct { - authToken string - baseURL *url.URL - httpClient *http.Client + authToken string + baseURL *url.URL + httpClient *http.Client + rateLimiter *rate.Limiter + maxRetries int } type ErrorMessage struct { @@ -60,17 +69,43 @@ func NewClient(cfg ClientConfig) (*Client, error) { httpClient = &http.Client{} } + rateLimit := cfg.RateLimit + if rateLimit <= 0 { + rateLimit = 10.0 + } + + rateBurst := cfg.RateBurst + if rateBurst <= 0 { + rateBurst = 10 + } + + rateLimiter := rate.NewLimiter(rate.Limit(rateLimit), rateBurst) + + maxRetries := cfg.MaxRetries + if maxRetries <= 0 { + maxRetries = 3 + } + client := &Client{ - baseURL: url, - authToken: cfg.AuthToken, - httpClient: httpClient, + baseURL: url, + authToken: cfg.AuthToken, + httpClient: httpClient, + rateLimiter: rateLimiter, + maxRetries: maxRetries, } return client, nil } func NewClientWithToken(auth *Auth) *Client { - return &Client{} + rateLimiter := rate.NewLimiter(rate.Limit(10.0), 10) + + return &Client{ + authToken: auth.AuthToken, + httpClient: &http.Client{}, + rateLimiter: rateLimiter, + maxRetries: 3, + } } func (c *Client) head(ctx context.Context, path string, queryParams url.Values, headers http.Header) (*http.Response, error) { @@ -102,6 +137,16 @@ func (c *Client) createEndpoint(p string) (*url.URL, error) { } func (c *Client) execute(ctx context.Context, method string, path string, params url.Values, headers http.Header, body io.Reader) (*http.Response, error) { + return c.executeWithRetry(ctx, method, path, params, headers, body, 0) +} + +func (c *Client) executeWithRetry(ctx context.Context, method string, path string, params url.Values, headers http.Header, body io.Reader, attempt int) (*http.Response, error) { + if c.rateLimiter != nil { + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limiting wait failed: %w", err) + } + } + endpoint, err := c.createEndpoint(path) if err != nil { return nil, err @@ -127,9 +172,44 @@ func (c *Client) execute(ctx context.Context, method string, path string, params return nil, err } + if resp.StatusCode == 429 && attempt < c.maxRetries { + resp.Body.Close() + waitTime := c.calculateBackoffWait(attempt, resp) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(waitTime): + } + return c.executeWithRetry(ctx, method, path, params, headers, body, attempt+1) + } + return resp, nil } +// calculateBackoffWait calculates the wait time for exponential backoff +func (c *Client) calculateBackoffWait(attempt int, resp *http.Response) time.Duration { + // Check if the server provided a Retry-After header + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + waitTime := time.Duration(seconds) * time.Second + // Cap at 60 seconds maximum + if waitTime > 60*time.Second { + waitTime = 60 * time.Second + } + return waitTime + } + } + + // Simple exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s + waitTime := time.Duration(1< 30*time.Second { + waitTime = 30 * time.Second + } + + return waitTime +} + func (c *Client) processResponse(r *http.Response, dst interface{}) error { content, err := ioutil.ReadAll(r.Body) defer r.Body.Close() @@ -171,6 +251,11 @@ func (c *Client) processResponse(r *http.Response, dst interface{}) error { } } return &result + case 429: + return &ErrorMessage{ + ErrorMessage: "Rate limit exceeded. All retry attempts have been exhausted. Please reduce request frequency or increase rate limiting configuration.", + ErrorCode: 429, + } default: return fmt.Errorf("Unhandled StatusCode: %d", r.StatusCode) } From b4605d6af94e496e32032f75ac769af329708852 Mon Sep 17 00:00:00 2001 From: Roberto Losanno Date: Fri, 19 Sep 2025 12:41:54 +0100 Subject: [PATCH 2/2] refactor: remove retry logic from SDK, keep only rate limiting - Remove retry-related fields and functions from client - Move retry logic to terraform provider for better separation - Simplify SDK to focus on rate limiting only --- management/client.go | 55 -------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/management/client.go b/management/client.go index f1b1e8a..c8d2c08 100644 --- a/management/client.go +++ b/management/client.go @@ -8,8 +8,6 @@ import ( "io/ioutil" "net/http" "net/url" - "strconv" - "time" "golang.org/x/time/rate" ) @@ -25,7 +23,6 @@ type ClientConfig struct { OrganizationUID string RateLimit float64 RateBurst int - MaxRetries int } type UserCredentials struct { @@ -38,7 +35,6 @@ type Client struct { baseURL *url.URL httpClient *http.Client rateLimiter *rate.Limiter - maxRetries int } type ErrorMessage struct { @@ -81,17 +77,11 @@ func NewClient(cfg ClientConfig) (*Client, error) { rateLimiter := rate.NewLimiter(rate.Limit(rateLimit), rateBurst) - maxRetries := cfg.MaxRetries - if maxRetries <= 0 { - maxRetries = 3 - } - client := &Client{ baseURL: url, authToken: cfg.AuthToken, httpClient: httpClient, rateLimiter: rateLimiter, - maxRetries: maxRetries, } return client, nil @@ -104,7 +94,6 @@ func NewClientWithToken(auth *Auth) *Client { authToken: auth.AuthToken, httpClient: &http.Client{}, rateLimiter: rateLimiter, - maxRetries: 3, } } @@ -137,10 +126,6 @@ func (c *Client) createEndpoint(p string) (*url.URL, error) { } func (c *Client) execute(ctx context.Context, method string, path string, params url.Values, headers http.Header, body io.Reader) (*http.Response, error) { - return c.executeWithRetry(ctx, method, path, params, headers, body, 0) -} - -func (c *Client) executeWithRetry(ctx context.Context, method string, path string, params url.Values, headers http.Header, body io.Reader, attempt int) (*http.Response, error) { if c.rateLimiter != nil { if err := c.rateLimiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limiting wait failed: %w", err) @@ -172,44 +157,9 @@ func (c *Client) executeWithRetry(ctx context.Context, method string, path strin return nil, err } - if resp.StatusCode == 429 && attempt < c.maxRetries { - resp.Body.Close() - waitTime := c.calculateBackoffWait(attempt, resp) - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(waitTime): - } - return c.executeWithRetry(ctx, method, path, params, headers, body, attempt+1) - } - return resp, nil } -// calculateBackoffWait calculates the wait time for exponential backoff -func (c *Client) calculateBackoffWait(attempt int, resp *http.Response) time.Duration { - // Check if the server provided a Retry-After header - if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { - if seconds, err := strconv.Atoi(retryAfter); err == nil { - waitTime := time.Duration(seconds) * time.Second - // Cap at 60 seconds maximum - if waitTime > 60*time.Second { - waitTime = 60 * time.Second - } - return waitTime - } - } - - // Simple exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s - waitTime := time.Duration(1< 30*time.Second { - waitTime = 30 * time.Second - } - - return waitTime -} - func (c *Client) processResponse(r *http.Response, dst interface{}) error { content, err := ioutil.ReadAll(r.Body) defer r.Body.Close() @@ -251,11 +201,6 @@ func (c *Client) processResponse(r *http.Response, dst interface{}) error { } } return &result - case 429: - return &ErrorMessage{ - ErrorMessage: "Rate limit exceeded. All retry attempts have been exhausted. Please reduce request frequency or increase rate limiting configuration.", - ErrorCode: 429, - } default: return fmt.Errorf("Unhandled StatusCode: %d", r.StatusCode) }