Repository: Unleash/unleash-client-python
Pull request #342 (2025-01-27 13:03:20)

feat: client identification headers
# Description

Identification headers consistent with other Unleash SDKs.

> This PR adds standardized client identification headers to the feature and metrics calls that the client makes to Unleash. The headers are:
> - `x-unleash-appname`: the name of the application that is using the client. `UNLEASH-APPNAME` will be deleted in another PR (expand/contract pattern)
> - `x-unleash-connection-id`: an internal unique identifier for the current instance of the client generated by the built-in crypto lib
> - `x-unleash-sdk`: sdk information in the format `unleash-client-<language>:<version>`
> 
> All the headers are intended for the Unleash team. Changes should be implemented in a way that does not change SDK behavior in a significant way.
> The main use cases we have are:
> * capacity planning by knowing the number of unique connections made to the backend API
> * debugging misconfigured SDKs sending more traffic than expected

Fixes [issue/1-3274](https://linear.app/unleash/issue/1-3274/uniqueness-headers-in-python-sdk)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

- [x] Unit tests
- [ ] Spec Tests
- [ ] Integration tests / Manual Tests

# Checklist:

- [x] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules


coveralls: 
[![Coverage Status](https://coveralls.io/builds/71931394/badge)](https://coveralls.io/builds/71931394)

coverage: 95.338% (+0.02%) from 95.316%
when pulling **63ae07ef36d8ed5c2e2d0cd65415a395e911a467 on 1-3274**
into **b46cd17bcf22465c092824e098cc09695d3e81e1 on main**.

kwasniew: Can you run the example app https://github.com/Unleash/unleash-sdk-examples/tree/main/Python with the latest version after publishing this package and checking the headers work in the user space code?
Tymek: Verified with real-world app

Patch:
diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py
index 2cb7dc6..a19decc 100644
--- a/UnleashClient/__init__.py
+++ b/UnleashClient/__init__.py
@@ -21,6 +21,8 @@ from UnleashClient.constants import (
     METRIC_LAST_SENT_TIME,
     REQUEST_RETRIES,
     REQUEST_TIMEOUT,
+    SDK_NAME,
+    SDK_VERSION,
 )
 from UnleashClient.events import UnleashEvent, UnleashEventType
 from UnleashClient.loader import load_features
@@ -211,12 +213,19 @@ class UnleashClient:
         if not self.is_initialized:
             # pylint: disable=no-else-raise
             try:
+                headers = {
+                    **self.unleash_custom_headers,
+                    "x-unleash-connection-id": str(uuid.uuid4()),
+                    "x-unleash-appname": self.unleash_app_name,
+                    "x-unleash-sdk": f"{SDK_NAME}:{SDK_VERSION}",
+                }
+
                 # Setup
                 metrics_args = {
                     "url": self.unleash_url,
                     "app_name": self.unleash_app_name,
                     "instance_id": self.unleash_instance_id,
-                    "custom_headers": self.unleash_custom_headers,
+                    "headers": headers,
                     "custom_options": self.unleash_custom_options,
                     "request_timeout": self.unleash_request_timeout,
                     "engine": self.engine,
@@ -229,7 +238,7 @@ class UnleashClient:
                         self.unleash_app_name,
                         self.unleash_instance_id,
                         self.unleash_metrics_interval,
-                        self.unleash_custom_headers,
+                        headers,
                         self.unleash_custom_options,
                         self.strategy_mapping,
                         self.unleash_request_timeout,
@@ -240,7 +249,7 @@ class UnleashClient:
                         "url": self.unleash_url,
                         "app_name": self.unleash_app_name,
                         "instance_id": self.unleash_instance_id,
-                        "custom_headers": self.unleash_custom_headers,
+                        "headers": headers,
                         "custom_options": self.unleash_custom_options,
                         "cache": self.cache,
                         "engine": self.engine,
diff --git a/UnleashClient/api/features.py b/UnleashClient/api/features.py
index f1558a3..176091a 100644
--- a/UnleashClient/api/features.py
+++ b/UnleashClient/api/features.py
@@ -13,7 +13,7 @@ def get_feature_toggles(
     url: str,
     app_name: str,
     instance_id: str,
-    custom_headers: dict,
+    headers: dict,
     custom_options: dict,
     request_timeout: int,
     request_retries: int,
@@ -30,7 +30,7 @@ def get_feature_toggles(
     :param url:
     :param app_name:
     :param instance_id:
-    :param custom_headers:
+    :param headers:
     :param custom_options:
     :param request_timeout:
     :param request_retries:
@@ -41,10 +41,13 @@ def get_feature_toggles(
     try:
         LOGGER.info("Getting feature flag.")
 
-        headers = {"UNLEASH-APPNAME": app_name, "UNLEASH-INSTANCEID": instance_id}
+        request_specific_headers = {
+            "UNLEASH-APPNAME": app_name,
+            "UNLEASH-INSTANCEID": instance_id,
+        }
 
         if cached_etag:
-            headers["If-None-Match"] = cached_etag
+            request_specific_headers["If-None-Match"] = cached_etag
 
         base_url = f"{url}{FEATURES_URL}"
         base_params = {}
@@ -60,7 +63,7 @@ def get_feature_toggles(
             session.mount("http://", adapter)
             resp = session.get(
                 base_url,
-                headers={**custom_headers, **headers},
+                headers={**headers, **request_specific_headers},
                 params=base_params,
                 timeout=request_timeout,
                 **custom_options,
diff --git a/UnleashClient/api/metrics.py b/UnleashClient/api/metrics.py
index f181606..0d9e75c 100644
--- a/UnleashClient/api/metrics.py
+++ b/UnleashClient/api/metrics.py
@@ -10,7 +10,7 @@ from UnleashClient.utils import LOGGER, log_resp_info
 def send_metrics(
     url: str,
     request_body: dict,
-    custom_headers: dict,
+    headers: dict,
     custom_options: dict,
     request_timeout: int,
 ) -> bool:
@@ -22,7 +22,7 @@ def send_metrics(
 
     :param url:
     :param request_body:
-    :param custom_headers:
+    :param headers:
     :param custom_options:
     :param request_timeout:
     :return: true if registration successful, false if registration unsuccessful or exception.
@@ -34,7 +34,7 @@ def send_metrics(
         resp = requests.post(
             url + METRICS_URL,
             data=json.dumps(request_body),
-            headers={**custom_headers, **APPLICATION_HEADERS},
+            headers={**headers, **APPLICATION_HEADERS},
             timeout=request_timeout,
             **custom_options,
         )
diff --git a/UnleashClient/api/register.py b/UnleashClient/api/register.py
index 5d6bfff..24c0d6e 100644
--- a/UnleashClient/api/register.py
+++ b/UnleashClient/api/register.py
@@ -22,7 +22,7 @@ def register_client(
     app_name: str,
     instance_id: str,
     metrics_interval: int,
-    custom_headers: dict,
+    headers: dict,
     custom_options: dict,
     supported_strategies: dict,
     request_timeout: int,
@@ -38,7 +38,7 @@ def register_client(
     :param app_name:
     :param instance_id:
     :param metrics_interval:
-    :param custom_headers:
+    :param headers:
     :param custom_options:
     :param supported_strategies:
     :param request_timeout:
@@ -64,7 +64,7 @@ def register_client(
         resp = requests.post(
             url + REGISTER_URL,
             data=json.dumps(registration_request),
-            headers={**custom_headers, **APPLICATION_HEADERS},
+            headers={**headers, **APPLICATION_HEADERS},
             timeout=request_timeout,
             **custom_options,
         )
diff --git a/UnleashClient/periodic_tasks/fetch_and_load.py b/UnleashClient/periodic_tasks/fetch_and_load.py
index 9740701..cf25c90 100644
--- a/UnleashClient/periodic_tasks/fetch_and_load.py
+++ b/UnleashClient/periodic_tasks/fetch_and_load.py
@@ -13,7 +13,7 @@ def fetch_and_load_features(
     url: str,
     app_name: str,
     instance_id: str,
-    custom_headers: dict,
+    headers: dict,
     custom_options: dict,
     cache: BaseCache,
     request_timeout: int,
@@ -25,7 +25,7 @@ def fetch_and_load_features(
         url,
         app_name,
         instance_id,
-        custom_headers,
+        headers,
         custom_options,
         request_timeout,
         request_retries,
diff --git a/UnleashClient/periodic_tasks/send_metrics.py b/UnleashClient/periodic_tasks/send_metrics.py
index 81790a0..ef532ca 100644
--- a/UnleashClient/periodic_tasks/send_metrics.py
+++ b/UnleashClient/periodic_tasks/send_metrics.py
@@ -12,7 +12,7 @@ def aggregate_and_send_metrics(
     url: str,
     app_name: str,
     instance_id: str,
-    custom_headers: dict,
+    headers: dict,
     custom_options: dict,
     request_timeout: int,
     engine: UnleashEngine,
@@ -30,8 +30,6 @@ def aggregate_and_send_metrics(
     }
 
     if metrics_bucket:
-        send_metrics(
-            url, metrics_request, custom_headers, custom_options, request_timeout
-        )
+        send_metrics(url, metrics_request, headers, custom_options, request_timeout)
     else:
         LOGGER.debug("No feature flags with metrics, skipping metrics submission.")


Test patch:
diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py
index 5bbcec8..22fe3d9 100644
--- a/tests/unit_tests/test_client.py
+++ b/tests/unit_tests/test_client.py
@@ -1032,3 +1032,49 @@ def test_is_enabled_works_with_properties_field_in_the_context_root():
 
     context = {"myContext": "1234"}
     assert unleash_client.is_enabled("customContextToggle", context)
+
+
+@responses.activate
+def test_identification_headers_sent_and_consistent(unleash_client):
+    responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
+    responses.add(
+        responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
+    )
+    responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
+    unleash_client.initialize_client()
+
+    connection_id = responses.calls[0].request.headers["X-UNLEASH-CONNECTION-ID"]
+    app_name = responses.calls[0].request.headers["X-UNLEASH-APPNAME"]
+    sdk = responses.calls[0].request.headers["X-UNLEASH-SDK"]
+
+    for api_call in responses.calls:
+        assert api_call.request.headers["X-UNLEASH-CONNECTION-ID"] == connection_id
+        assert api_call.request.headers["X-UNLEASH-APPNAME"] == app_name
+        assert api_call.request.headers["X-UNLEASH-SDK"] == sdk
+
+
+@responses.activate
+def test_identification_headers_unique_connection_id():
+    responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
+    responses.add(
+        responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
+    )
+    responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
+
+    unleash_client = UnleashClient(
+        URL, APP_NAME, disable_metrics=True, disable_registration=True
+    )
+    unleash_client.initialize_client()
+    connection_id_first_client = responses.calls[0].request.headers[
+        "X-UNLEASH-CONNECTION-ID"
+    ]
+
+    other_unleash_client = UnleashClient(
+        URL, APP_NAME, disable_metrics=True, disable_registration=True
+    )
+    other_unleash_client.initialize_client()
+
+    connection_id_second_client = responses.calls[1].request.headers[
+        "X-UNLEASH-CONNECTION-ID"
+    ]
+    assert connection_id_first_client != connection_id_second_client