Repository: dwavesystems/dwave-system (python)

Issue:
Add close method to DWaveSampler()

Pull request description:
Add `close()` to all QPU and hybrid samplers
Close #77.

Note: ~tests will fail because we need to release `dwave-cloud-client==0.13.3` first.~ We skip tests that require `UseAfterCloseError` from cloud client 0.13.3.

Follow-up: add support for context manager protocol to all samplers.

Patch:
diff --git a/docs/reference/samplers.rst b/docs/reference/samplers.rst
index 80e5add..c0d5d68 100644
--- a/docs/reference/samplers.rst
+++ b/docs/reference/samplers.rst
@@ -58,6 +58,7 @@ Methods
    DWaveSampler.sample_qubo
    DWaveSampler.validate_anneal_schedule
    DWaveSampler.to_networkx_graph
+   DWaveSampler.close
 
 DWaveCliqueSampler
 ==================
@@ -88,6 +89,7 @@ Methods
    DWaveCliqueSampler.sample
    DWaveCliqueSampler.sample_ising
    DWaveCliqueSampler.sample_qubo
+   DWaveCliqueSampler.close
 
 
 LeapHybridSampler
@@ -116,6 +118,7 @@ Methods
    LeapHybridSampler.sample_ising
    LeapHybridSampler.sample_qubo
    LeapHybridSampler.min_time_limit
+   LeapHybridSampler.close
 
 LeapHybridCQMSampler
 ====================
@@ -140,6 +143,7 @@ Methods
 
    LeapHybridCQMSampler.sample_cqm
    LeapHybridCQMSampler.min_time_limit
+   LeapHybridCQMSampler.close
 
 LeapHybridNLSampler
 ====================
@@ -164,6 +168,7 @@ Methods
 
    LeapHybridNLSampler.sample
    LeapHybridNLSampler.estimated_min_time_limit
+   LeapHybridNLSampler.close
 
 LeapHybridDQMSampler
 ====================
@@ -189,3 +194,4 @@ Methods
 
    LeapHybridDQMSampler.sample_dqm
    LeapHybridDQMSampler.min_time_limit
+   LeapHybridDQMSampler.close
diff --git a/dwave/system/samplers/clique.py b/dwave/system/samplers/clique.py
index b39443c..bc38e7d 100644
--- a/dwave/system/samplers/clique.py
+++ b/dwave/system/samplers/clique.py
@@ -176,6 +176,7 @@ class DWaveCliqueSampler(dimod.Sampler):
         >>> sampler.largest_clique_size > 5  # doctest: +SKIP
         True
         >>> sampleset = sampler.sample(bqm, num_reads=100)   # doctest: +SKIP
+        >>> sampler.close()     # doctest: +SKIP
 
     """
     def __init__(self, *,
@@ -184,6 +185,18 @@ class DWaveCliqueSampler(dimod.Sampler):
         self.child = DWaveSampler(
             failover=failover, retry_interval=retry_interval, **config)
 
+    def close(self):
+        """Close the child sampler to release system resources such as threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.system.samplers.dwave_sampler.DWaveSampler.close`.
+        """
+        self.child.close()
+
     @property
     def parameters(self) -> dict:
         try:
diff --git a/dwave/system/samplers/dwave_sampler.py b/dwave/system/samplers/dwave_sampler.py
index bbcf64a..f39b60d 100644
--- a/dwave/system/samplers/dwave_sampler.py
+++ b/dwave/system/samplers/dwave_sampler.py
@@ -157,6 +157,7 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
         ...                                  num_reads=100)
         >>> print(sampleset.first.sample[qubit_a] == 1 and sampleset.first.sample[qubit_b] == -1)
         True
+        >>> sampler.close()
 
     See `Ocean Glossary <https://docs.ocean.dwavesys.com/en/stable/concepts/index.html>`_
     for explanations of technical terms in descriptions of Ocean tools.
@@ -180,6 +181,19 @@ class DWaveSampler(dimod.Sampler, dimod.Structured):
         self.client = Client.from_config(**config)
         self.solver = self._get_solver(penalty=self._solver_penalty)
 
