Repository: dwavesystems/dwave-cloud-client
Pull request #697 (2025-06-11 19:10:05)

Enforce sapi version validation in base client workers


Patch:
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index 8868bde..31cab51 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -34,7 +34,7 @@ from packaging.specifiers import SpecifierSet
 import dwave.cloud.config
 from dwave.cloud.api import exceptions
 from dwave.cloud.utils.exception import is_caused_by
-from dwave.cloud.utils.http import PretimedHTTPAdapter, BaseUrlSession, default_user_agent
+from dwave.cloud.utils.http import PretimedHTTPAdapter, BaseUrlSessionMixin, default_user_agent
 from dwave.cloud.utils.time import epochnow
 
 if TYPE_CHECKING:
@@ -45,7 +45,7 @@ __all__ = ['DWaveAPIClient', 'SolverAPIClient', 'MetadataAPIClient', 'LeapAPICli
 logger = logging.getLogger(__name__)
 
 
-class LoggingSession(BaseUrlSession):
+class LoggingSessionMixin:
     """:class:`.BaseUrlSession` extended to unify timeout exceptions and to log
     all requests (and responses).
 
@@ -133,7 +133,19 @@ class LoggingSession(BaseUrlSession):
         return response
 
 
-class PayloadCompressingSession(LoggingSession):
+class LoggingSession(LoggingSessionMixin, BaseUrlSessionMixin, requests.Session):
+    def __init__(self, *args, **kwargs):
+        # note: this deprecation warning is raised by all subclasses, up to and
+        # including `CachingSession`. The message adapts accordingly.
+        cls = type(self).__name__
+        warnings.warn(
+            f"`{cls}` is deprecated in favor of `{cls}Mixin` since "
+            f"dwave-cloud-client 0.13.6, and will be removed in 0.15.0.",
+            DeprecationWarning, stacklevel=2)
+        super().__init__(*args, **kwargs)
+
+
+class PayloadCompressingSessionMixin:
     """A :class:`requests.Session` subclass (technically, a further specialized
     :class:`.LoggingSession`) that adds support for payload compression on the
     fly.
@@ -233,7 +245,11 @@ class PayloadCompressingSession(LoggingSession):
         return super().send(request, **kwargs)
 
 
-class VersionedAPISession(PayloadCompressingSession):
+class PayloadCompressingSession(PayloadCompressingSessionMixin, LoggingSession):
+    pass
+
+
+class VersionedAPISessionMixin:
     """A :class:`requests.Session` subclass (technically, further specialized
     :class:`.PayloadCompressingSession`) that enforces conformance of API response
     version with supported version range(s).
@@ -350,7 +366,11 @@ def _create_default_cache_store(**kwargs) -> abc.Mapping:
     return diskcache.Cache(directory=directory)
 
 
-class CachingSession(VersionedAPISession):
+class VersionedAPISession(VersionedAPISessionMixin, PayloadCompressingSession):
+    pass
+
+
+class CachingSessionMixin:
     """A :class:`requests.Session` subclass (technically, further specialized
     :class:`.VersionedAPISession`) that caches responses and uses conditional
     requests for smart cache updates.
@@ -576,6 +596,10 @@ class CachingSession(VersionedAPISession):
             return res
 
 
+class CachingSession(CachingSessionMixin, VersionedAPISession):
+    pass
+
+
 class DWaveAPIClient:
     """Low-level client for D-Wave APIs. A thin wrapper around
     `requests.Session` that handles API specifics such as authentication,
@@ -596,7 +620,19 @@ class DWaveAPIClient:
 
     """
 
+    class DefaultSession(CachingSessionMixin,
+                         VersionedAPISessionMixin,
+                         PayloadCompressingSessionMixin,
+                         LoggingSessionMixin,
+                         BaseUrlSessionMixin,
+                         requests.Session):
+        """Default session class is based on `requests.Session` extended with
+        base url, logging, compressing, API versioning and caching mixins.
+        """
+
     DEFAULTS = {
+        'session_class': DefaultSession,
+
         'endpoint': None,
         'token': None,
         'cert': None,
@@ -748,14 +784,25 @@ class DWaveAPIClient:
         if not endpoint.endswith('/'):
             endpoint += '/'
 
+        session_class = config['session_class']
+
+        # build kwargs from mixins used
+        # note: we could automate param extraction with a metaclass, but let's kiss
+        session_kwargs = {}
+        if issubclass(session_class, BaseUrlSessionMixin):
+            session_kwargs.update(base_url=endpoint)
+        if issubclass(session_class, LoggingSessionMixin):
+            session_kwargs.update(history_size=config['history_size'])
+        if issubclass(session_class, PayloadCompressingSessionMixin):
+            session_kwargs.update(compress=config['compress'])
+        if issubclass(session_class, VersionedAPISessionMixin):
+            session_kwargs.update(strict_mode=config['version_strict_mode'])
+        if issubclass(session_class, CachingSessionMixin):
+            session_kwargs.update(cache=config['cache'])
+
+        session = session_class(**session_kwargs)
+
         # configure request timeout and retries
-        session = CachingSession(
-            base_url=endpoint,
-            history_size=config['history_size'],
-            compress=config['compress'],
-            strict_mode=config['version_strict_mode'],
-            cache=config['cache'],
-        )
         timeout = config['timeout']
         retry = config['retry']
         session.mount('http://', PretimedHTTPAdapter(
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index a5c6c27..28627b8 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -374,8 +374,8 @@ def _get_client_solver(config, output=None):
         raise CLIError("Authentication error. Check credentials in your configuration file.", 2)
     except SolverNotFoundError:
         raise CLIError("Solver not available.", 6)
-    except (InvalidAPIResponseError, UnsupportedSolverError):
-        raise CLIError("Invalid or unexpected API response.", 3)
+    except (InvalidAPIResponseError, UnsupportedSolverError) as e:
+        raise CLIError(f"Invalid or unexpected API response: {e!s}", 3)
     except RequestTimeout:
         raise CLIError("API connection timed out.", 4)
     except requests.exceptions.SSLError as e:
@@ -408,8 +408,10 @@ def _sample(solver, problem, params, output):
         raise CLIError("API connection timed out.", 8)
     except PollingTimeout:
         raise CLIError("Polling timeout exceeded.", 9)
+    except InvalidAPIResponseError as e:
+        raise CLIError(f"Invalid or unexpected API response: {e!s}", 3)
     except Exception as e:
-        raise CLIError("Sampling error: {!r}".format(e), 10)
+        raise CLIError(f"Sampling error: {e!s}", 10)
 
     return response
 
@@ -487,7 +489,10 @@ def standardized_output(fn):
 def ping(*, config_file, profile, endpoint, region, client_type, solver_def,
          sampling_params, request_timeout, polling_timeout, label, json_output,
          output):
-    """Ping the QPU by submitting a single-qubit problem."""
+    """Ping a solver by submitting a single-qubit problem.
+
+    If solver is not specified, a QPU solver is selected by default.
+    """
 
     # parse params (TODO: move to click validator)
     params = {}
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index fe566aa..28e8cb9 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -530,6 +530,9 @@ class Client(object):
         self._download_answer_executor = \
             ThreadPoolExecutor(self._DOWNLOAD_ANSWER_THREAD_COUNT)
 
+    class _Session(api.client.VersionedAPISessionMixin, BaseUrlSession):
+        pass
+
     def create_session(self):
         """Create a new requests session based on client's (self) params.
 
@@ -542,7 +545,7 @@ class Client(object):
         if not endpoint.endswith('/'):
             endpoint += '/'
 
-        session = BaseUrlSession(base_url=endpoint)
+        session = self._Session(base_url=endpoint, strict_mode=True)
         session.mount('http://', PretimedHTTPAdapter(
             timeout=self.config.request_timeout,
             max_retries=self.config.request_retry.to_urllib3_retry()))
@@ -1268,6 +1271,8 @@ class Client(object):
             return []
 
         session = self.create_session()
+        session.set_accept(media_type='application/vnd.dwave.sapi.problems+json',
+                           accept_version='>=2.1,<3')
         try:
             while True:
                 # Pull as many problems as we can, block on the first one,
@@ -1443,6 +1448,8 @@ class Client(object):
             This method is always run inside of a daemon thread.
         """
         session = self.create_session()
+        session.set_accept(media_type='application/vnd.dwave.sapi.problems+json',
+                           accept_version='>=2.1,<3')
         try:
             while True:
                 # Pull as many problems as we can, block when none are available.
@@ -1541,6 +1548,8 @@ class Client(object):
             This method is always run inside of a daemon thread.
         """
         session = self.create_session()
+        session.set_accept(media_type='application/vnd.dwave.sapi.problems+json',
+                           accept_version='>=2.1,<3')
         try:
             # grouped futures (all scheduled within _POLL_GROUP_TIMEFRAME)
             # and/or up to _STATUS_QUERY_SIZE (depending on strategy)
@@ -1622,7 +1631,7 @@ class Client(object):
 
                     except SAPIError as exc:
                         # assume 5xx errors are transient, and don't abort polling
-                        if 500 <= exc.error_code < 600:
+                        if exc.error_code and 500 <= exc.error_code < 600:
                             logger.warning(
                                 "Received an internal server error response on "
                                 "problem status polling request (%s). Assuming "
@@ -1678,6 +1687,8 @@ class Client(object):
             This method is always run inside of a daemon thread.
         """
         session = self.create_session()
+        session.set_accept(media_type='application/vnd.dwave.sapi.problem+json',
+                           accept_version='>=2.1,<3')
         try:
             while True:
                 # Select a problem
@@ -1807,6 +1818,9 @@ class Client(object):
         # execute request
         try:
             response = meth(*args, **kwargs)
+        except api.exceptions.ResourceBadResponseError as e:
+            # returned by VersionedAPISessionMixin; wrap for backwards-compatibility
+            raise InvalidAPIResponseError(e) from e
         except Exception as exc:
             if is_caused_by(exc, (requests.exceptions.Timeout,
                                   urllib3.exceptions.TimeoutError)):
diff --git a/dwave/cloud/utils/http.py b/dwave/cloud/utils/http.py
index 3d08396..58fb05f 100644
--- a/dwave/cloud/utils/http.py
+++ b/dwave/cloud/utils/http.py
@@ -69,26 +69,47 @@ class TimeoutingHTTPAdapter(PretimedHTTPAdapter):
 # If we later decide to use additional features from `requests-toolbelt`,
 # remove it from here.
 
-class BaseUrlSession(requests.Session):
-    """A Session with a URL that all requests will use as a base."""
+class BaseUrlSessionMixin:
+    """A :class:`~requests.Session` mixin that allows setting of a base URL for
+    all session requests.
 
+    See also :class:`.BaseUrlSession`.
+
+    Args:
+        base_url:
+            Base URL.
+
+    """
     base_url = None
 
-    def __init__(self, base_url=None):
+    def __init__(self, base_url: Optional[str] = None):
         if base_url:
             self.base_url = base_url
         super().__init__()
 
-    def request(self, method, url, *args, **kwargs):
+    def request(self, method: str, url: str, *args, **kwargs) -> requests.Response:
         """Send the request after generating the complete URL."""
         url = self.create_url(url)
         return super().request(method, url, *args, **kwargs)
 
-    def create_url(self, url):
+    def create_url(self, url: str) -> str:
         """Create the URL based off this partial path."""
         return urljoin(self.base_url, url)
 
 
+class BaseUrlSession(BaseUrlSessionMixin, requests.Session):
+    """A Session with a URL that all requests will use as a base.
+
+    See also :class:`.BaseUrlSessionMixin`.
+
+    Example::
+
+        session = BaseUrlSession(base_url='http://example.com/api/')
+        session.get('version')
+
+    """
+
+
 def user_agent(name: Optional[str] = None,
                version: Optional[str] = None,
                *,
diff --git a/releasenotes/notes/enforce-sapi-version-on-base-client-9baa3795a339a1c6.yaml b/releasenotes/notes/enforce-sapi-version-on-base-client-9baa3795a339a1c6.yaml
new file mode 100644
index 0000000..8073806
--- /dev/null
+++ b/releasenotes/notes/enforce-sapi-version-on-base-client-9baa3795a339a1c6.yaml
@@ -0,0 +1,18 @@
+---
+features:
+  - |
+    Convert ``requests.Session`` subclasses in ``dwave.cloud.api.client`` to
+    mixins. ``BaseUrlSessionMixin``, ``LoggingSessionMixin``,
+    ``PayloadCompressingSessionMixin``, ``VersionedAPISessionMixin`` and
+    ``CachingSessionMixin`` can now be combined and used independently of each
+    other.
+  - |
+    Enforce supported API response version on base client methods (problem submit,
+    status poll, problem cancel, answer download). In case of unsupported API
+    response, an ``InvalidAPIResponseError`` exception is raised.
+deprecations:
+  - |
+    The following ``requests.Session`` subclasses are deprecated in favor of their
+    mixin variants: ``LoggingSession``, ``PayloadCompressingSession``,
+    ``VersionedAPISession``, ``CachingSession``. They'll be removed in
+    dwave-cloud-client 0.15.0.


Test patch:
diff --git a/tests/api/mocks.py b/tests/api/mocks.py
index 5ada89b..2e1c053 100644
--- a/tests/api/mocks.py
+++ b/tests/api/mocks.py
@@ -13,8 +13,10 @@
 # limitations under the License.
 
 import collections
+import datetime
 import io
 import uuid
+from collections.abc import Iterable
 from typing import Any, Optional, Union
 from unittest import mock
 
@@ -29,7 +31,12 @@ from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
 from dwave.cloud.testing.mocks import qpu_clique_solver_data, hybrid_bqm_solver_data
 
 
-def choose_reply(path, replies, statuses=None, date=None):
+def choose_reply(path: str,
+                 replies: dict[str, Union[str, bytes, Any]],
+                 statuses: Optional[dict[str, Iterable[int]]] = None,
+                 date: Optional[datetime.date] = None,
+                 headers: Optional[dict[str, str]] = None,
+                 ) -> mock.Mock:
     """Choose the right response based on the path and make a mock response."""
 
     if statuses is None:
@@ -38,6 +45,11 @@ def choose_reply(path, replies, statuses=None, date=None):
     if date is None:
         date = utcrel(0)
 
+    if headers is None:
+        headers = {}
+
+    headers = CaseInsensitiveDict(headers | {'Date': date.isoformat()})
+
     if path in replies:
         response = mock.Mock(['content', 'text', 'json', 'raise_for_status', 'headers'])
         response.status_code = next(statuses[path])
@@ -49,7 +61,7 @@ def choose_reply(path, replies, statuses=None, date=None):
         response.content = content
         response.text = content.decode('utf8')
         response.json.side_effect = lambda: replies[path]
-        response.headers = CaseInsensitiveDict({'Date': date.isoformat()})
+        response.headers = headers
 
         def raise_for_status():
             if not 200 <= response.status_code < 400:
diff --git a/tests/api/resources/test_solvers.py b/tests/api/resources/test_solvers.py
index 4cba329..48f1d63 100644
--- a/tests/api/resources/test_solvers.py
+++ b/tests/api/resources/test_solvers.py
@@ -25,7 +25,7 @@ import requests
 import requests_mock
 
 from dwave.cloud.api import exceptions, models
-from dwave.cloud.api.client import CachingSession
+from dwave.cloud.api.client import CachingSessionMixin
 from dwave.cloud.api.resources import Solvers
 from dwave.cloud.config import validate_config_v1
 from dwave.cloud.testing.mocks import qpu_clique_solver_data
@@ -262,7 +262,7 @@ class TestLiveSolversCaching(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         cls.tmpdir = tempfile.TemporaryDirectory()
-        cls.store = partial(CachingSession._default_cache_config['store'],
+        cls.store = partial(CachingSessionMixin._default_cache_config['store'],
                             directory=cls.tmpdir.name)
 
     @classmethod
diff --git a/tests/api/test_api_clients.py b/tests/api/test_api_clients.py
index 81944c2..6a52b46 100644
--- a/tests/api/test_api_clients.py
+++ b/tests/api/test_api_clients.py
@@ -21,13 +21,20 @@ import zlib
 import diskcache
 import requests
 import requests_mock
-from parameterized import parameterized
+from parameterized import parameterized, parameterized_class
 
 from dwave.cloud.api import exceptions
 from dwave.cloud.api.client import (
-    DWaveAPIClient, SolverAPIClient, MetadataAPIClient, LeapAPIClient)
+    DWaveAPIClient, SolverAPIClient, MetadataAPIClient, LeapAPIClient,
+    LoggingSessionMixin, PayloadCompressingSessionMixin,
+    VersionedAPISessionMixin, CachingSessionMixin)
 from dwave.cloud.config import ClientConfig, constants
 from dwave.cloud.package_info import __packagename__, __version__
+from dwave.cloud.utils.http import BaseUrlSession
+
+
+def make_session_class(*bases):
+    return type('TestSession', bases, {})
 
 
 class TestConfig(unittest.TestCase):
@@ -164,8 +171,12 @@ class TestRequests(unittest.TestCase):
             self.assertEqual(client.session.get(path_a).json(), data_a)
             self.assertEqual(client.session.get(path_b).json(), data_b)
 
+    @parameterized.expand([
+        ("mixin", make_session_class(LoggingSessionMixin, BaseUrlSession)),
+        ("default", DWaveAPIClient.DefaultSession),
+    ])
     @requests_mock.Mocker()
-    def test_session_history(self, m):
+    def test_session_history(self, name, session_class, m):
         """Session history is available."""
 
         baseurl = 'https://test.com'
@@ -174,7 +185,7 @@ class TestRequests(unittest.TestCase):
         m.get(requests_mock.ANY, status_code=404)
         m.get(f"{baseurl}/path", json=dict(data=True))
 
-        with DWaveAPIClient(**config) as client:
+        with DWaveAPIClient(session_class=session_class, **config) as client:
             client.session.get('path')
             self.assertEqual(client.session.history[-1].request.path_url, '/path')
 
@@ -185,8 +196,12 @@ class TestRequests(unittest.TestCase):
             client.session.get('/path')
             self.assertEqual(client.session.history[-1].request.path_url, '/path')
 
+    @parameterized.expand([
+        ("mixin", make_session_class(PayloadCompressingSessionMixin, BaseUrlSession)),
+        ("default", DWaveAPIClient.DefaultSession),
+    ])
     @requests_mock.Mocker()
-    def test_payload_compression(self, m):
+    def test_payload_compression(self, name, session_class, m):
         """Payload compression on upload is supported."""
 
         baseurl = 'https://test.com'
@@ -205,19 +220,25 @@ class TestRequests(unittest.TestCase):
         m.post(baseurl, additional_matcher=match_data, text='ok')
 
         with self.subTest('compress all'):
-            with DWaveAPIClient(compress=True, **config) as client:
+            with DWaveAPIClient(session_class=session_class, compress=True, **config) as client:
                 resp = client.session.post('', data=data)
                 self.assertEqual(resp.text, 'ok')
 
         with self.subTest('compress request'):
-            with DWaveAPIClient(**config) as client:
+            with DWaveAPIClient(session_class=session_class, **config) as client:
                 resp = client.session.post('', data=data, compress=True)
                 self.assertEqual(resp.text, 'ok')
 
 
+@parameterized_class(("version_strict_mode", "session_class"), [
+    (True, make_session_class(VersionedAPISessionMixin, BaseUrlSession)),
+    (True, DWaveAPIClient.DefaultSession),
+    (False, make_session_class(VersionedAPISessionMixin, BaseUrlSession)),
+    (False, DWaveAPIClient.DefaultSession),
+])
 class TestVersionValidation(unittest.TestCase):
-
-    version_strict_mode = True
+    version_strict_mode = None
+    session_class = None
 
     def setUp(self):
         self.mocker = requests_mock.Mocker()
@@ -238,7 +259,8 @@ class TestVersionValidation(unittest.TestCase):
         self.mocker.start()
 
         self.client = DWaveAPIClient(
-            endpoint=endpoint, version_strict_mode=self.version_strict_mode)
+            endpoint=endpoint, version_strict_mode=self.version_strict_mode,
+            session_class=self.session_class)
 
     def tearDown(self):
         self.client.close()
@@ -249,7 +271,11 @@ class TestVersionValidation(unittest.TestCase):
         self.assertEqual(self.client.session.get('version').json(), self.data)
 
     def test_no_type(self):
-        with self.assertRaisesRegex(exceptions.ResourceBadResponseError, r'^Media type not present'):
+        if self.version_strict_mode:
+            with self.assertRaisesRegex(exceptions.ResourceBadResponseError, r'^Media type not present'):
+                self.client.session.set_accept(media_type=self.media_type, accept_version='~=1.2.0')
+                self.assertEqual(self.client.session.get('no-type').json(), self.data)
+        else:
             self.client.session.set_accept(media_type=self.media_type, accept_version='~=1.2.0')
             self.assertEqual(self.client.session.get('no-type').json(), self.data)
 
@@ -273,16 +299,12 @@ class TestVersionValidation(unittest.TestCase):
             self.assertEqual(self.client.session.get('version').json(), self.data)
 
 
-class TestNonStrictVersionValidation(TestVersionValidation):
-
-    version_strict_mode = False
-
-    def test_no_type(self):
-        self.client.session.set_accept(media_type=self.media_type, accept_version='~=1.2.0')
-        self.assertEqual(self.client.session.get('no-type').json(), self.data)
-
-
+@parameterized_class(("session_class", ), [
+    (make_session_class(VersionedAPISessionMixin, BaseUrlSession), ),
+    (DWaveAPIClient.DefaultSession, ),
+])
 class TestResponseParsing(unittest.TestCase):
+    session_class = None
 
     @requests_mock.Mocker()
     def test_json(self, m):
@@ -293,7 +315,7 @@ class TestResponseParsing(unittest.TestCase):
         m.get(requests_mock.ANY, content=json.dumps(mock_response).encode('ascii'),
               status_code=200, headers={'Content-Type': 'application/octet-stream'})
 
-        with DWaveAPIClient(endpoint='https://mock') as client:
+        with DWaveAPIClient(endpoint='https://mock', session_class=self.session_class) as client:
 
             with self.subTest("binary response when expecting json"):
                 client.session.set_accept(media_type="application/json")
@@ -318,7 +340,7 @@ class TestResponseParsing(unittest.TestCase):
 
         m.get(requests_mock.ANY, json=error, status_code=error_code)
 
-        with DWaveAPIClient(endpoint='https://mock') as client:
+        with DWaveAPIClient(endpoint='https://mock', session_class=self.session_class) as client:
 
             with self.assertRaisesRegex(exceptions.ResourceNotFoundError, error_msg) as exc:
                 client.session.get('test')
@@ -335,7 +357,7 @@ class TestResponseParsing(unittest.TestCase):
 
         m.get(requests_mock.ANY, text=error_msg, status_code=error_code)
 
-        with DWaveAPIClient(endpoint='https://mock') as client:
+        with DWaveAPIClient(endpoint='https://mock', session_class=self.session_class) as client:
 
             with self.assertRaisesRegex(exceptions.ResourceNotFoundError, error_msg) as exc:
                 client.session.get('test')
@@ -352,7 +374,7 @@ class TestResponseParsing(unittest.TestCase):
 
         m.get(requests_mock.ANY, text=error_msg, status_code=error_code)
 
-        with DWaveAPIClient(endpoint='https://mock') as client:
+        with DWaveAPIClient(endpoint='https://mock', session_class=self.session_class) as client:
 
             with self.assertRaisesRegex(exceptions.RequestError, error_msg) as exc:
                 client.session.get('test')
@@ -361,26 +383,35 @@ class TestResponseParsing(unittest.TestCase):
                 self.assertEqual(exc.error_code, error_code)
 
 
+@parameterized_class(("session_class", ), [
+    (make_session_class(CachingSessionMixin, LoggingSessionMixin, BaseUrlSession), ),
+    (DWaveAPIClient.DefaultSession, ),
+])
 class TestResponseCaching(unittest.TestCase):
+    session_class = None
 
     def test_config(self):
         with self.subTest("cache disabled by default"):
-            with DWaveAPIClient(endpoint='https://mock') as client:
+            with DWaveAPIClient(endpoint='https://mock',
+                                session_class=self.session_class) as client:
                 self.assertFalse(client.session._cache_enabled)
 
         with self.subTest("cache disabled with config"):
-            with DWaveAPIClient(endpoint='https://mock', cache=False) as client:
+            with DWaveAPIClient(endpoint='https://mock', cache=False,
+                                session_class=self.session_class) as client:
                 self.assertFalse(client.session._cache_enabled)
 
         with self.subTest("cache enabled with config"):
-            with DWaveAPIClient(endpoint='https://mock', cache=True) as client:
+            with DWaveAPIClient(endpoint='https://mock', cache=True,
+                                session_class=self.session_class) as client:
                 self.assertTrue(client.session._cache_enabled)
                 self.assertIsNotNone(client.session._maxage)
                 self.assertIsInstance(client.session._store, diskcache.Cache)
 
         with self.subTest("cache configured"):
             with DWaveAPIClient(endpoint='https://mock',
-                                cache=dict(maxage=5, store={})) as client:
+                                cache=dict(maxage=5, store={}),
+                                session_class=self.session_class) as client:
                 self.assertTrue(client.session._cache_enabled)
                 self.assertEqual(client.session._maxage, 5)
                 self.assertIsInstance(client.session._store, dict)
@@ -392,8 +423,9 @@ class TestResponseCaching(unittest.TestCase):
     ])
     def test_invalid_maxage(self, maxage):
         with self.assertRaises(ValueError):
-            DWaveAPIClient(
-                endpoint='https://mock', cache=dict(maxage=maxage, store={}))
+            DWaveAPIClient(endpoint='https://mock',
+                           cache=dict(maxage=maxage, store={}),
+                           session_class=self.session_class)
 
     @parameterized.expand([
         (0, ),
@@ -402,7 +434,8 @@ class TestResponseCaching(unittest.TestCase):
     ])
     def test_valid_maxage(self, maxage):
         with DWaveAPIClient(endpoint='https://mock',
-                            cache=dict(maxage=maxage, store={})) as client:
+                            cache=dict(maxage=maxage, store={}),
+                            session_class=self.session_class) as client:
             self.assertEqual(client.session._maxage, maxage)
 
     @requests_mock.Mocker()
@@ -433,7 +466,8 @@ class TestResponseCaching(unittest.TestCase):
 
         store = {}
 
-        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store), history_size=1) as client:
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store),
+                            history_size=1, session_class=self.session_class) as client:
 
             with self.subTest("cache miss"):
                 self.assertEqual(len(store), 0)
