Skip to content

Commit 97f43d2

Browse files
mrm9084rossgrambo
andauthored
App Config - Feature Flag load/refresh (#33411)
* Splitting Out Feature Flag Refresh * Fixing Feature Flag Refresh * Fixing Feature Management Refresh Tests * Fixing refresh lock * Fixing lint * Fixing lint * Test error * Update linting * Update _azureappconfigurationprovider.py * Fix merge issue. Added Sample * Update feature_flag_sample.py * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py Co-authored-by: Ross Grambo <[email protected]> * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py Co-authored-by: Ross Grambo <[email protected]> * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py Co-authored-by: Ross Grambo <[email protected]> * Fixing param definition. And trimming logic. * Formatting * Update sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py Co-authored-by: Ross Grambo <[email protected]> * Update _azureappconfigurationprovider.py --------- Co-authored-by: Ross Grambo <[email protected]>
1 parent 8fa957d commit 97f43d2

File tree

13 files changed

+286
-45
lines changed

13 files changed

+286
-45
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_51fb3fb738"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_29963ddf05"
66
}

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

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ def load(
100100
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
101101
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
102102
specified, errors will be raised.
103+
:paramtype feature_flag_enabled: bool
104+
:keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False.
105+
:paramtype feature_flag_selectors: List[SettingSelector]
106+
:keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all
107+
feature flags without a label.
108+
:paramtype feature_flag_refresh_enabled: bool
109+
:keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is
110+
False.
111+
:paramtype feature_flag_trim_prefixes: List[str]
112+
:keyword feature_flag_trim_prefixes: After the FEATURE_FLAG_PREFIX is trimmed, the first match in
113+
feature_flag_trim_prefixes will be trimmed, if there is one.
103114
"""
104115

105116

@@ -144,6 +155,17 @@ def load(
144155
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
145156
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
146157
specified, errors will be raised.
158+
:paramtype feature_flag_enabled: bool
159+
:keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False.
160+
:paramtype feature_flag_selectors: List[SettingSelector]
161+
:keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all
162+
feature flags without a label.
163+
:paramtype feature_flag_refresh_enabled: bool
164+
:keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is
165+
False.
166+
:paramtype feature_flag_trim_prefixes: List[str]
167+
:keyword feature_flag_trim_prefixes: After the FEATURE_FLAG_PREFIX is trimmed, the first match in
168+
feature_flag_trim_prefixes will be trimmed, if there is one.
147169
"""
148170

149171

@@ -438,12 +460,19 @@ def __init__(self, **kwargs) -> None:
438460
or self._keyvault_client_configs is not None
439461
or self._secret_resolver is not None
440462
)
463+
self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False)
464+
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")])
465+
self._refresh_on_feature_flags = None
466+
self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
467+
self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False)
468+
feature_flag_trim_prefixes = kwargs.pop("feature_flag_trim_prefixes", [])
469+
self._feature_flag_trim_prefixes: List[str] = sorted(feature_flag_trim_prefixes, key=len, reverse=True)
441470
self._update_lock = Lock()
442471
self._refresh_lock = Lock()
443472

444473
def refresh(self, **kwargs) -> None:
445-
if not self._refresh_on:
446-
logging.debug("Refresh called but no refresh options set.")
474+
if not self._refresh_on and not self._feature_flag_refresh_enabled:
475+
logging.debug("Refresh called but no refresh enabled.")
447476
return
448477
if not self._refresh_timer.needs_refresh():
449478
logging.debug("Refresh called but refresh interval not elapsed.")
@@ -503,7 +532,76 @@ def refresh(self, **kwargs) -> None:
503532
elif need_refresh and self._on_refresh_success:
504533
self._on_refresh_success()
505534

