Repository: rigetti/pyquil (python)

Issue:
Meta-Issue: User Experience
We need to improve user experience in the following areas:
- [ ] jobs can get stuck indefinitely on 'job is currently running' if there were issues with background workers
- [x] errors from the sync QVM are returned as strings, they should throw as exception instead otherwise they could be used as results
- [x] errors from async QVM/QPU are thrown differently than when they come from the sync QVM. (as in, there's no pretty printing of the error like in _base_connection)
- [ ] measure function that takes ranges (ie. `program.measure([0..8], [0..8])`)


Discussion:
Somewhat related to: https://github.com/rigetticomputing/pyquil/issues/193
- [x] `run_and_measure` and likely other API calls will print periodic status updates that cannot be turned off. It would be nice to either add a flag to `run_and_measure` itself or make this configurable at the `QPUConnection`/`QVMConnection` level. Here is an example within a jupyter notebook that demonstrates this
![image](https://user-images.githubusercontent.com/1573961/33732305-ef50a188-db3a-11e7-8ea8-0c7e664147c4.png)

@ntezak  - take a look here: https://github.com/rigetticomputing/pyquil/pull/213
Got this warning message:
`DeprecationWarning: The wait_for_job function is now deprecated. See https://go.rigetti.com/connections for more info.`
But in the docs there's this example:
```
job = qvm.wait_for_job(job_id)
print(job.result())
```
It'd be more clear if the deprecation warning would have said something like "job_results.wait_for_job has moved to qvm.wait_for_job"

Patch:
diff --git a/pyquil/api/__init__.py b/pyquil/api/__init__.py
--- a/pyquil/api/__init__.py
+++ b/pyquil/api/__init__.py
@@ -18,7 +18,7 @@
 """
 import warnings
 
-__all__ = ['QVMConnection', 'QPUConnection', 'Job', 'get_devices']
+__all__ = ['QVMConnection', 'QPUConnection', 'Job', 'get_devices', 'errors']
 
 from pyquil.api.job import Job
 from pyquil.api.qvm import QVMConnection
diff --git a/pyquil/api/_base_connection.py b/pyquil/api/_base_connection.py
--- a/pyquil/api/_base_connection.py
+++ b/pyquil/api/_base_connection.py
@@ -16,6 +16,8 @@
 
 from __future__ import print_function
 
+import re
+
 import requests
 import sys
 
@@ -24,6 +26,8 @@
 from six import integer_types
 from urllib3 import Retry
 
+from pyquil.api.errors import QVMError, DeviceOfflineError, DeviceRetuningError, InvalidInputError, InvalidUserError, \
+    JobNotFoundError, MissingPermissionsError, error_mapping, UnknownApiError, TooManyQubitsError
 from .job import Job
 from ._config import PyquilConfig
 
@@ -33,127 +37,102 @@
 TYPE_WAVEFUNCTION = "wavefunction"
 
 
-class BaseConnection(object):
-    def __init__(self, async_endpoint, api_key, user_id, ping_time, status_time):
-        self._session = requests.Session()
-        retry_adapter = HTTPAdapter(max_retries=Retry(total=3,
-                                                      method_whitelist=['POST'],
-                                                      status_forcelist=[502, 503, 504, 521, 523],
-                                                      backoff_factor=0.2,
-                                                      raise_on_status=False))
-
-        # We need this to get binary payload for the wavefunction call.
-        self._session.headers.update({"Accept": "application/octet-stream"})
-
-        self._session.mount("http://", retry_adapter)
-        self._session.mount("https://", retry_adapter)
-
-        config = PyquilConfig()
-        self.api_key = api_key if api_key else config.api_key
-        self.user_id = user_id if user_id else config.user_id
-
-        self.async_endpoint = async_endpoint
-
-        self.ping_time = ping_time
-        self.status_time = status_time
-
-    def get_job(self, job_id):
-        """
-        Given a job id, return information about the status of the job
-
-        :param str job_id: job id
-        :return: Job object with the status and potentially results of the job
-        :rtype: Job
-        """
-        response = self._get_json(self.async_endpoint + "/job/" + job_id)
-        return Job(response.json())
-
-    def wait_for_job(self, job_id, ping_time=None, status_time=None):
-        """
-        Wait for the results of a job and periodically print status
-
-        :param job_id: Job id
-        :param ping_time: How often to poll the server.
-                          Defaults to the value specified in the constructor. (0.1 seconds)
-        :param status_time: How often to print status, set to False to never print status.
-                            Defaults to the value specified in the constructor (2 seconds)
-        :return: Completed Job
-        """
-        if ping_time is None:
-            ping_time = self.ping_time
-        if status_time is None:
-            status_time = self.status_time
-
-        count = 0
-        while True:
-            job = self.get_job(job_id)
-            if job.is_done():
-                break
-
-            if status_time and count % int(status_time / ping_time) == 0:
-                if job.is_queued():
-                    print("job {} is currently queued at position {}".format(job.job_id, job.position_in_queue()))
-                elif job.is_running():
-                    print("job {} is currently running".format(job.job_id))
-
-            time.sleep(ping_time)
-            count += 1
-
-        return job
-
-    def _post_json(self, url, json):
-        """
-        Post JSON to the Forest endpoint.
-
-        :param str url: The full url to post to
-        :param dict json: JSON.
-        :return: A non-error response.
-        """
-        headers = {
-            'X-Api-Key': self.api_key,
-            'X-User-Id': self.user_id,
-            'Content-Type': 'application/json; charset=utf-8'
-        }
-        res = self._session.post(url, json=json, headers=headers)
-
-        # Print some nice info for unauthorized/permission errors.
-        if res.status_code == 401 or res.status_code == 403:
-            print("! ERROR:\n"
-                  "!   There was an issue validating your forest account.\n"
-                  "!   Have you run the pyquil-config-setup command yet?\n"
-                  "! The server came back with the following information:\n"
-                  "%s\n%s\n%s" % ("=" * 80, res.text, "=" * 80), file=sys.stderr)
-            print("! If you suspect this to be a bug in pyQuil or Rigetti Forest,\n"
-                  "! then please describe the problem in a GitHub issue at:\n!\n"
-                  "!      https://github.com/rigetticomputing/pyquil/issues\n", file=sys.stderr)
-
-        # Print some nice info for invalid input or internal server errors.
-        if res.status_code == 400 or res.status_code >= 500:
-            print("! ERROR:\n"
-                  "!   Server caught an error. This could be due to a bug in the server\n"
-                  "!   or a bug in your code. The server came back with the following\n"
-                  "!   information:\n"
-                  "%s\n%s\n%s" % ("=" * 80, res.text, "=" * 80), file=sys.stderr)
-            print("! If you suspect this to be a bug in pyQuil or Rigetti Forest,\n"
-                  "! then please describe the problem in a GitHub issue at:\n!\n"
-                  "!      https://github.com/rigetticomputing/pyquil/issues\n", file=sys.stderr)
-
-        res.raise_for_status()
-        return res
-
-    def _get_json(self, url):
-        """
-        Get JSON from a Forest endpoint.
-
-        :param str url: The full url to fetch
-        :return: Response object
-        """
-        headers = {
-            'X-Api-Key': self.api_key,
-            'X-User-Id': self.user_id,
-            'Content-Type': 'application/json; charset=utf-8'
-        }
-        return requests.get(url, headers=headers)
+def wait_for_job(get_job_fn, ping_time=None, status_time=None):
+    """
+    Wait for job logic
+    """
+    count = 0
+    while True:
+        job = get_job_fn()
+        if job.is_done():
+            break
+
+        if status_time and count % int(status_time / ping_time) == 0:
+            if job.is_queued():
+                print("job {} is currently queued at position {}".format(job.job_id, job.position_in_queue()))
+            elif job.is_running():
+                print("job {} is currently running".format(job.job_id))
+
+        time.sleep(ping_time)
+        count += 1
+
+    return job
+
+
+def get_json(session, url):
+    """
+    Get JSON from a Forest endpoint.
+    """
+    res = session.get(url)
+    if res.status_code >= 400:
+        raise parse_error(res)
+    return res
+
+
+def post_json(session, url, json):
+    """
+    Post JSON to the Forest endpoint.
+    """
+    res = session.post(url, json=json)
+    if res.status_code >= 400:
+        raise parse_error(res)
+    return res
+
+
+def parse_error(res):
+    """
+    Every server error should contain a "status" field with a human readable explanation of what went wrong as well as
+    a "error_type" field indicating the kind of error that can be mappen to a Python type.
+
+    There's a fallback error UnknownError for other types of exceptions (network issues, api gateway problems, etc.)
+    """
+    body = res.json()
+
+    if body is None:
+        raise UnknownApiError(res.text)
+    elif 'error_type' not in body:
+        raise UnknownApiError(body)
+
+    error_type = body['error_type']
+    status = body['status']
+
+    if re.search(r"[0-9]+ qubits were requested, but the QVM is limited to [0-9]+ qubits.", status):
+        return TooManyQubitsError(status)
+
+    error_cls = error_mapping.get(error_type, UnknownApiError)
+    return error_cls(status)
+
+
+def get_session(api_key, user_id):
+    """
+    Create a requests session to access the cloud API with the proper authentication
+
+    :param str api_key: custom api key, if None will fallback to reading from the config
+    :param str user_id: custom user id, if None will fallback to reading from the config
+    :return: requests session
+    :rtype: Session
+    """
+    session = requests.Session()
+    retry_adapter = HTTPAdapter(max_retries=Retry(total=3,
+                                                  method_whitelist=['POST'],
+                                                  status_forcelist=[502, 503, 504, 521, 523],
+                                                  backoff_factor=0.2,
+                                                  raise_on_status=False))
+
+    session.mount("http://", retry_adapter)
+    session.mount("https://", retry_adapter)
+
+    # We need this to get binary payload for the wavefunction call.
+    session.headers.update({"Accept": "application/octet-stream"})
+
+    config = PyquilConfig()
+    session.headers.update({
+        'X-Api-Key': api_key if api_key else config.api_key,
+        'X-User-Id': user_id if user_id else config.user_id,
+        'Content-Type': 'application/json; charset=utf-8'
+    })
+
+    return session
 
 
 def validate_noise_probabilities(noise_parameter):
diff --git a/pyquil/api/errors.py b/pyquil/api/errors.py
new file mode 100644
--- /dev/null
+++ b/pyquil/api/errors.py
@@ -0,0 +1,142 @@
+
+class ApiError(Exception):
+    def __init__(self, server_status, explanation):
+        super(ApiError, self).__init__(self, server_status)
+        self.server_status = server_status
+        self.explanation = explanation
+
+    def __repr__(self):
+        return repr(str(self))
+
+    def __str__(self):
+        return self.server_status + "\n" + self.explanation
+
+
+class CancellationError(ApiError):
+    def __init__(self, server_status):
+        explanation = "Please try resubmitting the job again."
+        super(CancellationError, self).__init__(server_status, explanation)
+
+
+class DeviceOfflineError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The device you requested is offline. Use the following code to check for the
+currently available devices:
+
+    from pyquil.api import get_devices
+    print(get_devices())"""
+        super(DeviceOfflineError, self).__init__(server_status, explanation)
+
+
+class DeviceRetuningError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The device you requested is temporarily down for retuning. Use the following
+code to check for the currently available devices:
+
+    from pyquil.api import get_devices
+    print(get_devices())"""
+        super(DeviceRetuningError, self).__init__(server_status, explanation)
+        ApiError.__init__(self, server_status, explanation)
+
+
+class InvalidInputError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The server returned the above error because something was wrong with the HTTP
+request sent to it. This could be due to a bug in the server or a bug in your
+code. If you suspect this to be a bug in pyQuil or Rigetti Forest, then please
+describe the problem in a GitHub issue at:
+    https://github.com/rigetticomputing/pyquil/issues"""
+        super(InvalidInputError, self).__init__(server_status, explanation)
+        ApiError.__init__(self, server_status, explanation)
+
+
+class InvalidUserError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+There was an issue validating your Forest account!
+Have you run the `pyquil-config-setup` command yet?
+
+If you do not yet have a Forest account then sign up for one at:
+    https://forest.rigetti.com"""
+        super(InvalidUserError, self).__init__(server_status, explanation)
+        ApiError.__init__(self, server_status, explanation)
+
+
+class JobNotFoundError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The above job may have been deleted manually or due to some bug in the server.
+If you suspect this to be a bug then please describe the problem in a Github
+issue at:
+    https://github.com/rigetticomputing/pyquil/issues"""
+        super(JobNotFoundError, self).__init__(server_status, explanation)
+        ApiError.__init__(self, server_status, explanation)
+
+
+class MissingPermissionsError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+Your account may not be whitelisted for QPU access. To request the appropriate
+permissions please read the information located at:
+    https://forest.rigetti.com"""
+        super(MissingPermissionsError, self).__init__(server_status, explanation)
+        ApiError.__init__(self, server_status, explanation)
+
+
+class QPUError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The QPU returned the above error. This could be due to a bug in the server or a
+bug in your code. If you suspect this to be a bug in pyQuil or Rigetti Forest,
+then please describe the problem in a GitHub issue at:
+    https://github.com/rigetticomputing/pyquil/issues"""
+        super(QPUError, self).__init__(server_status, explanation)
+
+
+class QVMError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The QVM returned the above error. This could be due to a bug in the server or a
+bug in your code. If you suspect this to be a bug in pyQuil or Rigetti Forest,
+then please describe the problem in a GitHub issue at:
+    https://github.com/rigetticomputing/pyquil/issues"""
+        super(QVMError, self).__init__(server_status, explanation)
+
+
+class TooManyQubitsError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+You requested too many qubits on the QVM. More qubits are available when you use
+the queue. Pass the use_queue parameter to QVMConnection to enable additional
+qubits (however, each program will take longer to run). For example:
+
+    qvm = QVMConnection(use_queue=True)
+    qvm.run(twenty_qubit_program)
+
+See https://go.rigetti.com/connections for more info."""
+        super(TooManyQubitsError, self).__init__(server_status, explanation)
+
+
+class UnknownApiError(ApiError):
+    def __init__(self, server_status):
+        explanation = """
+The server has failed to return a proper response. Please describe the problem
+and copy the above message into a GitHub issue at:
+    https://github.com/rigetticomputing/pyquil/issues"""
+        super(UnknownApiError, self).__init__(server_status, explanation)
+
+
+# NB: Some errors are not included here if they are only returned by async endpoints
+# The source of truth for this mapping is the errors.py file on the server
+error_mapping = {
+    'device_offline': DeviceOfflineError,
+    'device_retuning': DeviceRetuningError,
+    'invalid_input': InvalidInputError,
+    'invalid_user': InvalidUserError,
+    'job_not_found': JobNotFoundError,
+    'missing_permissions': MissingPermissionsError,
+    'qvm_error': QVMError,
+}
diff --git a/pyquil/api/job.py b/pyquil/api/job.py
--- a/pyquil/api/job.py
+++ b/pyquil/api/job.py
@@ -17,6 +17,7 @@
 import base64
 import warnings
 
+from pyquil.api.errors import CancellationError, QVMError, QPUError
 from pyquil.parser import parse_program
 from pyquil.wavefunction import Wavefunction
 
@@ -29,8 +30,9 @@ class Job(object):
     They transition to RUNNING when they have been started
     Finally they are marked as FINISHED, ERROR, or CANCELLED once completed
     """
-    def __init__(self, raw):
+    def __init__(self, raw, machine):
         self._raw = raw
+        self._machine = machine
 
     @property
     def job_id(self):
@@ -50,16 +52,19 @@ def result(self):
         """
         The result of the job if available
         throws ValueError is result is not available yet
-        throws RuntimeError if server returned an error indicating program execution was not successful
+        throws ApiError if server returned an error indicating program execution was not successful
             or if the job was cancelled
         """
         if not self.is_done():
             raise ValueError("Cannot get a result for a program that isn't completed.")
 
         if self._raw['status'] == 'CANCELLED':
-            raise RuntimeError("Job was cancelled: {}".format(self._raw['result']))
+            raise CancellationError(self._raw['result'])
         elif self._raw['status'] == 'ERROR':
-            raise RuntimeError("Server returned an error: {}".format(self._raw['result']))
+            if self._machine == 'QVM':
+                raise QVMError(self._raw['result'])
+            else:  # self._machine == 'QPU'
+                raise QPUError(self._raw['result'])
 
         if self._raw['program']['type'] == 'wavefunction':
             return Wavefunction.from_bit_packed_string(
diff --git a/pyquil/api/qpu.py b/pyquil/api/qpu.py
--- a/pyquil/api/qpu.py
+++ b/pyquil/api/qpu.py
@@ -15,12 +15,12 @@
 ##############################################################################
 import warnings
 
-import requests
 from six import integer_types
 
+from pyquil.api import Job
 from pyquil.quil import Program
-from ._base_connection import validate_run_items, TYPE_MULTISHOT, TYPE_MULTISHOT_MEASURE, get_job_id, BaseConnection
-from ._config import PyquilConfig
+from ._base_connection import validate_run_items, TYPE_MULTISHOT, TYPE_MULTISHOT_MEASURE, get_job_id, \
+    get_session, wait_for_job, post_json, get_json
 
 
 def get_devices(async_endpoint='https://job.rigetti.com/beta', api_key=None, user_id=None):
@@ -31,17 +31,8 @@ def get_devices(async_endpoint='https://job.rigetti.com/beta', api_key=None, use
     :return: set of online and offline devices
     :rtype: set
     """
-    config = PyquilConfig()
-    api_key = api_key if api_key else config.api_key
-    user_id = user_id if user_id else config.user_id
-
-    headers = {
-        'X-Api-Key': api_key,
-        'X-User-Id': user_id,
-        'Content-Type': 'application/json; charset=utf-8'
-    }
-
-    response = requests.get(async_endpoint + '/devices', headers=headers)
+    session = get_session(api_key, user_id)
+    response = session.get(async_endpoint + '/devices')
     return {Device(name, device) for (name, device) in response.json()['devices'].items()}
 
 
@@ -74,7 +65,7 @@ def __repr__(self):
         return str(self)
 
 
-class QPUConnection(BaseConnection):
+class QPUConnection(object):
     """
     Represents a connection to the QPU (Quantum Processing Unit)
     """
@@ -114,9 +105,12 @@ def __init__(self, device_name=None, async_endpoint='https://job.rigetti.com/bet
 
 To suppress this warning, see Python's warning module.
 """)
+        self.async_endpoint = async_endpoint
+        self.session = get_session(api_key, user_id)
+
+        self.ping_time = ping_time
+        self.status_time = status_time
 
-        super(QPUConnection, self).__init__(async_endpoint=async_endpoint, api_key=api_key, user_id=user_id,
-                                            ping_time=ping_time, status_time=status_time)
         self.device_name = device_name
 
     def run(self, quil_program, classical_addresses, trials=1):
@@ -130,20 +124,17 @@ def run(self, quil_program, classical_addresses, trials=1):
                  in `classical_addresses`.
         :rtype: list
         """
-        payload = self._run_payload(quil_program, classical_addresses, trials)
-
-        response = self._post_json(self.async_endpoint + "/job", self._wrap_program(payload))
-        job = self.wait_for_job(get_job_id(response))
-        return job.result()
+        raise DeprecationWarning("""
+The QPU does not currently support arbitrary measure operations. For now, the
+only supported operation on the QPU is run_and_measure.""")
 
     def run_async(self, quil_program, classical_addresses, trials=1):
         """
         Similar to run except that it returns a job id and doesn't wait for the program to be executed.
         See https://go.rigetti.com/connections for reasons to use this method.
         """
-        payload = self._run_payload(quil_program, classical_addresses, trials)
-        response = self._post_json(self.async_endpoint + "/job", self._wrap_program(payload))
-        return get_job_id(response)
+        # NB: Throw the same deprecation warning as in run
+        return self.run(quil_program, classical_addresses, trials)
 
     def _run_payload(self, quil_program, classical_addresses, trials):
         if not isinstance(quil_program, Program):
@@ -172,7 +163,7 @@ def run_and_measure(self, quil_program, qubits, trials=1):
         """
         payload = self._run_and_measure_payload(quil_program, qubits, trials)
 
-        response = self._post_json(self.async_endpoint + "/job", self._wrap_program(payload))
+        response = post_json(self.session, self.async_endpoint + "/job", self._wrap_program(payload))
         job = self.wait_for_job(get_job_id(response))
         return job.result()
 
@@ -182,7 +173,7 @@ def run_and_measure_async(self, quil_program, qubits, trials):
         See https://go.rigetti.com/connections for reasons to use this method.
         """
         payload = self._run_and_measure_payload(quil_program, qubits, trials)
-        response = self._post_json(self.async_endpoint + "/job", self._wrap_program(payload))
+        response = post_json(self.session, self.async_endpoint + "/job", self._wrap_program(payload))
         return get_job_id(response)
 
     def _run_and_measure_payload(self, quil_program, qubits, trials):
@@ -199,6 +190,34 @@ def _run_and_measure_payload(self, quil_program, qubits, trials):
 
         return payload
 
+    def get_job(self, job_id):
+        """
+        Given a job id, return information about the status of the job
+
+        :param str job_id: job id
+        :return: Job object with the status and potentially results of the job
+        :rtype: Job
+        """
+        response = get_json(self.session, self.async_endpoint + "/job/" + job_id)
+        return Job(response.json(), 'QPU')
+
+    def wait_for_job(self, job_id, ping_time=None, status_time=None):
+        """
+        Wait for the results of a job and periodically print status
+
+        :param job_id: Job id
+        :param ping_time: How often to poll the server.
+                          Defaults to the value specified in the constructor. (0.1 seconds)
+        :param status_time: How often to print status, set to False to never print status.
+                            Defaults to the value specified in the constructor (2 seconds)
+        :return: Completed Job
+        """
+        def get_job_fn():
+            return self.get_job(job_id)
+        return wait_for_job(get_job_fn,
+                            ping_time if ping_time else self.ping_time,
+                            status_time if status_time else self.status_time)
+
     def _wrap_program(self, program):
         return {
             "machine": "QPU",
diff --git a/pyquil/api/qvm.py b/pyquil/api/qvm.py
--- a/pyquil/api/qvm.py
+++ b/pyquil/api/qvm.py
@@ -14,17 +14,17 @@
 #    limitations under the License.
 ##############################################################################
 
-import json
-
 from six import integer_types
 
+from pyquil.api import Job
 from pyquil.quil import Program
 from pyquil.wavefunction import Wavefunction
-from ._base_connection import BaseConnection, validate_noise_probabilities, validate_run_items, TYPE_MULTISHOT, \
-    TYPE_MULTISHOT_MEASURE, TYPE_WAVEFUNCTION, TYPE_EXPECTATION, get_job_id
+from ._base_connection import validate_noise_probabilities, validate_run_items, TYPE_MULTISHOT, \
+    TYPE_MULTISHOT_MEASURE, TYPE_WAVEFUNCTION, TYPE_EXPECTATION, get_job_id, get_session, wait_for_job, \
+    post_json, get_json
 
 
-class QVMConnection(BaseConnection):
+class QVMConnection(object):
     """
     Represents a connection to the QVM.
     """
@@ -58,10 +58,13 @@ def __init__(self, sync_endpoint='https://api.rigetti.com', async_endpoint='http
         :param random_seed: A seed for the QVM's random number generators. Either None (for an
                             automatically generated seed) or a non-negative integer.
         """
-        super(QVMConnection, self).__init__(async_endpoint=async_endpoint, api_key=api_key, user_id=user_id,
-                                            ping_time=ping_time, status_time=status_time)
+        self.async_endpoint = async_endpoint
         self.sync_endpoint = sync_endpoint
+        self.session = get_session(api_key, user_id)
+
         self.use_queue = use_queue
+        self.ping_time = ping_time
+        self.status_time = status_time
 
         validate_noise_probabilities(gate_noise)
         validate_noise_probabilities(measurement_noise)
@@ -92,12 +95,12 @@ def run(self, quil_program, classical_addresses, trials=1):
         """
         payload = self._run_payload(quil_program, classical_addresses, trials)
         if self.use_queue:
-            response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+            response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
             job = self.wait_for_job(get_job_id(response))
             return job.result()
         else:
             payload = self._run_payload(quil_program, classical_addresses, trials)
-            response = self._post_json(self.sync_endpoint + "/qvm", payload)
+            response = post_json(self.session, self.sync_endpoint + "/qvm", payload)
             return response.json()
 
     def run_async(self, quil_program, classical_addresses, trials=1):
@@ -106,7 +109,7 @@ def run_async(self, quil_program, classical_addresses, trials=1):
         See https://go.rigetti.com/connections for reasons to use this method.
         """
         payload = self._run_payload(quil_program, classical_addresses, trials)
-        response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+        response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
         return get_job_id(response)
 
     def _run_payload(self, quil_program, classical_addresses, trials):
@@ -144,12 +147,12 @@ def run_and_measure(self, quil_program, qubits, trials=1):
         """
         payload = self._run_and_measure_payload(quil_program, qubits, trials)
         if self.use_queue:
-            response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+            response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
             job = self.wait_for_job(get_job_id(response))
             return job.result()
         else:
             payload = self._run_and_measure_payload(quil_program, qubits, trials)
-            response = self._post_json(self.sync_endpoint + "/qvm", payload)
+            response = post_json(self.session, self.sync_endpoint + "/qvm", payload)
             return response.json()
 
     def run_and_measure_async(self, quil_program, qubits, trials=1):
@@ -158,7 +161,7 @@ def run_and_measure_async(self, quil_program, qubits, trials=1):
         See https://go.rigetti.com/connections for reasons to use this method.
         """
         payload = self._run_and_measure_payload(quil_program, qubits, trials)
-        response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+        response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
         return get_job_id(response)
 
     def _run_and_measure_payload(self, quil_program, qubits, trials):
@@ -200,12 +203,12 @@ def wavefunction(self, quil_program, classical_addresses=None):
 
         if self.use_queue:
             payload = self._wavefunction_payload(quil_program, classical_addresses)
-            response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+            response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
             job = self.wait_for_job(get_job_id(response))
             return job.result()
         else:
             payload = self._wavefunction_payload(quil_program, classical_addresses)
-            response = self._post_json(self.sync_endpoint + "/qvm", payload)
+            response = post_json(self.session, self.sync_endpoint + "/qvm", payload)
             return Wavefunction.from_bit_packed_string(response.content, classical_addresses)
 
     def wavefunction_async(self, quil_program, classical_addresses=None):
@@ -217,7 +220,7 @@ def wavefunction_async(self, quil_program, classical_addresses=None):
             classical_addresses = []
 
         payload = self._wavefunction_payload(quil_program, classical_addresses)
-        response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+        response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
         return get_job_id(response)
 
     def _wavefunction_payload(self, quil_program, classical_addresses):
@@ -252,12 +255,12 @@ def expectation(self, prep_prog, operator_programs=None):
         """
         if self.use_queue:
             payload = self._expectation_payload(prep_prog, operator_programs)
-            response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+            response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
             job = self.wait_for_job(get_job_id(response))
             return job.result()
         else:
             payload = self._expectation_payload(prep_prog, operator_programs)
-            response = self._post_json(self.sync_endpoint + "/qvm", payload)
+            response = post_json(self.session, self.sync_endpoint + "/qvm", payload)
             return response.json()
 
     def expectation_async(self, prep_prog, operator_programs=None):
@@ -266,7 +269,7 @@ def expectation_async(self, prep_prog, operator_programs=None):
         See https://go.rigetti.com/connections for reasons to use this method.
         """
         payload = self._expectation_payload(prep_prog, operator_programs)
-        response = self._post_json(self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
+        response = post_json(self.session, self.async_endpoint + "/job", {"machine": "QVM", "program": payload})
         return get_job_id(response)
 
     def _expectation_payload(self, prep_prog, operator_programs):
@@ -284,6 +287,34 @@ def _expectation_payload(self, prep_prog, operator_programs):
 
         return payload
 
+    def get_job(self, job_id):
+        """
+        Given a job id, return information about the status of the job
+
+        :param str job_id: job id
+        :return: Job object with the status and potentially results of the job
+        :rtype: Job
+        """
+        response = get_json(self.session, self.async_endpoint + "/job/" + job_id)
+        return Job(response.json(), 'QVM')
+
+    def wait_for_job(self, job_id, ping_time=None, status_time=None):
+        """
+        Wait for the results of a job and periodically print status
+
+        :param job_id: Job id
+        :param ping_time: How often to poll the server.
+                          Defaults to the value specified in the constructor. (0.1 seconds)
+        :param status_time: How often to print status, set to False to never print status.
+                            Defaults to the value specified in the constructor (2 seconds)
+        :return: Completed Job
+        """
+        def get_job_fn():
+            return self.get_job(job_id)
+        return wait_for_job(get_job_fn,
+                            ping_time if ping_time else self.ping_time,
+                            status_time if status_time else self.status_time)
+
     def _add_noise_to_payload(self, payload):
         """
         Set the gate noise and measurement noise of a payload.
diff --git a/pyquil/job_results.py b/pyquil/job_results.py
--- a/pyquil/job_results.py
+++ b/pyquil/job_results.py
@@ -1,6 +1,11 @@
 def wait_for_job(res, ping_time=0.5):
-    raise DeprecationWarning(
-        "The wait_for_job function is now deprecated. See https://go.rigetti.com/connections for more info.")
+    raise DeprecationWarning("""
+The wait_for_job function has been moved inside the QVMConnection or
+QPUConnection object. For instance:
+    job = qvm.wait_for_job(job_id)
+    print(job.result())
+
+See https://go.rigetti.com/connections for more info.""")
 
 
 class JobResult(object):


Test patch:
diff --git a/pyquil/tests/test_api.py b/pyquil/tests/test_api.py
--- a/pyquil/tests/test_api.py
+++ b/pyquil/tests/test_api.py
@@ -154,8 +154,8 @@ def test_qpu_connection():
     qpu = QPUConnection(device_name='fake_device')
 
     program = {
-        "type": "multishot",
-        "addresses": [0, 1],
+        "type": "multishot-measure",
+        "qubits": [0, 1],
         "trials": 2,
         "quil-instructions": "H 0\nCNOT 0 1\n"
     }
@@ -176,7 +176,7 @@ def mock_queued_response(request, context):
                                  "result": [[0, 0], [1, 1]], "program": program})}
         ])
 
