Skip to content

Commit

Permalink
adding WithRetry option
Browse files Browse the repository at this point in the history
  • Loading branch information
luthermonson committed Mar 19, 2020
1 parent fa02f85 commit 5b6f777
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 30 deletions.
90 changes: 62 additions & 28 deletions goshopify.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type Client struct {
// A permanent access token
token string

// max number of retries, defaults to 0 for no retries see WithRetry option
retries int

RateLimits RateLimitInfo

// Services used for communicating with the API
Expand Down Expand Up @@ -215,21 +218,6 @@ func (c *Client) NewRequest(method, relPath string, body, options interface{}) (
return req, nil
}

// Option is used to configure client with options
type Option func(c *Client)

// WithVersion optionally sets the api-version if the passed string is valid
func WithVersion(apiVersion string) Option {
return func(c *Client) {
pathPrefix := defaultApiPathPrefix
if len(apiVersion) > 0 && apiVersionRegex.MatchString(apiVersion) {
pathPrefix = fmt.Sprintf("admin/api/%s", apiVersion)
}
c.apiVersion = apiVersion
c.pathPrefix = pathPrefix
}
}

// NewClient returns a new Shopify API client with an already authenticated shopname and
// token. The shopName parameter is the shop's myshopify domain,
// e.g. "theshop.myshopify.com", or simply "theshop"
Expand All @@ -254,6 +242,7 @@ func NewClient(app App, shopName, token string, opts ...Option) *Client {
app: app,
baseURL: baseURL,
token: token,
retries: 0,
apiVersion: defaultApiVersion,
pathPrefix: defaultApiPathPrefix,
}
Expand Down Expand Up @@ -308,17 +297,53 @@ func (c *Client) Do(req *http.Request, v interface{}) error {

// doGetHeaders executes a request, decoding the response into `v` and also returns any response headers.
func (c *Client) doGetHeaders(req *http.Request, v interface{}) (http.Header, error) {
resp, err := c.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var resp *http.Response
var err error
retries := c.retries
for {
resp, err = c.Client.Do(req)
if err != nil {
return nil, err //http client errors, not api responses
}

err = CheckResponseError(resp)
if err != nil {
return nil, err
respErr := CheckResponseError(resp)
if respErr == nil {
break // no errors, break out of the retry loop
}

// retry scenario, close resp and any continue will retry
if closeErr := resp.Body.Close(); closeErr != nil {
return nil, closeErr
}

if rateLimitErr, isRetryErr := respErr.(RateLimitError); isRetryErr {
// back off and retry
if retries <= 0 {
return nil, respErr
}
wait := time.Duration(rateLimitErr.RetryAfter) * time.Second
time.Sleep(wait)
retries--
continue
}

var doRetry bool
switch resp.StatusCode {
case http.StatusServiceUnavailable:
doRetry = true
retries--
}

if doRetry {
continue
}

// no retry attempts, just return the err
return nil, respErr
}

defer resp.Body.Close()

if c.apiVersion == defaultApiVersion && resp.Header.Get("X-Shopify-API-Version") != "" {
// if using stable on first request set the api version
c.apiVersion = resp.Header.Get("X-Shopify-API-Version")
Expand All @@ -343,21 +368,30 @@ func (c *Client) doGetHeaders(req *http.Request, v interface{}) (http.Header, er
}

func wrapSpecificError(r *http.Response, err ResponseError) error {
if err.Status == 429 {
f, _ := strconv.ParseFloat(r.Header.Get("retry-after"), 64)
// see https://www.shopify.dev/concepts/about-apis/response-codes
if err.Status == http.StatusTooManyRequests {
f, _ := strconv.ParseFloat(r.Header.Get("Retry-After"), 64)
return RateLimitError{
ResponseError: err,
RetryAfter: int(f),
}
}
if err.Status == 406 {
err.Message = "Not acceptable"

if err.Status == http.StatusSeeOther {
// todo
// The response to the request can be found under a different URL in the
// Location header and can be retrieved using a GET method on that resource.
}

if err.Status == http.StatusNotAcceptable {
err.Message = http.StatusText(err.Status)
}

return err
}

func CheckResponseError(r *http.Response) error {
if r.StatusCode >= 200 && r.StatusCode < 300 {
if http.StatusOK <= r.StatusCode && r.StatusCode < http.StatusMultipleChoices {
return nil
}

Expand Down
92 changes: 90 additions & 2 deletions goshopify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func setup() {
Scope: "read_products",
Password: "privateapppassword",
}
client = NewClient(app, "fooshop", "abcd", WithVersion(testApiVersion))
client = NewClient(app, "fooshop", "abcd", WithVersion(testApiVersion), WithRetry(3))
httpmock.ActivateNonDefault(client.Client)
}

Expand Down Expand Up @@ -337,7 +337,7 @@ func TestDo(t *testing.T) {
httpmock.NewStringResponder(406, ``),
ResponseError{
Status: 406,
Message: "Not acceptable",
Message: "Not Acceptable",
},
},
{
Expand Down Expand Up @@ -378,6 +378,94 @@ func TestDo(t *testing.T) {
}
}

func TestRateLimitRetry(t *testing.T) {
setup()
defer teardown()

type MyStruct struct {
Foo string `json:"foo"`
}

expected := &MyStruct{Foo: "bar"}
retries := 3
rateLimitResponder := func(req *http.Request) (*http.Response, error) {
if retries > 1 {
resp := httpmock.NewStringResponse(http.StatusTooManyRequests, `{"errors":"Exceeded 2 calls per second for api client. Reduce request rates to resume uninterrupted service."}`)
resp.Header.Add("Retry-After", "2.0")
retries--
return resp, nil
}
return httpmock.NewStringResponse(http.StatusOK, `{"foo": "bar"}`), nil
}

shopUrl := fmt.Sprintf("https://fooshop.myshopify.com/foo/1")
httpmock.RegisterResponder("GET", shopUrl, rateLimitResponder)

body := new(MyStruct)
req, err := client.NewRequest("GET", "foo/1", nil, nil)
if err != nil {
t.Error("error creating request: ", err)
}

err = client.Do(req, body)
if err != nil {
if e, ok := err.(*url.Error); ok {
err = e.Err
} else if e, ok := err.(*json.SyntaxError); ok {
err = errors.New(e.Error())
}

if !reflect.DeepEqual(err, expected) {
t.Errorf("Do(): expected error %#v, actual %#v", expected, err)
}
} else if err == nil && !reflect.DeepEqual(body, expected) {
t.Errorf("Do(): expected %#v, actual %#v", expected, body)
}
}

func TestServiceUnavailableRetry(t *testing.T) {
setup()
defer teardown()

type MyStruct struct {
Foo string `json:"foo"`
}

expected := &MyStruct{Foo: "bar"}
retries := 3
rateLimitResponder := func(req *http.Request) (*http.Response, error) {
if retries > 1 {
retries--
return httpmock.NewStringResponse(http.StatusServiceUnavailable, "<html></html>"), nil
}
return httpmock.NewStringResponse(http.StatusOK, `{"foo": "bar"}`), nil
}

shopUrl := fmt.Sprintf("https://fooshop.myshopify.com/foo/1")
httpmock.RegisterResponder("GET", shopUrl, rateLimitResponder)

body := new(MyStruct)
req, err := client.NewRequest("GET", "foo/1", nil, nil)
if err != nil {
t.Error("error creating request: ", err)
}

err = client.Do(req, body)
if err != nil {
if e, ok := err.(*url.Error); ok {
err = e.Err
} else if e, ok := err.(*json.SyntaxError); ok {
err = errors.New(e.Error())
}

if !reflect.DeepEqual(err, expected) {
t.Errorf("Do(): expected error %#v, actual %#v", expected, err)
}
} else if err == nil && !reflect.DeepEqual(body, expected) {
t.Errorf("Do(): expected %#v, actual %#v", expected, body)
}
}

func TestClientDoAutoApiVersion(t *testing.T) {
u := "foo/1"
responder := func(req *http.Request) (*http.Response, error) {
Expand Down
24 changes: 24 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package goshopify

import "fmt"

// Option is used to configure client with options
type Option func(c *Client)

// WithVersion optionally sets the api-version if the passed string is valid
func WithVersion(apiVersion string) Option {
return func(c *Client) {
pathPrefix := defaultApiPathPrefix
if len(apiVersion) > 0 && apiVersionRegex.MatchString(apiVersion) {
pathPrefix = fmt.Sprintf("admin/api/%s", apiVersion)
}
c.apiVersion = apiVersion
c.pathPrefix = pathPrefix
}
}

func WithRetry(retries int) Option {
return func(c *Client) {
c.retries = retries
}
}

0 comments on commit 5b6f777

Please sign in to comment.