Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ._constants import (
FEATURE_MANAGEMENT_KEY,
FEATURE_FLAG_KEY,
SNAPSHOT_REF_CONTENT_TYPE,
)
from ._azureappconfigurationproviderbase import (
AzureAppConfigurationProviderBase,
Expand Down Expand Up @@ -299,7 +300,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f

if settings_refreshed:
# Configuration Settings have been refreshed
processed_settings = self._process_configurations(configuration_settings)
processed_settings = self._process_configurations(configuration_settings, client)

processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags)
self._dict = processed_settings
Expand Down Expand Up @@ -383,7 +384,7 @@ def _load_all(self, **kwargs: Any) -> None:
try:
configuration_settings = client.load_configuration_settings(self._selects, headers=headers, **kwargs)
watched_settings = self._update_watched_settings(configuration_settings)
processed_settings = self._process_configurations(configuration_settings)
processed_settings = self._process_configurations(configuration_settings, client)

if self._feature_flag_enabled:
feature_flags: List[FeatureFlagConfigurationSetting] = client.load_feature_flags(
Expand Down Expand Up @@ -422,7 +423,9 @@ def _load_all(self, **kwargs: Any) -> None:
is_failover_request = True
raise exception

def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]:
def _process_configurations(
self, configuration_settings: List[ConfigurationSetting], client: ConfigurationClient
) -> Dict[str, Any]:
# configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take
# precedence. Only process the settings with the highest priority (i.e. the last one in the list).
unique_settings = self._deduplicate_settings(configuration_settings)
Expand All @@ -440,6 +443,31 @@ def _process_configurations(self, configuration_settings: List[ConfigurationSett

if self._feature_flag_refresh_enabled:
self._watched_feature_flags[(settings.key, settings.label)] = settings.etag
elif settings.content_type and SNAPSHOT_REF_CONTENT_TYPE in settings.content_type:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
elif settings.content_type and SNAPSHOT_REF_CONTENT_TYPE in settings.content_type:
elif settings.content_type and SNAPSHOT_REF_CONTENT_TYPE == settings.content_type:

# Check if this is a snapshot reference

# Track snapshot reference usage for telemetry
self._tracing_context.uses_snapshot_reference = True
try:
# Resolve the snapshot reference to actual settings
snapshot_settings = client.resolve_snapshot_reference(settings)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snapshot references should be resolved before _configuration_mapper is run - i.e. _configuration_mapper runs on all the settings loaded by snapshot or snapshot reference


# Recursively process the resolved snapshot settings to handle feature flags,
# configuration mapping
snapshot_configuration_list = list(snapshot_settings.values())
resolved_settings = self._process_configurations(snapshot_configuration_list, client)
Comment on lines +455 to +458
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive call to _process_configurations on line 456 could lead to infinite recursion if a snapshot contains a snapshot reference that points back to itself or creates a circular reference chain. There's no depth tracking or cycle detection to prevent this.

Consider adding a depth limit parameter or cycle detection mechanism to prevent stack overflow from circular snapshot references.

Copilot uses AI. Check for mistakes.

# Merge the resolved settings into our configuration
configuration_settings_processed.update(resolved_settings)
except (ValueError, AzureError) as e:
logger.warning(
"Failed to resolve snapshot reference for key '%s' (label: '%s'): %s",
settings.key,
settings.label,
str(e),
)
# Continue processing other settings even if snapshot resolution fails

else:
key = self._process_key_name(settings)
value = self._process_key_value(settings)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ALLOCATION_ID_KEY,
APP_CONFIG_AI_MIME_PROFILE,
APP_CONFIG_AICC_MIME_PROFILE,
SNAPSHOT_REF_CONTENT_TYPE,
FEATURE_MANAGEMENT_KEY,
FEATURE_FLAG_KEY,
)
Expand Down Expand Up @@ -471,6 +472,8 @@ def _process_key_value_base(self, config: ConfigurationSetting) -> Union[str, Di
self._tracing_context.uses_ai_configuration = True
if APP_CONFIG_AICC_MIME_PROFILE in config.content_type:
self._tracing_context.uses_aicc_configuration = True
if SNAPSHOT_REF_CONTENT_TYPE in config.content_type:
self._tracing_context.uses_snapshot_reference = True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already tracked in _process_configurations, do we still need to check content type here?

return json.loads(config.value)
except json.JSONDecodeError:
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing_extensions import Self
from azure.core import MatchConditions
from azure.core.tracing.decorator import distributed_trace
from azure.core.exceptions import HttpResponseError
from azure.core.exceptions import HttpResponseError, AzureError
from azure.core.credentials import TokenCredential
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
ConfigurationSetting,
Expand All @@ -28,6 +28,8 @@
from ._models import SettingSelector
from ._constants import FEATURE_FLAG_PREFIX
from ._discovery import find_auto_failover_endpoints
from ._snapshot_reference_parser import SnapshotReferenceParser
from ._constants import SNAPSHOT_REF_CONTENT_TYPE


@dataclass
Expand Down Expand Up @@ -261,6 +263,43 @@ def __enter__(self):
def __exit__(self, *args):
self._client.__exit__(*args)

def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> Dict[str, ConfigurationSetting]:
"""
Resolve a snapshot reference configuration setting to the actual snapshot data.

:param ConfigurationSetting setting: The snapshot reference configuration setting
:return: A dictionary of resolved configuration settings from the snapshot
:rtype: Dict[str, ConfigurationSetting]
:raises ValueError: When the setting is not a valid snapshot reference
"""
if not setting.content_type or not SNAPSHOT_REF_CONTENT_TYPE in setting.content_type:
raise ValueError("Setting is not a snapshot reference")

try:
# Parse the snapshot reference
snapshot_name = SnapshotReferenceParser.parse(setting)

# Create a selector for the snapshot
snapshot_selector = SettingSelector(snapshot_name=snapshot_name)

# Use existing load_configuration_settings to load from snapshot
configurations = self.load_configuration_settings([snapshot_selector], **kwargs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If snapshot contains feature flags, will those be ignored?


# Build a dictionary keyed by configuration key
snapshot_settings = {}
for config in configurations:
# Last wins for duplicate keys during iteration
snapshot_settings[config.key] = config

return snapshot_settings

except AzureError as e:
# Wrap Azure errors with more context
raise ValueError(
f"Failed to resolve snapshot reference for key '{setting.key}' "
f"(label: '{setting.label}'). Azure service error occurred."
) from e


class ConfigurationClientManager(ConfigurationClientManagerBase): # pylint:disable=too-many-instance-attributes
def __init__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@
# Mime profiles
APP_CONFIG_AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/"
APP_CONFIG_AICC_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"

# Snapshot reference tracing key
SNAPSHOT_REFERENCE_TAG = "UsesSnapshotReference"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
SNAPSHOT_REFERENCE_TAG = "UsesSnapshotReference"
SNAPSHOT_REFERENCE_TAG = "SnapshotRef"


# Snapshot reference constants
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains all the constants. Could you organize them into sections so that related constants are grouped together? Eg tracing constants, content types, FM constants.

SNAPSHOT_REF_CONTENT_TYPE = 'application/json; profile="https://azconfig.io/mime-profiles/snapshot-ref"; charset=utf-8'
SNAPSHOT_NAME_FIELD = "snapshot_name"
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
KubernetesEnvironmentVariable,
APP_CONFIG_AI_MIME_PROFILE,
APP_CONFIG_AICC_MIME_PROFILE,
SNAPSHOT_REFERENCE_TAG,
)

# Feature flag filter names
Expand Down Expand Up @@ -86,6 +87,7 @@ def __init__(self, load_balancing_enabled: bool = False) -> None:
self.uses_load_balancing = load_balancing_enabled
self.uses_ai_configuration = False
self.uses_aicc_configuration = False # AI Chat Completion
self.uses_snapshot_reference = False
self.uses_telemetry = False
self.uses_seed = False
self.max_variants: Optional[int] = None
Expand Down Expand Up @@ -218,6 +220,9 @@ def update_correlation_context_header(
if self.is_failover_request:
tags.append(FAILOVER_TAG)

if self.uses_snapshot_reference:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldnt this be part of features_string?

tags.append(SNAPSHOT_REFERENCE_TAG)

# Build the correlation context string
context_parts: List[str] = []

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------

import json
from azure.appconfiguration import ConfigurationSetting # type: ignore
from ._constants import SNAPSHOT_NAME_FIELD


class SnapshotReferenceParser:
"""
Parser for snapshot reference configuration settings.
"""

@staticmethod
def parse(setting: ConfigurationSetting) -> str:
"""
Parse a snapshot reference from a configuration setting containing snapshot reference JSON.
:param ConfigurationSetting setting: The configuration setting containing the snapshot reference JSON
:return: The snapshot name extracted from the reference
:rtype: str
:raises ValueError: When the setting is None
:raises ValueError: When the setting contains invalid JSON, invalid snapshot reference format,
or empty/whitespace snapshot name
"""
if setting is None:
raise ValueError("Setting cannot be None")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does ValueError get handled properly if the provider is optional?


if not setting.value or setting.value.strip() == "":
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). Value cannot be empty."
)

try:
# Parse the JSON content
json_content = json.loads(setting.value)

if not isinstance(json_content, dict):
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). Expected JSON object."
)

