Skip to content

Commit 524aef3

Browse files
authored
fix(http): return last response for non-JSON bodies (#1653)
1 parent dba4646 commit 524aef3

4 files changed

Lines changed: 139 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,14 +588,17 @@ except AuthException as e:
588588
- When enabled, only the **most recent** HTTP response is stored
589589
- `get_last_response()` returns `None` when verbose mode is disabled
590590
- The response object provides dict-like access to JSON data while also exposing HTTP metadata
591+
- A response object is always truthy, including when the body is empty or not JSON, so `if response:` is a safe presence check
591592

592593
**Available metadata on response objects:**
593594
- `response.headers` - HTTP response headers (dict-like object)
594595
- `response.status_code` - HTTP status code (int)
595596
- `response.text` - Raw response body as text (str)
596597
- `response.url` - Request URL (str)
597598
- `response.ok` - Whether status code is < 400 (bool)
598-
- `response.json()` - Parsed JSON response (dict/list)
599+
- `response.json()` - Parsed JSON response (dict/list), raises if the body is not JSON
600+
- `response.is_json` - Whether the body can be parsed as JSON (bool)
601+
- `response.raw` - The underlying `httpx.Response`
599602
- `response["key"]` - Dict-like access to JSON data (for backward compatibility)
600603

601604
For a complete example, see [samples/verbose_mode_example.py](https://github.com/descope/python-sdk/blob/main/samples/verbose_mode_example.py).

descope/_http_client_base.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def sdk_version():
3030
return version("descope")
3131

3232

33+
# Longest non-JSON body echoed into str()/repr() of a response
34+
_MAX_TEXT_PREVIEW = 200
35+
3336
# HTTP status codes that should trigger automatic retries
3437
_RETRY_STATUS_CODES = {503, 520, 521, 522, 524, 530}
3538
# Delays in seconds between retries: first retry after 100ms, subsequent retries after 5s
@@ -50,6 +53,12 @@ class DescopeResponse:
5053
5154
This allows backward compatibility (acting like a dict) while exposing
5255
HTTP metadata like cf-ray headers for debugging.
56+
57+
Members that need the parsed body (``json()``, ``__getitem__``, ``get``,
58+
``keys``, ``values``, ``items``, ``__len__``, ``__iter__``, ``__contains__``)
59+
raise on a non-JSON body. Inspecting the response itself never does:
60+
``bool()`` is always True, and ``str()``/``repr()`` fall back to the raw
61+
text, so a response is always loggable. Use ``is_json`` to check first.
5362
"""
5463

5564
def __init__(self, response: httpx.Response):
@@ -62,6 +71,15 @@ def json(self):
6271
self._json_data = self.raw.json()
6372
return self._json_data
6473

74+
@property
75+
def is_json(self) -> bool:
76+
"""True if the response body can be parsed as JSON."""
77+
try:
78+
self.json()
79+
except ValueError:
80+
return False
81+
return True
82+
6583
# Dict-like interface for backward compatibility
6684
def __getitem__(self, key):
6785
return self.json()[key]
@@ -81,22 +99,43 @@ def items(self):
8199
def get(self, key, default=None):
82100
return self.json().get(key, default)
83101

102+
def _text_preview(self):
103+
"""Bounded view of a non-JSON body: its size is upstream-controlled."""
104+
text = self.raw.text
105+
if len(text) <= _MAX_TEXT_PREVIEW:
106+
return text
107+
return f"{text[:_MAX_TEXT_PREVIEW]}... ({len(text)} chars, use .text for the full body)"
108+
109+
# Inspection dunders never parse-fail: a non-JSON body (an nginx 502 HTML
110+
# page, for example) must still be loggable and truthy as a response object.
84111
def __str__(self):
85-
return str(self.json())
112+
try:
113+
return str(self.json())
114+
except ValueError:
115+
return self._text_preview()
86116

87117
def __repr__(self):
88-
return f"DescopeResponse({repr(self.json())})"
118+
try:
119+
return f"DescopeResponse({repr(self.json())})"
120+
except ValueError:
121+
return f"DescopeResponse(status_code={self.raw.status_code}, text={self._text_preview()!r})"
89122

90123
def __bool__(self):
91-
return bool(self.json())
124+
# A response object is always truthy: truthiness answers "did I get a
125+
# response", not "is the body non-empty". Must stay explicit — without
126+
# it Python falls back to __len__, which parses the body.
127+
return True
92128

93129
def __len__(self):
94130
return len(self.json())
95131

96132
def __eq__(self, other):
97-
if isinstance(other, DescopeResponse):
98-
return self.json() == other.json()
99-
return self.json() == other
133+
try:
134+
if isinstance(other, DescopeResponse):
135+
return self.json() == other.json()
136+
return self.json() == other
137+
except ValueError:
138+
return self is other
100139

101140
def __ne__(self, other):
102141
return not self.__eq__(other)

tests/test_descope_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,33 @@ async def test_verbose_mode_captures_mgmt_response(self, client_factory):
839839
assert last_resp["user"]["id"] == "u1"
840840
assert last_resp.headers.get("cf-ray") == "mgmt-ray-123"
841841
assert last_resp.status_code == 200
842+
843+
async def test_verbose_mode_returns_response_on_non_json_body(self, client_factory):
844+
"""get_last_response() must not parse the body: a 502 HTML page is still returned."""
845+
html = "<html><head><title>502 Bad Gateway</title></head></html>"
846+
mock_response = mock.Mock()
847+
mock_response.is_success = False
848+
mock_response.status_code = 502
849+
mock_response.text = html
850+
mock_response.headers = {"cf-ray": "mgmt-ray-502"}
851+
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0)
852+
853+
client = client_factory.make(
854+
PROJECT_ID,
855+
public_key=PUBLIC_KEY_DICT,
856+
management_key="test-mgmt-key",
857+
verbose=True,
858+
)
859+
if client_factory.mode == "async":
860+
client._raw._license_attempted = True
861+
862+
with client.mock_mgmt_post(mock_response):
863+
with pytest.raises(AuthException):
864+
await client.invoke(client.mgmt.user.create(login_id="test@example.com"))
865+
866+
last_resp = client.get_last_response()
867+
assert last_resp is not None
868+
assert last_resp.status_code == 502
869+
assert last_resp.text == html
870+
assert last_resp.headers.get("cf-ray") == "mgmt-ray-502"
871+
assert last_resp.is_json is False

tests/test_http_client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import unittest
34
from unittest.mock import Mock, patch
@@ -124,6 +125,65 @@ def test_cookies_and_content(self):
124125
assert resp.cookies.get("session") == "abc123"
125126
assert resp.content == b'{"data":"test"}'
126127

128+
def test_non_json_body_is_inspectable(self):
129+
"""A non-JSON body (e.g. an nginx 502 HTML page) must not break inspection."""
130+
html = "<html><head><title>502 Bad Gateway</title></head></html>"
131+
mock_response = Mock()
132+
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", html, 0)
133+
mock_response.status_code = 502
134+
mock_response.text = html
135+
mock_response.headers = {"cf-ray": "abc123"}
136+
mock_response.is_success = False
137+
138+
resp = DescopeResponse(mock_response)
139+
140+
assert bool(resp) is True
141+
assert str(resp) == html
142+
assert "502" in repr(resp)
143+
assert resp.is_json is False
144+
assert resp.status_code == 502
145+
assert resp.text == html
146+
assert resp.headers.get("cf-ray") == "abc123"
147+
assert resp.ok is False
148+
# Equality falls back to identity rather than raising
149+
assert (resp == DescopeResponse(mock_response)) is False
150+
151+
# Explicit JSON access still raises
152+
with self.assertRaises(json.JSONDecodeError):
153+
resp.json()
154+
with self.assertRaises(json.JSONDecodeError):
155+
resp.__getitem__("errorCode")
156+
157+
def test_long_non_json_body_is_truncated_in_str_and_repr(self):
158+
"""Body size is upstream-controlled, so logging must not echo it unbounded."""
159+
body = "x" * 5000
160+
mock_response = Mock()
161+
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0)
162+
mock_response.status_code = 502
163+
mock_response.text = body
164+
165+
resp = DescopeResponse(mock_response)
166+
167+
assert len(str(resp)) < 300
168+
assert "5000 chars" in str(resp)
169+
assert len(repr(resp)) < 300
170+
assert resp.text == body # full body still reachable
171+
172+
def test_is_json_true_for_json_body(self):
173+
mock_response = Mock()
174+
mock_response.json.return_value = {"data": "test"}
175+
assert DescopeResponse(mock_response).is_json is True
176+
177+
def test_empty_json_body_is_truthy(self):
178+
"""Truthiness means "a response exists", not "the body is non-empty"."""
179+
mock_response = Mock()
180+
mock_response.json.return_value = {}
181+
resp = DescopeResponse(mock_response)
182+
183+
assert bool(resp) is True
184+
mock_response.json.assert_not_called() # truthiness must not parse the body
185+
assert len(resp) == 0
186+
127187
@patch("httpx.get")
128188
def test_verbose_mode_captures_response_before_error(self, mock_get):
129189
"""Test that verbose mode captures response even when errors are raised.

0 commit comments

Comments
 (0)