You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mesos.apache.org by kl...@apache.org on 2018/02/07 15:09:41 UTC

[4/4] mesos git commit: Added 'mesos.http' and 'mesos.exceptions' for the python-based CLI.

Added 'mesos.http' and 'mesos.exceptions' for the python-based CLI.

Part of MESOS-7310, this patch adds the mesos.http and mesos.exceptions
modules, which provides a Resource class and its descendants for
abstracting away common operations over http connections with JSON
serialization.

Review: https://reviews.apache.org/r/61172/


Project: http://git-wip-us.apache.org/repos/asf/mesos/repo
Commit: http://git-wip-us.apache.org/repos/asf/mesos/commit/99421373
Tree: http://git-wip-us.apache.org/repos/asf/mesos/tree/99421373
Diff: http://git-wip-us.apache.org/repos/asf/mesos/diff/99421373

Branch: refs/heads/master
Commit: 994213739b1afc473bbd9d15ded7c3fd26eaa924
Parents: dea1b31
Author: Eric Chung <ci...@gmail.com>
Authored: Wed Feb 7 16:02:17 2018 +0100
Committer: Kevin Klues <kl...@gmail.com>
Committed: Wed Feb 7 16:08:47 2018 +0100

----------------------------------------------------------------------
 src/python/lib/mesos/exceptions.py      | 110 ++++++++
 src/python/lib/mesos/http.py            | 391 +++++++++++++++++++++++++++
 src/python/lib/tests/conftest.py        |  33 +++
 src/python/lib/tests/test_exceptions.py |  63 +++++
 src/python/lib/tests/test_http.py       | 353 ++++++++++++++++++++++++
 5 files changed, 950 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/mesos/exceptions.py