# Extract the snapshot name
snapshot_name = json_content.get(SNAPSHOT_NAME_FIELD)

if snapshot_name is None:
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). The '{SNAPSHOT_NAME_FIELD}' "
f"property is required."
)

if not isinstance(snapshot_name, str):
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). The '{SNAPSHOT_NAME_FIELD}' "
f"property must be a string value, but found {type(snapshot_name).__name__}."
)

if not snapshot_name.strip():
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). Snapshot name cannot be empty or whitespace."
)

return snapshot_name.strip()

except json.JSONDecodeError as json_ex:
raise ValueError(
f"Invalid snapshot reference format for key '{setting.key}' "
f"(label: '{setting.label}'). Invalid JSON format."
) from json_ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing_extensions import Self
from azure.core import MatchConditions
from azure.core.tracing.decorator import distributed_trace
from azure.core.exceptions import HttpResponseError
from azure.core.exceptions import HttpResponseError, AzureError
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
ConfigurationSetting,
FeatureFlagConfigurationSetting,
Expand All @@ -26,6 +26,8 @@
)
from .._models import SettingSelector
from .._constants import FEATURE_FLAG_PREFIX
from .._snapshot_reference_parser import SnapshotReferenceParser
from .._constants import SNAPSHOT_REF_CONTENT_TYPE
from ._async_discovery import find_auto_failover_endpoints