535+
def refresh_configuration_settings(self, configuration_settings, sentinel_keys, timer, **kwargs) -> None:
536+
success = False
537+
need_refresh = False
538+
updated_feature_flags = False
539+
headers = _get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs)
540+
try:
541+
for (key, label), etag in sentinel_keys.items():
542+
etag, update, is_feature_flag, configuration_settings = self._check_configuration_settings(
543+
key, label, etag, headers, configuration_settings, **kwargs
544+
)
545+
if update and not is_feature_flag:
546+
need_refresh = True
547+
elif update and is_feature_flag:
548+
updated_feature_flags = True
549+
550+
sentinel_keys[(key, label)] = etag
551+
# Need to only update once, no matter how many sentinels are updated
552+
if need_refresh:
553+
configuration_settings, sentinel_keys = self._load_configuration_settings(
554+
headers=headers, sentinel_keys=sentinel_keys, **kwargs
555+
)
556+
success = True
557+
# Even if we don't need to refresh, we should reset the timer
558+
timer.reset()
559+
except (ServiceRequestError, ServiceResponseError, HttpResponseError) as e:
560+
# If a call back is provided, we should call it, otherwise raise the error
561+
if not self._on_refresh_error:
562+
raise
563+
self._on_refresh_error(e)
564+
finally:
565+
# If we get an error we should retry sooner than the next refresh interval
566+
if not success:
567+
timer.backoff()
568+
return configuration_settings, sentinel_keys, need_refresh or updated_feature_flags
569+
570+
def _check_configuration_settings(self, key, label, etag, headers, configuration_settings, **kwargs) -> None:
571+
is_feature_flag = False
572+
try:
573+
updated_config = self._client.get_configuration_setting(
574+
key=key, label=label, etag=etag, match_condition=MatchConditions.IfModified, headers=headers, **kwargs
575+
)
576+
is_feature_flag = not isinstance(updated_config, FeatureFlagConfigurationSetting)
577+
if updated_config is not None:
578+
if not is_feature_flag:
579+
logging.debug("Refresh all triggered by key: %s label %s.", key, label)
580+
elif is_feature_flag:
581+
configuration_settings[self._process_key_name(updated_config)] = updated_config.value
582+
return updated_config.etag, True, is_feature_flag, configuration_settings
583+
except HttpResponseError as e:
584+
if e.status_code == 404:
585+
if etag is not None and not is_feature_flag:
586+
# If the sentinel is not found, it means the key/label was deleted, so we should refresh
587+
logging.debug("Refresh all triggered by key: %s label %s.", key, label)
588+
elif etag is not None and is_feature_flag:
589+
configuration_settings[key] = None
590+
return None, True, is_feature_flag, configuration_settings
591+
raise e
592+
return etag, False, is_feature_flag, configuration_settings
593+
506594
def _load_all(self, **kwargs):
595+
configuration_settings, sentinel_keys = self._load_configuration_settings(**kwargs)
596+
feature_flags, feature_flag_sentinel_keys = self._load_feature_flags(**kwargs)
597+
if self._feature_flag_enabled:
598+
configuration_settings[FEATURE_MANAGEMENT_KEY] = feature_flags
599+
self._refresh_on_feature_flags = feature_flag_sentinel_keys
600+
with self._update_lock:
601+
self._refresh_on = sentinel_keys
602+
self._dict = configuration_settings
603+
604+
def _load_configuration_settings(self, **kwargs):
507605
configuration_settings = {}
508606
sentinel_keys = kwargs.pop("sentinel_keys", self._refresh_on)
509607
for select in self._selects:
@@ -514,30 +612,51 @@ def _load_all(self, **kwargs):
514612
key = self._process_key_name(config)
515613
value = self._process_key_value(config)
516614
if isinstance(config, FeatureFlagConfigurationSetting):
517-
feature_management = configuration_settings.get(FEATURE_MANAGEMENT_KEY, {})
518-
feature_management[key] = value
519-
if FEATURE_MANAGEMENT_KEY not in configuration_settings:
520-
configuration_settings[FEATURE_MANAGEMENT_KEY] = feature_management
615+
# Feature flags are ignored when loaded by Selects, as they are selected from
616+
# `feature_flag_selectors`
617+
pass
521618
else:
522619
configuration_settings[key] = value
523620
# Every time we run load_all, we should update the etag of our refresh sentinels
524621
# so they stay up-to-date.
525622
# Sentinel keys will have unprocessed key names, so we need to use the original key.
526623
if (config.key, config.label) in self._refresh_on:
527624
sentinel_keys[(config.key, config.label)] = config.etag
528-
self._refresh_on = sentinel_keys
529-
with self._update_lock:
530-
self._dict = configuration_settings
625+
return configuration_settings, sentinel_keys
626+
627+
def _load_feature_flags(self, **kwargs):
628+
feature_flag_sentinel_keys = {}
629+
loaded_feature_flags = {}
630+
if self._feature_flag_enabled:
631+
for select in self._feature_flag_selectors:
632+
feature_flags = self._client.list_configuration_settings(
633+
key_filter=FEATURE_FLAG_PREFIX + select.key_filter, label_filter=select.label_filter, **kwargs
634+
)
635+
for feature_flag in feature_flags:
636+
key = self._process_key_name(feature_flag)
637+
loaded_feature_flags[key] = feature_flag.value
638+
if self._feature_flag_refresh_enabled:
639+
feature_flag_sentinel_keys[(feature_flag.key, feature_flag.label)] = feature_flag.etag
640+
return loaded_feature_flags, feature_flag_sentinel_keys
531641

