diff --git a/README.md b/README.md index 5abbb5d07..4eb774f67 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,7 @@ except AuthException as e: - When enabled, only the **most recent** HTTP response is stored - `get_last_response()` returns `None` when verbose mode is disabled - The response object provides dict-like access to JSON data while also exposing HTTP metadata +- A response object is always truthy, including when the body is empty or not JSON, so `if response:` is a safe presence check **Available metadata on response objects:** - `response.headers` - HTTP response headers (dict-like object) @@ -595,7 +596,9 @@ except AuthException as e: - `response.text` - Raw response body as text (str) - `response.url` - Request URL (str) - `response.ok` - Whether status code is < 400 (bool) -- `response.json()` - Parsed JSON response (dict/list) +- `response.json()` - Parsed JSON response (dict/list), raises if the body is not JSON +- `response.is_json` - Whether the body can be parsed as JSON (bool) +- `response.raw` - The underlying `httpx.Response` - `response["key"]` - Dict-like access to JSON data (for backward compatibility) For a complete example, see [samples/verbose_mode_example.py](https://github.com/descope/python-sdk/blob/main/samples/verbose_mode_example.py). diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 5e2a1a9df..432181a08 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -30,6 +30,9 @@ def sdk_version(): return version("descope") +# Longest non-JSON body echoed into str()/repr() of a response +_MAX_TEXT_PREVIEW = 200 + # HTTP status codes that should trigger automatic retries _RETRY_STATUS_CODES = {503, 520, 521, 522, 524, 530} # Delays in seconds between retries: first retry after 100ms, subsequent retries after 5s @@ -50,6 +53,12 @@ class DescopeResponse: This allows backward compatibility (acting like a dict) while exposing HTTP metadata like cf-ray headers for debugging. + + Members that need the parsed body (``json()``, ``__getitem__``, ``get``, + ``keys``, ``values``, ``items``, ``__len__``, ``__iter__``, ``__contains__``) + raise on a non-JSON body. Inspecting the response itself never does: + ``bool()`` is always True, and ``str()``/``repr()`` fall back to the raw + text, so a response is always loggable. Use ``is_json`` to check first. """ def __init__(self, response: httpx.Response): @@ -62,6 +71,15 @@ def json(self): self._json_data = self.raw.json() return self._json_data + @property + def is_json(self) -> bool: + """True if the response body can be parsed as JSON.""" + try: + self.json() + except ValueError: + return False + return True + # Dict-like interface for backward compatibility def __getitem__(self, key): return self.json()[key] @@ -81,22 +99,43 @@ def items(self): def get(self, key, default=None): return self.json().get(key, default) + def _text_preview(self): + """Bounded view of a non-JSON body: its size is upstream-controlled.""" + text = self.raw.text + if len(text) <= _MAX_TEXT_PREVIEW: + return text + return f"{text[:_MAX_TEXT_PREVIEW]}... ({len(text)} chars, use .text for the full body)" + + # Inspection dunders never parse-fail: a non-JSON body (an nginx 502 HTML + # page, for example) must still be loggable and truthy as a response object. def __str__(self): - return str(self.json()) + try: + return str(self.json()) + except ValueError: + return self._text_preview() def __repr__(self): - return f"DescopeResponse({repr(self.json())})" + try: + return f"DescopeResponse({repr(self.json())})" + except ValueError: + return f"DescopeResponse(status_code={self.raw.status_code}, text={self._text_preview()!r})" def __bool__(self): - return bool(self.json()) + # A response object is always truthy: truthiness answers "did I get a + # response", not "is the body non-empty". Must stay explicit — without + # it Python falls back to __len__, which parses the body. + return True def __len__(self): return len(self.json()) def __eq__(self, other): - if isinstance(other, DescopeResponse): - return self.json() == other.json() - return self.json() == other + try: + if isinstance(other, DescopeResponse): + return self.json() == other.json() + return self.json() == other + except ValueError: + return self is other def __ne__(self, other): return not self.__eq__(other) diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 9fa791213..8406b3497 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -839,3 +839,33 @@ async def test_verbose_mode_captures_mgmt_response(self, client_factory): assert last_resp["user"]["id"] == "u1" assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" assert last_resp.status_code == 200 + + async def test_verbose_mode_returns_response_on_non_json_body(self, client_factory): + """get_last_response() must not parse the body: a 502 HTML page is still returned.""" + html = "502 Bad Gateway" + mock_response = mock.Mock() + mock_response.is_success = False + mock_response.status_code = 502 + mock_response.text = html + mock_response.headers = {"cf-ray": "mgmt-ray-502"} + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0) + + client = client_factory.make( + PROJECT_ID, + public_key=PUBLIC_KEY_DICT, + management_key="test-mgmt-key", + verbose=True, + ) + if client_factory.mode == "async": + client._raw._license_attempted = True + + with client.mock_mgmt_post(mock_response): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.create(login_id="test@example.com")) + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp.status_code == 502 + assert last_resp.text == html + assert last_resp.headers.get("cf-ray") == "mgmt-ray-502" + assert last_resp.is_json is False diff --git a/tests/test_http_client.py b/tests/test_http_client.py index ac497b59f..ab48302d5 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,3 +1,4 @@ +import json import os import unittest from unittest.mock import Mock, patch @@ -124,6 +125,65 @@ def test_cookies_and_content(self): assert resp.cookies.get("session") == "abc123" assert resp.content == b'{"data":"test"}' + def test_non_json_body_is_inspectable(self): + """A non-JSON body (e.g. an nginx 502 HTML page) must not break inspection.""" + html = "502 Bad Gateway" + mock_response = Mock() + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0) + mock_response.status_code = 502 + mock_response.text = html + mock_response.headers = {"cf-ray": "abc123"} + mock_response.is_success = False + + resp = DescopeResponse(mock_response) + + assert bool(resp) is True + assert str(resp) == html + assert "502" in repr(resp) + assert resp.is_json is False + assert resp.status_code == 502 + assert resp.text == html + assert resp.headers.get("cf-ray") == "abc123" + assert resp.ok is False + # Equality falls back to identity rather than raising + assert (resp == DescopeResponse(mock_response)) is False + + # Explicit JSON access still raises + with self.assertRaises(json.JSONDecodeError): + resp.json() + with self.assertRaises(json.JSONDecodeError): + resp.__getitem__("errorCode") + + def test_long_non_json_body_is_truncated_in_str_and_repr(self): + """Body size is upstream-controlled, so logging must not echo it unbounded.""" + body = "x" * 5000 + mock_response = Mock() + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0) + mock_response.status_code = 502 + mock_response.text = body + + resp = DescopeResponse(mock_response) + + assert len(str(resp)) < 300 + assert "5000 chars" in str(resp) + assert len(repr(resp)) < 300 + assert resp.text == body # full body still reachable + + def test_is_json_true_for_json_body(self): + mock_response = Mock() + mock_response.json.return_value = {"data": "test"} + assert DescopeResponse(mock_response).is_json is True + + def test_empty_json_body_is_truthy(self): + """Truthiness means "a response exists", not "the body is non-empty".""" + mock_response = Mock() + mock_response.json.return_value = {} + resp = DescopeResponse(mock_response) + + assert bool(resp) is True + mock_response.json.assert_not_called() # truthiness must not parse the body + assert len(resp) == 0 + @patch("httpx.get") def test_verbose_mode_captures_response_before_error(self, mock_get): """Test that verbose mode captures response even when errors are raised.