Skip to content

Commit 2cad639

Browse files
raju-opticlaude
andcommitted
[FSSDK-12273] Add support for custom headers in polling datafile managers
This change adds support for custom headers in PollingConfigManager and AuthDatafilePollingConfigManager. Users can now pass custom headers when initializing the SDK client instance, which will be included in HTTP requests to fetch the datafile. Key changes: - Added custom_headers parameter to PollingConfigManager.__init__() - Added custom_headers parameter to AuthDatafilePollingConfigManager (inherited) - Added custom_headers parameter to Optimizely.__init__() and passed to config managers - Updated fetch_datafile() methods to merge custom headers with internal headers - User-provided headers take precedence over SDK internal headers - Added comprehensive test coverage for custom headers functionality Reference: optimizely/javascript-sdk#1107 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent f98886a commit 2cad639

File tree

3 files changed

+174
-0
lines changed

3 files changed

+174
-0
lines changed

optimizely/config_manager.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def __init__(
203203
notification_center: Optional[NotificationCenter] = None,
204204
skip_json_validation: Optional[bool] = False,
205205
retries: Optional[int] = 3,
206+
custom_headers: Optional[dict[str, str]] = None,
206207
):
207208
""" Initialize config manager. One of sdk_key or datafile has to be set to be able to use.
208209
@@ -223,9 +224,12 @@ def __init__(
223224
skip_json_validation: Optional boolean param which allows skipping JSON schema
224225
validation upon object invocation. By default
225226
JSON schema validation will be performed.
227+
custom_headers: Optional dictionary of custom headers to include in datafile fetch requests.
228+
User-provided headers take precedence over SDK internal headers.
226229
227230
"""
228231
self.retries = retries
232+
self.custom_headers = custom_headers or {}
229233
self._config_ready_event = threading.Event()
230234
super().__init__(
231235
datafile=datafile,
@@ -394,6 +398,9 @@ def fetch_datafile(self) -> None:
394398
if self.last_modified:
395399
request_headers[enums.HTTPHeaders.IF_MODIFIED_SINCE] = self.last_modified
396400

401+
# Merge custom headers, with user-provided headers taking precedence
402+
request_headers.update(self.custom_headers)
403+
397404
try:
398405
session = requests.Session()
399406

@@ -487,6 +494,9 @@ def fetch_datafile(self) -> None:
487494
if self.last_modified:
488495
request_headers[enums.HTTPHeaders.IF_MODIFIED_SINCE] = self.last_modified
489496

497+
# Merge custom headers, with user-provided headers taking precedence
498+
request_headers.update(self.custom_headers)
499+
490500
try:
491501
session = requests.Session()
492502

optimizely/optimizely.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
event_processor_options: Optional[dict[str, Any]] = None,
7777
settings: Optional[OptimizelySdkSettings] = None,
7878
cmab_service: Optional[DefaultCmabService] = None,
79+
custom_headers: Optional[dict[str, str]] = None,
7980
) -> None:
8081
""" Optimizely init method for managing Custom projects.
8182
@@ -104,6 +105,8 @@ def __init__(
104105
default_decide_options: Optional list of decide options used with the decide APIs.
105106
event_processor_options: Optional dict of options to be passed to the default batch event processor.
106107
settings: Optional instance of OptimizelySdkSettings for sdk configuration.
108+
custom_headers: Optional dictionary of custom headers to include in datafile fetch requests.
109+
User-provided headers take precedence over SDK internal headers.
107110
"""
108111
self.logger_name = '.'.join([__name__, self.__class__.__name__])
109112
self.is_valid = True
@@ -163,6 +166,8 @@ def __init__(
163166
if not self.config_manager:
164167
if sdk_key:
165168
config_manager_options['sdk_key'] = sdk_key
169+
if custom_headers:
170+
config_manager_options['custom_headers'] = custom_headers
166171
if datafile_access_token:
167172
config_manager_options['datafile_access_token'] = datafile_access_token
168173
self.config_manager = AuthDatafilePollingConfigManager(**config_manager_options)

tests/test_config_manager.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,80 @@ def test_is_running(self, _):
531531

532532
project_config_manager.stop()
533533

534+
def test_custom_headers(self, _):
535+
""" Test that custom headers are included in datafile fetch requests. """
536+
sdk_key = 'some_key'
537+
custom_headers = {
538+
'X-Custom-Header': 'custom_value',
539+
'X-Another-Header': 'another_value'
540+
}
541+
542+
expected_datafile_url = enums.ConfigManager.DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
543+
test_headers = {'Last-Modified': 'New Time'}
544+
test_datafile = json.dumps(self.config_dict_with_features)
545+
test_response = requests.Response()
546+
test_response.status_code = 200
547+
test_response.headers = test_headers
548+
test_response._content = test_datafile
549+
550+
with mock.patch('requests.Session.get', return_value=test_response) as mock_request:
551+
project_config_manager = config_manager.PollingConfigManager(
552+
sdk_key=sdk_key,
553+
custom_headers=custom_headers
554+
)
555+
project_config_manager.stop()
556+
557+
# Assert that custom headers were included in the request
558+
mock_request.assert_called_once_with(
559+
expected_datafile_url,
560+
headers=custom_headers,
561+
timeout=enums.ConfigManager.REQUEST_TIMEOUT
562+
)
563+
self.assertEqual(test_headers['Last-Modified'], project_config_manager.last_modified)
564+
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
565+
566+
def test_custom_headers_override_internal_headers(self, _):
567+
""" Test that custom headers override internal SDK headers. """
568+
sdk_key = 'some_key'
569+
custom_last_modified = 'Custom Last Modified Time'
570+
custom_headers = {
571+
'If-Modified-Since': custom_last_modified,
572+
'X-Custom-Header': 'custom_value'
573+
}
574+
575+
expected_datafile_url = enums.ConfigManager.DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
576+
test_headers = {'Last-Modified': 'New Time'}
577+
test_datafile = json.dumps(self.config_dict_with_features)
578+
test_response = requests.Response()
579+
test_response.status_code = 200
580+
test_response.headers = test_headers
581+
test_response._content = test_datafile
582+
583+
# First request to set last_modified
584+
with mock.patch('requests.Session.get', return_value=test_response):
585+
project_config_manager = config_manager.PollingConfigManager(
586+
sdk_key=sdk_key,
587+
custom_headers=custom_headers
588+
)
589+
project_config_manager.stop()
590+
591+
# Second request should use custom header value instead of internal last_modified
592+
with mock.patch('requests.Session.get', return_value=test_response) as mock_request:
593+
project_config_manager._initialize_thread()
594+
project_config_manager.start()
595+
project_config_manager.stop()
596+
597+
# Assert that custom If-Modified-Since header overrides the internal one
598+
expected_headers = {
599+
'If-Modified-Since': custom_last_modified, # User's value should be used
600+
'X-Custom-Header': 'custom_value'
601+
}
602+
mock_request.assert_called_once_with(
603+
expected_datafile_url,
604+
headers=expected_headers,
605+
timeout=enums.ConfigManager.REQUEST_TIMEOUT
606+
)
607+
534608

535609
@mock.patch('requests.Session.get')
536610
class AuthDatafilePollingConfigManagerTest(base.BaseTest):
@@ -637,3 +711,88 @@ def test_fetch_datafile__request_exception_raised(self, _):
637711
)
638712
self.assertEqual(test_headers['Last-Modified'], project_config_manager.last_modified)
639713
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
714+
715+
def test_custom_headers(self, _):
716+
""" Test that custom headers are included in authenticated datafile fetch requests. """
717+
datafile_access_token = 'some_token'
718+
sdk_key = 'some_key'
719+
custom_headers = {
720+
'X-Custom-Header': 'custom_value',
721+
'X-Another-Header': 'another_value'
722+
}
723+
724+
with mock.patch('optimizely.config_manager.AuthDatafilePollingConfigManager.fetch_datafile'), mock.patch(
725+
'optimizely.config_manager.AuthDatafilePollingConfigManager._run'
726+
):
727+
project_config_manager = config_manager.AuthDatafilePollingConfigManager(
728+
datafile_access_token=datafile_access_token,
729+
sdk_key=sdk_key,
730+
custom_headers=custom_headers
731+
)
732+
733+
expected_datafile_url = enums.ConfigManager.AUTHENTICATED_DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
734+
test_headers = {'Last-Modified': 'New Time'}
735+
test_datafile = json.dumps(self.config_dict_with_features)
736+
test_response = requests.Response()
737+
test_response.status_code = 200
738+
test_response.headers = test_headers
739+
test_response._content = test_datafile
740+
741+
# Call fetch_datafile and assert that request was sent with both authorization and custom headers
742+
with mock.patch('requests.Session.get', return_value=test_response) as mock_request:
743+
project_config_manager.fetch_datafile()
744+
745+
expected_headers = {
746+
'Authorization': f'Bearer {datafile_access_token}',
747+
'X-Custom-Header': 'custom_value',
748+
'X-Another-Header': 'another_value'
749+
}
750+
mock_request.assert_called_once_with(
751+
expected_datafile_url,
752+
headers=expected_headers,
753+
timeout=enums.ConfigManager.REQUEST_TIMEOUT,
754+
)
755+
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
756+
757+
def test_custom_headers_override_authorization(self, _):
758+
""" Test that custom Authorization header overrides internal SDK authorization header. """
759+
datafile_access_token = 'some_token'
760+
custom_auth = 'Bearer custom_token'
761+
sdk_key = 'some_key'
762+
custom_headers = {
763+
'Authorization': custom_auth,
764+
'X-Custom-Header': 'custom_value'
765+
}
766+
767+
with mock.patch('optimizely.config_manager.AuthDatafilePollingConfigManager.fetch_datafile'), mock.patch(
768+
'optimizely.config_manager.AuthDatafilePollingConfigManager._run'
769+
):
770+
project_config_manager = config_manager.AuthDatafilePollingConfigManager(
771+
datafile_access_token=datafile_access_token,
772+
sdk_key=sdk_key,
773+
custom_headers=custom_headers
774+
)
775+
776+
expected_datafile_url = enums.ConfigManager.AUTHENTICATED_DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
777+
test_headers = {'Last-Modified': 'New Time'}
778+
test_datafile = json.dumps(self.config_dict_with_features)
779+
test_response = requests.Response()
780+
test_response.status_code = 200
781+
test_response.headers = test_headers
782+
test_response._content = test_datafile
783+
784+
# Call fetch_datafile and assert that custom Authorization header is used
785+
with mock.patch('requests.Session.get', return_value=test_response) as mock_request:
786+
project_config_manager.fetch_datafile()
787+
788+
expected_headers = {
789+
'Authorization': custom_auth, # User's custom auth should override
790+
'X-Custom-Header': 'custom_value'
791+
}
792+
mock_request.assert_called_once_with(
793+
expected_datafile_url,
794+
headers=expected_headers,
795+
timeout=enums.ConfigManager.REQUEST_TIMEOUT,
796+
)
797+
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
798+

0 commit comments

Comments
 (0)