@@ -511,7 +545,8 @@ class TestResponseCaching(unittest.TestCase):
 
         store = {}
 
-        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store), history_size=1) as client:
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store),
+                            history_size=1, session_class=self.session_class) as client:
 
             with self.subTest("cache miss"):
                 self.assertEqual(len(store), 0)
@@ -560,7 +595,8 @@ class TestResponseCaching(unittest.TestCase):
 
         store = {}
 
-        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store), history_size=1) as client:
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store),
+                            history_size=1, session_class=self.session_class) as client:
 
             with self.subTest("cache populate"):
                 self.assertEqual(len(store), 0)
@@ -620,7 +656,8 @@ class TestResponseCaching(unittest.TestCase):
 
         store = {}
 
-        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store)) as client:
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store),
+                            session_class=self.session_class) as client:
 
             with self.subTest("cache miss"):
                 self.assertEqual(len(store), 0)
@@ -649,7 +686,8 @@ class TestResponseCaching(unittest.TestCase):
 
         store = {}
 
-        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store), history_size=1) as client:
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store),
+                            history_size=1, session_class=self.session_class) as client:
 
             with self.subTest("cache miss"):
                 self.assertEqual(len(store), 0)
@@ -683,7 +721,8 @@ class TestResponseCaching(unittest.TestCase):
         maxage = 10
 
         with DWaveAPIClient(endpoint=endpoint,
-                            cache=dict(store=store, maxage=maxage)) as client:
+                            cache=dict(store=store, maxage=maxage),
+                            session_class=self.session_class) as client:
 
             with self.subTest("cache miss"):
                 self.assertEqual(len(store), 0)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 453c8d1..107c595 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -15,7 +15,7 @@
 import os
 import json
 import unittest