----------------------------------------------------------------------
diff --git a/src/python/lib/mesos/exceptions.py b/src/python/lib/mesos/exceptions.py
new file mode 100644
index 0000000..63c7aa6
--- /dev/null
+++ b/src/python/lib/mesos/exceptions.py
@@ -0,0 +1,110 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Exceptions Classes
+"""
+
+from __future__ import absolute_import
+
+
+class MesosException(Exception):
+    """
+    Exceptions class to be inherited by all Mesos exceptions.
+    """
+    def __init__(self, message=None, original_exception=None):
+        if original_exception is not None:
+            message = "{msg}: {exception}".format(
+                msg=message, exception=original_exception)
+        self.original_exception = original_exception
+        super(MesosException, self).__init__(message)
+
+
+class MesosHTTPException(MesosException):
+    """
+    A wrapper around Response objects for HTTP error codes.
+
+    :param response: requests Response object
+    :type response: Response
+    """
+    STATUS_CODE = None
+
+    def __init__(self, response):
+        super(MesosHTTPException, self).__init__()
+        self.response = response
+
+    def status(self):
+        """
+        Return status code from response.
+
+        :return: status code
+        :rtype: int
+        """
+        if self.STATUS_CODE is None:
+            return self.response.status_code
+        return self.STATUS_CODE
+
+    def __str__(self):
+        return "The url '{url}' returned HTTP {status_code}: {text}"\
+            .format(url=self.response.request.url,
+                    status_code=self.response.status_code,
+                    text=self.response.text)
+
+
+class MesosAuthenticationException(MesosHTTPException):
+    """
+    Indicates an authentication failure.
+    """
+    STATUS_CODE = 401
+
+
+class MesosUnprocessableException(MesosHTTPException):
+    """
+    The request was well-formed but was unable to be followed due to semantic
+    errors.
+    """
+    STATUS_CODE = 422
+
+
+class MesosAuthorizationException(MesosHTTPException):
+    """
+    The request was valid but the server is refusing action.
+    """
+    STATUS_CODE = 403
+
+
+class MesosBadRequestException(MesosHTTPException):
+    """
+    The server cannot or will not process the request due to an apparent client
+    error.
+    """
+    STATUS_CODE = 400
+
+
+class MesosServiceUnavailableException(MesosHTTPException):
+    """
+    The server is currently unavailable (because it is overloaded or down for
+    maintenance).
+    """
+    STATUS_CODE = 503
+
+
+class MesosInternalServerErrorException(MesosHTTPException):
+    """
+    An unexpected condition was encountered and no more specific
+    message is suitable.
+    """
+    STATUS_CODE = 500

http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/mesos/http.py
----------------------------------------------------------------------
diff --git a/src/python/lib/mesos/http.py b/src/python/lib/mesos/http.py
new file mode 100644
index 0000000..073c159
--- /dev/null
+++ b/src/python/lib/mesos/http.py
@@ -0,0 +1,391 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=too-many-arguments
+
+"""
+Classes and functions for interacting with the Mesos HTTP RESTful API
+"""
+
+from __future__ import absolute_import
+
+from urlparse import urlparse
+from copy import deepcopy
+
+import requests
+import tenacity
+import ujson
+
+from mesos.exceptions import MesosException
+from mesos.exceptions import MesosHTTPException
+from mesos.exceptions import MesosAuthenticationException
+from mesos.exceptions import MesosAuthorizationException
+from mesos.exceptions import MesosBadRequestException
+from mesos.exceptions import MesosInternalServerErrorException
+from mesos.exceptions import MesosServiceUnavailableException
+from mesos.exceptions import MesosUnprocessableException
+
+METHOD_HEAD = 'HEAD'
+METHOD_GET = 'GET'
+METHOD_POST = 'POST'
+METHOD_PUT = 'PUT'
+METHOD_PATCH = 'PATCH'
+METHOD_DELETE = 'DELETE'
+METHODS = {
+    METHOD_HEAD,
+    METHOD_GET,
+    METHOD_POST,
+    METHOD_PUT,
+    METHOD_PATCH,
+    METHOD_DELETE}
+
+REQUEST_JSON_HEADERS = {'Accept': 'application/json'}
+REQUEST_GZIP_HEADERS = {'Accept-Encoding': 'gzip'}
+
+BASE_HEADERS = {}
+DEFAULT_TIMEOUT = 30
+DEFAULT_AUTH = None
+DEFAULT_USE_GZIP_ENCODING = True
+DEFAULT_MAX_ATTEMPTS = 3
+
+
+def simple_urljoin(base, other):
+    """
+    Do a join by rstrip'ing / from base_url and lstrp'ing / from other.
+
+    This is needed since urlparse.urljoin tries to be too smart
+    and strips the subpath from base_url.
+
+    :type base: str
+    :type other: str
+    :rtype: str
+    """
+    return '/'.join([base.rstrip('/'), other.lstrip('/')])
+
+
+class Resource(object):
+    """
+    Encapsulate the context for an HTTP resource.
+
+    Context for an HTTP resource may include properties such as the URL,
+    default timeout for connections, default headers to be included in each
+    request, and auth.
+    """
+    SUCCESS_CODES = frozenset(xrange(200, 300))
+    ERROR_CODE_MAP = {c.STATUS_CODE: c for c in (
+        MesosBadRequestException,
+        MesosAuthenticationException,
+        MesosAuthorizationException,
+        MesosUnprocessableException,
+        MesosInternalServerErrorException,
+        MesosServiceUnavailableException)}
+
+    def __init__(self,
+                 url,
+                 default_headers=None,
+                 default_timeout=DEFAULT_TIMEOUT,
+                 default_auth=DEFAULT_AUTH,
+                 default_use_gzip_encoding=DEFAULT_USE_GZIP_ENCODING,
+                 default_max_attempts=DEFAULT_MAX_ATTEMPTS):
+        """
+        :param url: URL identifying the resource
+        :type url: str
+        :param default_headers: headers to attache to requests
+        :type default_headers: dict[str, str]
+        :param default_timeout: timeout in seconds
+        :type default_timeout: float
+        :param default_auth: auth scheme
+        :type default_auth: requests.auth.AuthBase
+        :param default_use_gzip_encoding: use gzip encoding by default or not
+        :type default_use_gzip_encoding: bool
+        :param default_max_attempts: max number of attempts when retrying
+        :type default_max_attempts: int
+        """
+        self.url = urlparse(url)
+        self.default_timeout = default_timeout
+        self.default_auth = default_auth
+        self.default_use_gzip_encoding = default_use_gzip_encoding
+        self.default_max_attempts = default_max_attempts
+
+        if default_headers is None:
+            self._default_headers = {}
+        else:
+            self._default_headers = deepcopy(default_headers)
+
+    def default_headers(self):
+        """
+        Return a copy of the default headers.
+
+        :rtype: dict[str, str]
+        """
+        return deepcopy(self._default_headers)
+
+    def subresource(self, subpath):
+        """
+        Return a new Resource object at a subpath of the current resource's URL.
+
+        :param subpath: subpath of the resource
+        :type subpath: str
+        :return: Resource at subpath
+        :rtype: Resource
+        """
+        return self.__class__(
+            url=simple_urljoin(self.url.geturl(), subpath),
+            default_headers=self.default_headers(),
+            default_timeout=self.default_timeout,
+            default_auth=self.default_auth,
+            default_use_gzip_encoding=self.default_use_gzip_encoding,
+            default_max_attempts=self.default_max_attempts,
+        )
+
+    def _request(self,
+                 method,
+                 additional_headers=None,
+                 timeout=None,
+                 auth=None,
+                 use_gzip_encoding=None,
+                 params=None,
+                 **kwargs):
+        """
+        Make an HTTP request with given method and an optional timeout.
+
+        :param method: request method
+        :type method: str
+        :param additional_headers: additional headers to include in the request
+        :type additional_headers: dict[str, str]
+        :param timeout: timeout in seconds
+        :type timeout: float
+        :param auth: auth scheme for request
+        :type auth: requests.auth.AuthBase
+        :param use_gzip_encoding: boolean indicating whether to
+                                  pass gzip encoding in the request
+                                  headers or not
+        :type use_gzip_encoding: boolean
+        :param params: additional params to include in the request
+        :type params: str | dict[str, T]
+        :param kwargs: additional arguments to pass to requests.request
+        :type kwargs: dict[str, T]
+        :return: HTTP response
+        :rtype: requests.Response
+        """
+        headers = self.default_headers()
+        if additional_headers is not None:
+            headers.update(additional_headers)
+
+        if timeout is None:
+            timeout = self.default_timeout
+
+        if auth is None:
+            auth = self.default_auth
+
+        if use_gzip_encoding is None:
+            use_gzip_encoding = self.default_use_gzip_encoding
+
+        if headers and use_gzip_encoding:
+            headers.update(REQUEST_GZIP_HEADERS)
+
+        kwargs.update(dict(
+            url=self.url.geturl(),
+            method=method,
+            headers=headers,
+            timeout=timeout,
+            auth=auth,
+            params=params,
+        ))
+
+        # Here we call request without a try..except block since all exceptions
+        # raised here will be used to determine whether or not a retry is
+        # necessary in self.request.
+        response = requests.request(**kwargs)
+
+        if response.status_code in self.SUCCESS_CODES:
+            return response
+
+        known_exception = self.ERROR_CODE_MAP.get(response.status_code)
+        if known_exception:
+            raise known_exception(response)
+        else:
+            raise MesosHTTPException(response)
+
+    def request(self,
+                method,
+                additional_headers=None,
+                timeout=None,
+                auth=None,
+                use_gzip_encoding=None,
+                params=None,
+                max_attempts=None,
+                **kwargs):
+        """
+        Make an HTTP request by calling self._request with backoff retry.
+
+        :param method: request method
+        :type method: str
+        :param additional_headers: additional headers to include in the request
+        :type additional_headers: dict[str, str]
+        :param timeout: timeout in seconds, overrides default_timeout_secs
+        :type timeout: float
+        :param timeout: timeout in seconds
+        :type timeout: float
+        :param auth: auth scheme for the request
+        :type auth: requests.auth.AuthBase
+        :param use_gzip_encoding: boolean indicating whether to pass gzip
+                                  encoding in the request headers or not
+        :type use_gzip_encoding: boolean | None
+        :param params: additional params to include in the request
+        :type params: str | dict[str, T] | None
+        :param max_attempts: maximum number of attempts to try for any request
+        :type max_attempts: int
+        :param kwargs: additional arguments to pass to requests.request
+        :type kwargs: dict[str, T]
+        :return: HTTP response
+        :rtype: requests.Response
+        """
+        if max_attempts is None:
+            max_attempts = self.default_max_attempts
+
+        # We retry only when it makes sense: either due to a network partition
+        # (e.g. connection errors) or if the request failed due to a server
+        # error such as 500s, timeouts, and so on.
+        request_with_retry = tenacity.retry(
+            stop=tenacity.stop_after_attempt(max_attempts),
+            wait=tenacity.wait_exponential(),
+            retry=tenacity.retry_if_exception_type((
+                requests.exceptions.Timeout,
+                requests.exceptions.ConnectionError,
+                MesosServiceUnavailableException,
+                MesosInternalServerErrorException,
+            )),
+            reraise=True,
+        )(self._request)
+
+        try:
+            return request_with_retry(
+                method=method,
+                additional_headers=additional_headers,
+                timeout=timeout,
+                auth=auth,
+                use_gzip_encoding=use_gzip_encoding,
+                params=params,
+                **kwargs
+            )
+        # If the request itself failed, an exception subclassed from
+        # RequestException will be raised. Catch this and reraise as
+        # MesosException since we want the caller to be able to catch
+        # and handle this.
+        except requests.exceptions.RequestException as err:
+            raise MesosException('Request failed', err)
+
+    def request_json(self,
+                     method,
+                     timeout=None,
+                     auth=None,
+                     payload=None,
+                     decoder=None,
+                     params=None,
+                     **kwargs):
+        """
+        Make an HTTP request and deserialize the response as JSON. Optionally
+        decode the deserialized json dict into a decoded object.
+
+        :param method: request method
+        :type method: str
+        :param timeout: timeout in seconds
+        :type timeout: float
+        :param auth: auth scheme for the request
+        :type auth: requests.auth.AuthBase
+        :param payload: json payload in the request
+        :type payload: dict[str, T] | str
+        :param decoder: decoder for json response
+        :type decoder: (dict) -> T
+        :param params: additional params to include in the request
+        :type params: str | dict[str, T]
+        :param kwargs: additional arguments to pass to requests.request
+        :type kwargs: dict[str, T]
+        :return: JSON response
+        :rtype: dict[str, T]
+        """
+        resp = self.request(method=method,
+                            timeout=timeout,
+                            auth=auth,
+                            json=payload,
+                            additional_headers=REQUEST_JSON_HEADERS,
+                            params=params,
+                            **kwargs)
+
+        try:
+            json_dict = ujson.loads(resp.text)
+        except ValueError as exception:
+            raise MesosException(
+                'could not load JSON from "{data}"'.format(data=resp.text),
+                exception)
+
+        if decoder is not None:
+            return decoder(json_dict)
+
+        return json_dict
+
+    def get_json(self,
+                 timeout=None,
+                 auth=None,
+                 decoder=None,
+                 params=None):
+        """
+        Send a GET request.
+
+        :param timeout: timeout in seconds
+        :type  timeout: float
+        :param auth: auth scheme for the request
+        :type auth: requests.auth.AuthBase
+        :param decoder: decoder for json response
+        :type decoder: (dict) -> T
+        :param params: additional params to include in the request
+        :type params: str | dict[str, U]
+        :rtype: dict[str, U]
+        """
+        return self.request_json(METHOD_GET,
+                                 timeout=timeout,
+                                 auth=auth,
+                                 decoder=decoder,
+                                 params=params)
+
+    def post_json(self,
+                  timeout=None,
+                  auth=None,
+                  payload=None,
+                  decoder=None,
+                  params=None):
+        """
+        Sends a POST request.
+
+        :param timeout: timeout in seconds
+        :type  timeout: float
+        :param auth: auth scheme for the request
+        :type auth: requests.auth.AuthBase
+        :param payload: post data
+        :type  payload: dict[str, T] | str
+        :param decoder: decoder for json response
+        :type decoder: (dict) -> T
+        :param params: additional params to include in the request
+        :type params: str | dict[str, T]
+        :rtype: dict[str, T]
+        """
+        return self.request_json(METHOD_POST,
+                                 timeout=timeout,
+                                 auth=auth,
+                                 payload=payload,
+                                 decoder=decoder,
+                                 params=params)

http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/conftest.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/conftest.py b/src/python/lib/tests/conftest.py
new file mode 100644
index 0000000..d4d6efc
--- /dev/null
+++ b/src/python/lib/tests/conftest.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+PyTest configuration
+"""
+
+from __future__ import absolute_import
+
+import mock
+import pytest
+
+
+@pytest.yield_fixture()
+def mock_mesos_http_request():
+    """
+    PyTest fixture for monkey patching mesos.http.requests.request.
+    """
+    with mock.patch('mesos.http.requests.request') as mock_request:
+        yield mock_request