532642
def _process_key_name(self, config):
533643
trimmed_key = config.key
534644
# Trim the key if it starts with one of the prefixes provided
645+
646+
# Feature Flags have there own prefix, so we need to trim that first
647+
if isinstance(config, FeatureFlagConfigurationSetting):
648+
if trimmed_key.startswith(FEATURE_FLAG_PREFIX):
649+
trimmed_key = trimmed_key[len(FEATURE_FLAG_PREFIX) :]
650+
for trim in self._feature_flag_trim_prefixes:
651+
if trimmed_key.startswith(trim):
652+
trimmed_key = trimmed_key[len(trim) :]
653+
break
654+
return trimmed_key
655+
535656
for trim in self._trim_prefixes:
536657
if config.key.startswith(trim):
537658
trimmed_key = config.key[len(trim) :]
538659
break
539-
if isinstance(config, FeatureFlagConfigurationSetting) and trimmed_key.startswith(FEATURE_FLAG_PREFIX):
540-
return trimmed_key[len(FEATURE_FLAG_PREFIX) :]
541660
return trimmed_key
542661

543662
def _process_key_value(self, config):

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# license information.
55
# -------------------------------------------------------------------------
66

7-
FEATURE_MANAGEMENT_KEY = "FeatureManagementFeatureFlags"
7+
FEATURE_MANAGEMENT_KEY = "FeatureManagement"
88
FEATURE_FLAG_PREFIX = ".appconfig.featureflag/"
99

1010
EMPTY_LABEL = "\0"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
7+
from azure.appconfiguration.provider import load
8+
import os
9+
from sample_utilities import get_authority, get_audience, get_credential, get_client_modifications
10+
11+
endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING")
12+
authority = get_authority(endpoint)
13+
audience = get_audience(authority)
14+
credential = get_credential(authority)
15+
kwargs = get_client_modifications()
16+
17+
# Connecting to Azure App Configuration using AAD, selects [] results in no configuration settings loading
18+
# By default, feature flags with no label are loaded when feature_flag_enabled is set to True
19+
config = load(endpoint=endpoint, credential=credential, selects=[], feature_flag_enabled=True, **kwargs)
20+
21+
print(config["FeatureManagement"]["Alpha"])
22+
print(config["FeatureManagement"]["Beta"])
23+
print(config["FeatureManagement"]["Targeting"])
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 azure.appconfiguration.provider import load, WatchKey
7+
from azure.appconfiguration import (
8+
AzureAppConfigurationClient,
9+
ConfigurationSetting,
10+
FeatureFlagConfigurationSetting,
11+
)
12+
from sample_utilities import get_client_modifications
13+
import os
14+
import time
15+
16+
kwargs = get_client_modifications()
17+
connection_string = os.environ.get("APPCONFIGURATION_CONNECTION_STRING")
18+
19+
# Setting up a configuration setting with a known value
20+
client = AzureAppConfigurationClient.from_connection_string(connection_string)
21+
22+
configuration_setting = ConfigurationSetting(key="message", value="Hello World!")
23+
feature_flag_setting = FeatureFlagConfigurationSetting("Beta", enabled=True)
24+
25+
client.set_configuration_setting(configuration_setting=configuration_setting)
26+
client.set_configuration_setting(configuration_setting=feature_flag_setting)
27+
28+
29+
def my_callback_on_fail(error):
30+
print("Refresh failed!")
31+
32+
33+
# Connecting to Azure App Configuration using connection string, and refreshing when the configuration setting message changes
34+
config = load(
35+
connection_string=connection_string,
36+
refresh_on=[WatchKey("message")],
37+
refresh_on_feature_flags=True,
38+
refresh_interval=1,
39+
on_refresh_error=my_callback_on_fail,
40+
feature_flag_enabled=True,
41+
**kwargs,
42+
)
43+
44+
print(config["message"])
45+
print(config["my_json"]["key"])
46+
print(config["FeatureManagement"]["Beta"])
47+
48+
# Updating the configuration setting
49+
feature_flag_setting.enabled = False
50+
51+
client.set_configuration_setting(configuration_setting=feature_flag_setting)
52+
53+
# Waiting for the refresh interval to pass
54+
time.sleep(2)
55+
56+
# Refreshing the configuration setting
57+
config.refresh()
58+
59+
# Printing the updated value
60+
print(config["message"])
61+
print(config["my_json"]["key"])
62+
print(config["FeatureManagement"]["Beta"])
63+
64+
# Waiting for the refresh interval to pass
65+
time.sleep(2)
66+
67+
# Refreshing the configuration setting with no changes
68+
config.refresh()
69+
70+
# Printing the updated value
71+
print(config["message"])
72+
print(config["my_json"]["key"])
73+
print(config["FeatureManagement"]["Beta"])

sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async def create_aad_client(
2727
secret_resolver=None,
2828
key_vault_options=None,
2929
on_refresh_success=None,
30+
feature_flag_enabled=False,
3031
):
3132
cred = self.get_credential(AzureAppConfigurationClient, is_async=True)
3233

@@ -50,6 +51,7 @@ async def create_aad_client(
5051
user_agent="SDK/Integration",
5152
keyvault_credential=keyvault_cred,
5253
on_refresh_success=on_refresh_success,
54+
feature_flag_enabled=feature_flag_enabled,
5355
)
5456
if key_vault_options:
5557
if not key_vault_options.secret_resolver:
@@ -64,6 +66,7 @@ async def create_aad_client(
6466
user_agent="SDK/Integration",
6567
key_vault_options=key_vault_options,
6668
on_refresh_success=on_refresh_success,
69+
feature_flag_enabled=feature_flag_enabled,
6770
)
6871
return await load(
6972
credential=cred,
@@ -75,6 +78,7 @@ async def create_aad_client(
7578
user_agent="SDK/Integration",
7679
secret_resolver=secret_resolver,
7780
on_refresh_success=on_refresh_success,
81+
feature_flag_enabled=feature_flag_enabled,
7882
)
7983

8084
async def create_client(
@@ -88,6 +92,7 @@ async def create_client(
8892
secret_resolver=None,
8993
key_vault_options=None,
9094
on_refresh_success=None,
95+
feature_flag_enabled=False,
9196
):
9297
client = AzureAppConfigurationClient.from_connection_string(appconfiguration_connection_string)
9398
await setup_configs(client, keyvault_secret_url)
@@ -102,6 +107,7 @@ async def create_client(
102107
user_agent="SDK/Integration",
103108
keyvault_credential=self.get_credential(AzureAppConfigurationClient, is_async=True),
104109
on_refresh_success=on_refresh_success,
110+
feature_flag_enabled=feature_flag_enabled,
105111
)
106112
if key_vault_options:
107113
if not key_vault_options.secret_resolver:
@@ -117,6 +123,7 @@ async def create_client(
117123
user_agent="SDK/Integration",
118124
key_vault_options=key_vault_options,
119125
on_refresh_success=on_refresh_success,
126+
feature_flag_enabled=feature_flag_enabled,
120127
)
121128
return await load(
122129
connection_string=appconfiguration_connection_string,
@@ -127,6 +134,7 @@ async def create_client(
127134
user_agent="SDK/Integration",
128135
secret_resolver=secret_resolver,
129136
on_refresh_success=on_refresh_success,
137+
feature_flag_enabled=feature_flag_enabled,
130138
)
131139

132140

0 commit comments

Comments
 (0)