Repository: dwavesystems/dwave-cloud-client
Pull request #608 (2024-02-29 22:37:08)

Speed-up `User-Agent` construction
Default UA we now use consistently across all clients (`base.Client`, `api.Client`, `auth.flow`) is now constructed in **under 5us instead of 11ms+** (on my machine).

Consequently, client construction is sped-up as well.


Patch:
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index ac4cf05..8d7ab50 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -26,32 +26,14 @@ from werkzeug.http import parse_options_header, dump_options_header
 from dwave.cloud.api import constants, exceptions
 from dwave.cloud.config import load_config, validate_config_v1, update_config
 from dwave.cloud.config.models import ClientConfig
-from dwave.cloud.package_info import __packagename__, __version__
 from dwave.cloud.utils import (
-    PretimedHTTPAdapter, BaseUrlSession, user_agent, is_caused_by)
+    PretimedHTTPAdapter, BaseUrlSession, default_user_agent, is_caused_by)
 
 __all__ = ['DWaveAPIClient', 'SolverAPIClient', 'MetadataAPIClient', 'LeapAPIClient']
 
 logger = logging.getLogger(__name__)
 
 
-class LazyUserAgentClassProperty:
-    # roughly equivalent to ``classmethod(property(cached_user_agent))``, but it
-    # doesn't require chained decorators support available in py39+
-    _user_agent = None
-
-    def __get__(self, obj, objtype=None):
-        # Note: The only tags that might change are platform tags, as returned
-        # by `dwave.common.platform.tags` entry points, and `platform.platform()`
-        # (like linux kernel version). Assuming OS/machine won't change during
-        # client's lifespan, and typical platform tags defined via entry points
-        # depend on process environment variables (which rarely change), it's
-        # pretty safe to cache the user-agent per-class (or even globally).
-        if self._user_agent is None:
-            self._user_agent = user_agent(__packagename__, __version__)
-        return self._user_agent
-
-
 class LoggingSession(BaseUrlSession):
     """:class:`.BaseUrlSession` extended to unify timeout exceptions and to log
     all requests (and responses).
@@ -293,12 +275,6 @@ class DWaveAPIClient:
     # client instance config, populated on init from kwargs overridding DEFAULTS
     config = None
 
-    # User-Agent string used in API requests, as returned by
-    # :meth:`~dwave.cloud.utils.user_agent`, computed on first access and
-    # cached for the lifespan of the class.
-    # TODO: consider exposing "user_agent" config parameter
-    user_agent = LazyUserAgentClassProperty()
-
     def __init__(self, **config):
         self.config = {}
         for opt, default in self.DEFAULTS.items():
@@ -428,7 +404,7 @@ class DWaveAPIClient:
             timeout=timeout, max_retries=cls._retry_config(**retry)))
 
         # configure headers
-        session.headers.update({'User-Agent': cls.user_agent})
+        session.headers.update({'User-Agent': default_user_agent()})
         if config['headers']:
             session.headers.update(config['headers'])
 
diff --git a/dwave/cloud/auth/flows.py b/dwave/cloud/auth/flows.py
index 6b1092e..7403aea 100644
--- a/dwave/cloud/auth/flows.py
+++ b/dwave/cloud/auth/flows.py
@@ -32,7 +32,7 @@ from dwave.cloud.auth.creds import Credentials
 from dwave.cloud.auth.server import SingleRequestAppServer, RequestCaptureAndRedirectApp
 from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.regions import resolve_endpoints
-from dwave.cloud.utils import pretty_argvalues
+from dwave.cloud.utils import pretty_argvalues, default_user_agent
 
 __all__ = ['AuthFlow', 'LeapAuthFlow', 'OAuthError']
 
@@ -96,6 +96,9 @@ class AuthFlow:
             authorization_endpoint=authorization_endpoint,
             token_endpoint=token_endpoint)
 
+        # set default headers (overwritten by ``session_config['headers']``)
+        self.session.headers.update({'User-Agent': default_user_agent()})
+
         if session_config is not None:
             self.update_session(session_config)
 
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index d8ef670..b1457fe 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -72,7 +72,7 @@ from dwave.cloud.regions import get_regions, resolve_endpoints
 from dwave.cloud.upload import ChunkedData
 from dwave.cloud.events import dispatches_events
 from dwave.cloud.utils import (
-    PretimedHTTPAdapter, BaseUrlSession, user_agent,
+    PretimedHTTPAdapter, BaseUrlSession, default_user_agent,
     datetime_to_timestamp, utcnow, cached, retried, is_caused_by)
 
 __all__ = ['Client']
@@ -536,22 +536,6 @@ class Client(object):
         self._encode_problem_executor = \
             ThreadPoolExecutor(self._ENCODE_PROBLEM_THREAD_COUNT)
 
-    @cached_property
-    def _user_agent(self):
-        """User-Agent string for this client instance, as returned by
-        :meth:`~dwave.cloud.utils.user_agent`, computed on first access and
-        cached for the lifespan of the client.
-
-        Note:
-            The only tags that might change are platform tags, as returned by
-            ``dwave.common.platform.tags`` entry points, and `platform.platform()`
-            (like linux kernel version). Assuming OS/machine won't change during
-            client's lifespan, and typical platform tags defined via entry points
-            depend on process environments (which rarely change), it's pretty safe
-            to always use the per-instance cached user agent.
-        """
-        return user_agent(__packagename__, __version__)
-
     def create_session(self):
         """Create a new requests session based on client's (self) params.
 