-import subprocess
+import importlib.metadata
 from unittest import mock
 
 from click.testing import CliRunner
@@ -48,11 +48,11 @@ class TestConfigCreate(unittest.TestCase):
         config_file = 'path/to/dwave.conf'
         profile = 'profile'
 
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             result = runner.invoke(cli, [
                 'config', 'create', '--config-file', config_file, '--profile', profile
-            ] + extra_opts, input='\n'.join('' if v is None else v for v in inputs.values()))
+            ] + extra_opts, input=''.join(f"{'' if v is None else v}\n" for v in inputs.values()))
             self.assertEqual(result.exit_code, 0)
 
             # load and verify config
@@ -67,13 +67,13 @@ class TestConfigCreate(unittest.TestCase):
                                   endpoint="endpoint", token="token", client=None, solver=None)),
     ])
     def test_default_flows(self, name, extra_opts, inputs):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             with mock.patch("dwave.cloud.config.loaders.get_configfile_paths", lambda: ['dwave.conf']):
                 with mock.patch("dwave.cloud.utils.cli.input", side_effect=inputs):
                     result = runner.invoke(cli, [
                         'config', 'create'
-                    ] + extra_opts, input='\n'.join('' if v is None else v for v in inputs.values()))
+                    ] + extra_opts, input=''.join(f"{'' if v is None else v}\n" for v in inputs.values()))
                     self.assertEqual(result.exit_code, 0)
 
                 # load and verify config
