From 80db99fb1362f76e2b96d5f92e245cbbcfa0487a Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 7 Jul 2026 15:27:10 -0700 Subject: [PATCH 1/2] Bug Fixes --- .../CHANGELOG.md | 4 ++++ .../_azureappconfigurationproviderbase.py | 9 ++++++--- .../provider/_client_manager.py | 14 ++++++++++---- .../appconfiguration/provider/_discovery.py | 4 ++-- .../provider/aio/_async_client_manager.py | 18 ++++++++++++------ .../provider/aio/_async_discovery.py | 4 ++-- .../test_configuration_async_client_manager.py | 3 +++ ...ration_async_client_manager_load_balance.py | 3 +++ .../tests/test_configuration_client_manager.py | 3 +++ ...onfiguration_client_manager_load_balance.py | 3 +++ 10 files changed, 48 insertions(+), 17 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 54481723b4d3..5fe8b8e0da41 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -8,6 +8,10 @@ ### Bugs Fixed +- Fixed a resource leak where replica clients that were no longer part of the auto-failover set were not closed during client refresh. +- Fixed auto-failover replica discovery so that a DNS SRV lookup timeout is distinguished from an empty replica list. A timeout now correctly triggers the longer fallback refresh interval, while an empty result refreshes at the normal interval. +- Fixed a thread-safety issue where iterating, checking membership, or getting the length of the provider's configuration mapping was not guarded by the update lock. + ### Other Changes - Bumped minimum dependency on `azure-core` to `>=1.31.0`. diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index e5b6240d74e6..858364e4e001 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -264,17 +264,20 @@ def __getitem__(self, key: str) -> Any: return self._dict[key] def __iter__(self) -> Iterator[str]: - return self._dict.__iter__() + with self._update_lock: + return self._dict.__iter__() def __len__(self) -> int: - return len(self._dict) + with self._update_lock: + return len(self._dict) def __contains__(self, __x: object) -> bool: # pylint:disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype """ Returns True if the configuration settings contains the specified key. """ - return self._dict.__contains__(__x) + with self._update_lock: + return self._dict.__contains__(__x) def keys(self) -> KeysView[str]: """ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index 651a55577222..802163f11b85 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -483,10 +483,10 @@ def refresh_clients(self): if self._next_update_time and self._next_update_time > time.time(): return - failover_endpoints = find_auto_failover_endpoints(self._original_endpoint, self._replica_discovery_enabled) - - if failover_endpoints is None: - # SRV record not found, so we should refresh after a longer interval + try: + failover_endpoints = find_auto_failover_endpoints(self._original_endpoint, self._replica_discovery_enabled) + except TimeoutError: + # SRV record resolution timed out, so we should refresh after a longer interval self._next_update_time = time.time() + FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL return @@ -530,6 +530,12 @@ def refresh_clients(self): ) ) self._next_update_time = time.time() + MINIMAL_CLIENT_REFRESH_INTERVAL + # Close any replica clients that are no longer part of the failover. + retained_endpoints = {self._original_client.endpoint} + retained_endpoints.update(client.endpoint for client in discovered_clients) + for client in self._replica_clients: + if client.endpoint not in retained_endpoints: + client.close() if not self._load_balancing_enabled: random.shuffle(discovered_clients) self._replica_clients = [self._original_client] + discovered_clients diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py index 6dbfe722a964..5f960a1b4e08 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_discovery.py @@ -40,8 +40,8 @@ def find_auto_failover_endpoints(endpoint: str, replica_discovery_enabled: bool) replicas = _find_replicas(origin.target) - if not replicas: - return None # Timeout + if replicas is None: + raise TimeoutError("Timed out while resolving auto-failover replica endpoints.") srv_records = [origin] + replicas endpoints = [] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index abcb6233e9a1..fba4badad8e7 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -485,12 +485,12 @@ async def refresh_clients(self): if self._next_update_time and self._next_update_time > time.time(): return - failover_endpoints = await find_auto_failover_endpoints( - self._original_endpoint, self._replica_discovery_enabled - ) - - if failover_endpoints is None: - # SRV record not found, so we should refresh after a longer interval + try: + failover_endpoints = await find_auto_failover_endpoints( + self._original_endpoint, self._replica_discovery_enabled + ) + except TimeoutError: + # SRV record resolution timed out, so we should refresh after a longer interval self._next_update_time = time.time() + FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL return @@ -534,6 +534,12 @@ async def refresh_clients(self): ) ) self._next_update_time = time.time() + MINIMAL_CLIENT_REFRESH_INTERVAL + # Close any replica clients that are no longer part of the failover. + retained_endpoints = {self._original_client.endpoint} + retained_endpoints.update(client.endpoint for client in discovered_clients) + for client in self._replica_clients: + if client.endpoint not in retained_endpoints: + await client.close() if not self._load_balancing_enabled: random.shuffle(discovered_clients) self._replica_clients = [self._original_client] + discovered_clients diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_discovery.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_discovery.py index 1713c0d0d833..ecc26d0a7e0e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_discovery.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_discovery.py @@ -25,8 +25,8 @@ async def find_auto_failover_endpoints(endpoint: str, replica_discovery_enabled: replicas = await _find_replicas(origin.target) - if not replicas: - return None # Timeout + if replicas is None: + raise TimeoutError("Timed out while resolving auto-failover replica endpoints.") srv_records = [origin] + replicas endpoints = [] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager.py index e8c2c66dc2d9..956e986637f0 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager.py @@ -27,6 +27,9 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_total = retry_total self.retry_backoff = retry_backoff + async def close(self): + pass + @pytest.mark.usefixtures("caplog") class TestAsyncConfigurationClientManager: diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager_load_balance.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager_load_balance.py index f252798367b1..7d5ac234453a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager_load_balance.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_configuration_async_client_manager_load_balance.py @@ -24,6 +24,9 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_total = retry_total self.retry_backoff = retry_backoff + async def close(self): + pass + @pytest.mark.usefixtures("caplog") class TestConfigurationAsyncClientManagerLoadBalance: diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py index 5edaab158bf4..d2123f6f4f3a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py @@ -26,6 +26,9 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_total = retry_total self.retry_backoff = retry_backoff + def close(self): + pass + @pytest.mark.usefixtures("caplog") class TestConfigurationClientManager(unittest.TestCase): diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager_load_balance.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager_load_balance.py index f73942364e19..3d0fca436379 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager_load_balance.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager_load_balance.py @@ -25,6 +25,9 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_total = retry_total self.retry_backoff = retry_backoff + def close(self): + pass + @pytest.mark.usefixtures("caplog") class TestConfigurationClientManagerLoadBalance(unittest.TestCase): From 08fd904f38711ace7fca57d9e600b3fa49bf4137 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 7 Jul 2026 15:56:36 -0700 Subject: [PATCH 2/2] Other small bug fixes --- .../azure-appconfiguration-provider/CHANGELOG.md | 2 ++ .../provider/_azureappconfigurationprovider.py | 2 +- .../provider/_azureappconfigurationproviderbase.py | 12 +++++++----- .../appconfiguration/provider/_client_manager.py | 4 ++-- .../provider/_key_vault/_secret_provider_base.py | 2 +- .../provider/aio/_async_client_manager.py | 4 ++-- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 5fe8b8e0da41..defe7499cb04 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -11,6 +11,8 @@ - Fixed a resource leak where replica clients that were no longer part of the auto-failover set were not closed during client refresh. - Fixed auto-failover replica discovery so that a DNS SRV lookup timeout is distinguished from an empty replica list. A timeout now correctly triggers the longer fallback refresh interval, while an empty result refreshes at the normal interval. - Fixed a thread-safety issue where iterating, checking membership, or getting the length of the provider's configuration mapping was not guarded by the update lock. +- Fixed `refresh_on` handling so that a single-string watched setting is treated as a key with the default (no) label instead of being incorrectly unpacked character-by-character. +- Fixed a `KeyError` when loading with an endpoint and credential (no connection string). ### Other Changes diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py index a3f8f826679c..517d25478e47 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -79,7 +79,7 @@ def __init__(self, **kwargs: Any) -> None: self._startup_timeout: int = kwargs.pop("startup_timeout", DEFAULT_STARTUP_TIMEOUT) self._replica_client_manager = ConfigurationClientManager( - connection_string=kwargs.pop("connection_string"), + connection_string=kwargs.pop("connection_string", None), endpoint=kwargs.pop("endpoint"), credential=kwargs.pop("credential", None), user_agent=user_agent, diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index 858364e4e001..63e29a906800 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -70,11 +70,13 @@ def is_json_content_type(content_type: str) -> bool: def _build_watched_setting(setting: Union[str, Tuple[str, str]]) -> Tuple[str, str]: - try: - key, label = setting # type:ignore - except (IndexError, ValueError): - key = str(setting) # Ensure key is a string - label = NULL_CHAR + if isinstance(setting, str): + key, label = setting, NULL_CHAR + else: + try: + key, label = setting + except (TypeError, ValueError): + key, label = str(setting), NULL_CHAR if "*" in key or "*" in label: raise ValueError("Wildcard key or label filters are not supported for refresh.") return key, label diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index 802163f11b85..013903b42f44 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -132,7 +132,7 @@ def _check_configuration_setting( self.LOGGER.debug("Refresh all triggered by key: %s label %s.", key, label) return True, None else: - raise e + raise return False, None @distributed_trace @@ -343,7 +343,7 @@ def _validate_snapshot(self, snapshot_name: str) -> bool: if e.status_code == 404: self.LOGGER.warning("Snapshot '%s' not found when resolving snapshot.", snapshot_name) return False - raise e + raise if snapshot.composition_type != SnapshotComposition.KEY: raise ValueError(f"Composition type for '{snapshot_name}' must be 'key'.") return True diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py index 7b1abb9d97dc..d3a838e57c86 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py @@ -31,7 +31,7 @@ def __init__(self, **kwargs: Any) -> None: ) if kwargs.get("secret_refresh_interval", 60) < 1: - raise ValueError("Secret refresh interval must be greater than 1 second.") + raise ValueError("Secret refresh interval must be at least 1 second.") self.secret_refresh_timer: Optional[_RefreshTimer] = ( _RefreshTimer(refresh_interval=kwargs.pop("secret_refresh_interval", 60)) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index fba4badad8e7..592e045fe804 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -134,7 +134,7 @@ async def _check_configuration_setting( self.LOGGER.debug("Refresh all triggered by key: %s label %s.", key, label) return True, None else: - raise e + raise return False, None @distributed_trace @@ -345,7 +345,7 @@ async def _validate_snapshot(self, snapshot_name: str) -> bool: if e.status_code == 404: self.LOGGER.warning("Snapshot '%s' not found when resolving snapshot.", snapshot_name) return False - raise e + raise if snapshot.composition_type != SnapshotComposition.KEY: raise ValueError(f"Composition type for '{snapshot_name}' must be 'key'.") return True