Repository: Unleash/unleash-client-python (python)

Issue:
Add retries to get_feature_toggles
**Is your feature request related to a problem? Please describe.**
HTTP requests in get_feature_toggles might fail due to intermittent network issues. 

Since this is called quite often, it could create plenty of long stack traces, since the entire stack trace is printed: https://github.com/Unleash/unleash-client-python/blob/main/UnleashClient/api/features.py#L67


**Describe the solution you'd like**
Retry the request a couple of times and log only in situations where it does not recover.


I'd be happy to provide a proposal fo this.

Pull request description:
Add retries to get features
# Description

Create session and provide it with a custom HTTPAdapter, that has max_retries set.


Fixes #217 

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

- [x] Unit tests
- [x] Spec Tests
- [x] Integration tests / Manual Tests

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules

Patch:
diff --git a/UnleashClient/api/features.py b/UnleashClient/api/features.py
index 267373c..9774788 100644
--- a/UnleashClient/api/features.py
+++ b/UnleashClient/api/features.py
@@ -1,6 +1,10 @@
 from typing import Tuple
 import requests
-from UnleashClient.constants import REQUEST_TIMEOUT, FEATURES_URL
+from requests.adapters import HTTPAdapter
+from urllib3 import Retry
+
+from UnleashClient.constants import REQUEST_TIMEOUT, FEATURES_URL, \
+    REQUEST_RETRIES
 from UnleashClient.utils import LOGGER, log_resp_info
 
 
@@ -45,10 +49,14 @@ def get_feature_toggles(url: str,
         if project:
             base_params = {'project': project}
 
-        resp = requests.get(base_url,
-                            headers={**custom_headers, **headers},
-                            params=base_params,
-                            timeout=REQUEST_TIMEOUT, **custom_options)
+        adapter = HTTPAdapter(max_retries=Retry(total=REQUEST_RETRIES, status_forcelist=[500, 502, 504]))
+        with requests.Session() as session:
+            session.mount("https://", adapter)
+            session.mount("http://", adapter)
+            resp = session.get(base_url,
+                               headers={**custom_headers, **headers},
+                               params=base_params,
+                               timeout=REQUEST_TIMEOUT, **custom_options)
 
         if resp.status_code not in [200, 304]:
             log_resp_info(resp)
diff --git a/UnleashClient/constants.py b/UnleashClient/constants.py
index 9bbc91f..1c97b4e 100644
--- a/UnleashClient/constants.py
+++ b/UnleashClient/constants.py
@@ -4,6 +4,7 @@ from importlib_metadata import version
 SDK_NAME = "unleash-client-python"
 SDK_VERSION = version("UnleashClient")
 REQUEST_TIMEOUT = 30
+REQUEST_RETRIES = 3
 METRIC_LAST_SENT_TIME = "mlst"
 
 # =Unleash=


Test patch:
diff --git a/tests/unit_tests/api/test_feature.py b/tests/unit_tests/api/test_feature.py
index ec1e48b..d929ceb 100644
--- a/tests/unit_tests/api/test_feature.py
+++ b/tests/unit_tests/api/test_feature.py
@@ -1,5 +1,6 @@
 import responses
 from pytest import mark, param
+
 from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE, MOCK_FEATURE_RESPONSE_PROJECT
 from tests.utilities.testing_constants import URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, PROJECT_URL, PROJECT_NAME, ETAG_VALUE
 from UnleashClient.constants import FEATURES_URL
@@ -10,12 +11,12 @@ FULL_FEATURE_URL = URL + FEATURES_URL
 
 
 @responses.activate
-@mark.parametrize("response,status,expected", (
-    param(MOCK_FEATURE_RESPONSE, 200, lambda result: result["version"] == 1, id="success"),
-    param(MOCK_FEATURE_RESPONSE, 202, lambda result: not result, id="failure"),
-    param({}, 500, lambda result: not result, id="failure"),
+@mark.parametrize("response,status,calls,expected", (
+    param(MOCK_FEATURE_RESPONSE, 200, 1, lambda result: result["version"] == 1, id="success"),
+    param(MOCK_FEATURE_RESPONSE, 202, 1, lambda result: not result, id="failure"),
+    param({}, 500, 4, lambda result: not result, id="failure"),
 ))
-def test_get_feature_toggle(response, status, expected):
+def test_get_feature_toggle(response, status, calls, expected):
     responses.add(responses.GET, FULL_FEATURE_URL, json=response, status=status, headers={'etag': ETAG_VALUE})
 
     (result, etag) = get_feature_toggles(URL,
@@ -24,7 +25,7 @@ def test_get_feature_toggle(response, status, expected):
                                          CUSTOM_HEADERS,
                                          CUSTOM_OPTIONS)
 
-    assert len(responses.calls) == 1
+    assert len(responses.calls) == calls
     assert expected(result)
 
 
@@ -55,7 +56,7 @@ def test_get_feature_toggle_failed_etag():
                                          CUSTOM_OPTIONS,
                                          PROJECT_NAME)
 
-    assert len(responses.calls) == 1
+    assert len(responses.calls) == 4
     assert etag == ''
 
 
@@ -75,3 +76,21 @@ def test_get_feature_toggle_etag_present():
     assert not result
     assert responses.calls[0].request.headers['If-None-Match'] == ETAG_VALUE
     assert etag == ETAG_VALUE
+
+
+@responses.activate
+def test_get_feature_toggle_retries():
+    responses.add(responses.GET, PROJECT_URL, json={}, status=500)
+    responses.add(responses.GET, PROJECT_URL, json=MOCK_FEATURE_RESPONSE_PROJECT, status=200, headers={'etag': ETAG_VALUE})
+
+    (result, etag) = get_feature_toggles(URL,
+                                     APP_NAME,
+                                     INSTANCE_ID,
+                                     CUSTOM_HEADERS,
+                                     CUSTOM_OPTIONS,
+                                     PROJECT_NAME,
+                                     ETAG_VALUE)
+
+    assert len(responses.calls) == 2
+    assert len(result["features"]) == 1
+    assert etag == ETAG_VALUE