@@ -87,7 +87,7 @@ class TestConfigCreate(unittest.TestCase):
     @mock.patch('dwave.cloud.cli._get_sapi_token_for_leap_project',
                 return_value=(LeapProject(id=1, name='Project', code='PRJ'), 'auto-token'))
     def test_auto_create(self, mock_fetch_sapi_token):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             local_config_file = './dwave.conf'
             result = runner.invoke(cli, [
@@ -108,7 +108,7 @@ class TestConfigCreate(unittest.TestCase):
         config_file = 'dwave.conf'
         profile = 'profile'
 
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             # create config
             config_body = '\n'.join(f"{k} = old-{v}" for k,v in inputs.items())
@@ -137,7 +137,7 @@ class TestConfigCreate(unittest.TestCase):
 class TestCli(unittest.TestCase):
 
     def test_config_ls(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             touch('dwave.conf')
             with mock.patch('dwave.cloud.config.loaders.homebase.site_config_dir_list',
@@ -222,7 +222,7 @@ class TestCli(unittest.TestCase):
                                          os.path.join('.', 'dwave.conf'))
 
     def test_configure_inspect(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         with runner.isolated_filesystem():
             config_file = 'dwave.conf'
             with open(config_file, 'w') as f:
@@ -271,7 +271,7 @@ class TestCli(unittest.TestCase):
             client = m.from_config.return_value
             client.get_solver.return_value.nodes = [5, 7, 3]
 
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['ping',
@@ -322,7 +322,7 @@ class TestCli(unittest.TestCase):
 
         with mock.patch('dwave.cloud.cli.Client') as m:
 
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['sample',
@@ -371,7 +371,7 @@ class TestCli(unittest.TestCase):
             client = m.from_config.return_value.__enter__.return_value
             client.get_solvers.return_value = solvers
 
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['solvers',
@@ -409,7 +409,7 @@ class TestCli(unittest.TestCase):
 
         with mock.patch('dwave.cloud.cli.Client') as m:
 
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 touch(config_file)
                 touch(filename)
@@ -438,7 +438,7 @@ class TestCli(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
 
     def test_platform(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         result = runner.invoke(cli, ['--platform'])
 
         # verify exit code and stdout printout
@@ -466,7 +466,7 @@ class TestAuthCli(unittest.TestCase):
 
         with self.subTest('dwave auth login'):
             flow.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'login'])
 
@@ -476,7 +476,7 @@ class TestAuthCli(unittest.TestCase):
         with self.subTest('dwave auth login --skip-valid'):
             flow.reset_mock()
             flow.ensure_active_token.return_value = True
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'login', '--skip-valid'])
 
@@ -486,7 +486,7 @@ class TestAuthCli(unittest.TestCase):
 
         with self.subTest('dwave auth login --oob'):
             flow.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'login', '--oob'])
 
@@ -500,7 +500,7 @@ class TestAuthCli(unittest.TestCase):
             config_lines = [f'[{profile}]', f'leap_api_endpoint = {leap_api_endpoint}']
 
             flow_factory.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 # create config
                 with open(config_file, 'w') as fp:
@@ -522,7 +522,7 @@ class TestAuthCli(unittest.TestCase):
 
         with self.subTest('dwave auth get access-token'):
             flow.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'get', 'access-token'])
 
@@ -531,7 +531,7 @@ class TestAuthCli(unittest.TestCase):
 
         with self.subTest('dwave auth get access-token --raw'):
             flow.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'get', 'access-token', '--raw'])
 