http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/test_exceptions.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/test_exceptions.py b/src/python/lib/tests/test_exceptions.py
new file mode 100644
index 0000000..096eab8
--- /dev/null
+++ b/src/python/lib/tests/test_exceptions.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=redefined-outer-name,missing-docstring
+
+import mock
+import pytest
+
+from mesos.exceptions import MesosHTTPException
+from mesos.exceptions import MesosAuthenticationException
+from mesos.exceptions import MesosAuthorizationException
+from mesos.exceptions import MesosBadRequestException
+from mesos.exceptions import MesosUnprocessableException
+
+
+@pytest.mark.parametrize([
+    'exception',
+    'status_code',
+    'expected_string'
+], [
+    (MesosHTTPException,
+     400,
+     "The url 'http://some.url' returned HTTP 400: something bad happened"),
+    (MesosAuthenticationException,
+     401,
+     "The url 'http://some.url' returned HTTP 401: something bad happened"),
+    (MesosAuthorizationException,
+     403,
+     "The url 'http://some.url' returned HTTP 403: something bad happened"),
+    (MesosBadRequestException,
+     400,
+     "The url 'http://some.url' returned HTTP 400: something bad happened"),
+    (MesosUnprocessableException,
+     422,
+     "The url 'http://some.url' returned HTTP 422: something bad happened"),
+])
+def test_exceptions(exception, status_code, expected_string):
+    """
+    Test exceptions
+    """
+    mock_resp = mock.Mock()
+    mock_resp.status_code = status_code
+    mock_resp.reason = 'some_reason'
+    mock_resp.request.url = 'http://some.url'
+    mock_resp.text = 'something bad happened'
+
+    # Test MesosHTTPException
+    err = exception(mock_resp)
+    assert str(err) == expected_string
+    assert err.status() == status_code

