diff --git a/.circleci/config_continue.yml b/.circleci/config_continue.yml index 4db73516..f7d0aa3e 100644 --- a/.circleci/config_continue.yml +++ b/.circleci/config_continue.yml @@ -51,8 +51,16 @@ jobs: - checkout - run: apt-get install lsof - run: git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - - run: curl -fsSL https://deb.nodesource.com/setup_16.x | bash - - run: apt install -y nodejs + - run: curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash + - run: | + set +e + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" + nvm install 16 + + echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV - run: node --version - run: echo "127.0.0.1 localhost.org" >> /etc/hosts - run: go version diff --git a/.circleci/setupAndTestWithAuthReact.sh b/.circleci/setupAndTestWithAuthReact.sh index a028f45a..ee139d26 100755 --- a/.circleci/setupAndTestWithAuthReact.sh +++ b/.circleci/setupAndTestWithAuthReact.sh @@ -49,7 +49,6 @@ git clone git@github.com:supertokens/supertokens-auth-react.git cd supertokens-auth-react git checkout $2 npm run init -(cd ./examples/for-tests && npm run link) # this is there because in linux machine, postinstall in npm doesn't work.. cd ./test/server/ npm i -d npm i git+https://github.com:supertokens/supertokens-node.git#$3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3575708a..fd477894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +## [0.10.9] - 2023-08-28 + +- Adds logic to retry network calls if the core returns status 429 + ## [0.10.8] - 2023-04-18 - Email template for verify email updated diff --git a/addDevTag b/addDevTag index 871e9c94..a3dee496 100755 --- a/addDevTag +++ b/addDevTag @@ -1,11 +1,5 @@ #!/bin/bash -# check if we need to merge master into this branch------------ -if [[ $(git log origin/master ^HEAD) ]]; then - echo "You need to merge master into this branch. Exiting" - exit 1 -fi - # get version------------ version=`cat ./supertokens/constants.go | grep -e 'const VERSION'` while IFS='"' read -ra ADDR; do diff --git a/recipe/session/querier_test.go b/recipe/session/querier_test.go new file mode 100644 index 00000000..e0c74754 --- /dev/null +++ b/recipe/session/querier_test.go @@ -0,0 +1,285 @@ +package session + +import ( + "encoding/json" + "errors" + "github.com/stretchr/testify/assert" + "github.com/supertokens/supertokens-golang/supertokens" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func resetQuerier() { + supertokens.SetQuerierApiVersionForTests("") +} + +func TestThatNetworkCallIsRetried(t *testing.T) { + resetAll() + mux := http.NewServeMux() + + numberOfTimesCalled := 0 + numberOfTimesSecondCalled := 0 + numberOfTimesThirdCalled := 0 + + mux.HandleFunc("/testing", func(rw http.ResponseWriter, r *http.Request) { + numberOfTimesCalled++ + rw.WriteHeader(supertokens.RateLimitStatusCode) + rw.Header().Set("Content-Type", "application/json") + response, err := json.Marshal(map[string]interface{}{}) + if err != nil { + t.Error(err.Error()) + } + rw.Write(response) + }) + + mux.HandleFunc("/testing2", func(rw http.ResponseWriter, r *http.Request) { + numberOfTimesSecondCalled++ + rw.Header().Set("Content-Type", "application/json") + + if numberOfTimesSecondCalled == 3 { + rw.WriteHeader(200) + } else { + rw.WriteHeader(supertokens.RateLimitStatusCode) + } + + response, err := json.Marshal(map[string]interface{}{}) + if err != nil { + t.Error(err.Error()) + } + rw.Write(response) + }) + + mux.HandleFunc("/testing3", func(rw http.ResponseWriter, r *http.Request) { + numberOfTimesThirdCalled++ + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(200) + response, err := json.Marshal(map[string]interface{}{}) + if err != nil { + t.Error(err.Error()) + } + rw.Write(response) + }) + + testServer := httptest.NewServer(mux) + + defer func() { + testServer.Close() + }() + + config := supertokens.TypeInput{ + Supertokens: &supertokens.ConnectionInfo{ + // We need the querier to call the test server and not the core + ConnectionURI: testServer.URL, + }, + AppInfo: supertokens.AppInfo{ + AppName: "SuperTokens", + WebsiteDomain: "supertokens.io", + APIDomain: "api.supertokens.io", + }, + RecipeList: []supertokens.Recipe{ + Init(nil), + }, + } + + err := supertokens.Init(config) + + if err != nil { + t.Error(err.Error()) + } + + q, err := supertokens.GetNewQuerierInstanceOrThrowError("") + supertokens.SetQuerierApiVersionForTests("3.0") + defer resetQuerier() + + if err != nil { + t.Error(err.Error()) + } + + _, err = q.SendGetRequest("/testing", map[string]string{}) + if err == nil { + t.Error(errors.New("request should have failed but didnt").Error()) + } else { + if !strings.Contains(err.Error(), "with status code: 429") { + t.Error(errors.New("request failed with an unexpected error").Error()) + } + } + + _, err = q.SendGetRequest("/testing2", map[string]string{}) + if err != nil { + t.Error(err.Error()) + } + + _, err = q.SendGetRequest("/testing3", map[string]string{}) + if err != nil { + t.Error(err.Error()) + } + + // One initial call + 5 retries + assert.Equal(t, numberOfTimesCalled, 6) + assert.Equal(t, numberOfTimesSecondCalled, 3) + assert.Equal(t, numberOfTimesThirdCalled, 1) +} + +func TestThatRateLimitErrorsAreThrownBackToTheUser(t *testing.T) { + resetAll() + mux := http.NewServeMux() + + mux.HandleFunc("/testing", func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(supertokens.RateLimitStatusCode) + rw.Header().Set("Content-Type", "application/json") + response, err := json.Marshal(map[string]interface{}{ + "status": "RATE_LIMIT_ERROR", + }) + if err != nil { + t.Error(err.Error()) + } + rw.Write(response) + }) + + testServer := httptest.NewServer(mux) + + defer func() { + testServer.Close() + }() + + config := supertokens.TypeInput{ + Supertokens: &supertokens.ConnectionInfo{ + // We need the querier to call the test server and not the core + ConnectionURI: testServer.URL, + }, + AppInfo: supertokens.AppInfo{ + AppName: "SuperTokens", + WebsiteDomain: "supertokens.io", + APIDomain: "api.supertokens.io", + }, + RecipeList: []supertokens.Recipe{ + Init(nil), + }, + } + + err := supertokens.Init(config) + + if err != nil { + t.Error(err.Error()) + } + + q, err := supertokens.GetNewQuerierInstanceOrThrowError("") + supertokens.SetQuerierApiVersionForTests("3.0") + defer resetQuerier() + + if err != nil { + t.Error(err.Error()) + } + + _, err = q.SendGetRequest("/testing", map[string]string{}) + if err == nil { + t.Error(errors.New("request should have failed but didnt").Error()) + } else { + if !strings.Contains(err.Error(), "with status code: 429") { + t.Error(errors.New("request failed with an unexpected error").Error()) + } + + assert.True(t, strings.Contains(err.Error(), "message: {\"status\":\"RATE_LIMIT_ERROR\"}")) + } +} + +func TestThatParallelCallsHaveIndependentRetryCounters(t *testing.T) { + resetAll() + mux := http.NewServeMux() + + numberOfTimesFirstCalled := 0 + numberOfTimesSecondCalled := 0 + + mux.HandleFunc("/testing", func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("id") == "1" { + numberOfTimesFirstCalled++ + } else { + numberOfTimesSecondCalled++ + } + + rw.WriteHeader(supertokens.RateLimitStatusCode) + rw.Header().Set("Content-Type", "application/json") + response, err := json.Marshal(map[string]interface{}{}) + if err != nil { + t.Error(err.Error()) + } + rw.Write(response) + }) + + testServer := httptest.NewServer(mux) + + defer func() { + testServer.Close() + }() + + config := supertokens.TypeInput{ + Supertokens: &supertokens.ConnectionInfo{ + // We need the querier to call the test server and not the core + ConnectionURI: testServer.URL, + }, + AppInfo: supertokens.AppInfo{ + AppName: "SuperTokens", + WebsiteDomain: "supertokens.io", + APIDomain: "api.supertokens.io", + }, + RecipeList: []supertokens.Recipe{ + Init(nil), + }, + } + + err := supertokens.Init(config) + + if err != nil { + t.Error(err.Error()) + } + + q, err := supertokens.GetNewQuerierInstanceOrThrowError("") + supertokens.SetQuerierApiVersionForTests("3.0") + defer resetQuerier() + + if err != nil { + t.Error(err.Error()) + } + + var wg sync.WaitGroup + + wg.Add(2) + + go func() { + _, err = q.SendGetRequest("/testing", map[string]string{ + "id": "1", + }) + if err == nil { + t.Error(errors.New("request should have failed but didnt").Error()) + } else { + if !strings.Contains(err.Error(), "with status code: 429") { + t.Error(errors.New("request failed with an unexpected error").Error()) + } + } + + wg.Done() + }() + + go func() { + _, err = q.SendGetRequest("/testing", map[string]string{ + "id": "2", + }) + if err == nil { + t.Error(errors.New("request should have failed but didnt").Error()) + } else { + if !strings.Contains(err.Error(), "with status code: 429") { + t.Error(errors.New("request failed with an unexpected error").Error()) + } + } + + wg.Done() + }() + + wg.Wait() + + assert.Equal(t, numberOfTimesFirstCalled, 6) + assert.Equal(t, numberOfTimesSecondCalled, 6) +} diff --git a/supertokens/constants.go b/supertokens/constants.go index ce131614..7e92c9d4 100644 --- a/supertokens/constants.go +++ b/supertokens/constants.go @@ -21,10 +21,12 @@ const ( ) // VERSION current version of the lib -const VERSION = "0.10.8" +const VERSION = "0.10.9" var ( cdiSupported = []string{"2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15", "2.16", "2.17", "2.18", "2.19", "2.20"} ) const DashboardVersion = "0.6" + +const RateLimitStatusCode = 429 diff --git a/supertokens/querier.go b/supertokens/querier.go index cd025e03..d3b66388 100644 --- a/supertokens/querier.go +++ b/supertokens/querier.go @@ -24,6 +24,7 @@ import ( "net/http" "strings" "sync" + "time" ) type Querier struct { @@ -45,6 +46,10 @@ var ( querierHostLock sync.Mutex ) +func SetQuerierApiVersionForTests(version string) { + querierAPIVersion = version +} + func (q *Querier) GetQuerierAPIVersion() (string, error) { querierLock.Lock() defer querierLock.Unlock() @@ -61,7 +66,7 @@ func (q *Querier) GetQuerierAPIVersion() (string, error) { } client := &http.Client{} return client.Do(req) - }, len(QuerierHosts)) + }, len(QuerierHosts), nil) if err != nil { return "", err @@ -141,7 +146,7 @@ func (q *Querier) SendPostRequest(path string, data map[string]interface{}) (map client := &http.Client{} return client.Do(req) - }, len(QuerierHosts)) + }, len(QuerierHosts), nil) } func (q *Querier) SendDeleteRequest(path string, data map[string]interface{}, params map[string]string) (map[string]interface{}, error) { @@ -182,7 +187,7 @@ func (q *Querier) SendDeleteRequest(path string, data map[string]interface{}, pa client := &http.Client{} return client.Do(req) - }, len(QuerierHosts)) + }, len(QuerierHosts), nil) } func (q *Querier) SendGetRequest(path string, params map[string]string) (map[string]interface{}, error) { @@ -217,7 +222,7 @@ func (q *Querier) SendGetRequest(path string, params map[string]string) (map[str client := &http.Client{} return client.Do(req) - }, len(QuerierHosts)) + }, len(QuerierHosts), nil) } func (q *Querier) SendPutRequest(path string, data map[string]interface{}) (map[string]interface{}, error) { @@ -251,12 +256,12 @@ func (q *Querier) SendPutRequest(path string, data map[string]interface{}) (map[ client := &http.Client{} return client.Do(req) - }, len(QuerierHosts)) + }, len(QuerierHosts), nil) } type httpRequestFunction func(url string) (*http.Response, error) -func (q *Querier) sendRequestHelper(path NormalisedURLPath, httpRequest httpRequestFunction, numberOfTries int) (map[string]interface{}, error) { +func (q *Querier) sendRequestHelper(path NormalisedURLPath, httpRequest httpRequestFunction, numberOfTries int, retryInfoMap *map[string]int) (map[string]interface{}, error) { if numberOfTries == 0 { return nil, errors.New("no SuperTokens core available to query") } @@ -264,14 +269,32 @@ func (q *Querier) sendRequestHelper(path NormalisedURLPath, httpRequest httpRequ querierHostLock.Lock() currentDomain := QuerierHosts[querierLastTriedIndex].Domain.GetAsStringDangerous() currentBasePath := QuerierHosts[querierLastTriedIndex].BasePath.GetAsStringDangerous() + + url := currentDomain + currentBasePath + path.GetAsStringDangerous() + + maxRetries := 5 + var _retryInfoMap map[string]int + + if retryInfoMap != nil { + _retryInfoMap = *retryInfoMap + } else { + _retryInfoMap = map[string]int{} + } + + _, ok := _retryInfoMap[url] + + if !ok { + _retryInfoMap[url] = maxRetries + } + querierLastTriedIndex = (querierLastTriedIndex + 1) % len(QuerierHosts) querierHostLock.Unlock() - resp, err := httpRequest(currentDomain + currentBasePath + path.GetAsStringDangerous()) + resp, err := httpRequest(url) if err != nil { if strings.Contains(err.Error(), "connection refused") { - return q.sendRequestHelper(path, httpRequest, numberOfTries-1) + return q.sendRequestHelper(path, httpRequest, numberOfTries-1, &_retryInfoMap) } if resp != nil { resp.Body.Close() @@ -286,6 +309,21 @@ func (q *Querier) sendRequestHelper(path NormalisedURLPath, httpRequest httpRequ return nil, readErr } if resp.StatusCode != 200 { + if resp.StatusCode == RateLimitStatusCode { + retriesLeft := _retryInfoMap[url] + + if retriesLeft > 0 { + _retryInfoMap[url] = retriesLeft - 1 + + attemptsMade := maxRetries - retriesLeft + delay := 10 + (250 * attemptsMade) + + time.Sleep(time.Millisecond * time.Duration(delay)) + + return q.sendRequestHelper(path, httpRequest, numberOfTries, &_retryInfoMap) + } + } + return nil, fmt.Errorf("SuperTokens core threw an error for a request to path: '%s' with status code: %v and message: %s", path.GetAsStringDangerous(), resp.StatusCode, body) } diff --git a/test/auth-react-server/main.go b/test/auth-react-server/main.go index 6b4d6073..88cc2b02 100644 --- a/test/auth-react-server/main.go +++ b/test/auth-react-server/main.go @@ -675,6 +675,43 @@ func callSTInit(passwordlessConfig *plessmodels.TypeInput) { rw.WriteHeader(200) rw.Write([]byte("{\"status\": \"OK\"}")) }).ServeHTTP(rw, r) + } else if r.URL.Path == "/deleteUser" { + bodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + rw.WriteHeader(500) + rw.Write([]byte("Internal error")) + return + } + var body map[string]interface{} + err = json.Unmarshal(bodyBytes, &body) + if err != nil { + rw.WriteHeader(500) + rw.Write([]byte("Internal error")) + return + } + + if body["rid"] != "emailpassword" { + rw.WriteHeader(400) + rw.Write([]byte("{\"message\": \"Not Implemented\"}")) + return + } + + user, err := emailpassword.GetUserByEmail(body["email"].(string)) + if err != nil { + rw.WriteHeader(500) + rw.Write([]byte("Internal error")) + return + } + + err = supertokens.DeleteUser(user.ID) + if err != nil { + rw.WriteHeader(500) + rw.Write([]byte("Internal error")) + return + } + + rw.WriteHeader(200) + rw.Write([]byte("{\"status\": \"OK\"}")) } })) diff --git a/test/frontendIntegration/main.go b/test/frontendIntegration/main.go index a1a0f1cf..d85dc894 100644 --- a/test/frontendIntegration/main.go +++ b/test/frontendIntegration/main.go @@ -17,6 +17,7 @@ package main import ( + "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -24,6 +25,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/supertokens/supertokens-golang/recipe/session" "github.com/supertokens/supertokens-golang/recipe/session/sessmodels" @@ -170,6 +172,8 @@ func callSTInit(enableAntiCsrf bool, enableJWT bool, jwtPropertyName string) { setEnableJWT(rw, r) } else if r.URL.Path == "/login" && r.Method == "POST" { login(rw, r) + } else if r.URL.Path == "/login-2.18" && r.Method == "POST" { + login218(rw, r) } else if r.URL.Path == "/beforeeach" && r.Method == "POST" { beforeeach(rw, r) } else if r.URL.Path == "/testUserConfig" && r.Method == "POST" { @@ -411,6 +415,69 @@ func login(response http.ResponseWriter, request *http.Request) { response.Write([]byte(sess.GetUserID())) } +func login218(response http.ResponseWriter, request *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(request.Body).Decode(&body) + + userID := body["userId"].(string) + payload := body["payload"].(map[string]interface{}) + + querier, err := supertokens.GetNewQuerierInstanceOrThrowError("session") + + if err != nil { + response.WriteHeader(500) + response.Write([]byte("")) + return + } + + supertokens.SetQuerierApiVersionForTests("2.18") + resp, err := querier.SendPostRequest("/recipe/session", map[string]interface{}{ + "userId": userID, + "userDataInJWT": payload, + "userDataInDatabase": map[string]interface{}{}, + "enableAntiCsrf": false, + }) + + if err != nil { + response.WriteHeader(500) + response.Write([]byte("")) + return + } + + supertokens.SetQuerierApiVersionForTests("") + + responseByte, err := json.Marshal(resp) + if err != nil { + response.WriteHeader(500) + response.Write([]byte("")) + return + } + var sessionResp sessmodels.CreateOrRefreshAPIResponse + err = json.Unmarshal(responseByte, &sessionResp) + if err != nil { + response.WriteHeader(500) + response.Write([]byte("")) + return + } + + legacyAccessToken := sessionResp.AccessToken.Token + legacyRefreshToken := sessionResp.RefreshToken.Token + + parsed, _ := json.Marshal(map[string]interface{}{ + "uid": userID, + "ate": uint64(time.Now().UnixNano()/1000000) + 3600000, + "up": payload, + }) + data := []byte(parsed) + + frontToken := base64.StdEncoding.EncodeToString(data) + + response.Header().Set("st-access-token", legacyAccessToken) + response.Header().Set("st-refresh-token", legacyRefreshToken) + response.Header().Set("front-token", frontToken) + response.Write([]byte("")) +} + func fail(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) w.Write([]byte(""))