@@ -540,7 +540,7 @@ class TestAuthCli(unittest.TestCase):
 
         with self.subTest('dwave auth get refresh-token'):
             flow.reset_mock()
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'get', 'refresh-token'])
 
@@ -553,7 +553,7 @@ class TestAuthCli(unittest.TestCase):
         type(flow).token = mock.PropertyMock(return_value=dict(refresh_token='123'))
 
         with self.subTest('dwave auth refresh'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'refresh'])
 
@@ -564,7 +564,7 @@ class TestAuthCli(unittest.TestCase):
         type(flow).token = mock.PropertyMock(return_value=dict())
 
         with self.subTest('dwave auth refresh (no token)'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'refresh'])
 
@@ -577,7 +577,7 @@ class TestAuthCli(unittest.TestCase):
         type(flow).token = mock.PropertyMock(return_value=token)
 
         with self.subTest('dwave auth revoke (default: access-token)'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'revoke'])
 
@@ -586,7 +586,7 @@ class TestAuthCli(unittest.TestCase):
                 token=token['access_token'], token_type_hint='access_token')
 
         with self.subTest('dwave auth revoke access-token'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'revoke', 'access-token'])
 
@@ -595,7 +595,7 @@ class TestAuthCli(unittest.TestCase):
                 token=token['access_token'], token_type_hint='access_token')
 
         with self.subTest('dwave auth revoke refresh-token'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'revoke', 'refresh-token'])
 
