From 4e9f38f149d7c2ac46a6dda84114a38335736587 Mon Sep 17 00:00:00 2001 From: Bartek Kubiak Date: Tue, 15 Dec 2020 16:11:31 +0100 Subject: [PATCH] API-7853: add support for retry add support for retry strategy add unit tests for retry strategy remove debug logs, add support for basic tokens, add docs for retry --- agent/api.go | 1 + agent/api_test.go | 85 ++++++++++++++++++++++++++++++++++++++++++ configuration/api.go | 1 + customer/api.go | 1 + internal/web_api.go | 88 ++++++++++++++++++++++++++++++++------------ 5 files changed, 153 insertions(+), 23 deletions(-) diff --git a/agent/api.go b/agent/api.go index 087c070..05ea8b0 100644 --- a/agent/api.go +++ b/agent/api.go @@ -15,6 +15,7 @@ type agentAPI interface { UploadFile(string, []byte) (string, error) SetCustomHost(string) SetCustomHeader(string, string) + SetRetryStrategy(i.RetryStrategyFunc) } // API provides the API operation methods for making requests to Agent Chat API via Web API. diff --git a/agent/api_test.go b/agent/api_test.go index cb17705..9ddf966 100644 --- a/agent/api_test.go +++ b/agent/api_test.go @@ -358,6 +358,33 @@ func createMockedErrorResponder(t *testing.T, method string) func(req *http.Requ } } +func createMockedMultipleAuthErrorsResponder(t *testing.T, fails int) func(req *http.Request) *http.Response { + var n int + + responseError := `{ + "error": { + "type": "authentication", + "message": "Invalid access token" + } + }` + + return func(req *http.Request) *http.Response { + n++ + if n > fails { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewBufferString(`{}`)), + Header: make(http.Header), + } + } + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: ioutil.NopCloser(bytes.NewBufferString(responseError)), + Header: make(http.Header), + } + } +} + func verifyErrorResponse(method string, resp error, t *testing.T) { if resp == nil { t.Errorf("%v should fail", method) @@ -1487,3 +1514,61 @@ func TestInvalidAuthorizationScheme(t *testing.T) { t.Errorf("Err should not be nil") } } + +func TestRetryStrategyAllFails(t *testing.T) { + client := NewTestClient(createMockedMultipleAuthErrorsResponder(t, 10)) + + api, err := agent.NewAPI(stubTokenGetter(authorization.BearerToken), client, "client_id") + if err != nil { + t.Errorf("API creation failed") + } + + var retries uint + api.SetRetryStrategy(func(attempts uint, err error) bool { + if attempts < 3 { + retries++ + return true + } + + return false + }) + + err = api.Call("", nil, nil) + if err == nil { + t.Errorf("Err should not be nil") + } + + if retries != 3 { + t.Errorf("Retries should be done 3 times") + } + +} + +func TestRetryStrategyLastSuccess(t *testing.T) { + client := NewTestClient(createMockedMultipleAuthErrorsResponder(t, 2)) + + api, err := agent.NewAPI(stubTokenGetter(authorization.BearerToken), client, "client_id") + if err != nil { + t.Errorf("API creation failed") + } + + var retries uint + api.SetRetryStrategy(func(attempts uint, err error) bool { + if attempts < 3 { + retries++ + return true + } + + return false + }) + + err = api.Call("", nil, &struct{}{}) + if err != nil { + t.Errorf("Err should be nil after 2 retries") + } + + if retries != 2 { + t.Errorf("Retries should be done 2 times") + } + +} diff --git a/configuration/api.go b/configuration/api.go index 85ed338..df86469 100644 --- a/configuration/api.go +++ b/configuration/api.go @@ -12,6 +12,7 @@ import ( type configurationAPI interface { Call(string, interface{}, interface{}) error SetCustomHost(string) + SetRetryStrategy(i.RetryStrategyFunc) } // API provides the API operation methods for making requests to Livechat Configuration API via Web API. diff --git a/customer/api.go b/customer/api.go index 43f0f6e..ead0948 100644 --- a/customer/api.go +++ b/customer/api.go @@ -14,6 +14,7 @@ type customerAPI interface { Call(string, interface{}, interface{}) error UploadFile(string, []byte) (string, error) SetCustomHost(string) + SetRetryStrategy(i.RetryStrategyFunc) } // API provides the API operation methods for making requests to Customer Chat API via Web API. diff --git a/internal/web_api.go b/internal/web_api.go index fb49c6f..1336834 100644 --- a/internal/web_api.go +++ b/internal/web_api.go @@ -16,6 +16,14 @@ import ( const apiVersion = "3.2" +// RetryStrategyFunc is called by each API method if set to retry when handling an error. +// If not set, there will be no retry at all. +// +// It accepts two arguments: attempts - number of sent requests (starting from 0) +// and err - error as ErrAPI struct (with StatusCode and Details) +// It returns info whether to retry the request. +type RetryStrategyFunc func(attempts uint, err error) bool + type api struct { httpClient *http.Client clientID string @@ -23,6 +31,7 @@ type api struct { httpRequestGenerator HTTPRequestGenerator host string customHeaders http.Header + retryStrategy RetryStrategyFunc } // HTTPRequestGenerator is called by each API method to generate api http url. @@ -59,12 +68,9 @@ func (a *api) Call(action string, reqPayload interface{}, respPayload interface{ if err != nil { return err } - token := a.tokenGetter() - if token == nil { - return fmt.Errorf("couldn't get token") - } - if token.Type != authorization.BearerToken && token.Type != authorization.BasicToken { - return fmt.Errorf("unsupported token type") + token, err := a.getToken() + if err != nil { + return err } req, err := a.httpRequestGenerator(token, a.host, action) @@ -93,6 +99,11 @@ func (a *api) SetCustomHeader(key, val string) { a.customHeaders.Set(key, val) } +// SetRetryStrategy allows to set a retry strategy that will be performed in every failed request +func (a *api) SetRetryStrategy(f RetryStrategyFunc) { + a.retryStrategy = f +} + type fileUploadAPI struct{ *api } // NewAPIWithFileUpload returns ready to use raw API client with file upload functionality. @@ -133,7 +144,7 @@ func (a *fileUploadAPI) UploadFile(filename string, file []byte) (string, error) req.Body = ioutil.NopCloser(body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type, token.AccessToken)) req.Header.Set("User-agent", fmt.Sprintf("GO SDK Application %s", a.clientID)) req.Header.Set("X-Region", token.Region) @@ -145,28 +156,59 @@ func (a *fileUploadAPI) UploadFile(filename string, file []byte) (string, error) } func (a *api) send(req *http.Request, respPayload interface{}) error { - resp, err := a.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - bodyBytes, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - apiErr := &api_errors.ErrAPI{} - if err := json.Unmarshal(bodyBytes, apiErr); err != nil { - return fmt.Errorf("couldn't unmarshal error response: %s (code: %d, raw body: %s)", err.Error(), resp.StatusCode, string(bodyBytes)) + var attempts uint + var do func() error + + do = func() error { + resp, err := a.httpClient.Do(req) + if err != nil { + return err } - if apiErr.Error() == "" { - return fmt.Errorf("couldn't unmarshal error response (code: %d, raw body: %s)", resp.StatusCode, string(bodyBytes)) + defer resp.Body.Close() + bodyBytes, err := ioutil.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + apiErr := &api_errors.ErrAPI{} + if err := json.Unmarshal(bodyBytes, apiErr); err != nil { + return fmt.Errorf("couldn't unmarshal error response: %s (code: %d, raw body: %s)", err.Error(), resp.StatusCode, string(bodyBytes)) + } + if apiErr.Error() == "" { + return fmt.Errorf("couldn't unmarshal error response (code: %d, raw body: %s)", resp.StatusCode, string(bodyBytes)) + } + + if a.retryStrategy == nil || !a.retryStrategy(attempts, apiErr) { + return apiErr + } + + token, err := a.getToken() + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type, token.AccessToken)) + attempts++ + return do() + } + + if err != nil { + return err } - return apiErr + + return json.Unmarshal(bodyBytes, respPayload) } - if err != nil { - return err + return do() +} + +func (a *api) getToken() (*authorization.Token, error) { + token := a.tokenGetter() + if token == nil { + return nil, fmt.Errorf("couldn't get token") + } + if token.Type != authorization.BearerToken && token.Type != authorization.BasicToken { + return nil, fmt.Errorf("unsupported token type") } - return json.Unmarshal(bodyBytes, respPayload) + return token, nil } // SetCustomHost allows to change API host address. This method is mostly for LiveChat internal testing and should not be used in production environments.