Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

### 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.

### Other Changes

- Bumped minimum dependency on `azure-core` to `>=1.31.0`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
from ._user_agent import USER_AGENT
from ._utils import get_startup_backoff

JSON = Mapping[str, Any]
logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -79,7 +78,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,
Expand All @@ -96,7 +95,19 @@ def __init__(self, **kwargs: Any) -> None:
self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None)
self._configuration_mapper: Optional[Callable] = kwargs.pop("configuration_mapper", None)

def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_failover_request: bool, **kwargs):
def _attempt_refresh(
self, client: ConfigurationClient, replica_count: int, is_failover_request: bool, **kwargs
) -> None:
"""
Attempts to refresh configuration settings and feature flags using a single client.

:param client: The configuration client to attempt the refresh against.
:type client: ~azure.appconfiguration.provider.ConfigurationClient
:param replica_count: The number of replica clients available, used for correlation telemetry.
:type replica_count: int
:param is_failover_request: Whether this attempt is a failover from a previously failed client.
:type is_failover_request: bool
"""
settings_refreshed = False
headers = self._update_correlation_context_header(
kwargs.pop("headers", {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -179,7 +181,7 @@ def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str
seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special # pylint:disable=line-too-long

:param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for.
:rtype: str
:rtype: Optional[str]
:return: The allocation ID.
"""

Expand Down Expand Up @@ -264,17 +266,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__()
Comment on lines 268 to +270

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]:
"""
Expand Down Expand Up @@ -303,11 +308,11 @@ def values(self) -> ValuesView[Union[str, Mapping[str, Any]]]:
resolved.

:return: A list of values loaded from Azure App Configuration. The values are either Strings or JSON objects,
based on there content type.
based on their content type.
:rtype: ValuesView[Union[str, Mapping[str, Any]]]
"""
with self._update_lock:
return (self._dict).values()
return self._dict.values()

@overload
def get(self, key: str, default: None = None) -> Union[str, JSON, None]: ...
Expand Down Expand Up @@ -440,7 +445,9 @@ def _update_correlation_context_header(

def _deduplicate_settings(self, configuration_settings: List[ConfigurationSetting]) -> List[ConfigurationSetting]:
"""
Deduplicates configuration settings by key.
Deduplicates configuration settings by key, ignoring label. The provider exposes a flat
key->value mapping, so when multiple selectors return the same key the last one wins,
regardless of label.

:param List[ConfigurationSetting] configuration_settings: The list of configuration settings to deduplicate
:return: A list of unique configuration settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ def _check_configuration_setting(
"""
Checks if the configuration setting have been updated since the last refresh.

:param str key: key to check for chances
:param str key: key to check for changes
:param str label: label to check for changes
:param str etag: etag to check for changes
:param Mapping[str, str] headers: headers to use for the request
:param Optional[str] etag: etag to check for changes
:param Dict[str, str] headers: headers to use for the request
:return: A tuple with the first item being true/false if a change is detected. The second item is the updated
value if a change was detected.
:rtype: Tuple[bool, Union[ConfigurationSetting, None]]
Expand All @@ -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
Expand Down Expand Up @@ -290,10 +290,10 @@ def get_updated_watched_settings(
Checks if any of the watch keys have changed, and updates them if they have.

:param Mapping[Tuple[str, str], Optional[str]] watched_settings: The configuration settings to check for changes
:param Mapping[str, str] headers: The headers to use for the request
:param Dict[str, str] headers: The headers to use for the request

:return: Updated value of the configuration watched settings.
:rtype: Union[Dict[Tuple[str, str], str], None]
:return: Updated value of the configuration watched settings. Empty if no change was detected.
:rtype: Mapping[Tuple[str, str], Optional[str]]
"""
updated_watched_settings = dict(watched_settings)
trigger_refresh = False
Expand All @@ -319,8 +319,8 @@ def get_configuration_setting(self, key: str, label: str, **kwargs) -> Optional[

:param str key: The key of the configuration setting
:param str label: The label of the configuration setting
:return: The configuration setting
:rtype: ConfigurationSetting
:return: The configuration setting, or None if not found
:rtype: Optional[ConfigurationSetting]
"""
return self._client.get_configuration_setting(key=key, label=label, **kwargs)

Expand All @@ -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
Expand Down Expand Up @@ -403,12 +403,12 @@ def __init__(
endpoint: str,
credential: Optional["TokenCredential"],
user_agent: str,
retry_total,
retry_backoff_max,
replica_discovery_enabled,
min_backoff_sec,
max_backoff_sec,
load_balancing_enabled,
retry_total: int,
retry_backoff_max: int,
replica_discovery_enabled: bool,
min_backoff_sec: int,
max_backoff_sec: int,
load_balancing_enabled: bool,
**kwargs,
):
super(ConfigurationClientManager, self).__init__(
Expand Down Expand Up @@ -445,6 +445,7 @@ def get_next_active_client(self) -> Optional[_ConfigurationClientWrapper]:
method returns None.

:return: The next client to be used for the request.
:rtype: Optional[_ConfigurationClientWrapper]
"""
if not self._active_clients:
self._last_active_client_name = ""
Expand Down Expand Up @@ -483,10 +484,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

Expand Down Expand Up @@ -530,6 +531,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@
# -------------------------------------------------------------------------
import random
from dataclasses import dataclass
from typing import Optional, Mapping, Any
from typing import Optional

FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL = 3600 # 1 hour in seconds
MINIMAL_CLIENT_REFRESH_INTERVAL = 30 # 30 seconds

JSON = Mapping[str, Any]


@dataclass
class _ConfigurationClientWrapperBase:
Expand All @@ -23,12 +21,12 @@ def __init__(
self,
endpoint: str,
user_agent: str,
retry_total,
retry_backoff_max,
replica_discovery_enabled,
min_backoff_sec,
max_backoff_sec,
_load_balancing_enabled: bool,
retry_total: int,
retry_backoff_max: int,
replica_discovery_enabled: bool,
min_backoff_sec: int,
max_backoff_sec: int,
load_balancing_enabled: bool,
**kwargs,
):
self._last_active_client_name = ""
Expand All @@ -41,9 +39,10 @@ def __init__(
self._args = dict(kwargs)
self._min_backoff_sec = min_backoff_sec
self._max_backoff_sec = max_backoff_sec
self._load_balancing_enabled = _load_balancing_enabled
self._load_balancing_enabled = load_balancing_enabled

def _calculate_backoff(self, attempts: int) -> float:

max_attempts = 63
ms_per_second = 1000 # 1 Second in milliseconds

Expand All @@ -55,8 +54,7 @@ def _calculate_backoff(self, attempts: int) -> float:

calculated_milliseconds = max(1, min_backoff_milliseconds) * (1 << min(attempts, max_attempts))

if calculated_milliseconds > max_backoff_milliseconds or calculated_milliseconds <= 0:
calculated_milliseconds = max_backoff_milliseconds
calculated_milliseconds = min(calculated_milliseconds, max_backoff_milliseconds)

return min_backoff_milliseconds + (
random.uniform(0.0, 1.0) * (calculated_milliseconds - min_backoff_milliseconds) # nosec
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
# Environment Variable Constants
# ------------------------------------------------------------------------
REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE = "AZURE_APP_CONFIGURATION_TRACING_DISABLED"
AzureFunctionEnvironmentVariable = "FUNCTIONS_EXTENSION_VERSION"
AzureWebAppEnvironmentVariable = "WEBSITE_SITE_NAME"
ContainerAppEnvironmentVariable = "CONTAINER_APP_NAME"
KubernetesEnvironmentVariable = "KUBERNETES_PORT"
ServiceFabricEnvironmentVariable = "Fabric_NodeName" # cspell:disable-line
AZURE_FUNCTION_ENVIRONMENT_VARIABLE = "FUNCTIONS_EXTENSION_VERSION"
AZURE_WEB_APP_ENVIRONMENT_VARIABLE = "WEBSITE_SITE_NAME"
CONTAINER_APP_ENVIRONMENT_VARIABLE = "CONTAINER_APP_NAME"
KUBERNETES_ENVIRONMENT_VARIABLE = "KUBERNETES_PORT"
SERVICE_FABRIC_ENVIRONMENT_VARIABLE = "Fabric_NodeName" # cspell:disable-line

# ------------------------------------------------------------------------
# Telemetry and Tracing Constants
Expand All @@ -38,9 +38,9 @@
APP_CONFIG_AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/"
APP_CONFIG_AICC_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"

# =============================================================================
# ------------------------------------------------------------------------
# Startup Retry Constants
# =============================================================================
# ------------------------------------------------------------------------
# Timeout
DEFAULT_STARTUP_TIMEOUT = 100 # seconds

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Comment on lines 41 to 45
srv_records = [origin] + replicas
endpoints = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def _find_string_end(text: str, index: int) -> int:

def remove_json_comments(text: str) -> str:
"""
Removes comments from a JSON file. Supports //, and /* ... */ comments.
Removes comments from a JSON string. Supports //, and /* ... */ comments.
Returns as string.
:param text: The input JSON string with comments.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from typing import Mapping, Any, Dict
from typing import Any, Dict
from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
from azure.core.exceptions import ServiceRequestError
from ._secret_provider_base import _SecretProviderBase

JSON = Mapping[str, Any]


class SecretProvider(_SecretProviderBase):

Expand Down Expand Up @@ -72,7 +70,7 @@ def __get_secret_value(self, key: str, secret_identifier: KeyVaultSecretIdentifi

def close(self) -> None:
"""
Closes the connection to Azure App Configuration.
Closes the connection to Azure Key Vault.
"""
for client in self._secret_clients.values():
client.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
# license information.
# -------------------------------------------------------------------------
from typing import (
Mapping,
Any,
TypeVar,
Optional,
Dict,
Tuple,
Expand All @@ -15,9 +13,6 @@
from azure.keyvault.secrets import KeyVaultSecretIdentifier
from .._azureappconfigurationproviderbase import _RefreshTimer

JSON = Mapping[str, Any]
_T = TypeVar("_T")


class _SecretProviderBase:

Expand All @@ -31,7 +26,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))
Expand Down
Loading
Loading