Skip to content

Commit 6ea3407

Browse files
mrm9084albertofori
andauthored
App Configuration Provider - Auto failover (#35426)
* Adding Replica * Fixing a few issues from the swap * Replicas now used * Fixing test issues * pylint changes * Fixing Tests * Add disable env * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py Co-authored-by: Albert Ofori <[email protected]> * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py Co-authored-by: Albert Ofori <[email protected]> * Code Review Items * fix order * Update _azureappconfigurationprovider.py * fixing merge formatting + sdoc * Update _azureappconfigurationprovider.py * Update _azureappconfigurationprovider.py * Updating after merge * fixing failover * constant * fixing failover * adding replica count, fixing correcation_context * pylint fixes * Update _replica_client.py * Fixing refresh * Update assets.json * fixing linting * fixing build issue * fixing sanitization * Update assets.json * Update setup.py * Update setup.py * Update setup.py * Review comments. * removeprefix is 3.9+ * fixing mypy issues * mypy issues fixed * Update _azureappconfigurationprovider.py * Update _azureappconfigurationprovider.py * Update _azureappconfigurationprovider.py * Update _azureappconfigurationprovider.py * review comments * formatting * added type to endpoint * removing dataclass * Update _discovery.py * updating header usage * updating logging and typing * fixed pylint * review comments * Rename Client Wrapper + Constant * Update _azureappconfigurationprovider.py * updated to use urllib * Changing from env to kwarg * Updating validate and known domain * pylint fixes * removed unused constant, fixed if, nit name * Update _discovery.py --------- Co-authored-by: Albert Ofori <[email protected]>
1 parent 956e5ad commit 6ea3407

File tree

13 files changed

+713
-287
lines changed

13 files changed

+713
-287
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration-provider",
5-
"Tag": "python/appconfiguration/azure-appconfiguration-provider_d412917208"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_c09fc0dba6"
66
}

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py