+    def close(self):
+        """Close the underlying cloud client to release system resources such as
+        threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.cloud.client.Client.close`.
+        """
+        self.client.close()
+
     def _get_solver(self, *, refresh: bool = False, penalty: Optional[Dict[str, int]] = None):
         """Get the least penalized solver from the list of solvers filtered and
         ordered according to user config.
diff --git a/dwave/system/samplers/leap_hybrid_sampler.py b/dwave/system/samplers/leap_hybrid_sampler.py
index 96c03eb..bb917cc 100644
--- a/dwave/system/samplers/leap_hybrid_sampler.py
+++ b/dwave/system/samplers/leap_hybrid_sampler.py
@@ -97,7 +97,8 @@ class LeapHybridSampler(dimod.Sampler):
         >>> sampler = LeapHybridSampler(solver=solver)      # doctest: +SKIP
         >>> sampler.solver        # doctest: +SKIP
         BQMSolver(id='hybrid_binary_quadratic_model_version2')
-
+        ...
+        >>> sampler.close()       # doctest: +SKIP
     """
 
     _INTEGER_BQM_SIZE_THRESHOLD = 10000
@@ -138,6 +139,19 @@ class LeapHybridSampler(dimod.Sampler):
         if 'bqm' not in self.solver.supported_problem_types:
             raise ValueError("selected solver does not support the 'bqm' problem type.")
 
+    def close(self):
+        """Close the underlying cloud client to release system resources such as
+        threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.cloud.client.Client.close`.
+        """
+        self.client.close()
+
     @property
     def properties(self) -> Dict[str, Any]:
         """Solver properties as returned by a SAPI query.
@@ -208,10 +222,12 @@ class LeapHybridSampler(dimod.Sampler):
             >>> bqm = dimod.BQM.from_qubo(qubo)
             ...
             >>> # Find a good solution
-            >>> sampler = LeapHybridSampler()    # doctest: +SKIP
-            >>> sampleset = sampler.sample(bqm)           # doctest: +SKIP
-
+            >>> sampler = LeapHybridSampler()       # doctest: +SKIP
+            >>> sampleset = sampler.sample(bqm)     # doctest: +SKIP
+            ...
+            >>> sampler.close()                     # doctest: +SKIP
         """
+
         if not isinstance(bqm, dimod.BQM):
             bqm = dimod.BQM(bqm)
 
@@ -343,6 +359,8 @@ class LeapHybridDQMSampler:
         >>> print("{} beats {}".format(cases[sampleset.first.sample['my_hand']],
         ...                            cases[sampleset.first.sample['their_hand']]))   # doctest: +SKIP
         rock beats scissors
+        ...
+        >>> dqm_sampler.close()                       # doctest: +SKIP
     """
 
     @classproperty
@@ -380,6 +398,19 @@ class LeapHybridDQMSampler:
         if 'dqm' not in self.solver.supported_problem_types:
             raise ValueError("selected solver does not support the 'dqm' problem type.")
 
+    def close(self):
+        """Close the underlying cloud client to release system resources such as
+        threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.cloud.client.Client.close`.
+        """
+        self.client.close()
+
     @property
     def properties(self) -> Dict[str, Any]:
         """Solver properties as returned by a SAPI query.
@@ -594,8 +625,10 @@ class LeapHybridCQMSampler:
         The best (lowest-energy) solution found has :math:`i=j=2` as expected,
         a solution that is feasible because all the constraints (one in this
         example) are satisfied.
-
+        ...
+        >>> sampler.close()                                 # doctest: +SKIP
     """
+
     def __init__(self, **config):
         # strongly prefer hybrid solvers; requires kwarg-level override
         config.setdefault('client', 'hybrid')
@@ -625,6 +658,19 @@ class LeapHybridCQMSampler:
         if 'cqm' not in self.solver.supported_problem_types:
             raise ValueError("selected solver does not support the 'cqm' problem type.")
 
+    def close(self):
+        """Close the underlying cloud client to release system resources such as
+        threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.cloud.client.Client.close`.
+        """
+        self.client.close()
+
     @classproperty
     def default_solver(cls) -> Dict[str, str]:
         """Features used to select the latest accessible hybrid CQM solver."""
@@ -827,6 +873,8 @@ class LeapHybridNLSampler:
         ... f"objective value {model.objective.state(0)} for order " \    # doctest: +SKIP
         ... f"{job_order.state(0)}.")     # doctest: +SKIP
         State 0 of 8 has an objective value 50.0 for order [1. 2. 0.].
+        ...
+        >>> sampler.close()       # doctest: +SKIP
     """
 
     def __init__(self, **config):
