Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,14 +588,17 @@ 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)
- `response.status_code` - HTTP status code (int)
- `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).
Expand Down
51 changes: 45 additions & 6 deletions descope/_http_client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -62,6 +71,15 @@ def json(self):
self._json_data = self.raw.json()
return self._json_data

@property
Comment thread
LioriE marked this conversation as resolved.
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]
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_descope_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<html><head><title>502 Bad Gateway</title></head></html>"
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
60 changes: 60 additions & 0 deletions tests/test_http_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import unittest
from unittest.mock import Mock, patch
Expand Down Expand Up @@ -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 = "<html><head><title>502 Bad Gateway</title></head></html>"
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.
Expand Down
Loading