Lines changed: 191 additions & 236 deletions
Large diffs are not rendered by default.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from logging import getLogger
7+
import json
8+
import time
9+
import random
10+
from dataclasses import dataclass
11+
from typing import Tuple, Union, Dict, List, Any, Optional, Mapping
12+
from typing_extensions import Self
13+
from azure.core import MatchConditions
14+
from azure.core.tracing.decorator import distributed_trace
15+
from azure.core.exceptions import HttpResponseError
16+
from azure.core.credentials import TokenCredential
17+
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
18+
ConfigurationSetting,
19+
AzureAppConfigurationClient,
20+
FeatureFlagConfigurationSetting,
21+
)
22+
from ._models import SettingSelector
23+
from ._constants import FEATURE_FLAG_PREFIX
24+
25+
26+
@dataclass
27+
class ConfigurationClientWrapper:
28+
endpoint: str
29+
_client: AzureAppConfigurationClient
30+
backoff_end_time: float = 0
31+
failed_attempts: int = 0
32+
LOGGER = getLogger(__name__)
33+
34+
@classmethod
35+
def from_credential(
36+
cls,
37+
endpoint: str,
38+
credential: TokenCredential,
39+
user_agent: str,
40+
retry_total: int,
41+
retry_backoff_max: int,
42+
**kwargs
43+
) -> Self:
44+
"""
45+
Creates a new instance of the ConfigurationClientWrapper class, using the provided credential to authenticate
46+
requests.
47+
48+
:param str endpoint: The endpoint of the App Configuration store
49+
:param TokenCredential credential: The credential to use for authentication
50+
:param str user_agent: The user agent string to use for the request
51+
:param int retry_total: The total number of retries to allow for requests
52+
:param int retry_backoff_max: The maximum backoff time for retries
53+
:return: A new instance of the ConfigurationClientWrapper class
54+
:rtype: ConfigurationClientWrapper
55+
"""
56+
return cls(
57+
endpoint,
58+
AzureAppConfigurationClient(
59+
endpoint,
60+
credential,
61+
user_agent=user_agent,
62+
retry_total=retry_total,
63+
retry_backoff_max=retry_backoff_max,
64+
**kwargs
65+
),
66+
)
67+
68+
@classmethod
69+
def from_connection_string(
70+
cls, endpoint: str, connection_string: str, user_agent: str, retry_total: int, retry_backoff_max: int, **kwargs
71+
) -> Self:
72+
"""
73+
Creates a new instance of the ConfigurationClientWrapper class, using the provided connection string to
74+
authenticate requests.
75+
76+
:param str endpoint: The endpoint of the App Configuration store
77+
:param str connection_string: The connection string to use for authentication
78+
:param str user_agent: The user agent string to use for the request
79+
:param int retry_total: The total number of retries to allow for requests
80+
:param int retry_backoff_max: The maximum backoff time for retries
81+
:return: A new instance of the ConfigurationClientWrapper class
82+
:rtype: ConfigurationClientWrapper
83+
"""
84+
return cls(
85+
endpoint,
86+
AzureAppConfigurationClient.from_connection_string(
87+
connection_string,
88+
user_agent=user_agent,
89+
retry_total=retry_total,
90+
retry_backoff_max=retry_backoff_max,
91+
**kwargs
92+
),
93+
)
94+
95+
def _check_configuration_setting(
96+
self, key: str, label: str, etag: Optional[str], headers: Dict[str, str], **kwargs
97+
) -> Tuple[bool, Union[ConfigurationSetting, None]]:
98+
"""
99+
Checks if the configuration setting have been updated since the last refresh.
100+
101+
:param str key: key to check for chances
102+
:param str label: label to check for changes
103+
:param str etag: etag to check for changes
104+
:param Mapping[str, str] headers: headers to use for the request
105+
:return: A tuple with the first item being true/false if a change is detected. The second item is the updated
106+
value if a change was detected.
107+
:rtype: Tuple[bool, Union[ConfigurationSetting, None]]
108+
"""
109+
try:
110+
updated_sentinel = self._client.get_configuration_setting( # type: ignore
111+
key=key, label=label, etag=etag, match_condition=MatchConditions.IfModified, headers=headers, **kwargs
112+
)
113+
if updated_sentinel is not None:
114+
self.LOGGER.debug(
115+
"Refresh all triggered by key: %s label %s.",
116+
key,
117+
label,
118+
)
119+
return True, updated_sentinel
120+
except HttpResponseError as e:
121+
if e.status_code == 404:
122+
if etag is not None:
123+
# If the sentinel is not found, it means the key/label was deleted, so we should refresh
124+
self.LOGGER.debug("Refresh all triggered by key: %s label %s.", key, label)
125+
return True, None
126+
else:
127+
raise e
128+
return False, None
129+
130+
@distributed_trace
131+
def load_configuration_settings(
132+
self, selects: List[SettingSelector], refresh_on: Dict[Tuple[str, str], str], **kwargs
133+
) -> Tuple[List[ConfigurationSetting], Dict[Tuple[str, str], str]]:
134+
configuration_settings = []
135+
sentinel_keys = kwargs.pop("sentinel_keys", refresh_on)
136+
for select in selects:
137+
configurations = self._client.list_configuration_settings(
138+
key_filter=select.key_filter, label_filter=select.label_filter, **kwargs
139+
)
140+
for config in configurations:
141+
if isinstance(config, FeatureFlagConfigurationSetting):
142+
# Feature flags are ignored when loaded by Selects, as they are selected from
143+
# `feature_flag_selectors`
144+
pass
145+
configuration_settings.append(config)
146+
# Every time we run load_all, we should update the etag of our refresh sentinels
147+
# so they stay up-to-date.
148+
# Sentinel keys will have unprocessed key names, so we need to use the original key.
149+
if (config.key, config.label) in refresh_on:
150+
sentinel_keys[(config.key, config.label)] = config.etag
151+
return configuration_settings, sentinel_keys
152+
153+
@distributed_trace
154+
def load_feature_flags(
155+
self, feature_flag_selectors: List[SettingSelector], feature_flag_refresh_enabled: bool, **kwargs
156+
) -> Tuple[List[FeatureFlagConfigurationSetting], Dict[Tuple[str, str], str]]:
157+
feature_flag_sentinel_keys = {}
158+
loaded_feature_flags = []
159+
# Needs to be removed unknown keyword argument for list_configuration_settings
160+
kwargs.pop("sentinel_keys", None)
161+
for select in feature_flag_selectors:
162+
feature_flags = self._client.list_configuration_settings(
163+
key_filter=FEATURE_FLAG_PREFIX + select.key_filter, label_filter=select.label_filter, **kwargs
164+
)
165+
for feature_flag in feature_flags:
166+
loaded_feature_flags.append(json.loads(feature_flag.value))
167+
168+
if feature_flag_refresh_enabled:
169+
feature_flag_sentinel_keys[(feature_flag.key, feature_flag.label)] = feature_flag.etag
170+
return loaded_feature_flags, feature_flag_sentinel_keys
171+
172+
@distributed_trace
173+
def refresh_configuration_settings(
174+
self, selects: List[SettingSelector], refresh_on: Dict[Tuple[str, str], str], headers: Dict[str, str], **kwargs
175+
) -> Tuple[bool, Dict[Tuple[str, str], str], List[Any]]:
176+
"""
177+
Gets the refreshed configuration settings if they have changed.
178+
179+
:param List[SettingSelector] selects: The selectors to use to load configuration settings
180+
:param List[SettingSelector] refresh_on: The configuration settings to check for changes
181+
:param Mapping[str, str] headers: The headers to use for the request
182+
183+
:return: A tuple with the first item being true/false if a change is detected. The second item is the updated
184+
value of the configuration sentinel keys. The third item is the updated configuration settings.
185+
:rtype: Tuple[bool, Union[Dict[Tuple[str, str], str], None], Union[List[ConfigurationSetting], None]]
186+
"""
187+
need_refresh = False
188+
updated_sentinel_keys = dict(refresh_on)
189+
for (key, label), etag in updated_sentinel_keys.items():
190+
changed, updated_sentinel = self._check_configuration_setting(
191+
key=key, label=label, etag=etag, headers=headers, **kwargs
192+
)
193+
if changed:
194+
need_refresh = True
195+
if updated_sentinel is not None:
196+
updated_sentinel_keys[(key, label)] = updated_sentinel.etag
197+
# Need to only update once, no matter how many sentinels are updated
198+
if need_refresh:
199+
configuration_settings, sentinel_keys = self.load_configuration_settings(selects, refresh_on, **kwargs)
200+
return True, sentinel_keys, configuration_settings
201+
return False, refresh_on, []
202+
203+
@distributed_trace
204+
def refresh_feature_flags(
205+
self,
206+
refresh_on: Mapping[Tuple[str, str], Optional[str]],
207+
feature_flag_selectors: List[SettingSelector],
208+
headers: Dict[str, str],
209+
**kwargs
210+
) -> Tuple[bool, Optional[Dict[Tuple[str, str], str]], Optional[List[Any]]]:
211+
"""
212+
Gets the refreshed feature flags if they have changed.
213+
214+
:param Mapping[Tuple[str, str], Optional[str]] refresh_on: The feature flags to check for changes
215+
:param List[SettingSelector] feature_flag_selectors: The selectors to use to load feature flags
216+
:param Mapping[str, str] headers: The headers to use for the request
217+
218+
:return: A tuple with the first item being true/false if a change is detected. The second item is the updated
219+
value of the feature flag sentinel keys. The third item is the updated feature flags.
220+
:rtype: Tuple[bool, Union[Dict[Tuple[str, str], str], None], Union[List[Dict[str, str]], None]]
221+
"""
222+
feature_flag_sentinel_keys: Mapping[Tuple[str, str], Optional[str]] = dict(refresh_on)
223+
for (key, label), etag in feature_flag_sentinel_keys.items():
224+
changed = self._check_configuration_setting(key=key, label=label, etag=etag, headers=headers, **kwargs)
225+
if changed:
226+
feature_flags, feature_flag_sentinel_keys = self.load_feature_flags(
227+
feature_flag_selectors, True, **kwargs
228+
)
229+
return True, feature_flag_sentinel_keys, feature_flags
230+
return False, None, None
231+
232+
@distributed_trace
233+
def get_configuration_setting(self, key: str, label: str, **kwargs) -> ConfigurationSetting:
234+
"""
235+
Gets a configuration setting from the replica client.
236+
237+
:param str key: The key of the configuration setting
238+
:param str label: The label of the configuration setting
239+
:return: The configuration setting
240+
:rtype: ConfigurationSetting
241+
"""
242+
return self._client.get_configuration_setting(key=key, label=label, **kwargs)
243+
244+
def is_active(self) -> bool:
245+
"""
246+
Checks if the client is active and can be used.
247+
248+
:return: True if the client is active, False otherwise
249+
:rtype: bool
250+
"""
251+
return self.backoff_end_time <= (time.time() * 1000)
252+
253+
def close(self) -> None:
254+
"""
255+
Closes the connection to Azure App Configuration.
256+
"""
257+
self._client.close()
258+
259+
def __enter__(self):
260+
self._client.__enter__()
261+
return self
262+
263+
def __exit__(self, *args):
264+
self._client.__exit__(*args)
265+
266+
267+
class ConfigurationClientManager:
268+
def __init__(self, min_backoff_sec: int, max_backoff_sec: int):
269+
self._replica_clients: List[ConfigurationClientWrapper] = []
270+
self._min_backoff_sec = min_backoff_sec
271+
self._max_backoff_sec = max_backoff_sec
272+
273+
def set_clients(self, replica_clients: List[ConfigurationClientWrapper]):
274+
self._replica_clients.clear()
275+
self._replica_clients.extend(replica_clients)
276+
277+
def get_active_clients(self):
278+
active_clients = []
279+
for client in self._replica_clients:
280+
if client.is_active():
281+
active_clients.append(client)
282+
return active_clients
283+
284+
def backoff(self, client: ConfigurationClientWrapper):
285+
client.failed_attempts += 1
286+
backoff_time = self._calculate_backoff(client.failed_attempts)
287+
client.backoff_end_time = (time.time() * 1000) + backoff_time
288+
289+
def get_client_count(self):
290+
return len(self._replica_clients)
291+
292+
def _calculate_backoff(self, attempts: int) -> float:
293+
max_attempts = 63
294+
ms_per_second = 1000 # 1 Second in milliseconds
295+
296+
min_backoff_milliseconds = self._min_backoff_sec * ms_per_second
297+
max_backoff_milliseconds = self._max_backoff_sec * ms_per_second
298+
299+
if self._max_backoff_sec <= self._min_backoff_sec:
300+
return min_backoff_milliseconds
301+
302+
calculated_milliseconds = max(1, min_backoff_milliseconds) * (1 << min(attempts, max_attempts))
303+
304+
if calculated_milliseconds > max_backoff_milliseconds or calculated_milliseconds <= 0:
305+
calculated_milliseconds = max_backoff_milliseconds
306+
307+
return min_backoff_milliseconds + (
308+
random.uniform(0.0, 1.0) * (calculated_milliseconds - min_backoff_milliseconds)
309+
)
310+
311+
def __eq__(self, other):
312+
if len(self._replica_clients) != len(other._replica_clients):
313+
return False
314+
for i in range(len(self._replica_clients)): # pylint:disable=consider-using-enumerate
315+
if self._replica_clients[i] != other._replica_clients[i]:
316+
return False
317+
return True
318+
319+
def close(self):
320+
for client in self._replica_clients:
321+
client.close()
322+
323+
def __enter__(self):
324+
for client in self._replica_clients:
325+
client.__enter__()
326+
return self
327+
328+
def __exit__(self, *args):
329+
for client in self._replica_clients:
330+
client.__exit__(*args)

0 commit comments

Comments
 (0)