@@ -609,7 +609,7 @@ class TestAuthCli(unittest.TestCase):
         type(flow).token = mock.PropertyMock(return_value={})
 
         with self.subTest('dwave auth revoke: token missing'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'revoke'])
 
@@ -620,7 +620,7 @@ class TestAuthCli(unittest.TestCase):
         type(flow).token = mock.PropertyMock(return_value=dict(access_token='123'))
 
         with self.subTest('dwave auth revoke: server-side failure'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['auth', 'revoke', 'access-token'])
 
@@ -636,7 +636,7 @@ class TestAuthCli(unittest.TestCase):
         flow.token_expires_soon.return_value = False
 
         with self.subTest('dwave leap project ls'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, ['leap', 'project', 'ls'])
 
@@ -663,7 +663,7 @@ class TestAuthCli(unittest.TestCase):
         account.get_project_token.return_value = token
 
         with self.subTest('dwave leap project token'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, [
                     'leap', 'project', 'token', '--project', project])
@@ -673,7 +673,7 @@ class TestAuthCli(unittest.TestCase):
             account.get_project_token.assert_called_with(project=projects[1])
 
         with self.subTest('dwave leap project token --raw'):
-            runner = CliRunner(mix_stderr=False)
+            runner = CliRunner()
             with runner.isolated_filesystem():
                 result = runner.invoke(cli, [
                     'leap', 'project', 'token', '--project', project, '--raw'])