@@ -860,6 +908,20 @@ class LeapHybridNLSampler:
 
         self._executor = concurrent.futures.ThreadPoolExecutor()
 
+    def close(self):
+        """Close the underlying cloud client to release system resources such as
+        threads.
+
+        .. note::
+
+            The method blocks for all the currently scheduled work (sampling
+            requests) to finish.
+
+        See: :meth:`~dwave.cloud.client.Client.close`.
+        """
+        self.client.close()
+        self._executor.shutdown()
+
     @classproperty
     def default_solver(cls) -> Dict[str, str]:
         """Features used to select the latest accessible hybrid nonlinear-model solver."""


Test patch:
diff --git a/dwave/system/testing.py b/dwave/system/testing.py
index 810fa3a..801ae4b 100644
--- a/dwave/system/testing.py
+++ b/dwave/system/testing.py
@@ -353,6 +353,9 @@ class MockDWaveSampler(dimod.Sampler, dimod.Structured):
             # topology-dependent arguments:
             self.properties.update(properties)
 
+    def close(self):
+        pass
+
     @classmethod
     def from_qpu_sampler(cls, sampler):
         return cls(properties=sampler.properties,
diff --git a/tests/qpu/test_dwavecliquesampler.py b/tests/qpu/test_dwavecliquesampler.py
index 9392a9a..7df3e8a 100644
--- a/tests/qpu/test_dwavecliquesampler.py
+++ b/tests/qpu/test_dwavecliquesampler.py
@@ -18,7 +18,7 @@ import unittest
 
 import dimod
 
-from dwave.cloud.exceptions import ConfigFileError, SolverNotFoundError
+import dwave.cloud.exceptions
 from dwave.system import DWaveCliqueSampler
 
 from parameterized import parameterized
@@ -32,10 +32,18 @@ def get_sampler(topology):
     try:
         _SAMPLERS[topology] = DWaveCliqueSampler(solver=dict(topology__type=topology.lower()))
         return _SAMPLERS[topology]
-    except (ValueError, ConfigFileError, SolverNotFoundError):
+    except (ValueError,
+            dwave.cloud.exceptions.ConfigFileError,
+            dwave.cloud.exceptions.SolverNotFoundError):
         raise unittest.SkipTest(f"no {topology}-structured QPU available")
 
 
+def tearDownModule():
+    # make sure all cached samplers are closed and resources released at exit
+    for sampler in _SAMPLERS.values():
+        sampler.close()
+
+
 @unittest.skipIf(os.getenv('SKIP_INT_TESTS'), "Skipping integration test.")
 class TestDWaveCliqueSampler(unittest.TestCase):
     @parameterized.expand([['Pegasus'], ['Zephyr']])
@@ -60,3 +68,11 @@ class TestDWaveCliqueSampler(unittest.TestCase):
 
         # if the range was not adjusted, this would raise an error.
         sampler.sample(bqm, chain_strength=chain_strength).resolve()
+
+    @unittest.skipUnless(hasattr(dwave.cloud.exceptions, 'UseAfterClose'), 'dwave-cloud-client>=0.13.3 required')
+    def test_close(self):
+        sampler = DWaveCliqueSampler()
+        sampler.close()
+
+        with self.assertRaises(dwave.cloud.exceptions.UseAfterCloseError):
+            sampler.sample_qubo({(0, 1): 1})
diff --git a/tests/qpu/test_dwavesampler.py b/tests/qpu/test_dwavesampler.py
index 31f0e15..b15dbd9 100644
--- a/tests/qpu/test_dwavesampler.py
+++ b/tests/qpu/test_dwavesampler.py
@@ -12,6 +12,7 @@
 #    See the License for the specific language governing permissions and
 #    limitations under the License.
 
+import threading
 import unittest
 import os
 from unittest import mock
@@ -39,6 +40,28 @@ class TestDWaveSampler(unittest.TestCase):
     def tearDownClass(cls):
         cls.qpu.client.close()
 
+    def test_close(self):
+        n_initial = threading.active_count()
+        sampler = DWaveSampler()
+        n_active = threading.active_count()
+        sampler.close()
+        n_closed = threading.active_count()
+
+        with self.subTest('verify all client threads shutdown'):
+            self.assertGreater(n_active, n_initial)
+            self.assertEqual(n_closed, n_initial)
+
+        try:
+            # requires `dwave-cloud-client>=0.13.3`
+            from dwave.cloud.exceptions import UseAfterCloseError
+        except:
+            pass
+        else:
+            with self.subTest('verify use after close disallowed'):
+                with self.assertRaises(UseAfterCloseError):
+                    h, J = self.nonzero_ising_problem(sampler)
+                    sampler.sample_ising(h, J)
+
     @staticmethod
     def nonzero_ising_problem(sampler):
         max_h = max(sampler.properties.get('h_range', [-1, 1]))
diff --git a/tests/qpu/test_leaphybrid_common.py b/tests/qpu/test_leaphybrid_common.py
index 93a9adc..b85c5ee 100644
--- a/tests/qpu/test_leaphybrid_common.py
+++ b/tests/qpu/test_leaphybrid_common.py
@@ -15,6 +15,7 @@
 
 import os
 import json
+import threading
 import unittest
 
 from parameterized import parameterized_class
@@ -22,6 +23,7 @@ from parameterized import parameterized_class
 import dimod
 from dwave.cloud.exceptions import ConfigFileError, SolverNotFoundError
 from dwave.cloud.testing import isolated_environ
+
 from dwave.system import LeapHybridSampler, LeapHybridDQMSampler, LeapHybridCQMSampler
 
 
@@ -43,7 +45,7 @@ class TestLegacySolverSelection(unittest.TestCase):
 
         self.assertIn(self.problem_type, sampler.solver.supported_problem_types)
 
-        sampler.client.close()
+        sampler.close()
 
     def test_new_positive(self):
         # given valid solver spec in env, init succeeds
@@ -57,7 +59,7 @@ class TestLegacySolverSelection(unittest.TestCase):
 
             self.assertIn(self.problem_type, sampler.solver.supported_problem_types)
 
-        sampler.client.close()
+        sampler.close()
 
     def test_new_negative(self):
         # given invalid solver spec in env, init fails
@@ -83,7 +85,7 @@ class TestLegacySolverSelection(unittest.TestCase):
 
             self.assertIn(self.problem_type, sampler.solver.supported_problem_types)
 
-        sampler.client.close()
+        sampler.close()
 
 
 @parameterized_class(
@@ -93,17 +95,23 @@ class TestLegacySolverSelection(unittest.TestCase):
         (LeapHybridCQMSampler, "sample_cqm", lambda self: dimod.CQM.from_bqm(dimod.BQM.from_qubo({'ab': 1}))),
     ])
 @unittest.skipIf(os.getenv('SKIP_INT_TESTS'), "Skipping integration test.")
