Repository: dwavesystems/dwave-cloud-client

Issue:
Handle errors in batch mode
On problem submit and cancel, we are reading responses from a list of JSON objects. In case any of these problems failed, the structure will be specific (as documented in the [REST API docs](https://cloud.dwavesys.com/qubist/docs/dev_guide_sapi/sapi_resources/)):
```
{
    "error_code": 400,
    "error_msg": "Missing parameter 'num_reads' in problem JSON"
}
```

Current problem response handler does not handle this case -- it always assumes this structure (in case problem failed):
```
{
    "status": "FAILED",
    "id": "f004cea4-0f18-4b44-8aca-4e3acbb6831b",
    "error_message": "Some error message"
    ...
}
```

Comments:

Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index 2661473..3e5941e 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -881,9 +881,20 @@ class Client(object):
             This method is always run inside of a daemon thread.
         """
         try:
-            status = message['status']
-            _LOGGER.debug("Handling response for %s with status %s", message['id'], status)
             _LOGGER.trace("Handling response: %r", message)
+            _LOGGER.debug("Handling response for %s with status %s", message.get('id'), message.get('status'))
+
+            # Handle errors in batch mode
+            if 'error_code' in message and 'error_msg' in message:
+                raise SolverFailureError(message['error_msg'])
+
+            if 'status' not in message:
+                raise InvalidAPIResponseError("'status' missing in problem description response")
+            if 'id' not in message:
+                raise InvalidAPIResponseError("'id' missing in problem description response")
+
+            future.id = message['id']
+            future.remote_status = status = message['status']
 
             # The future may not have the ID set yet
             with future._single_cancel_lock:
@@ -897,10 +908,6 @@ class Client(object):
                     # If a cancel request could meaningfully be sent it has been now
                     future._cancel_sent = True
 
-            # Set the id field in the future
-            future.id = message['id']
-            future.remote_status = status
-
             if not future.time_received and message.get('submitted_on'):
                 future.time_received = parse_datetime(message['submitted_on'])
 
@@ -931,15 +938,14 @@ class Client(object):
                 self._poll(future)
             elif status == self.STATUS_CANCELLED:
                 # If canceled return error
-                future._set_error(CanceledFutureError())
+                raise CanceledFutureError()
             else:
                 # Return an error to the future object
                 errmsg = message.get('error_message', 'An unknown error has occurred.')
                 if 'solver is offline' in errmsg.lower():
-                    err = SolverOfflineError(errmsg)
+                    raise SolverOfflineError(errmsg)
                 else:
-                    err = SolverFailureError(errmsg)
-                future._set_error(err)
+                    raise SolverFailureError(errmsg)
 
         except Exception as error:
             # If there were any unhandled errors we need to release the


Test patch:
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index 2c72d09..7eb3f97 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -12,7 +12,7 @@ from dateutil.tz import UTC
 from dateutil.parser import parse as parse_datetime
 from requests.structures import CaseInsensitiveDict
 
-from dwave.cloud.utils import evaluate_ising
+from dwave.cloud.utils import evaluate_ising, generate_random_ising_problem
 from dwave.cloud.qpu import Client, Solver
 from dwave.cloud.exceptions import SolverFailureError, CanceledFutureError
 from dwave.cloud.testing import mock
@@ -84,6 +84,14 @@ def error_reply(id_, solver_name, error):
     })
 
 
+def immediate_error_reply(code, msg):
+    """A reply saying an error has occurred (before scheduling for execution)."""
+    return json.dumps({
+        "error_code": code,
+        "error_msg": msg
+    })
+
+
 def cancel_reply(id_, solver_name):
     """A reply saying a problem was canceled."""
     return json.dumps({
@@ -204,6 +212,20 @@ class MockSubmission(_QueryTest):
             with self.assertRaises(SolverFailureError):
                 results.samples
 
+    def test_submit_immediate_error_reply(self):
+        """Handle an (obvious) error on problem submission."""
+        with Client('endpoint', 'token') as client:
+            client.session = mock.Mock()
+            client.session.post = lambda a, _: choose_reply(a, {
+                'endpoint/problems/': '[%s]' % immediate_error_reply(400, "Missing parameter 'num_reads' in problem JSON")})
+            solver = Solver(client, solver_data('abc123'))
+
+            linear, quad = generate_random_ising_problem(solver)
+            results = solver.sample_ising(linear, quad)
+
+            with self.assertRaises(SolverFailureError):
+                results.samples
+
     def test_submit_cancel_reply(self):
         """Handle a response for a canceled job."""
         with Client('endpoint', 'token') as client:
diff --git a/tests/test_solver.py b/tests/test_solver.py
index 957d3b5..15e1a73 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -40,7 +40,7 @@ class PropertyLoading(unittest.TestCase):
         """Ensure that the parameters are populated."""
         with Client(**config) as client:
             solver = client.get_solver()
-            assert 'not_a_parameter' not in solver.parameters
+            self.assertNotIn('not_a_parameter', solver.parameters)
             with self.assertRaises(KeyError):
                 solver.sample_ising({}, {}, not_a_parameter=True)
 
@@ -48,7 +48,7 @@ class PropertyLoading(unittest.TestCase):
         """Ensure that the experimental parameters are populated."""
         with Client(**config) as client:
             solver = client.get_solver()
-            assert 'x_test' not in solver.parameters
+            self.assertNotIn('x_test', solver.parameters)
             with self.assertRaises(SolverFailureError):
                 self.assertTrue(solver.sample_ising([0], {}, x_test=123).result())