Skip to content

Commit 70bebbf

Browse files
authored
App Configuration Python Provider - Refresh Success Callback (Azure#32464)
* Updated to add callback on successful refresh, fixed timer usage * Update CHANGELOG.md * Updated formatted and refresh on * Fixing line length * Update test_async_provider_refresh.py * Fixing _on_refresh_success, ignoring tests in 3.7 for mockasync * Update test_provider_refresh.py * Moving to correct file * Adding try for import * Trying to fix asyncMock issue * Updated sentinel key usage * Formatting * Deal with deleted keys after startup * Fixing merge issue * Fixed all merge things * Update assets.json * SentinelKey to WatchKey * Update _azureappconfigurationprovider.py * Updating Async with No matching watch key check * Move header computer, fixed error check to only refresh if previously there was an etag * formatting * Fixing header merge issue * Fixed async to match sync. removed redundant None. * Update assets.json * Update _azureappconfigurationproviderasync.py * rename retry to backoff. Fixing error logic * Removed extra backoff call
1 parent 289a764 commit 70bebbf

File tree

12 files changed

+295
-191
lines changed

12 files changed

+295
-191
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
### Features Added
66

7+
- Added on_refresh_success callback to load method. This callback is called when the refresh method successfully refreshes the configuration.
8+
79
### Breaking Changes
810

911
### Bugs Fixed
1012

13+
- Fixes issue where the refresh timer only reset after a change was found.
14+
1115
### Other Changes
1216

1317
## 1.1.0b2 (2023-09-29)

sdk/appconfiguration/azure-appconfiguration-provider/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,27 @@ In this example all configuration with empty label and the dev label are loaded.
7474

7575
## Dynamic Refresh
7676

77-
The provider can be configured to refresh configurations from the store on a set interval. This is done by providing a `refresh_on` to the provider, which is a list of key(s) that will be watched for changes, and when they do change a refresh can happen. `refresh_interval` is the period of time in seconds between refreshes. `on_refresh_error` is a callback that will be called when a refresh fails.
77+
The provider can be configured to refresh configurations from the store on a set interval. This is done by providing a `refresh_on` to the provider, which is a list of key(s) that will be watched for changes, and when they do change a refresh can happen. `refresh_interval` is the period of time in seconds between refreshes. `on_refresh_success` is a callback that will be called only if a change is detected and no error happens. `on_refresh_error` is a callback that will be called when a refresh fails.
7878

7979
```python
80-
from azure.appconfiguration.provider import load, SentinelKey
80+
from azure.appconfiguration.provider import load, WatchKey
8181
import os
8282

8383
connection_string = os.environ.get("APPCONFIGURATION_CONNECTION_STRING")
8484

85+
def my_callback_on_success():
86+
# Do something on success
87+
...
88+
8589
def my_callback_on_fail(error):
86-
print("Refresh failed!")
90+
# Do something on fail
91+
...
8792

8893
config = load(
8994
connection_string=connection_string,
90-
refresh_on=[SentinelKey("Sentinel")],
95+
refresh_on=[WatchKey("Sentinel")],
9196
refresh_interval=60,
97+
on_refresh_success=my_callback_on_success,
9298
on_refresh_error=my_callback_on_fail,
9399
**kwargs,
94100
)

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_03403fdb69"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_f12acb102d"
66
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ._models import (
99
AzureAppConfigurationKeyVaultOptions,
1010
SettingSelector,
11-
SentinelKey,
11+
WatchKey,
1212
)
1313

1414
from ._version import VERSION
@@ -19,5 +19,5 @@
1919
"AzureAppConfigurationProvider",
2020
"AzureAppConfigurationKeyVaultOptions",
2121
"SettingSelector",
22-
"SentinelKey",
22+
"WatchKey",
2323
]

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

Lines changed: 70 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def load(
6262
key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = None,
6363
refresh_on: Optional[List[Tuple[str, str]]] = None,
6464
refresh_interval: int = 30,
65+
on_refresh_success: Optional[Callable] = None,
6566
on_refresh_error: Optional[Callable[[Exception], None]] = None,
6667
**kwargs
6768
) -> "AzureAppConfigurationProvider":
@@ -89,6 +90,9 @@ def load(
8990
:paramtype refresh_on: List[Tuple[str, str]]
9091
:keyword int refresh_interval: The minimum time in seconds between when a call to `refresh` will actually trigger a
9192
service call to update the settings. Default value is 30 seconds.
93+
:paramtype on_refresh_success: Optional[Callable]
94+
:keyword on_refresh_success: Optional callback to be invoked when a change is found and a successful refresh has
95+
happened.
9296
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
9397
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
9498
specified, errors will be raised.
@@ -104,6 +108,7 @@ def load(
104108
key_vault_options: Optional[AzureAppConfigurationKeyVaultOptions] = None,
105109
refresh_on: Optional[List[Tuple[str, str]]] = None,
106110
refresh_interval: int = 30,
111+
on_refresh_success: Optional[Callable] = None,
107112
on_refresh_error: Optional[Callable[[Exception], None]] = None,
108113
**kwargs
109114
) -> "AzureAppConfigurationProvider":
@@ -129,6 +134,9 @@ def load(
129134
:paramtype refresh_on: List[Tuple[str, str]]
130135
:keyword int refresh_interval: The minimum time in seconds between when a call to `refresh` will actually trigger a
131136
service call to update the settings. Default value is 30 seconds.
137+
:paramtype on_refresh_success: Optional[Callable]
138+
:keyword on_refresh_success: Optional callback to be invoked when a change is found and a successful refresh has
139+
happened.
132140
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
133141
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
134142
specified, errors will be raised.
@@ -183,8 +191,19 @@ def load(*args, **kwargs) -> "AzureAppConfigurationProvider":
183191
# Refresh-All sentinels are not updated on load_all, as they are not necessarily included in the provider.
184192
for (key, label), etag in provider._refresh_on.items():
185193
if not etag:
186-
sentinel = provider._client.get_configuration_setting(key, label, headers=headers)
187-
provider._refresh_on[(key, label)] = sentinel.etag
194+
try:
195+
sentinel = provider._client.get_configuration_setting(key, label, headers=headers)
196+
provider._refresh_on[(key, label)] = sentinel.etag
197+
except HttpResponseError as e:
198+
if e.status_code == 404:
199+
# If the sentinel is not found a refresh should be triggered when it is created.
200+
logging.debug(
201+
"WatchKey key: %s label %s was configured but not found. Refresh will be triggered if created.",
202+
key,
203+
label,
204+
)
205+
else:
206+
raise e
188207
return provider
189208

190209

@@ -320,21 +339,6 @@ def _build_sentinel(setting: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
320339
return key, label
321340

322341

323-
def _is_retryable_error(error: HttpResponseError) -> bool:
324-
"""Determine whether the service error should be silently retried after a backoff period, or raised.
325-
Don't know what errors this applies to yet, so just always raising for now.
326-
:param error: The http error to check.
327-
:type error: ~azure.core.exceptions.HttpResponseError
328-
:return: Whether the error should be retried.
329-
:rtype: bool
330-
"""
331-
# 408: Request Timeout
332-
# 429: Too Many Requests
333-
# 500: Internal Server Error
334-
# 504: Gateway Timeout
335-
return error.status_code in [408, 429, 500, 504]
336-
337-
338342
class _RefreshTimer:
339343
"""
340344
A timer that tracks the next refresh time and the number of attempts.
@@ -353,7 +357,7 @@ def reset(self) -> None:
353357
self._next_refresh_time = time.time() + self._interval
354358
self._attempts = 1
355359

356-
def retry(self) -> None:
360+
def backoff(self) -> None:
357361
self._next_refresh_time = time.time() + self._calculate_backoff() / 1000
358362
self._attempts += 1
359363

@@ -402,6 +406,7 @@ def __init__(self, **kwargs) -> None:
402406
refresh_on: List[Tuple[str, str]] = kwargs.pop("refresh_on", None) or []
403407
self._refresh_on: Mapping[Tuple[str, str] : Optional[str]] = {_build_sentinel(s): None for s in refresh_on}
404408
self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
409+
self._on_refresh_success: Optional[Callable] = kwargs.pop("on_refresh_success", None)
405410
self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None)
406411
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
407412
self._secret_resolver = kwargs.pop("secret_resolver", None)
@@ -421,47 +426,62 @@ def refresh(self, **kwargs) -> None:
421426
if not self._refresh_timer.needs_refresh():
422427
logging.debug("Refresh called but refresh interval not elapsed.")
423428
return
429+
success = False
424430
try:
425431
with self._update_lock:
426-
for (key, label), etag in self._refresh_on.items():
427-
updated_sentinel = self._client.get_configuration_setting(
428-
key=key,
429-
label=label,
430-
etag=etag,
431-
match_condition=MatchConditions.IfModified,
432-
headers=_get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs),
433-
**kwargs
434-
)
435-
if updated_sentinel is not None:
436-
logging.debug(
437-
"Refresh all triggered by key: %s label %s.",
438-
key,
439-
label,
432+
need_refresh = False
433+
updated_sentinel_keys = dict(self._refresh_on)
434+
headers = _get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs)
435+
for (key, label), etag in updated_sentinel_keys.items():
436+
try:
437+
updated_sentinel = self._client.get_configuration_setting(
438+
key=key,
439+
label=label,
440+
etag=etag,
441+
match_condition=MatchConditions.IfModified,
442+
headers=headers,
443+
**kwargs
440444
)
441-
self._load_all(headers=_get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs))
442-
self._refresh_on[(key, label)] = updated_sentinel.etag
443-
self._refresh_timer.reset()
444-
return
445-
except (ServiceRequestError, ServiceResponseError) as e:
446-
logging.debug("Failed to refresh, retrying: %r", e)
447-
self._refresh_timer.retry()
448-
except HttpResponseError as e:
449-
# If we get an error we should retry sooner than the next refresh interval
450-
self._refresh_timer.retry()
451-
if _is_retryable_error(e):
452-
return
453-
if self._on_refresh_error:
454-
self._on_refresh_error(e)
445+
if updated_sentinel is not None:
446+
logging.debug(
447+
"Refresh all triggered by key: %s label %s.",
448+
key,
449+
label,
450+
)
451+
need_refresh = True
452+
453+
updated_sentinel_keys[(key, label)] = updated_sentinel.etag
454+
except HttpResponseError as e:
455+
if e.status_code == 404:
456+
if etag is not None:
457+
# If the sentinel is not found, it means the key/label was deleted, so we should refresh
458+
logging.debug("Refresh all triggered by key: %s label %s.", key, label)
459+
need_refresh = True
460+
updated_sentinel_keys[(key, label)] = None
461+
else:
462+
raise e
463+
# Need to only update once, no matter how many sentinels are updated
464+
if need_refresh:
465+
self._load_all(headers=headers, sentinel_keys=updated_sentinel_keys, **kwargs)
466+
if self._on_refresh_success:
467+
self._on_refresh_success()
468+
# Even if we don't need to refresh, we should reset the timer
469+
self._refresh_timer.reset()
470+
success = True
455471
return
456-
raise
457-
except Exception as e:
472+
except (ServiceRequestError, ServiceResponseError, HttpResponseError) as e:
473+
# If we get an error we should retry sooner than the next refresh interval
458474
if self._on_refresh_error:
459475
self._on_refresh_error(e)
460476
return
461477
raise
478+
finally:
479+
if not success:
480+
self._refresh_timer.backoff()
462481

463482
def _load_all(self, **kwargs):
464483
configuration_settings = {}
484+
sentinel_keys = kwargs.pop("sentinel_keys", self._refresh_on)
465485
for select in self._selects:
466486
configurations = self._client.list_configuration_settings(
467487
key_filter=select.key_filter, label_filter=select.label_filter, **kwargs
@@ -481,7 +501,8 @@ def _load_all(self, **kwargs):
481501
# so they stay up-to-date.
482502
# Sentinel keys will have unprocessed key names, so we need to use the original key.
483503
if (config.key, config.label) in self._refresh_on:
484-
self._refresh_on[(config.key, config.label)] = config.etag
504+
sentinel_keys[(config.key, config.label)] = config.etag
505+
self._refresh_on = sentinel_keys
485506
self._dict = configuration_settings
486507

487508
def _process_key_name(self, config):

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@ def __init__(self, *, key_filter: str, label_filter: Optional[str] = EMPTY_LABEL
5555
self.label_filter = label_filter
5656

5757

58-
class SentinelKey(NamedTuple):
58+
class WatchKey(NamedTuple):
5959
key: str
6060
label: str = EMPTY_LABEL

0 commit comments

Comments
 (0)