if TYPE_CHECKING:
Expand Down Expand Up @@ -265,6 +267,45 @@ async def __aenter__(self):
async def __aexit__(self, *args):
await self._client.__aexit__(*args)

async def resolve_snapshot_reference(
self, setting: ConfigurationSetting, **kwargs
) -> Dict[str, ConfigurationSetting]:
"""
Resolve a snapshot reference configuration setting to the actual snapshot data.

:param ConfigurationSetting setting: The snapshot reference configuration setting
:return: A dictionary of resolved configuration settings from the snapshot
:rtype: Dict[str, ConfigurationSetting]
:raises ValueError: When the setting is not a valid snapshot reference
"""
if not setting.content_type or not SNAPSHOT_REF_CONTENT_TYPE in setting.content_type:
raise ValueError("Setting is not a snapshot reference")

try:
# Parse the snapshot reference
snapshot_name = SnapshotReferenceParser.parse(setting)

# Create a selector for the snapshot
snapshot_selector = SettingSelector(snapshot_name=snapshot_name)

# Use existing load_configuration_settings to load from snapshot
configurations = await self.load_configuration_settings([snapshot_selector], **kwargs)

# Build a dictionary keyed by configuration key
snapshot_settings = {}
for config in configurations:
# Last wins for duplicate keys during iteration
snapshot_settings[config.key] = config

