Repository: Shopify/themekit
Pull request #839 (2020-11-18 19:10:04)

Smarter Throttle Gate
related #801 

This changes our rate limiter into a smart circuit breaking throttle gate. What this means functionally that:
- The request rate is limited to 4 per/second with bursts of 4 requests allowed
- When we get a 429 response, we cancel all inflight requests and they are all paused for the Retry-After value
- the allotted time is waited on
- The cancelled requests are started again
- The original request with the 429 response is restarted.
- None of these retries are counted against the http retry mechanism as they are not an error response from shopify or an error in the asset.

### Todo
Tests still need to be updated a little bit.

### Warn Checklist
- [ ] This changes the interface and requires a Major/Minor version change.
- [x] I have :tophat:'d these changes by using the commands I changed by hand.
- [ ] I have added a dependancy to the project.
- [x] I have considered any potential impact on [node-themekit](https://github.com/Shopify/node-themekit)


andyw8: > None of these retries are not counted

Should that say "None of these retries are counted"?
andyw8: > The request rate is limited to 4 per/second with bursts of 4 requests allowed

I think that's only for Plus, standard is 2 per second: https://shopify.dev/concepts/about-apis/rate-limits#rate-limiting-methods
tanema: I am referring to themekit and not shopify. Since themekit doesnt really have a way to know what the merchant is, I have set it to the larger limit, and rely on the throttling to handle it. Since the range is not the different, non-plus people will not notice the difference.

Patch:
diff --git a/.travis.yml b/.travis.yml
index b294691..2f5e494 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,6 +1,6 @@
 language: go
 go:
-  - 1.12
+  - 1.13
 script:
   - ./check_format.sh
   - env GO111MODULE=on make lint
diff --git a/changelog.txt b/changelog.txt
index 4d22a0e..191b9d1 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -6,6 +6,8 @@ v1.1.3
 - Added --live flag to get, download, configure to automatically set
 configuration to the published theme
 - Added --hidepb flag to hide preview bar (#829)
+- Improved request throttling and retrying, to eliminate ABS interactions and
+hanging operations (#839)
 
 v1.1.2 (Sep 29, 2020)
 =====================
diff --git a/src/httpify/client.go b/src/httpify/client.go
index 34b7e8b..fb05792 100644
--- a/src/httpify/client.go
+++ b/src/httpify/client.go
@@ -1,18 +1,15 @@
 package httpify
 
 import (
-	"bytes"
 	"context"
 	"crypto/tls"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"net"
 	"net/http"
 	"net/url"
 	"runtime"
-	"strconv"
 	"strings"
 	"time"
 
@@ -142,30 +139,22 @@ func (client *HTTPClient) do(method, path string, body interface{}, headers map[
 }
 
 func (client *HTTPClient) doWithRetry(req *http.Request, body interface{}) (*http.Response, error) {
-	for attempt := 0; attempt <= client.maxRetry; {
-		// reset the body when non-nil for every request (rewind)
-		if body != nil {
-			data, err := json.Marshal(body)
-			if err != nil {
-				return nil, err
-			}
-			req.Body = ioutil.NopCloser(bytes.NewBuffer(data))
+	var bodyData []byte
+	var err error
+	if body != nil {
+		bodyData, err = json.Marshal(body)
+		if err != nil {
+			return nil, err
 		}
+	}
 
-		client.limit.Wait()
-		resp, err := httpClient.Do(req)
-		if err == nil {
-			if resp.StatusCode >= 100 && resp.StatusCode <= 428 {
-				return resp, nil
-			} else if resp.StatusCode == http.StatusTooManyRequests {
-				after, _ := strconv.ParseFloat(resp.Header.Get("Retry-After"), 10)
-				client.limit.ResetAfter(time.Duration(after) * time.Second)
-				continue
-			}
+	for attempt := 0; attempt <= client.maxRetry; attempt++ {
+		resp, err := client.limit.GateReq(httpClient, req, bodyData)
+		if err == nil && resp.StatusCode >= 100 && resp.StatusCode < 500 {
+			return resp, nil
 		} else if strings.Contains(err.Error(), "no such host") {
 			return nil, ErrConnectionIssue
 		}
-		attempt++
 		time.Sleep(time.Duration(attempt) * time.Second)
 	}
 	return nil, fmt.Errorf("request failed after %v retries", client.maxRetry)
diff --git a/src/ratelimiter/rate_limiter.go b/src/ratelimiter/rate_limiter.go
index 1e29557..6350b83 100644
--- a/src/ratelimiter/rate_limiter.go
+++ b/src/ratelimiter/rate_limiter.go
@@ -1,8 +1,13 @@
 package ratelimiter
 
 import (
+	"bytes"
 	"context"
+	"errors"
 	"golang.org/x/time/rate"
+	"io/ioutil"
+	"net/http"
+	"strconv"
 	"time"
 )
 
@@ -12,34 +17,72 @@ var domainLimitMap = make(map[string]*Limiter)
 type Limiter struct {
 	perSecond rate.Limit
 	rate      *rate.Limiter
+	waiting   chan int
+	ctx       context.Context
+	cancel    context.CancelFunc
+	locked    bool
 }
 
 // New creates a new call rate limiter for a single domain
 func New(domain string, reqPerSec int) *Limiter {
 	if _, ok := domainLimitMap[domain]; !ok {
 		everySecond := rate.Every(time.Second / time.Duration(reqPerSec))
+		ctx, cancel := context.WithCancel(context.Background())
 		domainLimitMap[domain] = &Limiter{
 			perSecond: everySecond,
 			rate:      rate.NewLimiter(everySecond, reqPerSec),
+			ctx:       ctx,
+			cancel:    cancel,
 		}
 	}
 	return domainLimitMap[domain]
 }
 
-// ResetAfter will reset the bucket to 0, wait for the amount of time until it resumes
-// This will allow the rate limiter to stop all activity and restart slowly
-func (limiter *Limiter) ResetAfter(after time.Duration) {
-	if limiter.rate.Limit() == 0 {
+// GateReq will make the http request but will force it to comply with concurrent limits,
+// rate limits, and it will also retry requests that receive 429.
+// When a 429 occurs, it will cancel all inflight requests and pauses, so that the requests
+// dont continue to batter the server and cause bot detection
+func (limiter *Limiter) GateReq(client *http.Client, origReq *http.Request, body []byte) (*http.Response, error) {
+	limiter.rate.Wait(context.Background())
+	req := origReq.WithContext(limiter.ctx)
+	// reset the body when non-nil for every request (rewind)
+	if len(body) > 0 {
+		req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
+	}
+	resp, err := client.Do(req)
+	if err == nil && resp.StatusCode == http.StatusTooManyRequests {
+		limiter.retryAfter(resp.Header.Get("Retry-After"))
+		return limiter.GateReq(client, origReq, body)
+	} else if errors.Is(err, context.Canceled) {
+		<-limiter.waiting
+		return limiter.GateReq(client, origReq, body)
+	}
+	return resp, err
+}
+
+func (limiter *Limiter) retryAfter(header string) {
+	limiter.lock()
+	defer limiter.unlock()
+	after, _ := strconv.ParseFloat(header, 10)
+	time.Sleep(time.Duration(after) * time.Second)
+}
+
+func (limiter *Limiter) lock() {
+	if limiter.locked {
 		return
 	}
+	limiter.locked = true
+	limiter.waiting = make(chan int)
 	limiter.rate.SetLimit(0)
-	go func() {
-		time.Sleep(after)
-		limiter.rate.SetLimit(limiter.perSecond)
-	}()
+	limiter.cancel()
 }
 
-// Wait will block until enough time has passed and the limit will not be passed
-func (limiter *Limiter) Wait() {
-	limiter.rate.Wait(context.Background())
+func (limiter *Limiter) unlock() {
+	if !limiter.locked {
+		return
+	}
+	limiter.rate.SetLimit(limiter.perSecond)
+	limiter.ctx, limiter.cancel = context.WithCancel(context.Background())
+	close(limiter.waiting)
+	limiter.locked = false
 }


Test patch:
diff --git a/src/ratelimiter/rate_limiter_test.go b/src/ratelimiter/rate_limiter_test.go
index b4f96fb..f2d4d7b 100644
--- a/src/ratelimiter/rate_limiter_test.go
+++ b/src/ratelimiter/rate_limiter_test.go
@@ -16,11 +16,22 @@ func TestRateLimiterForDomain(t *testing.T) {
 	assert.NotEqual(t, limiter2, limiter3)
 }
 
-func TestRateLimiterResetAfter(t *testing.T) {
+func TestRateLimiterLockUnlock(t *testing.T) {
 	limiter := New("domain.com", 1)
 	assert.Equal(t, limiter.rate.Limit(), rate.Limit(1))
-	limiter.ResetAfter(time.Millisecond)
+	limiter.lock()
+	<-limiter.ctx.Done()
 	assert.Equal(t, limiter.rate.Limit(), rate.Limit(0))
-	time.Sleep(2 * time.Millisecond)
+	limiter.unlock()
 	assert.Equal(t, limiter.rate.Limit(), rate.Limit(1))
+	assert.Nil(t, limiter.ctx.Err())
+	limiter.unlock()
+}
+
+func TestRateLimiterRetryAfter(t *testing.T) {
+	limiter := New("domain.com", 1)
+	expected := time.Now().Add(2 * time.Second)
+	limiter.retryAfter("2.0")
+	after := time.Now()
+	assert.True(t, after.After(expected) || after.Equal(expected))
 }