Skip to content

Commit f54cef6

Browse files
authored
FFM-7177 Authentication Retries (#67)
* FFM-7177 Add check for retryable codes * FFM-7177 Use retrying package * FFM-7177 Improve code parsing * FFM-7177 Change to tenacity package as retrying is no longer available * FFM-7177 Tweak * FFM-7177 Tweak * FFM-7177 Tweak * FFM-7177 Add backoff * FFM-7177 Tweak * FFM-7177 Fix logging * FFM-7177 Fix logging * FFM-7177 Tidy up * FFM-7177 Tidy up * FFM-7177 Tidy up * FFM-7177 Tidy up * FFM-7177 Tidy up * FFM-7177 Remove annotation and use function directly * FFM-7177 Remove annotation and use function directly * FFM-7177 Serve defaults and add max retry * FFM-7177 Fix retry code * FFM-7177 Don't start poller or streaming if auth failed * FFM-7177 Mark client as initialized if auth fails * FFM-7177 Fix 200 response * FFM-7177 Tidyup * FFM-7177 Fix nested condigftion * FFM-7177 Doc update for max auth retries * FFM-7177 Use set for lookup * FFM-7177 Remove todo comment * FFM-7177 Lint * FFM-7177 Lint * FFM-7177 Fix retry handler * FFM-7177 Fix retry handler * FFM-7177 Fix retry handler
1 parent a36d286 commit f54cef6

File tree

6 files changed

+128
-81
lines changed

6 files changed

+128
-81
lines changed

docs/further_reading.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ You can pass the configuration in as options when the SDK client is created.
2323
| enableStream | with_stream_enabled(True), | Enable streaming mode. | true |
2424
| enableAnalytics | with_analytics_enabled(True) | Enable analytics. Metrics data is posted every 60s | true |
2525
| pollInterval | with_poll_interval(120) | When running in stream mode, the interval in seconds that we poll for changes. | 60 |
26+
| maxAuthRetries | with_max_auth_retries(10) | The number of retry attempts to make if client authentication fails on a retryable HTTP error | 10 |
2627

2728
## Logging Configuration
2829
The SDK provides a logger that wraps the standard python logging package. You can import and use it with:

featureflags/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
__author__ = """Enver Bisevac"""
44
__email__ = "enver.bisevac@harness.io"
5-
__version__ = '1.1.12'
5+
__version__ = '1.1.13'

featureflags/api/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Client:
1313
cookies: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
1414
headers: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
1515
timeout: float = attr.ib(5.0, kw_only=True)
16+
max_auth_retries: int
1617

1718
def get_headers(self) -> Dict[str, str]:
1819
"""Get headers to be used in all endpoints"""
@@ -35,6 +36,9 @@ def with_cookies(self, cookies: Dict[str, str]) -> "Client":
3536
def get_timeout(self) -> float:
3637
return self.timeout
3738

39+
def get_max_auth_retries(self) -> int:
40+
return self.max_auth_retries
41+
3842
def with_timeout(self, timeout: float) -> "Client":
3943
"""
4044
Get a new client matching this one with a new timeout (in seconds)

featureflags/api/default/authenticate.py

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
from typing import Any, Dict, Optional, Union
2+
from featureflags.util import log
23

34
import httpx
45

56
from featureflags.api.client import Client
67
from featureflags.api.types import Response
78
from featureflags.models.authentication_request import AuthenticationRequest
89
from featureflags.models.authentication_response import AuthenticationResponse
10+
from tenacity import retry_if_result, wait_exponential, \
11+
stop_after_attempt, Retrying, retry_all
12+
13+
14+
class UnrecoverableAuthenticationException(Exception):
15+
pass
916

1017

1118
def _get_kwargs(
12-
*,
13-
client: Client,
14-
json_body: AuthenticationRequest,
19+
*,
20+
client: Client,
21+
json_body: AuthenticationRequest,
1522
) -> Dict[str, Any]:
1623
url = "{}/client/auth".format(client.base_url)
1724

@@ -30,33 +37,17 @@ def _get_kwargs(
3037

3138

3239
def _parse_response(
33-
*, response: httpx.Response
40+
*, response: httpx.Response
3441
) -> Optional[Union[AuthenticationResponse, None]]:
3542
if response.status_code == 200:
36-
response_200 = AuthenticationResponse.from_dict(response.json())
37-
38-
return response_200
39-
if response.status_code == 401:
40-
response_401 = None
41-
42-
return response_401
43-
if response.status_code == 403:
44-
response_403 = None
45-
46-
return response_403
47-
if response.status_code == 404:
48-
response_404 = None
49-
50-
return response_404
51-
if response.status_code == 500:
52-
response_500 = None
53-
54-
return response_500
55-
return None
43+
return AuthenticationResponse.from_dict(response.json())
44+
else:
45+
raise UnrecoverableAuthenticationException(
46+
f'Authentication failed on an unrecoverable error: {response}')
5647

5748

5849
def _build_response(
59-
*, response: httpx.Response
50+
*, response: httpx.Response
6051
) -> Response[Union[AuthenticationResponse, None]]:
6152
return Response(
6253
status_code=response.status_code,
@@ -67,26 +58,50 @@ def _build_response(
6758

6859

6960
def sync_detailed(
70-
*,
71-
client: Client,
72-
json_body: AuthenticationRequest,
61+
*,
62+
client: Client,
63+
json_body: AuthenticationRequest,
7364
) -> Response[Union[AuthenticationResponse, None]]:
7465
kwargs = _get_kwargs(
7566
client=client,
7667
json_body=json_body,
7768
)
69+
max_auth_retries = client.get_max_auth_retries()
70+
response = _post_request(kwargs, max_auth_retries)
71+
return _build_response(response=response)
7872

79-
response = httpx.post(
80-
**kwargs,
81-
)
8273

83-
return _build_response(response=response)
74+
def handle_http_result(response):
75+
code = response.status_code
76+
if code in {408, 425, 429, 500, 502, 503, 504}:
77+
return True
78+
else:
79+
log.error(
80+
f'SDK_AUTH_2001: Authentication failed with HTTP code #{code} and '
81+
'will not attempt to reconnect')
82+
return False
83+
84+
85+
def _post_request(kwargs, max_auth_retries):
86+
retryer = Retrying(
87+
wait=wait_exponential(multiplier=1, min=4, max=10),
88+
retry=retry_all(
89+
retry_if_result(lambda response: response.status_code != 200),
90+
retry_if_result(handle_http_result)
91+
),
92+
before_sleep=lambda retry_state: log.warning(
93+
f'SDK_AUTH_2002: Authentication attempt #'
94+
f'{retry_state.attempt_number} '
95+
f'got {retry_state.outcome.result()} Retrying...'),
96+
stop=stop_after_attempt(max_auth_retries),
97+
)
98+
return retryer(httpx.post, **kwargs)
8499

85100

86101
def sync(
87-
*,
88-
client: Client,
89-
json_body: AuthenticationRequest,
102+
*,
103+
client: Client,
104+
json_body: AuthenticationRequest,
90105
) -> Optional[Union[AuthenticationResponse, None]]:
91106
"""Used to retrieve all target segments for certain account id."""
92107

@@ -97,9 +112,9 @@ def sync(
97112

98113

99114
async def asyncio_detailed(
100-
*,
101-
client: Client,
102-
json_body: AuthenticationRequest,
115+
*,
116+
client: Client,
117+
json_body: AuthenticationRequest,
103118
) -> Response[Union[AuthenticationResponse, None]]:
104119
kwargs = _get_kwargs(
105120
client=client,
@@ -113,9 +128,9 @@ async def asyncio_detailed(
113128

114129

115130
async def asyncio(
116-
*,
117-
client: Client,
118-
json_body: AuthenticationRequest,
131+
*,
132+
client: Client,
133+
json_body: AuthenticationRequest,
119134
) -> Optional[Union[AuthenticationResponse, None]]:
120135
"""Used to retrieve all target segments for certain account id."""
121136

featureflags/client.py

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import threading
44
from typing import Any, Callable, Dict, Optional
55

6+
from tenacity import RetryError
67
from jwt import decode
78

89
from featureflags.analytics import AnalyticsService
@@ -11,6 +12,7 @@
1112

1213
from .api.client import AuthenticatedClient, Client
1314
from .api.default.authenticate import AuthenticationRequest
15+
from .api.default.authenticate import UnrecoverableAuthenticationException
1416
from .api.default.authenticate import sync as authenticate
1517
from .config import Config, default_config
1618
from .evaluations.auth_target import Target
@@ -52,47 +54,61 @@ def __init__(
5254

5355
self.run()
5456

55-
5657
def run(self):
57-
self.authenticate()
58-
59-
streaming_event = threading.Event()
60-
polling_event = threading.Event()
61-
62-
self._polling_processor = PollingProcessor(
63-
client=self._client,
64-
config=self._config,
65-
environment_id=self._environment_id,
66-
# PollingProcessor is responsible for doing the initial
67-
# flag/group fetch and cache. So we allocate it the responsibility
68-
# for setting the Client is_initialized variable.
69-
wait_for_initialization=self._initialized,
70-
ready=polling_event,
71-
stream_ready=streaming_event,
72-
repository=self._repository
73-
)
74-
self._polling_processor.start()
58+
try:
59+
self.authenticate()
60+
streaming_event = threading.Event()
61+
polling_event = threading.Event()
7562

76-
if self._config.enable_stream:
77-
self._stream = StreamProcessor(
78-
repository=self._repository,
63+
self._polling_processor = PollingProcessor(
7964
client=self._client,
80-
environment_id=self._environment_id,
81-
api_key=self._sdk_key,
82-
token=self._auth_token,
8365
config=self._config,
84-
ready=streaming_event,
85-
poller=polling_event,
86-
cluster=self._cluster,
87-
)
88-
self._stream.start()
89-
90-
if self._config.enable_analytics:
91-
self._analytics = AnalyticsService(
92-
config=self._config,
93-
client=self._client,
94-
environment=self._environment_id
66+
environment_id=self._environment_id,
67+
# PollingProcessor is responsible for doing the initial
68+
# flag/group fetch and cache. So we allocate it the
69+
# responsibility
70+
# for setting the Client is_initialized variable.
71+
wait_for_initialization=self._initialized,
72+
ready=polling_event,
73+
stream_ready=streaming_event,
74+
repository=self._repository
9575
)
76+
self._polling_processor.start()
77+
78+
if self._config.enable_stream:
79+
self._stream = StreamProcessor(
80+
repository=self._repository,
81+
client=self._client,
82+
environment_id=self._environment_id,
83+
api_key=self._sdk_key,
84+
token=self._auth_token,
85+
config=self._config,
86+
ready=streaming_event,
87+
poller=polling_event,
88+
cluster=self._cluster,
89+
)
90+
self._stream.start()
91+
92+
if self._config.enable_analytics:
93+
self._analytics = AnalyticsService(
94+
config=self._config,
95+
client=self._client,
96+
environment=self._environment_id
97+
)
98+
99+
except RetryError:
100+
log.error(
101+
"Authentication failed and max retries have been exceeded - "
102+
"defaults will be served.")
103+
# Mark the client as initialized in case wait_for_initialization
104+
# is called. The SDK has already logged that authentication
105+
# failed and defaults will be returned.
106+
self._initialized.set()
107+
except UnrecoverableAuthenticationException:
108+
log.error(
109+
"Authentication failed - defaults will be served.")
110+
# Same again, just mark the client as initailized.
111+
self._initialized.set()
96112

97113
def wait_for_initialization(self):
98114
log.debug("Waiting for initialization to finish")
@@ -107,7 +123,8 @@ def get_environment_id(self):
107123
def authenticate(self):
108124
client = Client(
109125
base_url=self._config.base_url,
110-
events_url=self._config.events_url
126+
events_url=self._config.events_url,
127+
max_auth_retries=self._config.max_auth_retries
111128
)
112129
body = AuthenticationRequest(api_key=self._sdk_key)
113130
response = authenticate(client=client, json_body=body)
@@ -125,7 +142,8 @@ def authenticate(self):
125142
token=self._auth_token,
126143
params={
127144
'cluster': self._cluster
128-
}
145+
},
146+
max_auth_retries=self._config.max_auth_retries
129147
)
130148
# Additional headers used to track usage
131149
additional_headers = {

featureflags/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def __init__(
2525
cache: Cache = None,
2626
store: object = None,
2727
enable_stream: bool = True,
28-
enable_analytics: bool = True
28+
enable_analytics: bool = True,
29+
max_auth_retries: int = 10
2930
):
3031
self.base_url = base_url
3132
self.events_url = events_url
@@ -38,6 +39,7 @@ def __init__(
3839
self.store = store
3940
self.enable_stream = enable_stream
4041
self.enable_analytics = enable_analytics
42+
self.max_auth_retries = max_auth_retries
4143

4244

4345
default_config = Config()
@@ -76,3 +78,10 @@ def func(config: Config) -> None:
7678
config.pull_interval = value
7779

7880
return func
81+
82+
83+
def with_max_auth_retries(value: int) -> Callable:
84+
def func(config: Config) -> None:
85+
config.max_auth_retries = value
86+
87+
return func

0 commit comments

Comments
 (0)