Skip to content

Commit d7a899c

Browse files
authored
Various Onesettings implementation changes (#48027)
1 parent aa17095 commit d7a899c

10 files changed

Lines changed: 370 additions & 142 deletions

File tree

sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
- Simplify OneSettings change detection to use ETag-based mechanism instead of change version tracking to reflect spec update
1616
- Change OneSettings log messages from warning to debug level to reduce noise for users with firewalls
1717
([#47949](https://github.com/Azure/azure-sdk-for-python/pull/47949))
18+
- Harden OneSettings configuration manager and worker: handle non-retryable HTTP errors by slow-polling instead of retrying, fix worker holding its lock across network I/O, make shutdown a soft reset that leaves the singleton reusable, and make callback registration thread-safe and initialization-independent
19+
([#48027](https://github.com/Azure/azure-sdk-for-python/pull/48027))
1820

1921
## 1.0.0b55 (2026-07-01)
2022

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_configuration/__init__.py

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
_ONE_SETTINGS_CHANGE_URL,
1111
_ONE_SETTINGS_CONFIG_URL,
1212
_ONE_SETTINGS_MAX_REFRESH_INTERVAL_SECONDS,
13+
_ONE_SETTINGS_BACKOFF_BASE_SECONDS,
1314
_RETRYABLE_STATUS_CODES,
1415
)
1516
from azure.monitor.opentelemetry.exporter._configuration._utils import _ConfigurationProfile, OneSettingsResponse
1617
from azure.monitor.opentelemetry.exporter._configuration._utils import make_onesettings_request
1718
from azure.monitor.opentelemetry.exporter._utils import Singleton
1819

19-
2020
# Set up logger
2121
logger = logging.getLogger(__name__)
2222

@@ -26,14 +26,14 @@ class _ConfigurationState:
2626
"""Immutable state object for configuration data."""
2727

2828
etag: str = ""
29-
refresh_interval: int = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
29+
refresh_interval_s: int = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
3030
settings_cache: Dict[str, str] = field(default_factory=dict)
3131

3232
def with_updates(self, **kwargs) -> "_ConfigurationState": # pylint: disable=C4741,C4742
3333
"""Create a new state object with updated values."""
3434
return _ConfigurationState(
3535
etag=kwargs.get("etag", self.etag),
36-
refresh_interval=kwargs.get("refresh_interval", self.refresh_interval),
36+
refresh_interval_s=kwargs.get("refresh_interval_s", self.refresh_interval_s),
3737
settings_cache=kwargs.get("settings_cache", self.settings_cache.copy()),
3838
)
3939

@@ -46,6 +46,8 @@ def __init__(self):
4646
self._configuration_worker = None
4747
self._state_lock = Lock() # Single lock for all state
4848
self._current_state = _ConfigurationState()
49+
# Consecutive transient-error count for exponential backoff on change-detection (e2) calls
50+
self._backoff_attempts = 0
4951
self._callbacks = []
5052
self._initialized = False
5153

@@ -62,20 +64,24 @@ def initialize(self, **kwargs):
6264
from azure.monitor.opentelemetry.exporter._configuration._worker import _ConfigurationWorker
6365

6466
# Get initial refresh interval from current state
65-
initial_refresh_interval = self._current_state.refresh_interval
67+
initial_refresh_interval = self._current_state.refresh_interval_s
6668

6769
self._configuration_worker = _ConfigurationWorker(self, initial_refresh_interval)
6870
self._initialized = True
6971

7072
def register_callback(self, callback):
71-
# Register a callback to be invoked when configuration changes.
72-
if not self._initialized:
73-
return
73+
# Register a callback to be invoked when configuration changes. Registration is independent of
74+
# initialize(): the callback simply sits in the list until the worker fires a config change, so
75+
# there is no need to guard on initialization state.
7476
self._callbacks.append(callback)
7577

7678
def _notify_callbacks(self, settings: Dict[str, str]):
7779
# Notify all registered callbacks of configuration changes.
78-
for cb in self._callbacks:
80+
# Snapshot the list first so a concurrent register_callback on another thread can't trigger
81+
# "list changed size during iteration". list.append and list(...) are individually atomic
82+
# under the GIL, so no lock is needed here (and _state_lock stays scoped to config state).
83+
callbacks = list(self._callbacks)
84+
for cb in callbacks:
7985
try:
8086
cb(settings)
8187
except Exception as ex: # pylint: disable=broad-except
@@ -98,12 +104,18 @@ def get_configuration_and_refresh_interval(self, query_dict: Optional[Dict[str,
98104
99105
This method implements the change detection mechanism per the OneSettings spec:
100106
- Polls CHANGE endpoint (e2) with cached ETag via if-none-match header.
101-
- If 304 Not Modified: no changes, update refresh interval only.
107+
- If 304 Not Modified: no settings fetched; the refresh interval (and echoed ETag)
108+
are updated from the response headers.
102109
- If 200 (new ETag or no cached ETag): fetch from CONFIG endpoint (e1) for settings.
110+
If the e1 fetch fails, the new ETag is dropped so the change is re-attempted on the
111+
next poll rather than being silently marked as applied.
103112
104113
When transient errors are encountered (timeouts, network exceptions, or retryable
105-
HTTP status codes) from the CHANGE endpoint, the method doubles the current refresh
106-
interval (capped at 24 hours) and returns immediately.
114+
HTTP status codes) from the CHANGE endpoint, the method applies progressive exponential
115+
backoff from a fixed base (3600s, then 7200s, 14400s, ... capped at 24 hours), tracked
116+
via a consecutive-failure counter that resets on the next non-transient response, and
117+
returns immediately. Non-retryable HTTP errors keep the cached configuration, do not
118+
advance the ETag, and slow-poll at the max interval (24 hours).
107119
108120
:param query_dict: Optional query parameters to include in the OneSettings request.
109121
:type query_dict: Optional[Dict[str, str]]
@@ -119,32 +131,48 @@ def get_configuration_and_refresh_interval(self, query_dict: Optional[Dict[str,
119131
current_state = self._current_state
120132
if current_state.etag:
121133
headers["If-None-Match"] = current_state.etag
122-
if current_state.refresh_interval:
123-
headers["x-ms-onesetinterval"] = str(current_state.refresh_interval)
134+
if current_state.refresh_interval_s:
135+
# refresh_interval_s is stored in seconds internally; the header expects minutes.
136+
headers["x-ms-onesetinterval"] = str(current_state.refresh_interval_s // 60)
124137

125138
# Poll CHANGE endpoint (e2)
126139
response = make_onesettings_request(_ONE_SETTINGS_CHANGE_URL, query_dict, headers)
127140

128-
# Check for transient errors - double interval and return
141+
# Check for transient errors - apply exponential backoff and return
129142
if self._is_transient_error(response):
130143
with self._state_lock:
131-
doubled_interval = self._current_state.refresh_interval * 2
132-
current_refresh_interval = min(doubled_interval, _ONE_SETTINGS_MAX_REFRESH_INTERVAL_SECONDS)
144+
# Progressive exponential backoff from a fixed base, capped at the max interval.
145+
# Schedule (per spec): 1st failure -> 3600s, then 7200s, 14400s, ... up to 86400s.
146+
self._backoff_attempts += 1
147+
backoff_interval = min(
148+
_ONE_SETTINGS_BACKOFF_BASE_SECONDS * int(2 ** (self._backoff_attempts - 1)),
149+
_ONE_SETTINGS_MAX_REFRESH_INTERVAL_SECONDS,
150+
)
133151

134152
if response.has_exception:
135153
error_description = "network error"
136154
else:
137155
error_description = f"HTTP {response.status_code}"
138156

139-
logger.debug("OneSettings CHANGE request failed with transient error (%s). Retrying. ", error_description)
140-
return current_refresh_interval # type: ignore
157+
logger.debug(
158+
"OneSettings CHANGE request failed with transient error (%s). "
159+
"Backing off (attempt %d) for %d seconds.",
160+
error_description,
161+
self._backoff_attempts,
162+
backoff_interval,
163+
)
164+
return backoff_interval
165+
166+
# Non-transient response from the CHANGE endpoint counts as a success: reset backoff counter
167+
with self._state_lock:
168+
self._backoff_attempts = 0
141169

142170
# Prepare new state updates
143171
new_state_updates: Dict[str, Any] = {}
144172
if response.etag is not None:
145173
new_state_updates["etag"] = response.etag
146-
if response.refresh_interval and response.refresh_interval > 0: # type: ignore
147-
new_state_updates["refresh_interval"] = response.refresh_interval # type: ignore
174+
if response.refresh_interval_s and response.refresh_interval_s > 0: # type: ignore
175+
new_state_updates["refresh_interval_s"] = response.refresh_interval_s # type: ignore
148176

149177
if response.status_code == 304:
150178
# Not modified: no configuration changes published
@@ -159,8 +187,14 @@ def get_configuration_and_refresh_interval(self, query_dict: Optional[Dict[str,
159187
# Do not update etag to allow retry on next call
160188
new_state_updates.pop("etag", None)
161189
else:
162-
# Unexpected non-transient status code
163-
logger.debug("Unexpected response status from CHANGE endpoint: %d", response.status_code)
190+
# Non-retryable HTTP error from the CHANGE endpoint (e.g. 400/404/414). Retryable errors
191+
# (network/timeout and _RETRYABLE_STATUS_CODES) are already handled as transient above.
192+
# These remaining errors are effectively permanent from the SDK's perspective and won't be
193+
# resolved by retrying at the normal cadence, so keep the current cached configuration, do
194+
# not advance the ETag, and slow-poll at the max interval. Config fetching is internal, so
195+
# this stays silent (debug only) and never surfaces to users.
196+
logger.debug("Non-retryable response status from CHANGE endpoint: %d", response.status_code)
197+
return _ONE_SETTINGS_MAX_REFRESH_INTERVAL_SECONDS
164198

165199
notify_callbacks = False
166200
current_refresh_interval = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
@@ -170,7 +204,7 @@ def get_configuration_and_refresh_interval(self, query_dict: Optional[Dict[str,
170204
with self._state_lock:
171205
latest_state = self._current_state
172206
self._current_state = latest_state.with_updates(**new_state_updates)
173-
current_refresh_interval = self._current_state.refresh_interval
207+
current_refresh_interval = self._current_state.refresh_interval_s
174208
if "settings_cache" in new_state_updates:
175209
notify_callbacks = True
176210
state_for_callbacks = self._current_state
@@ -187,12 +221,18 @@ def get_settings(self) -> Dict[str, str]: # pylint: disable=C4741,C4742
187221
return self._current_state.settings_cache.copy() # type: ignore
188222

189223
def shutdown(self) -> None:
190-
"""Shutdown the configuration worker."""
224+
"""Shutdown the configuration worker.
225+
226+
This is a soft reset (matching the QuickpulseManager convention): the worker is stopped and
227+
transient state is cleared, but the singleton instance is left intact and reusable so a later
228+
initialize() can restart polling. The cached configuration (_current_state) is intentionally
229+
preserved across shutdown so the next initialize() resumes from the cached ETag.
230+
"""
191231
if self._configuration_worker:
192232
self._configuration_worker.shutdown()
193233
self._configuration_worker = None
234+
# Worker thread is now joined, so no callback notifications are in flight and no other thread
235+
# is registering callbacks, so we can clear this state directly. Callbacks are cleared so a
236+
# subsequent initialize()/re-registration does not accumulate duplicates.
194237
self._initialized = False
195238
self._callbacks.clear()
196-
# Clear the singleton instance from the metaclass
197-
if self.__class__ in _ConfigurationManager._instances: # pylint: disable=protected-access
198-
del _ConfigurationManager._instances[self.__class__] # pylint: disable=protected-access

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_configuration/_state.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
This module provides global access functions for the Configuration Manager singleton.
66
"""
7+
import logging
78
import os
89
from typing import Optional, TYPE_CHECKING
910

@@ -12,6 +13,8 @@
1213
if TYPE_CHECKING:
1314
from azure.monitor.opentelemetry.exporter._configuration import _ConfigurationManager
1415

16+
logger = logging.getLogger(__name__)
17+
1518
# Global singleton instance for easy access throughout the codebase
1619
_configuration_manager = None
1720

@@ -27,6 +30,10 @@ def get_configuration_manager() -> Optional["_ConfigurationManager"]:
2730
"""
2831
disabled = os.environ.get(_APPLICATIONINSIGHTS_CONTROLPLANE_DISABLED)
2932
if disabled is not None and disabled.lower() == "true":
33+
logger.debug(
34+
"OneSettings control plane disabled via %s; using built-in SDK default configuration.",
35+
_APPLICATIONINSIGHTS_CONTROLPLANE_DISABLED,
36+
)
3037
return None
3138
global _configuration_manager # pylint: disable=global-statement
3239
if _configuration_manager is None:

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_configuration/_utils.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class OneSettingsResponse:
5151
5252
Attributes:
5353
etag (Optional[str]): ETag header value for caching and conditional requests
54-
refresh_interval (int): Interval in seconds for the next configuration refresh
54+
refresh_interval_s (int): Interval in seconds for the next configuration refresh
5555
settings (Dict[str, str]): Dictionary of configuration key-value pairs
5656
status_code (int): HTTP status code from the response
5757
has_exception (bool): True if the request resulted in a transient error (network error, timeout, etc.)
@@ -60,7 +60,7 @@ class OneSettingsResponse:
6060
def __init__(
6161
self,
6262
etag: Optional[str] = None,
63-
refresh_interval: int = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS,
63+
refresh_interval_s: int = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS,
6464
settings: Optional[Dict[str, str]] = None,
6565
status_code: int = 200,
6666
has_exception: bool = False,
@@ -69,15 +69,15 @@ def __init__(
6969
7070
Args:
7171
etag (Optional[str], optional): ETag header value for caching. Defaults to None.
72-
refresh_interval (int, optional): Refresh interval in seconds.
72+
refresh_interval_s (int, optional): Refresh interval in seconds.
7373
Defaults to _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS.
7474
settings (Optional[Dict[str, str]], optional): Configuration settings dictionary.
7575
Defaults to empty dict if None.
7676
status_code (int, optional): HTTP status code. Defaults to 200.
7777
has_exception (bool, optional): Indicates if request failed with a transient error. Defaults to False.
7878
"""
7979
self.etag = etag
80-
self.refresh_interval = refresh_interval
80+
self.refresh_interval_s = refresh_interval_s
8181
self.settings = settings or {}
8282
self.status_code = status_code
8383
self.has_exception = has_exception
@@ -115,19 +115,20 @@ def make_onesettings_request(
115115

116116
try:
117117
result = requests.get(url, params=query_dict, headers=headers, timeout=10)
118-
result.raise_for_status() # Raises an exception for 4XX/5XX responses
119-
118+
# Do NOT call raise_for_status(): HTTP error codes (4xx/5xx) are handled by the parser so
119+
# the real status_code is preserved. This lets callers distinguish retryable errors
120+
# (see _RETRYABLE_STATUS_CODES) from non-retryable client errors (400/404/414). Only genuine
121+
# network/timeout failures below are surfaced as has_exception=True (transient).
120122
return _parse_onesettings_response(result)
121123
except requests.exceptions.Timeout as ex:
122124
logger.debug("OneSettings request timed out: %s", str(ex))
123125
return OneSettingsResponse(has_exception=True)
124126
except requests.exceptions.RequestException as ex:
125127
logger.debug("Failed to fetch configuration from OneSettings: %s", str(ex))
126128
return OneSettingsResponse(has_exception=True)
127-
except json.JSONDecodeError as ex:
128-
logger.debug("Failed to parse OneSettings response: %s", str(ex))
129-
return OneSettingsResponse(has_exception=True)
130129
except Exception as ex: # pylint: disable=broad-exception-caught
130+
# _parse_onesettings_response already swallows JSON/decode errors internally, so nothing
131+
# here raises json.JSONDecodeError; this catch-all covers any other unexpected failure.
131132
logger.debug("Unexpected error while fetching configuration: %s", str(ex))
132133
return OneSettingsResponse(has_exception=True)
133134

@@ -143,24 +144,24 @@ def _parse_onesettings_response(response: requests.Response) -> OneSettingsRespo
143144
The parser handles different HTTP status codes appropriately:
144145
- 200: New configuration data available, parse settings
145146
- 304: Not modified, configuration unchanged (empty settings)
146-
- 400/404/414/500: Various error conditions, logged with warnings
147+
- 400/404/414/500: Various error conditions, logged at debug
147148
148149
:param response: HTTP response object from the requests library containing
149150
the OneSettings API response with headers, status code, and content.
150151
:type response: requests.Response
151152
152153
:return: Structured response object containing:
153154
- etag: ETag header value for conditional requests
154-
- refresh_interval: Next refresh interval from headers
155+
- refresh_interval_s: Next refresh interval from headers
155156
- settings: Configuration key-value pairs (empty for 304/errors)
156157
- status_code: HTTP status code of the response
157158
:rtype: OneSettingsResponse
158159
Note:
159-
This function logs warnings for various error conditions but does not
160-
raise exceptions, always returning a valid OneSettingsResponse object.
160+
This function logs various error conditions at debug level (config fetching is internal)
161+
but does not raise exceptions, always returning a valid OneSettingsResponse object.
161162
"""
162163
etag = None
163-
refresh_interval = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
164+
refresh_interval_s = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
164165
settings: Dict[str, str] = {}
165166
status_code = response.status_code
166167

@@ -169,12 +170,12 @@ def _parse_onesettings_response(response: requests.Response) -> OneSettingsRespo
169170
etag = response.headers.get("ETag")
170171
refresh_interval_header = response.headers.get("x-ms-onesetinterval")
171172
try:
172-
# Note: OneSettings refresh interval is in minutes, convert to seconds
173+
# Note: OneSettings refresh interval returned is in minutes, convert to seconds
173174
if refresh_interval_header:
174-
refresh_interval = int(refresh_interval_header) * 60
175+
refresh_interval_s = int(refresh_interval_header) * 60
175176
except (ValueError, TypeError):
176177
logger.debug("Invalid refresh interval format: %s", refresh_interval_header)
177-
refresh_interval = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
178+
refresh_interval_s = _ONE_SETTINGS_DEFAULT_REFRESH_INTERVAL_SECONDS
178179

179180
# Handle different status codes
180181
if status_code == 304:
@@ -198,7 +199,7 @@ def _parse_onesettings_response(response: requests.Response) -> OneSettingsRespo
198199
elif status_code == 500:
199200
logger.debug("Internal server error from OneSettings: %s", response.content)
200201

201-
return OneSettingsResponse(etag, refresh_interval, settings, status_code)
202+
return OneSettingsResponse(etag, refresh_interval_s, settings, status_code)
202203

203204

204205
# mypy: disable-error-code="no-any-return"

0 commit comments

Comments
 (0)