Repository: dwavesystems/dwave-cloud-client

Issue:
Cache solver metadata
Once SAPI adds support for conditional requests ([rfc7232](https://datatracker.ietf.org/doc/html/rfc7232)), we can use it to avoid downloading of rather big solver metadata on each `Solver` init.

For this, we require:
- support for `ETag` and `If-None-Match`
- support on `/solvers/remote` and `/solvers/remote/<solver-id>` endpoints

Discussion:

Patch:
diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py
index fa8eb51..b91d290 100644
--- a/benchmarks/benchmarks.py
+++ b/benchmarks/benchmarks.py
@@ -15,10 +15,15 @@
 import json
 import subprocess
 
+from copy import deepcopy
 from functools import partial
 from pathlib import Path
+from typing import List
 
-from dwave.cloud import Client
+from pydantic import TypeAdapter
+
+from dwave.cloud.client import Client
+from dwave.cloud.api.models import SolverConfiguration
 from dwave.cloud.coders import encode_problem_as_qp
 from dwave.cloud.config import ClientConfig
 from dwave.cloud.solver import StructuredSolver, BQMSolver, CQMSolver, DQMSolver, NLSolver
@@ -166,16 +171,31 @@ class SolverMetadataJSONDecode:
 
 
 class SolverSelection:
-    version = "1"
+    version = "2"
 
     def setup(self):
         self.client = Client(token='mock')
         solvers_data = json.loads((Path(__file__).parent / 'fixtures/solvers.json').read_bytes())
-        self._original_sapi_request = Client._sapi_request
-        Client._sapi_request = lambda session, url: solvers_data
 
-    def teardown(self):
-        Client._sapi_request = self._original_sapi_request
+        static_data = deepcopy(solvers_data)
+        for solver in static_data:
+            del solver['avg_load']
+
+        dynamic_data = [{"id": s['id'], "avg_load": s['avg_load']} for s in solvers_data]
+
+        static_conf = TypeAdapter(List[SolverConfiguration]).validate_python(static_data)
+        dynamic_conf = TypeAdapter(List[SolverConfiguration]).validate_python(dynamic_data)
+
+        class mock_session:
+            def list_solvers(self, filter=None, **kwargs):
+                if filter == 'none,+id,+avg_load':
+                    return dynamic_conf
+                elif filter == 'all,-avg_load':
+                    return static_conf
+                else:
+                    raise ValueError
+
+        self.client._solvers_session = mock_session()
 
     def time_get_solvers(self):
         self.client.get_solvers()
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index d0d58d7..3b19f3b 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -14,9 +14,12 @@
 
 from __future__ import annotations
 
+import io
+import hashlib
 import logging
+import os
 from collections import deque, namedtuple
-from typing import Optional, Union, TYPE_CHECKING
+from typing import Callable, Mapping, Optional, TypedDict, Union, TYPE_CHECKING
 
 import requests
 import urllib3
@@ -27,6 +30,7 @@ import dwave.cloud.config   # don't use `from` to break circular import (config
 from dwave.cloud.api import constants, 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.time import epochnow
 
 if TYPE_CHECKING:
     from dwave.cloud.config.models import ClientConfig
@@ -173,7 +177,7 @@ class VersionedAPISession(LoggingSession):
         """Validate response's `Content-Type` matches the expected media type
         and version range.
         """
-        if self._media_type is None or not response.ok:
+        if self._media_type is None or not 200 <= response.status_code < 300:
             return
 
         content_type = response.headers.get('Content-Type')
@@ -228,6 +232,169 @@ class VersionedAPISession(LoggingSession):
         return response
 
 
+def _create_default_cache_store(**kwargs) -> Mapping:
+    # TODO: de-dup vs `dwave.cloud.utils.decorators.cached`?
+
+    # defer to break circular imports
+    from dwave.cloud.config import get_cache_dir
+
+    directory = kwargs.pop('directory', os.path.join(get_cache_dir(), 'api'))
+
+    # defer import and construction until needed
+    import diskcache
+    return diskcache.Cache(directory=directory)
+
+
+class CachingSession(VersionedAPISession):
+    """A `requests.Session` subclass (technically, further specialized
+    :class:`.VersionedAPISession`) that caches responses and uses conditional
+    requests for smart cache updates.
+
+    Args:
+        cache (dict):
+            Cache configuration in a dict with keys:
+
+                * ``enabled`` (bool):
+                    Enable request caching.
+
+                * ``maxage`` (float):
+                    Default response maxage, in case server response
+                    doesn't specify ``Cache-Control``.
+
+                * ``store`` (mapping, callable):
+                    Define cache storage (with a Mapping interface). Default to
+                    ``diskcache.Cache`` with pickle serialization.
+
+    :meth:`CachingSession.request` accepts the following keyword arguments,
+    in addition to the base class method:
+
+        * ``maxage_`` (float):
+            Suggested maxage for heuristic caching, overriding the default value
+            from cache config.
+
+        * ``no_cache_`` (bool):
+            Skip cache.
+
+        * ``refresh_`` (bool):
+            Force cache update, skipping time-based or etag-based validation.
+    """
+
+    _cache_enabled = False
+    _maxage = None
+    _store = None
+
+    class CacheConfig(TypedDict, total=False):
+        enabled: bool
+        maxage: float
+        store: Union[Mapping, Callable[[], Mapping]]
+
+    # default cache config
+    _default_cache_config = CacheConfig(enabled=True,
+                                        maxage=0,
+                                        store=_create_default_cache_store)
+
+    def __init__(self, cache: Union[CacheConfig, bool, None] = None, **kwargs):
+        if cache is None:
+            cache = self._default_cache_config
+
+        if not isinstance(cache, dict):
+            enabled = bool(cache)
+            cache = self._default_cache_config
+            cache.update(enabled=enabled)
+
+        self.configure_cache(cache)
+
+        super().__init__(**kwargs)
+
+    def configure_cache(self, cache: CacheConfig) -> None:
+        config = {opt: cache.get(opt, default)
+                  for opt, default in self._default_cache_config.items()}
+
+        self._cache_enabled = enabled = config['enabled']
+        if not enabled:
+            logger.debug("[%s] cache disabled.", type(self).__name__)
+            return
+
+        self._maxage = config['maxage']
+        self._store = config['store']
+        if callable(self._store):
+            self._store = self._store()
+
+        logger.debug("[%s] configured cache: (enabled=%r, maxage=%r, store=%r)",
+                     type(self).__name__, self._cache_enabled, self._maxage, self._store)
+
+        if hasattr(self._store, 'directory'):
+            logger.debug("cache.store.directory=%r", self._store.directory)
+
+    def request(self, method, url, *,
+                params: Optional[dict] = None,
+                headers: Optional[dict] = None,
+                **kwargs
+                ) -> requests.Response:
+
+        # read CachingSession-specific params
+        refresh = kwargs.pop('refresh_', False)
+        no_cache = kwargs.pop('no_cache_', False)
+        suggested_maxage = kwargs.pop('maxage_', self._maxage)
+
+        if not self._cache_enabled or no_cache or method.lower() != 'get':
+            # completely bypass cache lookup and conditional request
+            return super().request(method, url, params=params, headers=headers, **kwargs)
+
+        key = (method,
+               self.base_url, url,
+               self.params, params,
+               self.headers, headers)
+
+        # note: metadata split from data for faster metadata-only updates
+        key_data = hashlib.sha256(repr(key).encode('utf8')).hexdigest()
+        key_meta = f"{key_data}:meta"
+
+        def make_response(content):
+            res = requests.Response()
+            res.status_code = 200
+            res.raw = io.BytesIO(content)
+            return res
+
+        if not refresh and (meta := self._store.get(key_meta)):
+            maxage = meta.get('maxage', suggested_maxage) or 0
+
+            content = self._store.get(key_data)
+
+            if epochnow() - meta['created'] < maxage:
+                logger.debug('cache hit within maxage')
+                return make_response(content)
+
+            # conditional request
+            headers = {} if headers is None else headers.copy()
+            headers['If-None-Match'] = meta['etag']
+
+            res = super().request(method, url, params=params, headers=headers, **kwargs)
+
+            if res.status_code == requests.codes['not_modified']:
+                logger.debug('resource "not modified", using cached value')
+                meta['created'] = epochnow()
+                self._store[key_meta] = meta
+                return make_response(content)
+
+            else:
+                logger.debug('resource modified, updating cache')
+                meta = dict(created=epochnow(),
+                            etag=res.headers.get('etag'))
+                self._store[key_meta] = meta
+                self._store[key_data] = res.content
+                return res
+
+        else:
+            logger.debug('cache miss, updating cache')
+            res = super().request(method, url, params=params, headers=headers, **kwargs)
+            meta = dict(created=epochnow(),
+                        etag=res.headers.get('etag'))
+            self._store[key_meta] = meta
+            self._store[key_data] = res.content
+            return res
+
+
 class DWaveAPIClient:
     """Low-level client for D-Wave APIs. A thin wrapper around
     `requests.Session` that handles API specifics such as authentication,
@@ -271,10 +438,13 @@ class DWaveAPIClient:
         'history_size': 0,
 
         # response version strict mode validation, see :class:`VersionedAPISession`
-        'version_strict_mode': True
+        'version_strict_mode': True,
+
+        # enable conditional requests and response caching, see :class:`CachingSession`
+        'cache': dict(enabled=False),       # type: CachingSession.CacheConfig
     }
 
-    # client instance config, populated on init from kwargs overridding DEFAULTS
+    # client instance config, populated on init from kwargs overriding DEFAULTS
     config = None
 
     def __init__(self, **config):
@@ -394,10 +564,12 @@ class DWaveAPIClient:
             endpoint += '/'
 
         # configure request timeout and retries
-        session = VersionedAPISession(
+        session = CachingSession(
             base_url=endpoint,
             history_size=config['history_size'],
-            strict_mode=config['version_strict_mode'])
+            strict_mode=config['version_strict_mode'],
+            cache=config['cache'],
+        )
         timeout = config['timeout']
         retry = config['retry']
         session.mount('http://', PretimedHTTPAdapter(
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index 4cc8bfb..229aad4 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -27,6 +27,32 @@ from dwave.cloud.utils.coders import coerce_numpy_to_python
 AnyIncludingNumpy = Annotated[Any, AfterValidator(coerce_numpy_to_python)]
 
 
+class _RootGetterMixin:
+    """Proxy getter calls to root model."""
+
+    def __getattr__(self, item):
+        return getattr(self.root, item)
+
+    def __getitem__(self, name):
+        return getattr(self.root, name)
+
+    def get(self, key, default=None):
+        try:
+            return self[key]
+        except AttributeError:
+            return default
+
+
+class _RootSetterMixin:
+    """Proxy setter calls to root model."""
+
+    def __setattr__(self, name, value):
+        return setattr(self.root, name, value)
+
+    def __setitem__(self, name, value):
+        return setattr(self.root, name, value)
+
+
 class SolverCompleteConfiguration(BaseModel):
     id: str
     status: str
@@ -40,12 +66,12 @@ class SolverFilteredConfiguration(BaseModel):
     model_config = ConfigDict(extra='allow')
 
 
-class SolverConfiguration(RootModel):
+# NOTE: we implement getitem interface so that `SolverConfiguration` can be
+# used as a drop-in replacement for data dict in `Solver(..., data=...)`
+# TODO: break `Solver()` backwards compat and require pydantic model
+class SolverConfiguration(_RootGetterMixin, _RootSetterMixin, RootModel):
     root: Union[SolverCompleteConfiguration, SolverFilteredConfiguration]
 
-    def __getattr__(self, item):
-        return getattr(self.root, item)
-
 
 class ProblemInitialStatus(BaseModel):
     id: str
@@ -83,14 +109,11 @@ class UnstructuredProblemAnswerBinaryRef(BaseModel):
     shape: dict
 
 
-class ProblemAnswer(RootModel):
+class ProblemAnswer(_RootGetterMixin, RootModel):
     root: Union[StructuredProblemAnswer,
                 UnstructuredProblemAnswer,
                 UnstructuredProblemAnswerBinaryRef]
 
-    def __getattr__(self, item):
-        return getattr(self.root, item)
-
 
 class ProblemStatusWithAnswer(ProblemStatus):
     answer: ProblemAnswer
@@ -112,12 +135,9 @@ class UnstructuredProblemData(BaseModel):
     data: str
 
 
-class ProblemData(RootModel):
+class ProblemData(_RootGetterMixin, RootModel):
     root: Union[StructuredProblemData, UnstructuredProblemData]
 
-    def __getattr__(self, item):
-        return getattr(self.root, item)
-
 
 class ProblemMetadata(BaseModel):
     solver: str
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index 1c30406..d18c5cd 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -68,7 +68,7 @@ class ResourceBase:
     resource_path: Optional[str] = None
 
     def __init__(self, client: Optional[DWaveAPIClient] = None, **config):
-        if isinstance(client, DWaveAPIClient):
+        if client is not None:
             self.client = client
         else:
             self.client = self.client_class(**config)
@@ -126,18 +126,18 @@ class Solvers(ResourceBase):
     client_class = SolverAPIClient
 
     @accepts(media_type='application/vnd.dwave.sapi.solver-definition-list+json', version='~=2.0')
-    def list_solvers(self, filter: Optional[str] = None) -> List[models.SolverConfiguration]:
+    def list_solvers(self, filter: Optional[str] = None, **kwargs) -> List[models.SolverConfiguration]:
         path = 'remote/'
         params = {'filter': filter} if filter is not None else None
-        response = self.session.get(path, params=params)
+        response = self.session.get(path, params=params, **kwargs)
         solvers = response.json()
         return TypeAdapter(List[models.SolverConfiguration]).validate_python(solvers)
 
     @accepts(media_type='application/vnd.dwave.sapi.solver-definition+json', version='~=2.0')
-    def get_solver(self, solver_id: str, filter: Optional[str] = None) -> models.SolverConfiguration:
+    def get_solver(self, solver_id: str, filter: Optional[str] = None, **kwargs) -> models.SolverConfiguration:
         path = 'remote/{}'.format(solver_id)
         params = {'filter': filter} if filter is not None else None
-        response = self.session.get(path, params=params)
+        response = self.session.get(path, params=params, **kwargs)
         solver = response.json()
         return models.SolverConfiguration.model_validate(solver)
 
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index abf29c8..4bc4ef6 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -54,7 +54,7 @@ from itertools import chain, zip_longest
 from functools import partial, wraps
 from collections import namedtuple
 from concurrent.futures import ThreadPoolExecutor
-from typing import Dict, Optional
+from typing import Dict, List, Optional, Union
 
 import requests
 import urllib3
@@ -67,7 +67,7 @@ from dwave.cloud.exceptions import *    # TODO: fix
 from dwave.cloud.computation import Future
 from dwave.cloud.config import load_config, update_config, validate_config_v1
 from dwave.cloud.config.models import ClientConfig
-from dwave.cloud.solver import Solver, available_solvers
+from dwave.cloud.solver import available_solvers, StructuredSolver, UnstructuredSolver
 from dwave.cloud.concurrency import PriorityThreadPoolExecutor
 from dwave.cloud.regions import get_regions, resolve_endpoints
 from dwave.cloud.upload import ChunkedData
@@ -340,8 +340,11 @@ class Client(object):
     # Poll grouping time frame; two scheduled polls are grouped if closer than [sec]:
     _POLL_GROUP_TIMEFRAME = 2
 
-    # Downloaded solver definition cache maxage [sec]
-    _SOLVERS_CACHE_MAXAGE = 300     # 5 min
+    # Downloaded solver definition cache config
+    _DEFAULT_SOLVERS_CACHE_CONFIG = dict(
+        enabled=True,
+        maxage=900,     # 15 min heuristic maxage (TODO: API cache-control to override it)
+    )
 
     # Downloaded region metadata cache maxage [sec]
     _REGIONS_CACHE_MAXAGE = 7 * 86400   # 7 days
@@ -681,37 +684,71 @@ class Client(object):
 
         return {r.code: {"name": r.name, "endpoint": r.endpoint} for r in rs}
 
-    @cached(maxage=_SOLVERS_CACHE_MAXAGE)
-    def _fetch_solvers(self, name=None):
+    @property
+    def solvers_session(self) -> api.resources.Solvers:
+        session = getattr(self, '_solvers_session', None)
+
+        # init on first use
+        if session is None:
+            session = self._solvers_session = \
+                api.Solvers.from_config(
+                    config=self.config,
+                    cache=self._DEFAULT_SOLVERS_CACHE_CONFIG
+                )
+
+        return session
+
+    def _fetch_solvers(self,
+                       name: Optional[str] = None,
+                       refresh_: Optional[bool] = False,
+                       ) -> List[Union[StructuredSolver, UnstructuredSolver]]:
+
+        static_fields = 'all,-avg_load'
+        dynamic_fields = 'none,+id,+avg_load'
+
         if name is not None:
             logger.info("Fetching definition of a solver with name=%r", name)
-            url = 'solvers/remote/{}/'.format(name)
+
+            try:
+                solver = self.solvers_session.get_solver(
+                    solver_id=name, filter=static_fields, refresh_=refresh_)
+                load = self.solvers_session.get_solver(
+                    solver_id=name, filter=dynamic_fields, no_cache_=True)
+
+                # note: avg_load may not exist in solver data
+                if 'avg_load' in load:
+                    solver.avg_load = load.avg_load
+                solvers = [solver]
+
+            except api.exceptions.ResourceNotFoundError as exc:
+                raise SolverNotFoundError(f"No solver with name={name!r} available") from exc
+
         else:
             logger.info("Fetching definitions of all available solvers")
-            url = 'solvers/remote/'
 
-        try:
-            data = Client._sapi_request(self.session.get, url)
-        except SAPIError as exc:
-            if name is not None and exc.error_code == 404:
-                raise SolverNotFoundError("No solver with name={!r} available".format(name))
-            else:
-                raise
+            solvers = self.solvers_session.list_solvers(
+                filter=static_fields, refresh_=refresh_)
+            loads = {solver.id: solver.get('avg_load')
+                     for solver
+                     in self.solvers_session.list_solvers(
+                         filter=dynamic_fields, no_cache_=True)}
 
-        if name is not None:
-            data = [data]
+            for solver in solvers:
+                if avg_load := loads.get(solver.id) is not None:
+                    solver.avg_load = avg_load
 
-        logger.info("Received solver data for %d solver(s).", len(data))
-        logger.trace("Solver data received for solver name=%r: %r", name, data)
+        logger.info("Received solver data for %d solver(s).", len(solvers))
+        if logger.isEnabledFor(logging.TRACE):
+            logger.trace("Solver data received for solver name=%r: %r", name, solvers)
 
-        solvers = []
-        for solver_desc in data:
+        instantiated_solvers = []
+        for solver_desc in solvers:
             for solver_class in available_solvers:
                 try:
                     logger.debug("Trying to instantiate %r", solver_class.__name__)
-                    solver = solver_class(self, solver_desc)
+                    solver = solver_class(client=self, data=solver_desc)
                     if self.is_solver_handled(solver):
-                        solvers.append(solver)
+                        instantiated_solvers.append(solver)
                         logger.info("Adding solver %r", solver)
                         break
                     else:
@@ -722,7 +759,7 @@ class Client(object):
 
             # propagate all other/decoding errors, like InvalidAPIResponseError, etc.
 
-        return solvers
+        return instantiated_solvers
 
     def retrieve_answer(self, id_):
         """Retrieve a problem by id.
@@ -1054,7 +1091,17 @@ class Client(object):
             query['name'] = filters['name__eq']
 
         # filter
-        solvers = self._fetch_solvers(**query)
+        try:
+            solvers = self._fetch_solvers(**query)
+
+        # wrap exceptions for backwards-compatibility
+        except api.exceptions.ResourceAuthenticationError as e:
+            raise SolverAuthenticationError from e
+        except api.exceptions.ResourceBadResponseError as e:
+            raise InvalidAPIResponseError(e) from e
+        except requests.exceptions.JSONDecodeError as e:
+            raise InvalidAPIResponseError("JSON response expected") from e
+
         solvers = [s for s in solvers if all(p(s) for p in predicates)]
 
         # sort: undefined (None) key values go last
diff --git a/releasenotes/notes/api-caching-session-38332ad145ee5200.yaml b/releasenotes/notes/api-caching-session-38332ad145ee5200.yaml
new file mode 100644
index 0000000..4b6e9b0
--- /dev/null
+++ b/releasenotes/notes/api-caching-session-38332ad145ee5200.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Add API response caching support to ``dwave.cloud.api`` interface. Cache
+    is validated using a conditional request based on ``ETag``.
diff --git a/releasenotes/notes/solver-metadata-dynamic-caching-fe6333d315e0185c.yaml b/releasenotes/notes/solver-metadata-dynamic-caching-fe6333d315e0185c.yaml
new file mode 100644
index 0000000..c482776
--- /dev/null
+++ b/releasenotes/notes/solver-metadata-dynamic-caching-fe6333d315e0185c.yaml
@@ -0,0 +1,10 @@
+---
+features:
+  - |
+    Cache solver metadata on disk, using conditional requests for cache validation
+    (update).
+
+    Static and dynamic parts of solver metadata are fetched and stored separately,
+    to enable efficient caching.
+
+    See `#613 <https://github.com/dwavesystems/dwave-cloud-client/issues/613>`_.


Test patch:
diff --git a/tests/api/resources/test_solvers.py b/tests/api/resources/test_solvers.py
index 5c14365..08ca172 100644
--- a/tests/api/resources/test_solvers.py
+++ b/tests/api/resources/test_solvers.py
@@ -13,16 +13,20 @@
 # limitations under the License.
 
 import io
+import contextlib
+import tempfile
 import uuid
 import unittest
+from functools import partial
 from urllib.parse import urljoin, urlparse, parse_qsl
 
 import json
 import requests
 import requests_mock
 
-from dwave.cloud.api.resources import Solvers
 from dwave.cloud.api import exceptions, models
+from dwave.cloud.api.client import CachingSession
+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
 
@@ -142,6 +146,20 @@ class FilteringTestsMixin:
             self.assertNotIn('couplers', solver.properties)
             self.assertIn('qubits', solver.properties)
 
+    def test_solver_model_is_drop_in_for_dict(self):
+        solver = models.SolverConfiguration(id='id')
+
+        # getters
+        self.assertEqual(solver['id'], solver.id)
+        self.assertEqual(solver.get('id'), solver.id)
+        self.assertEqual(solver.get('nonexisting', 'x'), 'x')
+
+        # setters
+        solver['nonexisting'] = 'x'
+        self.assertEqual(solver.nonexisting, 'x')
+        solver.another = 'y'
+        self.assertEqual(solver['another'], 'y')
+
 
 class TestFiltering(FilteringTestsMixin, unittest.TestCase):
 
@@ -236,3 +254,39 @@ class TestLiveSolvers(FilteringTestsMixin, unittest.TestCase):
 
         with self.assertRaises(exceptions.ResourceNotFoundError):
             self.api.get_solver('non-existing-solver')
+
+
+@unittest.skipUnless(config, "SAPI access not configured.")
+class TestLiveSolversCaching(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tmpdir = tempfile.TemporaryDirectory()
+        cls.store = partial(CachingSession._default_cache_config['store'],
+                            directory=cls.tmpdir.name)
+
+    @classmethod
+    def tearDownClass(cls):
+        with contextlib.suppress(OSError):
+            cls.tmpdir.cleanup()
+
+    def test_cache_1_validation(self):
+        with Solvers.from_config(validate_config_v1(config),
+                                 cache=dict(store=self.store),
+                                 history_size=1,
+                                 ) as resource:
+            solvers = resource.list_solvers(filter='none,+id')
+            self.assertGreater(len(solvers), 0)
+
+            solvers = resource.list_solvers(filter='none,+id')
+            self.assertEqual(resource.session.history[-1].response.status_code, 304)
+
+    def test_cache_2_hit(self):
+        with Solvers.from_config(validate_config_v1(config),
+                                 cache=dict(maxage=3600, store=self.store),
+                                 history_size=1,
+                                 ) as resource:
+            solvers = resource.list_solvers(filter='none,+id')
+            self.assertGreater(len(solvers), 0)
+            print(resource.session.history)
+            self.assertEqual(len(resource.session.history), 0)
diff --git a/tests/api/test_api_clients.py b/tests/api/test_api_clients.py
index acaf8e3..ed670ad 100644
--- a/tests/api/test_api_clients.py
+++ b/tests/api/test_api_clients.py
@@ -16,6 +16,7 @@ import json
 import uuid
 import unittest
 
+import diskcache
 import requests
 import requests_mock
 
@@ -326,3 +327,109 @@ class TestResponseParsing(unittest.TestCase):
 
                 self.assertEqual(exc.error_msg, error_msg)
                 self.assertEqual(exc.error_code, error_code)
+
+
+class TestResponseCaching(unittest.TestCase):
+
+    def test_config(self):
+        with self.subTest("cache disabled by default"):
+            with DWaveAPIClient(endpoint='https://mock') as client:
+                self.assertFalse(client.session._cache_enabled)
+
+        with self.subTest("cache disabled with config"):
+            with DWaveAPIClient(endpoint='https://mock', cache=False) as client:
+                self.assertFalse(client.session._cache_enabled)
+
+        with self.subTest("cache enabled with config"):
+            with DWaveAPIClient(endpoint='https://mock', cache=True) 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:
+                self.assertTrue(client.session._cache_enabled)
+                self.assertEqual(client.session._maxage, 5)
+                self.assertIsInstance(client.session._store, dict)
+
+    @requests_mock.Mocker()
+    def test_caching(self, m):
+        endpoint = 'https://mock'
+
+        path_a = 'path-a'
+        etag_a = 'etag-a'
+        data_a = {"a": 1}
+
+        path_b = 'path-b'
+        etag_b = 'etag-b'
+        data_b = {"b": 1}
+        etag_bb = 'etag-bb'
+        data_bb = {"bb": 1}
+
+        # mock: resource not modified
+        m.get(f"{endpoint}/{path_a}", json=data_a, headers={'ETag': etag_a})
+        m.get(f"{endpoint}/{path_a}", request_headers={'If-None-Match': etag_a}, status_code=304)
+
+        # mock: resource modified between requests
+        m.get(f"{endpoint}/{path_b}", json=data_b, headers={'ETag': etag_b})
+        m.get(f"{endpoint}/{path_b}", request_headers={'If-None-Match': etag_b}, json=data_bb, headers={'ETag': etag_bb})
+
+        store = {}
+
+        with DWaveAPIClient(endpoint=endpoint, cache=dict(store=store), history_size=1) as client:
+
+            with self.subTest("cache miss"):
+                self.assertEqual(len(store), 0)
+
+                r = client.session.get(path_a)
+                self.assertEqual(r.json(), data_a)
+
+                self.assertTrue(m.called)
+                self.assertEqual(len(store), 2)     # data + meta
+
+            with self.subTest("cache hit"):
+                m.reset_mock()
+
+                r = client.session.get(path_a, maxage_=10)
+                self.assertEqual(r.json(), data_a)
+
+                # if mock not called, data came from cache
+                self.assertFalse(m.called)
+
+            with self.subTest("cache validate, not modified"):
+                m.reset_mock()
+
+                r = client.session.get(path_a)
+                self.assertEqual(r.json(), data_a)
+
+                self.assertTrue(m.called)
+                self.assertEqual(client.session.history[-1].response.status_code, 304)
+
+            with self.subTest("cache validate, modified"):
+                m.reset_mock()
+
+                r = client.session.get(path_b)
+                self.assertEqual(r.json(), data_b)
+
+                r = client.session.get(path_b)
+                self.assertEqual(r.json(), data_bb)
+
+                self.assertEqual(m.call_count, 2)
+                self.assertEqual(client.session.history[-1].response.status_code, 200)
+
+            with self.subTest("cache skipped"):
+                m.reset_mock()
+
+                r = client.session.get(path_a, no_cache_=True)
+                self.assertEqual(r.json(), data_a)
+
+                self.assertTrue(m.called)
+
+            with self.subTest("cache refreshed"):
+                m.reset_mock()
+
+                r = client.session.get(path_a, maxage_=10, refresh_=True)
+                self.assertEqual(r.json(), data_a)
+
+                self.assertTrue(m.called)
diff --git a/tests/test_client.py b/tests/test_client.py
index a920962..94c2533 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -667,7 +667,8 @@ class ClientConfigIntegration(unittest.TestCase):
                     self.assertEqual(kwargs.get('timeout'), request_timeout)
                     response = requests.Response()
                     response.status_code = 200
-                    response._content = b'{}'
+                    response._content = b'[]'
+                    response.headers = {'Content-Type': 'application/vnd.dwave.sapi.solver-definition-list+json; version=2.0'}
                     return response
 
                 with mock.patch("requests.adapters.HTTPAdapter.send", mock_send):
diff --git a/tests/test_mock_solver_loading.py b/tests/test_mock_solver_loading.py
index 8753f32..56fcbb8 100644
--- a/tests/test_mock_solver_loading.py
+++ b/tests/test_mock_solver_loading.py
@@ -14,8 +14,8 @@
 
 """Try to load solver data from mock servers."""
 
-import json
 import unittest
+import unittest.mock
 
 import requests_mock
 
@@ -27,16 +27,7 @@ from dwave.cloud.solver import Solver
 from dwave.cloud.exceptions import *
 
 
-url = 'https://dwavesys.com'
-token = 'abc123abc123abc123abc123abc123abc123'
-solver1_name = 'first_solver'
-solver2_name = 'second_solver'
-
-bad_url = 'https://not-a-subdomain.dwavesys.com'
-bad_token = '------------------------'
-
-
-def structured_solver_data(id_, cat='qpu', incomplete=False):
+def solver_data(id_, cat='qpu', incomplete=False, subset='all'):
     """Return data dict describing a single solver."""
     obj = {
         "properties": {
@@ -49,229 +40,228 @@ def structured_solver_data(id_, cat='qpu', incomplete=False):
         },
         "id": id_,
         "description": "A test solver",
-        "status": "ONLINE"
+        "status": "ONLINE",
+        "avg_load": 0.1
     }
 
     if incomplete:
         del obj['properties']['supported_problem_types']
 
-    return obj
-
-def solver_object(id_, cat='qpu', incomplete=False):
-    return Solver(client=None, data=structured_solver_data(id_, cat, incomplete))
+    if subset == 'static':
+        del obj['avg_load']
 
+    if subset == 'dynamic':
+        obj = {
+            "id": id_,
+            "avg_load": obj["avg_load"]
+        }
 
-# Define the endpoints
-solver1_url = '{}/solvers/remote/{}/'.format(url, solver1_name)
-solver2_url = '{}/solvers/remote/{}/'.format(url, solver2_name)
-all_solver_url = '{}/solvers/remote/'.format(url)
-
-
-def setup_server(m):
-    """Add endpoints to the server."""
-
-    solver1_data = structured_solver_data(solver1_name)
-    solver2_data = structured_solver_data(solver2_name)
-    all_solver_data = [solver1_data, solver2_data]
-
-    # Setup the server
-    valid_token_headers = {'X-Auth-Token': token}
-    invalid_token_headers = {'X-Auth-Token': bad_token}
+    return obj
 
-    m.get(requests_mock.ANY, status_code=404)
-    m.get(requests_mock.ANY, status_code=401, request_headers=invalid_token_headers)
 
-    m.get(solver1_url, json=solver1_data, request_headers=valid_token_headers)
-    m.get(solver2_url, json=solver2_data, request_headers=valid_token_headers)
-    m.get(all_solver_url, json=all_solver_data, request_headers=valid_token_headers)
+def solver_object(id_, **kwargs):
+    return Solver(client=None, data=solver_data(id_, **kwargs))
 
 
-class MockConnectivityTests(unittest.TestCase):
-    """Test connecting some related failure modes."""
+class MockSolverLoading(unittest.TestCase):
 
-    def test_bad_url(self):
-        """Connect with a bad URL."""
-        with requests_mock.Mocker() as m:
-            setup_server(m)
-            with self.assertRaises(SAPIError) as err:
-                with Client(endpoint=bad_url, token=token) as client:
-                    client.get_solvers()
-            # TODO: fix when exceptions/sapi call generalized
-            self.assertEqual(err.exception.error_code, 404)
+    endpoint = 'https://dwavesys.com'
+    token = 'abc123abc123abc123abc123abc123abc123'
+
+    solver1_name = 'first_solver'
+    solver2_name = 'second_solver'
+    solver3_incomplete_name = 'incomplete_properties'
+    solver4_truncated_name = 'invalid_data_format'
+
+    bad_endpoint = 'https://not-a-subdomain.dwavesys.com'
+    bad_token = '------------------------'
+
+    def setUp(self):
+        m = self.mocker = requests_mock.Mocker()
+
+        base = self.endpoint
+
+        solver1_url = f'{base}/solvers/remote/{self.solver1_name}'
+        solver2_url = f'{base}/solvers/remote/{self.solver2_name}'
+        solver3_incomplete_url = f'{base}/solvers/remote/{self.solver3_incomplete_name}'
+        solver4_truncated_url = f'{base}/solvers/remote/{self.solver4_truncated_name}'
+        all_solvers_url = f'{base}/solvers/remote/'
+
+        valid_token_headers = {'X-Auth-Token': self.token}
+        invalid_token_headers = {'X-Auth-Token': self.bad_token}
+
+        solver_content_type = {
+            'Content-Type': 'application/vnd.dwave.sapi.solver-definition+json; version=2.0'}
+        solvers_content_type = {
+            'Content-Type': 'application/vnd.dwave.sapi.solver-definition-list+json; version=2.0'}
+
+        m.get(requests_mock.ANY, status_code=404)
+        m.get(requests_mock.ANY, status_code=401, request_headers=invalid_token_headers)
+
+        def add_mock_solver(m, url, name, incomplete=False):
+            m.get(url, json=solver_data(name, incomplete=incomplete),
+                  request_headers=valid_token_headers, headers=solver_content_type)
+            m.get(f"{url}?filter=all,-avg_load",
+                  json=solver_data(name, subset='static', incomplete=incomplete),
+                  request_headers=valid_token_headers, headers=solver_content_type)
+            m.get(f"{url}?filter=none,+id,+avg_load",
+                  json=solver_data(name, subset='dynamic', incomplete=incomplete),
+                  request_headers=valid_token_headers, headers=solver_content_type)
+
+        add_mock_solver(m, solver1_url, self.solver1_name)
+        add_mock_solver(m, solver2_url, self.solver2_name)
+        add_mock_solver(m, solver3_incomplete_url, self.solver3_incomplete_name, incomplete=True)
+
+        m.get(solver4_truncated_url,
+              text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
+        m.get(f"{solver4_truncated_url}?filter=all,-avg_load",
+              text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
+        m.get(f"{solver4_truncated_url}?filter=none,+id,+avg_load",
+              text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
+
+        m.get(all_solvers_url,
+              json=[solver_data(self.solver1_name), solver_data(self.solver2_name)],
+              request_headers=valid_token_headers, headers=solvers_content_type)
+        m.get(f"{all_solvers_url}?filter=all,-avg_load",
+              json=[solver_data(self.solver1_name, subset='static'),
+                    solver_data(self.solver2_name, subset='static')],
+              request_headers=valid_token_headers, headers=solvers_content_type)
+        m.get(f"{all_solvers_url}?filter=none,+id,+avg_load",
+              json=[solver_data(self.solver1_name, subset='dynamic'),
+                    solver_data(self.solver2_name, subset='static')],
+              request_headers=valid_token_headers, headers=solvers_content_type)
+
+        m.start()
+
+    def tearDown(self):
+        self.mocker.stop()
+
+    def test_bad_endpoint(self):
+        with self.assertRaises(SAPIError):
+            with Client(endpoint=self.bad_endpoint, token=self.token) as client:
+                client.get_solvers()
 
     def test_bad_token(self):
-        """Connect with a bad token."""
-        with requests_mock.Mocker() as m:
-            setup_server(m)
-            with self.assertRaises(SolverAuthenticationError) as err:
-                with Client(endpoint=url, token=bad_token) as client:
-                    client.get_solvers()
+        with self.assertRaises(SolverAuthenticationError) as err:
+            with Client(endpoint=self.endpoint, token=self.bad_token) as client:
+                client.get_solvers()
 
     def test_good_connection(self):
-        """Connect with a valid URL and token."""
-        with requests_mock.mock() as m:
-            setup_server(m)
-            with Client(endpoint=url, token=token) as client:
-                self.assertTrue(len(client.get_solvers()) > 0)
+        with Client(endpoint=self.endpoint, token=self.token) as client:
+            self.assertTrue(len(client.get_solvers()) > 0)
 
+    def test_load_solver(self):
+        with Client(endpoint=self.endpoint, token=self.token) as client:
+            solver = client.get_solver(self.solver1_name)
+            self.assertEqual(solver.id, self.solver1_name)
 
-class MockSolverLoading(unittest.TestCase):
-    """Test loading solvers in a few different configurations.
+    def test_load_all_solvers(self):
+        with unittest.mock.patch.multiple(
+                Client, _DEFAULT_SOLVERS_CACHE_CONFIG=dict(maxage=60, store={})):
 
-    Note:
-        A mock server does not test authentication.
+            with Client(endpoint=self.endpoint, token=self.token) as client:
 
-    Expect three responses from the server for /solvers/*:
-        - A single solver when a single solver is requested
-        - A list of single solver objects when all solvers are requested
-        - A 404 error when the requested solver does not exist
-    An additional condition to test for is that the server may have configured
-    a given solver such that it does not provide all the required information
-    about it.
-    """
+                with self.subTest("initial solver fetch"):
+                    self.mocker.reset_mock()
 
-    def test_load_solver(self):
-        """Load a single solver."""
-        with requests_mock.mock() as m:
-            setup_server(m)
-
-            # test default, cached solver get
-            with Client(endpoint=url, token=token) as client:
-                # fetch solver not present in cache
-                solver = client.get_solver(solver1_name)
-                self.assertEqual(solver.id, solver1_name)
-
-                # modify cached solver and re-fetch it
-                solver.id = 'different-solver'
-                # cached solver name doesn't match, so it won't be returned
-                with self.assertRaises(SolverError):
-                    client.get_solver(solver1_name, refresh=False)
-                # cache is refreshed?
-                self.assertEqual(client.get_solver(solver1_name, refresh=True).id, solver1_name)
+                    solvers = client.get_solvers()
 
-    def test_load_all_solvers(self):
-        """Load the list of solver names."""
-
-        def spoof_cache(client, clear_val=False, clear_created=False):
-            store = client._fetch_solvers.cached.store
-            for args in store:
-                if clear_val:
-                    store[args]['val'] = []
-                if clear_created:
-                    store[args]['created'] = 0
+                    self.assertEqual(len(solvers), 2)
+                    self.assertEqual(self.mocker.call_count, 2)
 
-        with requests_mock.mock() as m:
-            setup_server(m)
+                with self.subTest("cache hit for static data"):
+                    self.mocker.reset_mock()
 
-            # test default case, fetch all solvers for the first time
-            with Client(endpoint=url, token=token) as client:
-                solvers = client.get_solvers()
+                    solvers = client.get_solvers()
 
-                self.assertEqual(len(solvers), 2)
+                    self.assertEqual(len(solvers), 2)
+                    self.assertEqual(self.mocker.call_count, 1)
 
-                # test default refresh
-                spoof_cache(client, clear_created=True)
-                self.assertEqual(len(client.get_solvers()), 2)      # should refresh
+                with self.subTest("cache refresh forced"):
+                    self.mocker.reset_mock()
 
-                # test no refresh
-                spoof_cache(client, clear_val=True)
-                self.assertEqual(len(client.get_solvers(refresh=False)), 0)     # should not refresh
+                    solvers = client.get_solvers(refresh=True)
 
-                # test refresh
-                self.assertEqual(len(client.get_solvers(refresh=True)), 2)      # should refresh
+                    self.assertEqual(len(solvers), 2)
+                    self.assertEqual(self.mocker.call_count, 2)
 
     def test_load_missing_solver(self):
-        """Try to load a solver that does not exist."""
-        with requests_mock.mock() as m:
-            m.get(requests_mock.ANY, status_code=404)
-            with Client(endpoint=url, token=token) as client:
-                with self.assertRaises(SolverNotFoundError):
-                    client.get_solver(solver1_name)
+        with Client(endpoint=self.endpoint, token=self.token) as client:
+            with self.assertRaises(SolverNotFoundError):
+                client.get_solver("non-existing")
 
     def test_load_solver_missing_data(self):
-        """Try to load a solver that has incomplete data."""
-        with requests_mock.mock() as m:
-            m.get(solver1_url, json=structured_solver_data(solver1_name, incomplete=True))
-            with Client(endpoint=url, token=token) as client:
-                with self.assertRaises(SolverNotFoundError):
-                    client.get_solver(solver1_name)
+        with Client(endpoint=self.endpoint, token=self.token) as client:
+            with self.assertRaises(SolverNotFoundError):
+                client.get_solver(self.solver3_incomplete_name)
 
     def test_load_solver_broken_response(self):
-        """Try to load a solver for which the server has returned a truncated response."""
-        with requests_mock.mock() as m:
-            body = json.dumps(structured_solver_data(solver1_name))
-            m.get(solver1_url, text=body[0:len(body)//2])
-            with Client(endpoint=url, token=token) as client:
-                with self.assertRaises(InvalidAPIResponseError):
-                    client.get_solver(solver1_name)
+        with Client(endpoint=self.endpoint, token=self.token) as client:
+            with self.assertRaises(InvalidAPIResponseError):
+                client.get_solver(self.solver4_truncated_name)
 
     def test_get_solver_reproducible(self):
-        """get_solver should return same solver (assuming cache hasn't changed)"""
-
-        with requests_mock.mock() as m:
-            setup_server(m)
-
-            # prefer solvers with longer name: that's our second solver
-            defaults = dict(solver=dict(order_by=lambda s: -len(s.id)))
+        # prefer solvers with longer name: that's our second solver
+        defaults = dict(solver=dict(order_by=lambda s: -len(s.id)))
 
-            with Client(endpoint=url, token=token, defaults=defaults) as client:
-                solver = client.get_solver()
-                self.assertEqual(solver.id, solver2_name)
+        with Client(endpoint=self.endpoint, token=self.token, defaults=defaults) as client:
+            solver = client.get_solver()
+            self.assertEqual(solver.id, self.solver2_name)
 
-                solver = client.get_solver()
-                self.assertEqual(solver.id, solver2_name)
+            solver = client.get_solver(refresh=True)
+            self.assertEqual(solver.id, self.solver2_name)
 
     def test_solver_filtering_in_client(self):
         # base client
-        self.assertTrue(Client.is_solver_handled(solver_object('test', 'qpu')))
-        self.assertTrue(Client.is_solver_handled(solver_object('test', 'software')))
-        self.assertTrue(Client.is_solver_handled(solver_object('test', 'hybrid')))
-        self.assertTrue(Client.is_solver_handled(solver_object('test', 'whatever')))
+        self.assertTrue(Client.is_solver_handled(solver_object('test', cat='qpu')))
+        self.assertTrue(Client.is_solver_handled(solver_object('test', cat='software')))
+        self.assertTrue(Client.is_solver_handled(solver_object('test', cat='hybrid')))
+        self.assertTrue(Client.is_solver_handled(solver_object('test', cat='whatever')))
         self.assertTrue(Client.is_solver_handled(None))
         # qpu client
-        self.assertTrue(QPUClient.is_solver_handled(solver_object('test', 'qpu')))
-        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', 'software')))
-        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', 'hybrid')))
-        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', 'whatever')))
+        self.assertTrue(QPUClient.is_solver_handled(solver_object('test', cat='qpu')))
+        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', cat='software')))
+        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', cat='hybrid')))
+        self.assertFalse(QPUClient.is_solver_handled(solver_object('test', cat='whatever')))
         self.assertFalse(QPUClient.is_solver_handled(None))
         # sw client
-        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', 'qpu')))
-        self.assertTrue(SoftwareClient.is_solver_handled(solver_object('test', 'software')))
-        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', 'hybrid')))
-        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', 'whatever')))
+        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', cat='qpu')))
+        self.assertTrue(SoftwareClient.is_solver_handled(solver_object('test', cat='software')))
+        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', cat='hybrid')))
+        self.assertFalse(SoftwareClient.is_solver_handled(solver_object('test', cat='whatever')))
         self.assertFalse(SoftwareClient.is_solver_handled(None))
         # hybrid client
-        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', 'qpu')))
-        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', 'software')))
-        self.assertTrue(HybridClient.is_solver_handled(solver_object('test', 'hybrid')))
-        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', 'whatever')))
+        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', cat='qpu')))
+        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', cat='software')))
+        self.assertTrue(HybridClient.is_solver_handled(solver_object('test', cat='hybrid')))
+        self.assertFalse(HybridClient.is_solver_handled(solver_object('test', cat='whatever')))
         self.assertFalse(HybridClient.is_solver_handled(None))
 
     def test_solver_feature_properties(self):
-        self.assertTrue(solver_object('solver', 'qpu').qpu)
-        self.assertTrue(solver_object('solver', 'QPU').qpu)
-        self.assertFalse(solver_object('solver', 'qpu').software)
-        self.assertFalse(solver_object('solver', 'qpu').hybrid)
-        self.assertFalse(solver_object('solver', 'software').qpu)
-        self.assertTrue(solver_object('solver', 'software').software)
-        self.assertFalse(solver_object('solver', 'software').hybrid)
-        self.assertTrue(solver_object('solver', 'hybrid').hybrid)
-        self.assertFalse(solver_object('solver', 'hybrid').qpu)
-        self.assertFalse(solver_object('solver', 'hybrid').software)
+        self.assertTrue(solver_object('solver', cat='qpu').qpu)
+        self.assertTrue(solver_object('solver', cat='QPU').qpu)
+        self.assertFalse(solver_object('solver', cat='qpu').software)
+        self.assertFalse(solver_object('solver', cat='qpu').hybrid)
+        self.assertFalse(solver_object('solver', cat='software').qpu)
+        self.assertTrue(solver_object('solver', cat='software').software)
+        self.assertFalse(solver_object('solver', cat='software').hybrid)
+        self.assertTrue(solver_object('solver', cat='hybrid').hybrid)
+        self.assertFalse(solver_object('solver', cat='hybrid').qpu)
+        self.assertFalse(solver_object('solver', cat='hybrid').software)
 
         self.assertFalse(solver_object('solver').is_vfyc)
         self.assertEqual(solver_object('solver').num_qubits, 3)
         self.assertFalse(solver_object('solver').has_flux_biases)
 
         # test .num_qubits vs .num_actual_qubits
-        data = structured_solver_data('test')
+        data = solver_data('test')
         data['properties']['num_qubits'] = 7
         solver = Solver(None, data)
         self.assertEqual(solver.num_qubits, 7)
         self.assertEqual(solver.num_active_qubits, 3)
 
         # test .is_vfyc
-        data = structured_solver_data('test')
+        data = solver_data('test')
         data['properties']['vfyc'] = 'error'
         self.assertFalse(Solver(None, data).is_vfyc)
         data['properties']['vfyc'] = True
@@ -289,7 +279,7 @@ class MockSolverLoading(unittest.TestCase):
 
         # test `.online` property
         self.assertTrue(solver_object('solver').online)
-        data = structured_solver_data('test')
+        data = solver_data('test')
         data['status'] = 'offline'
         self.assertFalse(Solver(None, data).online)
         del data['status']