-class TestSamplesetInterface(unittest.TestCase):
+class TestSamplerInterface(unittest.TestCase):
 
-    def test_wait_id_availability(self):
-        # verify https://github.com/dwavesystems/dwave-system/issues/540 is fixed
+    @classmethod
+    def setUpClass(cls):
         try:
-            sampler = self.sampler_cls()
-        except (ValueError, ConfigFileError):
-            raise unittest.SkipTest(f"{self.sampler_cls} not available")
+            cls.sampler = cls.sampler_cls()
+        except (ValueError, ConfigFileError, SolverNotFoundError):
+            raise unittest.SkipTest(f"{cls.sampler_cls} not available")
 
+    @classmethod
+    def tearDownClass(cls):
+        cls.sampler.close()
+
+    def test_sampleset_wait_id_availability(self):
+        # verify https://github.com/dwavesystems/dwave-system/issues/540 is fixed
         problem = self.problem_gen()
-        ss = getattr(sampler, self.sample_meth)(problem)
+        ss = getattr(self.sampler, self.sample_meth)(problem)
 
         with self.subTest("sampleset.wait_id() exists"):
             pid = ss.wait_id()
@@ -113,3 +121,25 @@ class TestSamplesetInterface(unittest.TestCase):
             ss.resolve()
             pid_post = ss.wait_id()
             self.assertEqual(pid, pid_post)