http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/test_http.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/test_http.py b/src/python/lib/tests/test_http.py
new file mode 100644
index 0000000..66dd6d7
--- /dev/null
+++ b/src/python/lib/tests/test_http.py
@@ -0,0 +1,353 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=missing-docstring,protected-access,too-many-locals,too-many-arguments
+
+from __future__ import absolute_import
+
+from collections import namedtuple
+
+import mock
+import pytest
+import requests.exceptions
+import ujson
+
+from mesos import http
+
+
+def test_resource():
+    # test initialization
+    resource = http.Resource('http://some.domain/some/prefix')
+    assert resource.url.geturl() == 'http://some.domain/some/prefix'
+
+    # test subresource
+    subresource = resource.subresource('some/subpath')
+    assert subresource.url.geturl() == \
+           'http://some.domain/some/prefix/some/subpath'
+
+
+@pytest.mark.parametrize(
+    ['default_tos',
+     'override_tos',
+     'expected_request_calls'],
+    [(None,
+      None,
+      [mock.call(auth=None,
+                 headers={'Content-Type': 'applicatioin/json',
+                          'some-header-key': 'some-header-val'},
+                 method='GET',
+                 params=None,
+                 somekey='someval',
+                 timeout=None,
+                 url='http://some.domain/some/prefix')]),
+     (10,
+      None,
+      [mock.call(auth=None,
+                 headers={'Content-Type': 'applicatioin/json',
+                          'some-header-key': 'some-header-val'},
+                 method='GET',
+                 params=None,
+                 somekey='someval',
+                 timeout=10,
+                 url='http://some.domain/some/prefix')]),
+     (10,
+      100,
+      [mock.call(auth=None,
+                 headers={'Content-Type': 'applicatioin/json',
+                          'some-header-key': 'some-header-val'},
+                 method='GET',
+                 params=None,
+                 somekey='someval',
+                 timeout=100,
+                 url='http://some.domain/some/prefix')])])
+def test_resource_request(mock_mesos_http_request, default_tos, override_tos,
+                          expected_request_calls):
+    # test request
+    mock_response = mock.Mock(status_code=200)
+    mock_mesos_http_request.return_value = mock_response
+    resource = http.Resource('http://some.domain/some/prefix',
+                             default_timeout=default_tos,
+                             default_headers={
+                                 'Content-Type': 'applicatioin/json'})
+    ret = resource.request(http.METHOD_GET,
+                           additional_headers={
+                               'some-header-key': 'some-header-val'
+                           },
+                           timeout=override_tos,
+                           auth=None,
+                           use_gzip_encoding=False,
+                           max_attempts=1,
+                           params=None,
+                           somekey='someval')
+    assert ret == mock_response
+    assert mock_mesos_http_request.mock_calls == expected_request_calls
+
+
+SomeModel = namedtuple('SomeModel', ['some'])
+
+RequestJsonParams = namedtuple('RequestJsonParams',
+                               [
+                                   'default_tos',
+                                   'override_tos',
+                                   'json_payload',
+                                   'obj_decoder',
+                                   'request_exception',
+                                   'resp_status',
+                                   'json_exception',
+                                   'expected_exception',
+                                   'expected_additional_kwargs',
+                               ])
+
+
+@mock.patch('mesos.http.ujson.loads')
+@pytest.mark.parametrize(
+    RequestJsonParams._fields,
+    [
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=None,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=lambda d: SomeModel(**d),
+            request_exception=None,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=None,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=requests.exceptions.SSLError,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=requests.exceptions.ConnectionError,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=requests.exceptions.Timeout,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=requests.exceptions.RequestException,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=299,
+            json_exception=None,
+            expected_exception=None,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=200,
+            json_exception=ValueError,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=10,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=None,
+            expected_additional_kwargs=[dict(timeout=10)]),
+        RequestJsonParams(
+            default_tos=10,
+            override_tos=100,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=200,
+            json_exception=None,
+            expected_exception=None,
+            expected_additional_kwargs=[dict(timeout=100)]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=400,
+            json_exception=None,
+            expected_exception=http.Resource.ERROR_CODE_MAP[400],
+            expected_additional_kwargs=[{}]),
+        RequestJsonParams(
+            default_tos=None,
+            override_tos=None,
+            json_payload={'some': 'payload'},
+            obj_decoder=None,
+            request_exception=None,
+            resp_status=999,
+            json_exception=None,
+            expected_exception=http.MesosException,
+            expected_additional_kwargs=[{}])])
+def test_resource_request_json(
+        mock_ujson_loads,
+        mock_mesos_http_request,
+        default_tos,
+        override_tos,
+        json_payload,
+        obj_decoder,
+        request_exception,
+        resp_status,
+        json_exception,
+        expected_exception,
+        expected_additional_kwargs):
+    call_kwargs = dict(method=http.METHOD_POST,
+                       json={'some': 'payload'},
+                       timeout=None,
+                       url='http://some.domain/some/prefix',
+                       auth=None,
+                       headers={'Accept': 'application/json',
+                                'Accept-Encoding': 'gzip'},
+                       params=None)
+    mock_calls = []
+    for kwargs in expected_additional_kwargs:
+        call_kwargs.update(kwargs)
+        mock_calls.append(mock.call(**call_kwargs))
+
+    def json_side_effect(_):
+        if json_exception is None:
+            return {'some': 'return_value'}
+        else:
+            raise json_exception
+
+    def request_side_effect(*_, **__):
+        if request_exception is None:
+            return mock.Mock(status_code=resp_status)
+        else:
+            raise request_exception
+
+    mock_mesos_http_request.side_effect = request_side_effect
+    mock_ujson_loads.side_effect = json_side_effect
+
+    resource = http.Resource('http://some.domain/some/prefix',
+                             default_timeout=default_tos,
+                             default_auth=None,
+                             default_max_attempts=1,
+                             default_use_gzip_encoding=True)
+
+    if expected_exception is None:
+        ret = resource.request_json(http.METHOD_POST,
+                                    timeout=override_tos,
+                                    payload=json_payload,
+                                    decoder=obj_decoder)
+        expected_ret = {'some': 'return_value'}
+        if obj_decoder is None:
+            assert ret == expected_ret
+        else:
+            assert ret == SomeModel(**expected_ret)
+    else:
+        with pytest.raises(expected_exception):
+            resource.request_json(http.METHOD_POST,
+                                  timeout=override_tos,
+                                  payload=json_payload)
+
+    assert mock_mesos_http_request.mock_calls == mock_calls
+
+
+def test_resource_get_json(mock_mesos_http_request):
+    mock_mesos_http_request.return_value = mock.Mock(
+        status_code=200,
+        text=ujson.dumps({'hello': 'world'}),
+    )
+    mock_auth = mock.Mock()
+    resource = http.Resource('http://some.domain/some/prefix',
+                             default_timeout=100,
+                             default_auth=mock_auth,
+                             default_max_attempts=1,
+                             default_use_gzip_encoding=True)
+    ret = resource.get_json()
+    assert mock_mesos_http_request.mock_calls == [
+        mock.call(
+            json=None,
+            method='GET',
+            url='http://some.domain/some/prefix',
+            auth=mock_auth,
+            headers={'Accept-Encoding': 'gzip',
+                     'Accept': 'application/json'},
+            params=None,
+            timeout=100,
+        )
+    ]
+    assert ret == {'hello': 'world'}
+
+
+def test_resource_post_json(mock_mesos_http_request):
+    mock_mesos_http_request.return_value = mock.Mock(
+        status_code=200,
+        text=ujson.dumps({'hello': 'world'}),
+    )
+    mock_auth = mock.Mock()
+    resource = http.Resource('http://some.domain/some/prefix',
+                             default_timeout=100,
+                             default_auth=mock_auth,
+                             default_max_attempts=1,
+                             default_use_gzip_encoding=True)
+    ret = resource.post_json(payload={'somekey': 'someval'})
+    assert mock_mesos_http_request.mock_calls == [
+        mock.call(json={'somekey': 'someval'},
+                  method='POST',
+                  url='http://some.domain/some/prefix',
+                  auth=mock_auth,
+                  headers={'Accept-Encoding': 'gzip',
+                           'Accept': 'application/json'},
+                  params=None,
+                  timeout=100)
+    ]
+    assert ret == {'hello': 'world'}