@@ -686,14 +686,14 @@ class TestAuthCli(unittest.TestCase):
 class TestCliLive(unittest.TestCase):
 
     def test_ping(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         result = runner.invoke(cli, ['ping',
                                      '--config-file', test_config_path,
                                      '--profile', test_config_profile])
         self.assertEqual(result.exit_code, 0)
 
     def test_ping_json(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         result = runner.invoke(cli, ['ping',
                                      '--config-file', test_config_path,
                                      '--profile', test_config_profile,
@@ -707,7 +707,7 @@ class TestCliLive(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
 
     def test_ping_json_timeout_error(self):
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         result = runner.invoke(cli, ['ping',
                                      '--config-file', test_config_path,
                                      '--profile', test_config_profile,
@@ -730,7 +730,7 @@ class TestCliLive(unittest.TestCase):
     def test_ping_unstructured_solver(self, problem_type, time_limit):
         solver = json.dumps({"supported_problem_types__contains": problem_type})
         params = json.dumps({"time_limit": time_limit})
-        runner = CliRunner(mix_stderr=False)
+        runner = CliRunner()
         result = runner.invoke(cli, ['ping',
                                      '--config-file', test_config_path,
                                      '--profile', test_config_profile,
@@ -741,6 +741,16 @@ class TestCliLive(unittest.TestCase):
 
 class TestLogging(unittest.TestCase):
 
+    # TODO: simplify the logic by requiring click>8.2 once we drop py39
+    @classmethod
+    def setUpClass(cls):
+        cls.clirunner_kwargs = {}
+        # in click<8.2 we need to use `mix_stderr=False` to split streams
+        # (`mix_stderr` is removed in click 8.2, and split streams are now the default)
+        click_version = tuple(map(int, importlib.metadata.version('click').split('.')))
+        if click_version < (8, 2, 0):
+            cls.clirunner_kwargs.update(mix_stderr=False)
+
     @isolated_environ(remove_dwave=True)
     def test_json_logs(self):
         env = {
@@ -749,7 +759,7 @@ class TestLogging(unittest.TestCase):
             'DWAVE_LOG_FORMAT': 'json',
         }
 
-        runner = CliRunner(env=env, mix_stderr=False)
+        runner = CliRunner(env=env, **self.clirunner_kwargs)
         result = runner.invoke(cli, ['ping'])
 
         # ping will fail because API token is undefined
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index 137b115..3c6197d 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -26,6 +26,7 @@ from urllib.parse import urlencode
 
 import numpy
 import orjson
+import requests_mock
 from parameterized import parameterized
 
 try:
@@ -45,7 +46,7 @@ from dwave.cloud.utils.time import utcrel
 from tests.api.mocks import StructuredSapiMockResponses, choose_reply
 
 
-def fix_status_paths(replies: dict[str, Any], **params):
+def fix_status_paths(replies: dict[str, Any], **params) -> dict[str, Any]:
     """Append URL params to problem status paths."""
 
     def fix(path, **params):
@@ -463,6 +464,88 @@ class MockSubmissionBaseTests(MockSubmissionBase):
                 self._check(results2, linear, quadratic, **params)
 
 
+class TestAPIVersionCheck(MockSubmissionBase, unittest.TestCase):
+
+    def setUp(self):
+        self.endpoint = 'http://test.com/path'
+        self.problem_type = 'application/vnd.dwave.sapi.problem+json'
+        self.problems_type = 'application/vnd.dwave.sapi.problems+json'
+        self.right_version = '2.5.0'
+        self.wrong_version = '1.0.0'
+
+        self.pid = '123'
+        self.sapi = StructuredSapiMockResponses(problem_id=self.pid)
+        self.config = dict(
+            endpoint=self.endpoint,
+            token='token',
+        )
+
+        self.mocker = requests_mock.Mocker()
+        self.mocker.get(requests_mock.ANY, status_code=404)
+        self.mocker.start()
+
+    def tearDown(self):
+        self.mocker.stop()
+
+    def test_submit(self):
+        # problem submit endpoint returns unsupported version
+        self.mocker.post(f"{self.endpoint}/problems/", json=[self.sapi.complete_no_answer_reply(id=self.pid)],
+                         headers={'Content-Type': f'{self.problems_type}; version={self.wrong_version}'})
+
+        with Client(**self.config) as client:
+            solver = Solver(client, self.sapi.solver.data)
+
+            with self.assertRaisesRegex(InvalidAPIResponseError, "Try upgrading"):
+                response = solver.sample_ising(*self.sapi.problem)
+                response.result()
+
+    def test_cancel(self):
+        # problem cancel endpoint returns unsupported version
+        self.mocker.post(f"{self.endpoint}/problems/", json=[self.sapi.continue_reply()],
+                         headers={'Content-Type': f'{self.problems_type}; version={self.right_version}'})
+        self.mocker.get(f"{self.endpoint}/problems/?id={self.pid}", json=[self.sapi.continue_reply()],
+                        headers={'Content-Type': f'{self.problems_type}; version={self.right_version}'})
+        self.mocker.delete(f"{self.endpoint}/problems/", json=[self.sapi.cancel_reply()],
+                           headers={'Content-Type': f'{self.problems_type}; version={self.wrong_version}'})
+
+        with Client(**self.config) as client:
+            solver = Solver(client, self.sapi.solver.data)
+
+            with self.assertRaisesRegex(InvalidAPIResponseError, "Try upgrading"):
+                response = solver.sample_ising(*self.sapi.problem)
+                response.wait_id()      # without id, sapi cancel is not called
+                response.cancel()
+                response.result()
+
+    def test_poll(self):
+        # problem status endpoint returns unsupported version
+        self.mocker.post(f"{self.endpoint}/problems/", json=[self.sapi.continue_reply(id=self.pid)],
+                         headers={'Content-Type': f'{self.problems_type}; version={self.right_version}'})
+        self.mocker.get(f"{self.endpoint}/problems/?id={self.pid}", json=[self.sapi.complete_no_answer_reply(id=self.pid)],
+                        headers={'Content-Type': f'{self.problems_type}; version={self.wrong_version}'})
+
+        with Client(**self.config) as client:
+            solver = Solver(client, self.sapi.solver.data)
+
+            with self.assertRaisesRegex(InvalidAPIResponseError, "Try upgrading"):
+                response = solver.sample_ising(*self.sapi.problem)
+                response.result()
+
+    def test_answer_load(self):
+        # problem answer endpoint returns unsupported version
+        self.mocker.post(f"{self.endpoint}/problems/", json=[self.sapi.complete_no_answer_reply(id=self.pid)],
+                         headers={'Content-Type': f'{self.problems_type}; version={self.right_version}'})
+        self.mocker.get(f"{self.endpoint}/problems/{self.pid}/", json=[self.sapi.complete_reply(id=self.pid)],
+                        headers={'Content-Type': f'{self.problem_type}; version={self.wrong_version}'})
+
+        with Client(**self.config) as client:
+            solver = Solver(client, self.sapi.solver.data)
+
+            with self.assertRaisesRegex(InvalidAPIResponseError, "Try upgrading"):
+                response = solver.sample_ising(*self.sapi.problem)
+                response.result()
+
+
 class TestUploadCompression(MockSubmissionBase, unittest.TestCase):
     """Verify `compress_qpu_problem_data` config directive is respected, i.e.
     QPU problem data is compressed on upload, and Content-Encoding is correctly