+
+    def test_close(self):
+        n_initial = threading.active_count()
+        sampler = self.sampler_cls()
+        n_active = threading.active_count()
+        sampler.close()
+        n_closed = threading.active_count()
+
+        with self.subTest('verify all client threads shutdown'):
+            self.assertGreater(n_active, n_initial)
+            self.assertEqual(n_closed, n_initial)
+
+        try:
+            # requires `dwave-cloud-client>=0.13.3`
+            from dwave.cloud.exceptions import UseAfterCloseError
+        except:
+            pass
+        else:
+            with self.subTest('verify use after close disallowed'):
+                with self.assertRaises(UseAfterCloseError):
+                    problem = self.problem_gen()
+                    getattr(sampler, self.sample_meth)(problem)
diff --git a/tests/qpu/test_leaphybridcqmsampler.py b/tests/qpu/test_leaphybridcqmsampler.py
index 2de1f30..b37e8d3 100644
--- a/tests/qpu/test_leaphybridcqmsampler.py
+++ b/tests/qpu/test_leaphybridcqmsampler.py
@@ -16,20 +16,25 @@ import os
 import unittest
 
 import dimod
-
 from dwave.cloud.exceptions import ConfigFileError, SolverNotFoundError
-from dwave.system import LeapHybridCQMSampler
 
-try:
-    sampler = LeapHybridCQMSampler()
-except (ValueError, ConfigFileError, SolverNotFoundError):
-    sampler = None
+from dwave.system import LeapHybridCQMSampler
 
 
 @unittest.skipIf(os.getenv('SKIP_INT_TESTS'), "Skipping integration test.")
-@unittest.skipIf(sampler is None, "no hybrid sampler available")
 class TestLeapHybridSampler(unittest.TestCase):
 
+    @classmethod
+    def setUpClass(cls):
+        try:
+            cls.sampler = LeapHybridCQMSampler()
+        except (ValueError, ConfigFileError, SolverNotFoundError):
+            raise unittest.SkipTest("hybrid sampler not available")
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.sampler.close()
+
     def assertConsistent(self, cqm, sampleset):
         self.assertEqual(cqm.variables, sampleset.variables)
 
@@ -55,15 +60,15 @@ class TestLeapHybridSampler(unittest.TestCase):
                 cqm = dimod.ConstrainedQuadraticModel()
                 cqm.set_objective(model)
                 cqm.add_constraint(model, rhs=0, sense='==')
-                sampleset = sampler.sample_cqm(cqm)
+                sampleset = self.sampler.sample_cqm(cqm)
                 self.assertConsistent(cqm, sampleset)
 
             with self.subTest(f"no constraints, vartype={vartype}"):
                 cqm = dimod.ConstrainedQuadraticModel()
                 cqm.set_objective(model)
-                sampleset = sampler.sample_cqm(cqm)
+                sampleset = self.sampler.sample_cqm(cqm)
                 self.assertConsistent(cqm, sampleset)
 
     def test_other_quadratic_model(self):
         with self.assertRaises(TypeError):
-            sampler.sample_cqm(dimod.BQM('SPIN'))
+            self.sampler.sample_cqm(dimod.BQM('SPIN'))
diff --git a/tests/qpu/test_leaphybriddqmsampler.py b/tests/qpu/test_leaphybriddqmsampler.py
index f0b2815..920f5af 100644
--- a/tests/qpu/test_leaphybriddqmsampler.py
+++ b/tests/qpu/test_leaphybriddqmsampler.py
@@ -18,19 +18,25 @@ import unittest
 
 import dimod
 import numpy as np
-
 from dwave.cloud.exceptions import ConfigFileError, SolverNotFoundError
-from dwave.system import LeapHybridDQMSampler
 
-try:
-    sampler = LeapHybridDQMSampler()
-except (ValueError, ConfigFileError, SolverNotFoundError):
-    sampler = None
+from dwave.system import LeapHybridDQMSampler
 
 
 @unittest.skipIf(os.getenv('SKIP_INT_TESTS'), "Skipping integration test.")
