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 = "