From 70b45d4ba04119d3b2a9c2f25a99a02b274d1359 Mon Sep 17 00:00:00 2001
From: Lior eliav <33252035+LioriE@users.noreply.github.com>
Date: Sun, 9 Aug 2026 11:22:55 +0300
Subject: [PATCH 1/4] fix(http): return last response for non-JSON bodies
get_last_response() raised JSONDecodeError instead of returning the
response when the body was not JSON (e.g. an nginx 502 HTML page):
`mgmt_resp or auth_resp` evaluates truthiness, which called
DescopeResponse.__bool__ -> json(). __str__ and __repr__ had the same
problem, so logging the response crashed too.
The inspection dunders now fall back on a parse failure instead of
raising, leaving JSON-body semantics unchanged. Explicit JSON access
(json(), __getitem__, get, keys, items, __len__, __iter__) still
raises. Adds an is_json property, and records the last response on
put(), which was missing in both the sync and async clients.
---
README.md | 6 +++--
descope/_http_client_base.py | 35 +++++++++++++++++++++++-----
descope/descope_client.py | 4 ++--
descope/descope_client_async.py | 2 +-
descope/http_client.py | 4 +++-
descope/http_client_async.py | 2 ++
samples/verbose_mode_example.py | 4 ++--
tests/test_descope_client.py | 30 ++++++++++++++++++++++++
tests/test_http_client.py | 41 +++++++++++++++++++++++++++++++++
9 files changed, 114 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index e01e34fd0..e776b7e21 100644
--- a/README.md
+++ b/README.md
@@ -565,7 +565,7 @@ try:
except AuthException as e:
# Access the last response metadata for debugging
response = client.get_last_response()
- if response:
+ if response is not None:
logger.error(f"Request failed with status {response.status_code}")
logger.error(f"cf-ray: {response.headers.get('cf-ray')}")
logger.error(f"x-request-id: {response.headers.get('x-request-id')}")
@@ -587,7 +587,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..673fe3c91 100644
--- a/descope/_http_client_base.py
+++ b/descope/_http_client_base.py
@@ -62,6 +62,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 +90,36 @@ def items(self):
def get(self, key, default=None):
return self.json().get(key, default)
+ # 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.raw.text
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.raw.text[:200]!r})"
def __bool__(self):
- return bool(self.json())
+ try:
+ return bool(self.json())
+ except ValueError:
+ 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/descope/descope_client.py b/descope/descope_client.py
index 8f39159f0..2a7563550 100644
--- a/descope/descope_client.py
+++ b/descope/descope_client.py
@@ -387,7 +387,7 @@ def get_last_response(self):
client.mgmt.user.create(login_id="test@example.com")
except AuthException:
resp = client.get_last_response()
- if resp:
+ if resp is not None:
# Access metadata for debugging
cf_ray = resp.headers.get("cf-ray")
status = resp.status_code
@@ -398,4 +398,4 @@ def get_last_response(self):
# Return whichever is not None, preferring mgmt if both exist
# (in practice, only one should be non-None at a time)
- return mgmt_resp or auth_resp
+ return mgmt_resp if mgmt_resp is not None else auth_resp
diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py
index 1da2a94ad..d60588653 100644
--- a/descope/descope_client_async.py
+++ b/descope/descope_client_async.py
@@ -322,4 +322,4 @@ def get_last_response(self):
"""Get the last HTTP response when verbose mode is enabled."""
mgmt_resp = self._mgmt_http.get_last_response()
auth_resp = self._auth_http.get_last_response()
- return mgmt_resp or auth_resp
+ return mgmt_resp if mgmt_resp is not None else auth_resp
diff --git a/descope/http_client.py b/descope/http_client.py
index badcf0c2a..2067d4b80 100644
--- a/descope/http_client.py
+++ b/descope/http_client.py
@@ -104,6 +104,8 @@ def put(
timeout=self.timeout_seconds,
)
)
+ if self.verbose:
+ self._thread_local.last_response = DescopeResponse(response)
self._raise_from_response(response)
return response
@@ -172,7 +174,7 @@ def get_last_response(self) -> DescopeResponse | None:
client.mgmt.user.create(login_id="u1")
except AuthException:
resp = client.get_last_response()
- if resp:
+ if resp is not None:
logger.error(f"cf-ray: {resp.headers.get('cf-ray')}")
"""
return getattr(self._thread_local, "last_response", None)
diff --git a/descope/http_client_async.py b/descope/http_client_async.py
index 205b5d225..6f4e11189 100644
--- a/descope/http_client_async.py
+++ b/descope/http_client_async.py
@@ -109,6 +109,8 @@ async def put(
params=params,
)
)
+ if self.verbose:
+ self._last_response_var.set(DescopeResponse(response))
self._raise_from_response(response)
return response
diff --git a/samples/verbose_mode_example.py b/samples/verbose_mode_example.py
index 872597827..03b365e7d 100644
--- a/samples/verbose_mode_example.py
+++ b/samples/verbose_mode_example.py
@@ -35,7 +35,7 @@ def example_with_verbose_mode():
# Access the last response metadata
response = client.get_last_response()
- if response:
+ if response is not None:
logger.info("Request succeeded!")
logger.info("Status: %s", response.status_code)
logger.info("cf-ray: %s", response.headers.get("cf-ray"))
@@ -44,7 +44,7 @@ def example_with_verbose_mode():
except AuthException:
# When an error occurs, capture the response metadata for debugging
response = client.get_last_response()
- if response:
+ if response is not None:
logger.error("Request failed with status %s", response.status_code)
logger.error("cf-ray: %s", response.headers.get("cf-ray"))
logger.error("x-request-id: %s", response.headers.get("x-request-id"))
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..6693c4a51 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,46 @@ 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
+ assert resp == resp
+ assert resp != DescopeResponse(mock_response)
+
+ # Explicit JSON access still raises
+ with self.assertRaises(json.JSONDecodeError):
+ resp.json()
+ with self.assertRaises(json.JSONDecodeError):
+ resp["errorCode"]
+
+ 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_falsy(self):
+ """Existing truthiness semantics for JSON bodies are preserved."""
+ mock_response = Mock()
+ mock_response.json.return_value = {}
+ assert bool(DescopeResponse(mock_response)) is False
+
@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.
From 8058e9f664eae73991fbee4e88d82bb64caae6e8 Mon Sep 17 00:00:00 2001
From: Lior eliav <33252035+LioriE@users.noreply.github.com>
Date: Sun, 9 Aug 2026 12:01:18 +0300
Subject: [PATCH 2/4] fix(http): make response truthiness mean "a response
exists"
bool(response) answered "is the JSON body non-empty", which meant
truthiness parsed the body. It is now unconditionally True: an empty
JSON body no longer reads as no-response, and truthiness never parses.
Kept explicit rather than deleted, since __len__ is defined and Python
would otherwise fall back to it for truthiness, reintroducing both the
parse and the empty-body falsiness.
---
README.md | 1 +
descope/_http_client_base.py | 14 ++++++++++----
tests/test_http_client.py | 10 +++++++---
3 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index e776b7e21..2899cd237 100644
--- a/README.md
+++ b/README.md
@@ -580,6 +580,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
+- Check for a response with `if response is not None:` — a response object is always truthy, including when the body is empty or not JSON
**Available metadata on response objects:**
- `response.headers` - HTTP response headers (dict-like object)
diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py
index 673fe3c91..f00036576 100644
--- a/descope/_http_client_base.py
+++ b/descope/_http_client_base.py
@@ -50,6 +50,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):
@@ -105,10 +111,10 @@ def __repr__(self):
return f"DescopeResponse(status_code={self.raw.status_code}, text={self.raw.text[:200]!r})"
def __bool__(self):
- try:
- return bool(self.json())
- except ValueError:
- return True
+ # 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())
diff --git a/tests/test_http_client.py b/tests/test_http_client.py
index 6693c4a51..2834aad62 100644
--- a/tests/test_http_client.py
+++ b/tests/test_http_client.py
@@ -159,11 +159,15 @@ def test_is_json_true_for_json_body(self):
mock_response.json.return_value = {"data": "test"}
assert DescopeResponse(mock_response).is_json is True
- def test_empty_json_body_is_falsy(self):
- """Existing truthiness semantics for JSON bodies are preserved."""
+ 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 = {}
- assert bool(DescopeResponse(mock_response)) is False
+ 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):
From 7026a260314a8f4c2cfad75f57905d55864379eb Mon Sep 17 00:00:00 2001
From: Lior eliav <33252035+LioriE@users.noreply.github.com>
Date: Sun, 9 Aug 2026 12:15:05 +0300
Subject: [PATCH 3/4] fix(http): bound non-JSON body in str/repr, test put
capture
Revert the get_last_response() accessor and the docs to plain truthiness
now that __bool__ no longer parses the body: `mgmt_resp or auth_resp` is
behaviorally identical to the explicit None check.
Address review:
- str() echoed the whole non-JSON body while repr() capped at 200 chars.
Body size is upstream-controlled, so both now share one bounded preview
and point at .text for the full body.
- put() verbose capture had no test in either client, which is how it was
missed in the first place. Added to both.
---
README.md | 4 ++--
descope/_http_client_base.py | 14 ++++++++++--
descope/descope_client.py | 4 ++--
descope/descope_client_async.py | 2 +-
descope/http_client.py | 2 +-
samples/verbose_mode_example.py | 4 ++--
tests/test_http_client.py | 39 ++++++++++++++++++++++++++++++---
tests/test_http_client_async.py | 12 ++++++++++
8 files changed, 68 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 2899cd237..c739e4d45 100644
--- a/README.md
+++ b/README.md
@@ -565,7 +565,7 @@ try:
except AuthException as e:
# Access the last response metadata for debugging
response = client.get_last_response()
- if response is not None:
+ if response:
logger.error(f"Request failed with status {response.status_code}")
logger.error(f"cf-ray: {response.headers.get('cf-ray')}")
logger.error(f"x-request-id: {response.headers.get('x-request-id')}")
@@ -580,7 +580,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
-- Check for a response with `if response is not None:` — a response object is always truthy, including when the body is empty or not JSON
+- 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)
diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py
index f00036576..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
@@ -96,19 +99,26 @@ 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):
try:
return str(self.json())
except ValueError:
- return self.raw.text
+ return self._text_preview()
def __repr__(self):
try:
return f"DescopeResponse({repr(self.json())})"
except ValueError:
- return f"DescopeResponse(status_code={self.raw.status_code}, text={self.raw.text[:200]!r})"
+ return f"DescopeResponse(status_code={self.raw.status_code}, text={self._text_preview()!r})"
def __bool__(self):
# A response object is always truthy: truthiness answers "did I get a
diff --git a/descope/descope_client.py b/descope/descope_client.py
index 2a7563550..8f39159f0 100644
--- a/descope/descope_client.py
+++ b/descope/descope_client.py
@@ -387,7 +387,7 @@ def get_last_response(self):
client.mgmt.user.create(login_id="test@example.com")
except AuthException:
resp = client.get_last_response()
- if resp is not None:
+ if resp:
# Access metadata for debugging
cf_ray = resp.headers.get("cf-ray")
status = resp.status_code
@@ -398,4 +398,4 @@ def get_last_response(self):
# Return whichever is not None, preferring mgmt if both exist
# (in practice, only one should be non-None at a time)
- return mgmt_resp if mgmt_resp is not None else auth_resp
+ return mgmt_resp or auth_resp
diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py
index d60588653..1da2a94ad 100644
--- a/descope/descope_client_async.py
+++ b/descope/descope_client_async.py
@@ -322,4 +322,4 @@ def get_last_response(self):
"""Get the last HTTP response when verbose mode is enabled."""
mgmt_resp = self._mgmt_http.get_last_response()
auth_resp = self._auth_http.get_last_response()
- return mgmt_resp if mgmt_resp is not None else auth_resp
+ return mgmt_resp or auth_resp
diff --git a/descope/http_client.py b/descope/http_client.py
index 2067d4b80..598bc7adb 100644
--- a/descope/http_client.py
+++ b/descope/http_client.py
@@ -174,7 +174,7 @@ def get_last_response(self) -> DescopeResponse | None:
client.mgmt.user.create(login_id="u1")
except AuthException:
resp = client.get_last_response()
- if resp is not None:
+ if resp:
logger.error(f"cf-ray: {resp.headers.get('cf-ray')}")
"""
return getattr(self._thread_local, "last_response", None)
diff --git a/samples/verbose_mode_example.py b/samples/verbose_mode_example.py
index 03b365e7d..872597827 100644
--- a/samples/verbose_mode_example.py
+++ b/samples/verbose_mode_example.py
@@ -35,7 +35,7 @@ def example_with_verbose_mode():
# Access the last response metadata
response = client.get_last_response()
- if response is not None:
+ if response:
logger.info("Request succeeded!")
logger.info("Status: %s", response.status_code)
logger.info("cf-ray: %s", response.headers.get("cf-ray"))
@@ -44,7 +44,7 @@ def example_with_verbose_mode():
except AuthException:
# When an error occurs, capture the response metadata for debugging
response = client.get_last_response()
- if response is not None:
+ if response:
logger.error("Request failed with status %s", response.status_code)
logger.error("cf-ray: %s", response.headers.get("cf-ray"))
logger.error("x-request-id: %s", response.headers.get("x-request-id"))
diff --git a/tests/test_http_client.py b/tests/test_http_client.py
index 2834aad62..3fc7d7e3d 100644
--- a/tests/test_http_client.py
+++ b/tests/test_http_client.py
@@ -145,14 +145,29 @@ def test_non_json_body_is_inspectable(self):
assert resp.text == html
assert resp.headers.get("cf-ray") == "abc123"
assert resp.ok is False
- assert resp == resp
- assert resp != DescopeResponse(mock_response)
+ # 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["errorCode"]
+ 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()
@@ -296,6 +311,24 @@ def test_verbose_mode_captures_patch_response(self, mock_patch):
assert last_resp["updated"] == "user1"
assert last_resp.status_code == 200
+ @patch("httpx.put")
+ def test_verbose_mode_captures_put_response(self, mock_put):
+ """Test that PUT responses are captured in verbose mode."""
+ mock_response = Mock()
+ mock_response.is_success = True
+ mock_response.json.return_value = {"replaced": "user1"}
+ mock_response.headers = {"cf-ray": "put123"}
+ mock_response.status_code = 200
+ mock_put.return_value = mock_response
+
+ client = HTTPClient(project_id="test123", verbose=True)
+ client.put("/users/1", body={"name": "replaced"})
+
+ last_resp = client.get_last_response()
+ assert last_resp is not None
+ assert last_resp["replaced"] == "user1"
+ assert last_resp.status_code == 200
+
@patch("httpx.delete")
def test_verbose_mode_captures_delete_response(self, mock_delete):
"""Test that DELETE responses are captured in verbose mode."""
diff --git a/tests/test_http_client_async.py b/tests/test_http_client_async.py
index 25f100dcb..82b627f3e 100644
--- a/tests/test_http_client_async.py
+++ b/tests/test_http_client_async.py
@@ -317,6 +317,18 @@ async def test_patch_captures_response_when_verbose(self):
assert last is not None
assert last.status_code == 200
+ async def test_put_captures_response_when_verbose(self):
+ client = make_async_client(verbose=True)
+ client._async_client.put = AsyncMock(
+ return_value=make_resp(status=200, json_data={"replaced": 1}, headers={"cf-ray": "r5"})
+ )
+
+ await client.put("/x", body={})
+
+ last = client.get_last_response()
+ assert last is not None
+ assert last.status_code == 200
+
async def test_delete_captures_response_when_verbose(self):
client = make_async_client(verbose=True)
client._async_client.delete = AsyncMock(
From 91f19586db337275308196697d1d741b87e36c62 Mon Sep 17 00:00:00 2001
From: Lior eliav <33252035+LioriE@users.noreply.github.com>
Date: Sun, 9 Aug 2026 17:00:16 +0300
Subject: [PATCH 4/4] revert: drop put() verbose capture from this PR
The put() last-response gap belongs to descope/etc#16377, together with
the get_last_response() staleness half of that issue. Keeping it here
would half-close that issue and put changes outside this bug's scope
into a fix for descope/etc#17556.
---
descope/http_client.py | 2 --
descope/http_client_async.py | 2 --
tests/test_http_client.py | 18 ------------------
tests/test_http_client_async.py | 12 ------------
4 files changed, 34 deletions(-)
diff --git a/descope/http_client.py b/descope/http_client.py
index 598bc7adb..badcf0c2a 100644
--- a/descope/http_client.py
+++ b/descope/http_client.py
@@ -104,8 +104,6 @@ def put(
timeout=self.timeout_seconds,
)
)
- if self.verbose:
- self._thread_local.last_response = DescopeResponse(response)
self._raise_from_response(response)
return response
diff --git a/descope/http_client_async.py b/descope/http_client_async.py
index 6f4e11189..205b5d225 100644
--- a/descope/http_client_async.py
+++ b/descope/http_client_async.py
@@ -109,8 +109,6 @@ async def put(
params=params,
)
)
- if self.verbose:
- self._last_response_var.set(DescopeResponse(response))
self._raise_from_response(response)
return response
diff --git a/tests/test_http_client.py b/tests/test_http_client.py
index 3fc7d7e3d..ab48302d5 100644
--- a/tests/test_http_client.py
+++ b/tests/test_http_client.py
@@ -311,24 +311,6 @@ def test_verbose_mode_captures_patch_response(self, mock_patch):
assert last_resp["updated"] == "user1"
assert last_resp.status_code == 200
- @patch("httpx.put")
- def test_verbose_mode_captures_put_response(self, mock_put):
- """Test that PUT responses are captured in verbose mode."""
- mock_response = Mock()
- mock_response.is_success = True
- mock_response.json.return_value = {"replaced": "user1"}
- mock_response.headers = {"cf-ray": "put123"}
- mock_response.status_code = 200
- mock_put.return_value = mock_response
-
- client = HTTPClient(project_id="test123", verbose=True)
- client.put("/users/1", body={"name": "replaced"})
-
- last_resp = client.get_last_response()
- assert last_resp is not None
- assert last_resp["replaced"] == "user1"
- assert last_resp.status_code == 200
-
@patch("httpx.delete")
def test_verbose_mode_captures_delete_response(self, mock_delete):
"""Test that DELETE responses are captured in verbose mode."""
diff --git a/tests/test_http_client_async.py b/tests/test_http_client_async.py
index 82b627f3e..25f100dcb 100644
--- a/tests/test_http_client_async.py
+++ b/tests/test_http_client_async.py
@@ -317,18 +317,6 @@ async def test_patch_captures_response_when_verbose(self):
assert last is not None
assert last.status_code == 200
- async def test_put_captures_response_when_verbose(self):
- client = make_async_client(verbose=True)
- client._async_client.put = AsyncMock(
- return_value=make_resp(status=200, json_data={"replaced": 1}, headers={"cf-ray": "r5"})
- )
-
- await client.put("/x", body={})
-
- last = client.get_last_response()
- assert last is not None
- assert last.status_code == 200
-
async def test_delete_captures_response_when_verbose(self):
client = make_async_client(verbose=True)
client._async_client.delete = AsyncMock(