-@unittest.skipIf(sampler is None, "no hybrid sampler available")
 class TestLeapHybridSampler(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        try:
+            cls.sampler = LeapHybridDQMSampler()
+        except (ValueError, ConfigFileError, SolverNotFoundError):
+            raise unittest.SkipTest("hybrid sampler not available")
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.sampler.close()
+
     def test_smoke(self):
         dqm = dimod.DQM()
         u = dqm.add_variable(2, 'a')
@@ -44,7 +50,7 @@ class TestLeapHybridSampler(unittest.TestCase):
         except AttributeError:
             pass
 
-        sampleset = sampler.sample_dqm(dqm)
+        sampleset = self.sampler.sample_dqm(dqm)
 
         np.testing.assert_array_almost_equal(dqm.energies(sampleset),
                                              sampleset.record.energy)
@@ -59,7 +65,7 @@ class TestLeapHybridSampler(unittest.TestCase):
         u = dqm.add_variable({'red', 'green', 'blue'}, shared_labels=True)
         dqm.offset = 5
 
-        sampleset = sampler.sample_dqm(dqm)
+        sampleset = self.sampler.sample_dqm(dqm)
 
         np.testing.assert_array_almost_equal(dqm.energies(sampleset),
                                              sampleset.record.energy)
@@ -72,7 +78,7 @@ class TestLeapHybridSampler(unittest.TestCase):
         dqm.set_quadratic(u, v, {(0, 1): 1, (0, 2): 1})
         label = 'problem label'
 
-        sampleset = sampler.sample_dqm(dqm, label=label)
+        sampleset = self.sampler.sample_dqm(dqm, label=label)
         self.assertIn('problem_id', sampleset.info)
         self.assertIn('problem_label', sampleset.info)
         self.assertEqual(sampleset.info['problem_label'], label)
diff --git a/tests/qpu/test_leaphybridsampler.py b/tests/qpu/test_leaphybridsampler.py
index 3bd1289..d7be158 100644
--- a/tests/qpu/test_leaphybridsampler.py
+++ b/tests/qpu/test_leaphybridsampler.py
@@ -17,22 +17,28 @@ import os
 import unittest
 
 import dimod
-
 from dwave.cloud.exceptions import ConfigFileError, SolverNotFoundError
-from dwave.system import LeapHybridSampler
 
-try:
-    sampler = LeapHybridSampler()
-except (ValueError, ConfigFileError, SolverNotFoundError):
-    sampler = None
+from dwave.system import LeapHybridSampler
 
 
 @unittest.skipIf(os.getenv('SKIP_INT_TESTS'), "Skipping integration test.")
-@unittest.skipIf(sampler is None, "no hybrid sampler available")
 # @dimod.testing.load_sampler_bqm_tests(sampler)  # these take a while
 class TestLeapHybridSampler(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        try:
+            cls.sampler = LeapHybridSampler()
+        except (ValueError, ConfigFileError, SolverNotFoundError):
+            raise unittest.SkipTest("hybrid sampler not available")
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.sampler.close()
+
     def test_smoke(self):
-        sampleset = sampler.sample_ising({'a': -1}, {'ab': 1})
+        sampleset = self.sampler.sample_ising({'a': -1}, {'ab': 1})
         sampleset.resolve()
 
     def test_problem_labelling(self):
@@ -40,12 +46,12 @@ class TestLeapHybridSampler(unittest.TestCase):
         label = 'problem label'
 
         # label set
-        sampleset = sampler.sample(bqm, label=label)
+        sampleset = self.sampler.sample(bqm, label=label)
         self.assertIn('problem_id', sampleset.info)
         self.assertIn('problem_label', sampleset.info)
         self.assertEqual(sampleset.info['problem_label'], label)
 
         # label unset
-        sampleset = sampler.sample(bqm)
+        sampleset = self.sampler.sample(bqm)
         self.assertIn('problem_id', sampleset.info)
         self.assertNotIn('problem_label', sampleset.info)
diff --git a/tests/test_dwavecliquesampler.py b/tests/test_dwavecliquesampler.py
index 4d40fa7..bfaaea8 100644
--- a/tests/test_dwavecliquesampler.py
+++ b/tests/test_dwavecliquesampler.py
@@ -214,3 +214,10 @@ class TestFailover(unittest.TestCase):
         self.assertIsNot(G, sampler.target_graph)
         self.assertIsNot(qlr, sampler.qpu_linear_range)
         self.assertIsNot(qqr, sampler.qpu_quadratic_range)
+
+    @unittest.mock.patch('dwave.system.samplers.clique.DWaveSampler')
+    def test_close(self, mock_child_sampler):
+        sampler = DWaveCliqueSampler()
+        sampler.close()
+
+        mock_child_sampler.return_value.close.assert_called_once()
diff --git a/tests/test_dwavesampler.py b/tests/test_dwavesampler.py
index 8547e67..f10ac7f 100644
--- a/tests/test_dwavesampler.py
+++ b/tests/test_dwavesampler.py
@@ -135,6 +135,15 @@ class TestDWaveSampler(unittest.TestCase):
             solver=solver,
             defaults={'solver': {'order_by': '-num_active_qubits'}})
 
+    @mock.patch('dwave.system.samplers.dwave_sampler.Client')
+    def test_close(self, MockClient):
+        """DWaveSampler.close() will close the underlying client."""
+
+        sampler = DWaveSampler()
+        sampler.close()
+
+        MockClient.from_config.return_value.close.assert_called_once()
+
     def test_sample_ising_variables(self):
 
         sampler = self.sampler
diff --git a/tests/test_leaphybridcqmsampler.py b/tests/test_leaphybridcqmsampler.py
index 9f4e647..36d1260 100644
--- a/tests/test_leaphybridcqmsampler.py
+++ b/tests/test_leaphybridcqmsampler.py
@@ -71,3 +71,14 @@ class TestTimeLimit(unittest.TestCase):
             new = dimod.ConstrainedQuadraticModel().from_file(f)
 
         self.assertEqual(sampler.sample_cqm(new), sampler.sample_cqm(cqm))
+
+    @unittest.mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
+    def test_close(self, mock_client):
+        mock_solver = mock_client.from_config.return_value.get_solver.return_value
+        mock_solver.properties = {'category': 'hybrid'}
+        mock_solver.supported_problem_types = ['cqm']
+
+        sampler = LeapHybridCQMSampler()
+        sampler.close()
+
+        mock_client.from_config.return_value.close.assert_called_once()
diff --git a/tests/test_leaphybriddqmsolver.py b/tests/test_leaphybriddqmsolver.py
index e1cd87e..cd4d7ff 100644
--- a/tests/test_leaphybriddqmsolver.py
+++ b/tests/test_leaphybriddqmsolver.py
@@ -60,3 +60,14 @@ class TestLeapHybridDQMSampler(unittest.TestCase):
 
         with self.assertRaises(ValueError):
             sampler.sample_dqm(dqm, time_limit=10000000)
+
+    @unittest.mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
+    def test_close(self, mock_client):
+        mock_solver = mock_client.from_config.return_value.get_solver.return_value
+        mock_solver.properties = {'category': 'hybrid'}
+        mock_solver.supported_problem_types = ['dqm']
+
+        sampler = LeapHybridDQMSampler()
+        sampler.close()
+
+        mock_client.from_config.return_value.close.assert_called_once()
diff --git a/tests/test_leaphybridnlsampler.py b/tests/test_leaphybridnlsampler.py
index 0ae5784..1600120 100644
--- a/tests/test_leaphybridnlsampler.py
+++ b/tests/test_leaphybridnlsampler.py
@@ -123,3 +123,14 @@ class TestNLSampler(unittest.TestCase):
             with self.assertWarns(UserWarning, msg=msg):
                 result = sampler.sample(model, time_limit=time_limit)
                 result.result()
+
+    @unittest.mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
+    def test_close(self, mock_client):
+        mock_solver = mock_client.from_config.return_value.get_solver.return_value
+        mock_solver.properties = {'category': 'hybrid'}
+        mock_solver.supported_problem_types = ['nl']
+
+        sampler = LeapHybridNLSampler()
+        sampler.close()
+
+        mock_client.from_config.return_value.close.assert_called_once()
diff --git a/tests/test_leaphybridsolver.py b/tests/test_leaphybridsolver.py
index fd2c23b..1fa44dc 100644
--- a/tests/test_leaphybridsolver.py
+++ b/tests/test_leaphybridsolver.py
@@ -109,6 +109,17 @@ class TestLeapHybridSampler(unittest.TestCase):
             connection_close=False,
             defaults=defaults)
 
+    @mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
+    def test_close(self, mock_client):
+        mock_solver = mock_client.from_config.return_value.get_solver.return_value
+        mock_solver.properties = {'category': 'hybrid'}
+        mock_solver.supported_problem_types = ['bqm']
+
+        sampler = LeapHybridSampler()
+        sampler.close()
+
+        mock_client.from_config.return_value.close.assert_called_once()
+
     @mock.patch('dwave.system.samplers.leap_hybrid_sampler.Client')
     def test_sample_bqm(self, mock_client):