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)
1516from azure .monitor .opentelemetry .exporter ._configuration ._utils import _ConfigurationProfile , OneSettingsResponse
1617from azure .monitor .opentelemetry .exporter ._configuration ._utils import make_onesettings_request
1718from azure .monitor .opentelemetry .exporter ._utils import Singleton
1819
19-
2020# Set up logger
2121logger = 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
0 commit comments