diff --git a/.sdk_metadata.json b/.sdk_metadata.json index 2ac58272..a64339be 100644 --- a/.sdk_metadata.json +++ b/.sdk_metadata.json @@ -7,7 +7,7 @@ "languages": [ "Python" ], - "userAgents": ["PythonClient"], + "userAgents": ["PythonClient", "PythonAsyncClient"], "features": { "allFlags": { "introduced": "2.0" }, "appMetadata": { "introduced": "7.6" }, diff --git a/ldclient/impl/aio/transport.py b/ldclient/impl/aio/transport.py index da3fda7d..e9aa374f 100644 --- a/ldclient/impl/aio/transport.py +++ b/ldclient/impl/aio/transport.py @@ -15,7 +15,7 @@ from ld_eventsource.config.retry_delay_strategy import RetryDelayStrategy from ldclient.impl.aio.transport_types import TransportResponse -from ldclient.impl.http import _base_headers, _get_proxy_url +from ldclient.impl.http import ASYNC_USER_AGENT, _base_headers, _get_proxy_url from ldclient.impl.util import log # Allows up to 5 minutes to elapse without any data sent across the stream. @@ -123,7 +123,7 @@ def create(self, url: str, initial_retry_delay: float, query_params=None) -> Asy proxy settings, and the retry/backoff policy come from the SDK config. ``query_params`` is an optional zero-argument callable evaluated on each (re)connect to produce additional query string parameters.""" - base_headers = _base_headers(self._config) + base_headers = _base_headers(self._config, ASYNC_USER_AGENT) aiohttp_request_options: dict = { "timeout": aiohttp.ClientTimeout( total=None, diff --git a/ldclient/impl/datasource/async_feature_requester.py b/ldclient/impl/datasource/async_feature_requester.py index 8371bbd7..66a732d5 100644 --- a/ldclient/impl/datasource/async_feature_requester.py +++ b/ldclient/impl/datasource/async_feature_requester.py @@ -9,6 +9,7 @@ from ldclient.impl.aio.transport import AsyncHTTPTransport from ldclient.impl.datasource.datasource_common import FDV1_POLLING_ENDPOINT +from ldclient.impl.http import ASYNC_USER_AGENT from ldclient.impl.util import _headers, log, throw_if_unsuccessful_response from ldclient.interfaces import AsyncFeatureRequester from ldclient.versioned_data_kind import FEATURES, SEGMENTS @@ -30,7 +31,7 @@ def __init__(self, config, transport: Optional[AsyncHTTPTransport] = None): async def get_all_data(self): uri = self._poll_uri - hdrs = _headers(self._config) + hdrs = _headers(self._config, ASYNC_USER_AGENT) cache_entry = self._cache.get(uri) hdrs['Accept-Encoding'] = 'gzip' if cache_entry is not None: diff --git a/ldclient/impl/datasourcev2/async_polling.py b/ldclient/impl/datasourcev2/async_polling.py index e93b6972..e3b6a3a3 100644 --- a/ldclient/impl/datasourcev2/async_polling.py +++ b/ldclient/impl/datasourcev2/async_polling.py @@ -26,6 +26,7 @@ polling_payload_to_changeset, polling_result_to_basis ) +from ldclient.impl.http import ASYNC_USER_AGENT from ldclient.impl.util import ( UnsuccessfulResponseException, _Fail, @@ -192,7 +193,7 @@ async def fetch(self, selector: Optional[Selector]) -> PollingResult: filter_query = parse.urlencode(query_params) uri += f"?{filter_query}" - hdrs = _headers(self._config) + hdrs = _headers(self._config, ASYNC_USER_AGENT) hdrs["Accept-Encoding"] = "gzip" if self._etag is not None: @@ -387,7 +388,7 @@ async def fetch(self, selector: Optional[Selector]) -> PollingResult: filter_query = parse.urlencode(query_params) uri += f"?{filter_query}" - hdrs = _headers(self._config) + hdrs = _headers(self._config, ASYNC_USER_AGENT) hdrs["Accept-Encoding"] = "gzip" if self._etag is not None: diff --git a/ldclient/impl/events/async_event_processor.py b/ldclient/impl/events/async_event_processor.py index 0e4e29d8..1e5b0215 100644 --- a/ldclient/impl/events/async_event_processor.py +++ b/ldclient/impl/events/async_event_processor.py @@ -29,6 +29,7 @@ EventOutputFormatter ) from ldclient.impl.events.types import EventInput +from ldclient.impl.http import ASYNC_USER_AGENT from ldclient.impl.lru_cache import SimpleLRUCache from ldclient.impl.sampler import Sampler from ldclient.impl.util import ( @@ -277,7 +278,7 @@ async def __aexit__(self, type, value, traceback): async def _post_events_with_retry(http_client: AsyncHTTPTransport, config: AsyncConfig, uri: str, payload_id: Optional[str], body: str, events_description: str): - hdrs = _headers(config) + hdrs = _headers(config, ASYNC_USER_AGENT) hdrs['Content-Type'] = 'application/json' if config.enable_event_compression: hdrs['Content-Encoding'] = 'gzip' diff --git a/ldclient/impl/http.py b/ldclient/impl/http.py index 0745bf28..5fc77c56 100644 --- a/ldclient/impl/http.py +++ b/ldclient/impl/http.py @@ -7,6 +7,12 @@ from ldclient.version import VERSION +#: User-Agent product token for the synchronous client. +SYNC_USER_AGENT = "PythonClient" +#: User-Agent product token for the asynchronous client. Sync and async ship in +#: one package, so the token is how LaunchDarkly tells the two apart. +ASYNC_USER_AGENT = "PythonAsyncClient" + def _application_header_value(application: dict) -> str: parts = [] @@ -22,8 +28,8 @@ def _application_header_value(application: dict) -> str: return " ".join(parts) -def _base_headers(config): - headers = {'Authorization': config.sdk_key or '', 'User-Agent': 'PythonClient/' + VERSION} +def _base_headers(config, user_agent=SYNC_USER_AGENT): + headers = {'Authorization': config.sdk_key or '', 'User-Agent': user_agent + '/' + VERSION} if config._instance_id is not None: headers['X-LaunchDarkly-Instance-Id'] = config._instance_id diff --git a/ldclient/impl/util.py b/ldclient/impl/util.py index fda0e4fd..69e7186a 100644 --- a/ldclient/impl/util.py +++ b/ldclient/impl/util.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import urlparse, urlunparse -from ldclient.impl.http import _base_headers +from ldclient.impl.http import SYNC_USER_AGENT, _base_headers def current_time_millis() -> int: @@ -82,8 +82,8 @@ def validate_sdk_key_format(sdk_key: str, logger: logging.Logger) -> str: return sdk_key -def _headers(config): - base_headers = _base_headers(config) +def _headers(config, user_agent=SYNC_USER_AGENT): + base_headers = _base_headers(config, user_agent) base_headers.update({'Content-Type': "application/json"}) return base_headers diff --git a/ldclient/testing/impl/test_user_agent.py b/ldclient/testing/impl/test_user_agent.py new file mode 100644 index 00000000..ef0fc63f --- /dev/null +++ b/ldclient/testing/impl/test_user_agent.py @@ -0,0 +1,35 @@ +"""Tests for the sync and async User-Agent tokens. + +Sync and async ship in one package, so the User-Agent token is how LaunchDarkly +tells the two clients apart. These tests lock the exact tokens sent on the wire. +""" + +from ldclient.config import Config +from ldclient.impl.http import ASYNC_USER_AGENT, SYNC_USER_AGENT, _base_headers +from ldclient.impl.util import _headers +from ldclient.version import VERSION + + +def _config(): + return Config(sdk_key='sdk-key') + + +def test_user_agent_tokens(): + assert SYNC_USER_AGENT == 'PythonClient' + assert ASYNC_USER_AGENT == 'PythonAsyncClient' + + +def test_base_headers_default_user_agent_is_sync(): + assert _base_headers(_config())['User-Agent'] == 'PythonClient/' + VERSION + + +def test_base_headers_async_user_agent(): + assert _base_headers(_config(), ASYNC_USER_AGENT)['User-Agent'] == 'PythonAsyncClient/' + VERSION + + +def test_headers_default_user_agent_is_sync(): + assert _headers(_config())['User-Agent'] == 'PythonClient/' + VERSION + + +def test_headers_async_user_agent(): + assert _headers(_config(), ASYNC_USER_AGENT)['User-Agent'] == 'PythonAsyncClient/' + VERSION