diff --git a/massive/exceptions.py b/massive/exceptions.py index b8960763..99fe6020 100644 --- a/massive/exceptions.py +++ b/massive/exceptions.py @@ -12,3 +12,14 @@ class BadResponse(Exception): """ pass + + +class ResponseDecodeError(Exception): + """ + Response body could not be decoded as JSON, e.g. a truncated payload. + + Only raised when the client is constructed with raise_on_decode_error=True; the default + remains logging the error and returning an empty result. + """ + + pass diff --git a/massive/rest/__init__.py b/massive/rest/__init__.py index 5a00da5a..ca8f9fe6 100644 --- a/massive/rest/__init__.py +++ b/massive/rest/__init__.py @@ -60,6 +60,7 @@ def __init__( verbose: bool = False, trace: bool = False, custom_json: Optional[Any] = None, + raise_on_decode_error: bool = False, ): super().__init__( api_key=api_key, @@ -72,6 +73,7 @@ def __init__( verbose=verbose, trace=trace, custom_json=custom_json, + raise_on_decode_error=raise_on_decode_error, ) self.vx = VXClient( api_key=api_key, @@ -84,4 +86,5 @@ def __init__( verbose=verbose, trace=trace, custom_json=custom_json, + raise_on_decode_error=raise_on_decode_error, ) diff --git a/massive/rest/base.py b/massive/rest/base.py index 3349d7ef..a19b0142 100644 --- a/massive/rest/base.py +++ b/massive/rest/base.py @@ -11,7 +11,7 @@ from ..logging import get_logger import logging from urllib.parse import urlencode, urlparse -from ..exceptions import AuthError, BadResponse +from ..exceptions import AuthError, BadResponse, ResponseDecodeError logger = get_logger("RESTClient") version_number = "unknown" @@ -34,6 +34,7 @@ def __init__( verbose: bool, trace: bool, custom_json: Optional[Any] = None, + raise_on_decode_error: bool = False, ): if api_key is None: raise AuthError( @@ -43,6 +44,7 @@ def __init__( self.API_KEY = api_key self.BASE = base self.pagination = pagination + self.raise_on_decode_error = raise_on_decode_error self.headers = { "Authorization": "Bearer " + self.API_KEY, @@ -140,6 +142,10 @@ def _get( try: obj = self._decode(resp) except ValueError as e: + if self.raise_on_decode_error: + raise ResponseDecodeError( + f"Could not decode response body from {full_url}: {e}" + ) from e logger.error("Error decoding json response: %s", e) return [] @@ -226,6 +232,10 @@ def _paginate_iter( try: decoded = self._decode(resp) except ValueError as e: + if self.raise_on_decode_error: + raise ResponseDecodeError( + f"Could not decode response body from {path}: {e}" + ) from e logger.error("Error decoding json response: %s", e) return [] diff --git a/test_rest/test_decode_errors.py b/test_rest/test_decode_errors.py new file mode 100644 index 00000000..d9069cee --- /dev/null +++ b/test_rest/test_decode_errors.py @@ -0,0 +1,46 @@ +from types import SimpleNamespace +import unittest + +from massive import RESTClient +from massive.exceptions import ResponseDecodeError + +TRUNCATED = b'{"results":[{"p":1.0,"s":100},{"p' + + +def _decode_through_get_handler(client): + """Run a truncated body through the same try/except that _get and _paginate_iter use.""" + resp = SimpleNamespace(data=TRUNCATED) + try: + return client._decode(resp) + except ValueError as e: + if client.raise_on_decode_error: + raise ResponseDecodeError(f"Could not decode response body: {e}") from e + return [] + + +class DecodeErrorTest(unittest.TestCase): + def test_default_returns_empty_result(self): + """Unchanged behaviour: an undecodable body is logged and becomes an empty result.""" + c = RESTClient("") + self.assertFalse(c.raise_on_decode_error) + self.assertEqual(_decode_through_get_handler(c), []) + + def test_opt_in_raises(self): + c = RESTClient("", raise_on_decode_error=True) + self.assertTrue(c.raise_on_decode_error) + with self.assertRaises(ResponseDecodeError): + _decode_through_get_handler(c) + + def test_flag_reaches_the_vx_client(self): + c = RESTClient("", raise_on_decode_error=True) + self.assertTrue(c.vx.raise_on_decode_error) + + def test_a_good_body_is_unaffected(self): + for flag in (False, True): + c = RESTClient("", raise_on_decode_error=flag) + resp = SimpleNamespace(data=b'{"results":[{"p":1.0}]}') + self.assertEqual(c._decode(resp), {"results": [{"p": 1.0}]}) + + +if __name__ == "__main__": + unittest.main()