@@ -572,7 +556,7 @@ class Client(object):
             timeout=self.config.request_timeout,
             max_retries=self.config.request_retry.to_urllib3_retry()))
 
-        session.headers.update({'User-Agent': self._user_agent})
+        session.headers.update({'User-Agent': default_user_agent()})
         if self.config.headers:
             session.headers.update(self.config.headers)
         if self.config.token:
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
index bfdb93d..3fa3e1e 100644
--- a/dwave/cloud/utils.py
+++ b/dwave/cloud/utils.py
@@ -39,6 +39,8 @@ import diskcache
 from importlib_metadata import entry_points
 from packaging.requirements import Requirement
 
+from dwave.cloud.package_info import __packagename__, __version__
+
 # Use numpy if available for fast decoding
 try:
     import numpy
@@ -417,18 +419,37 @@ def is_caused_by(exception, exception_types):
     return hasinstance(exception_chain(exception), exception_types)
 
 
-def user_agent(name=None, version=None):
-    """Return User-Agent ~ "name/version language/version interpreter/version os/version"."""
+def user_agent(name: Optional[str] = None,
+               version: Optional[str] = None,
+               *,
+               include_platform_tags: bool = True) -> str:
+    """Return User-Agent ~ "name/version language/version interpreter/version os/version".
+
+    Args:
+        name:
+            Package name, primary UA component name.
+        version:
+            Package version, primary UA component version.
+        include_platform_tags:
+            Look for, query and include externally-contributed platform tags
+            (via ``dwave.common.platform.tags`` entrypoint).
+            See :func:`dwave.cloud.utils.get_platform_tags`.
+
+    Return:
+        User-Agent string composed of "key/value" pairs (joined with a space
+        character), for following components: package, language, interpreter,
+        machine, system and platform.
+    """
 
     def _interpreter():
         name = platform.python_implementation()
         version = platform.python_version()
-        bitness = platform.architecture()[0]
         if name == 'PyPy':
             version = '.'.join(map(str, sys.pypy_version_info[:3]))
         full_version = [version]
-        if bitness:
-            full_version.append(bitness)
+        is_64bits = sys.maxsize > 2**32
+        if is_64bits:
+            full_version.append('64bit')
         return name, "-".join(full_version)
 
     tags = []
@@ -445,11 +466,19 @@ def user_agent(name=None, version=None):
     ])
 
     # add platform-specific tags
-    tags.extend(get_platform_tags())
+    if include_platform_tags:
+        tags.extend(get_platform_tags())
 
     return ' '.join("{}/{}".format(name, version) for name, version in tags)
 
 
+# defined as a function rather than constant because env might change during runtime
+def default_user_agent() -> str:
+    """Default user agent string to be used consistently across client(s)."""
+    return user_agent(
+        name=__packagename__, version=__version__, include_platform_tags=False)
+
+
 class CLIError(Exception):
     """CLI command error that includes the error code in addition to the
     standard error message."""
diff --git a/releasenotes/notes/use-consistent-user-agent-c2994b17a0f253ef.yaml b/releasenotes/notes/use-consistent-user-agent-c2994b17a0f253ef.yaml
new file mode 100644
index 0000000..87aa6ea
--- /dev/null
+++ b/releasenotes/notes/use-consistent-user-agent-c2994b17a0f253ef.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+  - |
+    Use a consistent ``User-Agent`` value for all API and non-API requests made
+    by ``Client``, ``api.Client`` and ``auth.flow`` client.
+  - Speed-up ``User-Agent`` string construction significantly (10ms to 5us).


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 413af5d..911bb08 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -36,8 +36,8 @@ from dwave.cloud.utils import (
     active_qubits, generate_random_ising_problem,
     NumpyEncoder, coerce_numpy_to_python,
     default_text_input, utcnow, cached, retried, deprecated, aliasdict,
-    parse_loglevel, user_agent, hasinstance, exception_chain, is_caused_by,
-    get_distribution, PackageNotFoundError, VersionNotFoundError)
+    parse_loglevel, user_agent, default_user_agent, hasinstance, exception_chain,
+    is_caused_by, get_distribution, PackageNotFoundError, VersionNotFoundError)
 
 
 class TestSimpleUtils(unittest.TestCase):
@@ -176,6 +176,13 @@ class TestSimpleUtils(unittest.TestCase):
         for key in required:
             self.assertIn(key, ua)
 
+    def test_default_user_agent(self):
+        from dwave.cloud.package_info import __packagename__, __version__
+        ua = default_user_agent()
+        ref = user_agent(
+            name=__packagename__, version=__version__, include_platform_tags=False)
+        self.assertEqual(ua, ref)
+
 
 # initially copied from dwave-hybrid/NumpyEncoder tests, but expanded to cover
 # `coerce_numpy_to_python`