return snapshot_settings

except AzureError as e:
# Wrap Azure errors with more context
raise ValueError(
f"Failed to resolve snapshot reference for key '{setting.key}' "
f"(label: '{setting.label}'). Azure service error occurred."
) from e


class AsyncConfigurationClientManager(ConfigurationClientManagerBase): # pylint:disable=too-many-instance-attributes
def __init__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .._constants import (
FEATURE_MANAGEMENT_KEY,
FEATURE_FLAG_KEY,
SNAPSHOT_REF_CONTENT_TYPE,
)
from .._azureappconfigurationproviderbase import (
AzureAppConfigurationProviderBase,
Expand Down Expand Up @@ -311,7 +312,7 @@ async def _attempt_refresh(

if settings_refreshed:
# Configuration Settings have been refreshed
processed_settings = await self._process_configurations(configuration_settings)
processed_settings = await self._process_configurations(configuration_settings, client)

processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags)
self._dict = processed_settings
Expand Down Expand Up @@ -397,7 +398,7 @@ async def _load_all(self, **kwargs: Any) -> None:
self._selects, headers=headers, **kwargs
)
watched_settings = self._update_watched_settings(configuration_settings)
processed_settings = await self._process_configurations(configuration_settings)
processed_settings = await self._process_configurations(configuration_settings, client)

if self._feature_flag_enabled:
feature_flags: List[FeatureFlagConfigurationSetting] = await client.load_feature_flags(
Expand Down Expand Up @@ -438,7 +439,9 @@ async def _load_all(self, **kwargs: Any) -> None:
is_failover_request = True
raise exception

async def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]:
async def _process_configurations(
self, configuration_settings: List[ConfigurationSetting], client: ConfigurationClient
) -> Dict[str, Any]:
# configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take
# precedence. Only process the settings with the highest priority (i.e. the last one in the list).
unique_settings = self._deduplicate_settings(configuration_settings)
Expand All @@ -455,6 +458,30 @@ async def _process_configurations(self, configuration_settings: List[Configurati

if self._feature_flag_refresh_enabled:
self._watched_feature_flags[(settings.key, settings.label)] = settings.etag
elif settings.content_type and SNAPSHOT_REF_CONTENT_TYPE in settings.content_type:
# Check if this is a snapshot reference

# Track snapshot reference usage for telemetry
self._tracing_context.uses_snapshot_reference = True
try:
# Resolve the snapshot reference to actual settings
snapshot_settings = await client.resolve_snapshot_reference(settings)

# Recursively process the resolved snapshot settings to handle feature flags,
# configuration mapping
snapshot_configuration_list = list(snapshot_settings.values())
resolved_settings = await self._process_configurations(snapshot_configuration_list, client)
Comment on lines +470 to +473
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive call to _process_configurations on line 473 could lead to infinite recursion if a snapshot contains a snapshot reference that points back to itself or creates a circular reference chain. There's no depth tracking or cycle detection to prevent this.

Consider adding a depth limit parameter or cycle detection mechanism to prevent stack overflow from circular snapshot references.

Copilot uses AI. Check for mistakes.
# Merge the resolved settings into our configuration
configuration_settings_processed.update(resolved_settings)
except (ValueError, AzureError) as e:
logger.warning(
"Failed to resolve snapshot reference for key '%s' (label: '%s'): %s",
settings.key,
settings.label,
str(e),
)
# Continue processing other settings even if snapshot resolution fails

else:
key = self._process_key_name(settings)
value = await self._process_key_value(settings)
Expand Down
Loading
Loading