-        result = qpu.run(BELL_STATE, [0, 1], trials=2)
+        result = qpu.run_and_measure(BELL_STATE, [0, 1], trials=2)
         assert result == [[0, 0], [1, 1]]
 
     with requests_mock.Mocker() as m:
@@ -192,7 +192,7 @@ def mock_queued_response(request, context):
                                  }})}
         ])
 
-        job = qpu.wait_for_job(qpu.run_async(BELL_STATE, [0, 1], trials=2))
+        job = qpu.wait_for_job(qpu.run_and_measure_async(BELL_STATE, [0, 1], trials=2))
         assert job.result() == [[0, 0], [1, 1]]
         assert job.compiled_quil() == Program(H(0), CNOT(0, 1))
         assert job.topological_swaps() == 0
@@ -206,7 +206,7 @@ def mock_queued_response(request, context):
                                  "result": [[0, 0], [1, 1]], "program": program})}
         ])
 
-        job = qpu.wait_for_job(qpu.run_async(BELL_STATE, [0, 1], trials=2))
+        job = qpu.wait_for_job(qpu.run_and_measure_async(BELL_STATE, [0, 1], trials=2))
         assert job.result() == [[0, 0], [1, 1]]
         assert job.compiled_quil() is None
         assert job.topological_swaps() is None