From 5fa683cf8eedeaa8469ddd2274b9f5f009e6cf6d Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:46:55 +0300 Subject: [PATCH 1/8] fix(http-client): capture last response on put, cache response accessors put() was the only verb not storing the verbose last-response, making any PUT invisible to get_last_response() in both the sync and async clients. DescopeResponse's derived accessors are now functools.cached_property, which also drops a re-parse when the JSON body is literally null. --- descope/_http_client_base.py | 31 ++++++++++++++++++++----------- descope/http_client.py | 2 ++ descope/http_client_async.py | 2 ++ tests/test_http_client.py | 31 +++++++++++++++++++++++++++++++ tests/test_http_client_async.py | 21 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 11 deletions(-) diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 432181a08..2538cc71f 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -4,6 +4,7 @@ import os import platform import ssl +from functools import cached_property from http import HTTPStatus from importlib.metadata import version @@ -59,19 +60,27 @@ class DescopeResponse: 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. + + The wrapped response is already complete, so the derived accessors are + ``cached_property``. ``functools.cache`` is not usable here: it keys on + ``self``, which is unhashable (``__eq__`` without ``__hash__``) and would + be pinned alive by the module-level cache. A failed parse is not cached — + ``cached_property`` stores nothing when the getter raises — so a non-JSON + body keeps raising from ``json()`` rather than caching a sentinel. """ def __init__(self, response: httpx.Response): self.raw = response - self._json_data = None + + @cached_property + def _json_data(self): + return self.raw.json() def json(self): """Get the parsed JSON response, cached after first access.""" - if self._json_data is None: - self._json_data = self.raw.json() return self._json_data - @property + @cached_property def is_json(self) -> bool: """True if the response body can be parsed as JSON.""" try: @@ -144,37 +153,37 @@ def __iter__(self): return iter(self.json()) # HTTP metadata properties - @property + @cached_property def headers(self): """Access response headers (e.g., response.headers.get('cf-ray')).""" return self.raw.headers - @property + @cached_property def status_code(self): """HTTP status code.""" return self.raw.status_code - @property + @cached_property def cookies(self): """Response cookies.""" return self.raw.cookies - @property + @cached_property def text(self): """Raw response text.""" return self.raw.text - @property + @cached_property def content(self): """Raw response content (bytes).""" return self.raw.content - @property + @cached_property def url(self): """Request URL.""" return self.raw.url - @property + @cached_property def ok(self): """True if status code indicates success (2xx).""" return self.raw.is_success diff --git a/descope/http_client.py b/descope/http_client.py index badcf0c2a..598bc7adb 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 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/tests/test_http_client.py b/tests/test_http_client.py index ab48302d5..924d3c8f1 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -311,6 +311,37 @@ 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.put") + def test_verbose_mode_not_capture_put_when_disabled(self, mock_put): + """Test that PUT responses are NOT captured when verbose mode is disabled.""" + mock_response = Mock() + mock_response.is_success = True + mock_response.json.return_value = {"replaced": "user1"} + mock_put.return_value = mock_response + + client = HTTPClient(project_id="test123", verbose=False) + client.put("/users/1", body={"name": "replaced"}) + + assert client.get_last_response() is None + @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..9125517a9 100644 --- a/tests/test_http_client_async.py +++ b/tests/test_http_client_async.py @@ -317,6 +317,27 @@ 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 + assert last.headers.get("cf-ray") == "r5" + + async def test_put_does_not_capture_when_not_verbose(self): + client = make_async_client(verbose=False) + client._async_client.put = AsyncMock(return_value=make_resp()) + + await client.put("/x", body={}) + + assert client.get_last_response() is None + async def test_delete_captures_response_when_verbose(self): client = make_async_client(verbose=True) client._async_client.delete = AsyncMock( From 27a8afaea52c66835b8df3cc9288e0598e092f38 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:29:17 +0300 Subject: [PATCH 2/8] fix(client): return the genuinely last response across auth and mgmt get_last_response() picked between two independently-overwritten stores with `mgmt_resp or auth_resp`, so once both had been used a stale mgmt response always shadowed a newer auth one. Neither slot knew which was written last, so any precedence rule between them was a guess. Collapse to one store per DescopeClient, injected into every HTTPClient it builds, so "last" means last and there is nothing to arbitrate. The store is threading.local for sync and a ContextVar for async because the isolation unit differs (OS thread vs asyncio task); concurrency semantics are unchanged. Also forwards verbose and the store into OutboundApplicationByToken's no_key_client, whose responses were never captured at all. --- descope/_http_client_base.py | 39 +++++++++++ descope/descope_client.py | 18 +++--- descope/descope_client_async.py | 17 +++-- descope/http_client.py | 23 ++++--- descope/http_client_async.py | 25 +++++--- descope/management/outbound_application.py | 2 + .../management/outbound_application_async.py | 2 + tests/test_descope_client.py | 64 +++++++++++++++++++ tests/test_http_client.py | 47 ++++++++++++++ tests/test_http_client_async.py | 42 +++++++++++- 10 files changed, 248 insertions(+), 31 deletions(-) diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 2538cc71f..8bd510d35 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -1,9 +1,11 @@ # This is not part of the public API but a code helper from __future__ import annotations +import contextvars import os import platform import ssl +import threading from functools import cached_property from http import HTTPStatus from importlib.metadata import version @@ -189,6 +191,43 @@ def ok(self): return self.raw.is_success +class ThreadLocalLastResponseStore: + """One last-response slot, isolated per thread. + + Shared by every ``HTTPClient`` a ``DescopeClient`` owns, so "last" means the + most recent response across auth and management calls rather than per-client. + """ + + def __init__(self) -> None: + self._local = threading.local() + + def set(self, response: DescopeResponse) -> None: + self._local.last_response = response + + def get(self) -> DescopeResponse | None: + return getattr(self._local, "last_response", None) + + +class ContextVarLastResponseStore: + """One last-response slot, isolated per async task. + + ContextVar rather than threading.local: every asyncio task runs on the same + event-loop thread, so a thread-local slot would be a single slot shared by + all concurrent tasks. + """ + + def __init__(self) -> None: + self._var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( + "descope_async_last_response", default=None + ) + + def set(self, response: DescopeResponse) -> None: + self._var.set(response) + + def get(self) -> DescopeResponse | None: + return self._var.get() + + class HTTPClientBase: """Shared, I/O-free base for HTTP client classes. diff --git a/descope/descope_client.py b/descope/descope_client.py index 8f39159f0..978bbd1d5 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -7,6 +7,7 @@ import httpx from descope._client_base import DescopeClientBase +from descope._http_client_base import ThreadLocalLastResponseStore from descope.auth import Auth from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 from descope.authmethod.magiclink import MagicLink # noqa: F401 @@ -54,6 +55,10 @@ def __init__( base_url=base_url, verbose=verbose, ) + # One store shared by every HTTP client below, so get_last_response() + # returns the genuinely most recent response rather than picking between + # per-client slots that were overwritten independently. + self._last_response_store = ThreadLocalLastResponseStore() auth_http_client = HTTPClient( project_id=self._project_id, base_url=base_url, @@ -61,6 +66,7 @@ def __init__( secure=not skip_verify, management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), verbose=verbose, + last_response_store=self._last_response_store, ) self._auth = Auth( self._project_id, @@ -87,6 +93,7 @@ def __init__( secure=auth_http_client.secure, management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), verbose=verbose, + last_response_store=self._last_response_store, ) self._mgmt = MGMT( http_client=mgmt_http_client, @@ -378,7 +385,8 @@ def get_last_response(self): Returns: DescopeResponse: The last response if verbose mode is enabled. - Returns the most recent response from either auth or mgmt operations. + Returns the most recent response across auth and mgmt + operations, whichever ran last. None if verbose mode is disabled or no requests have been made. Example: @@ -392,10 +400,4 @@ def get_last_response(self): cf_ray = resp.headers.get("cf-ray") status = resp.status_code """ - # Return the most recently used response - mgmt_resp = self._mgmt_http_client.get_last_response() - auth_resp = self._auth_http_client.get_last_response() - - # 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 self._last_response_store.get() diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 1da2a94ad..63e59a6c2 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -8,6 +8,7 @@ import httpx from descope._client_base import DescopeClientBase +from descope._http_client_base import ContextVarLastResponseStore from descope.auth_async import AuthAsync from descope.authmethod.enchantedlink_async import EnchantedLinkAsync from descope.authmethod.magiclink_async import MagicLinkAsync @@ -87,6 +88,10 @@ def __init__( verbose=verbose, ) + # One store shared by every HTTP client below, so get_last_response() + # returns the genuinely most recent response rather than picking between + # per-client slots that were overwritten independently. + self._last_response_store = ContextVarLastResponseStore() self._auth_http = HTTPClientAsync( project_id=self._project_id, base_url=base_url, @@ -94,6 +99,7 @@ def __init__( secure=not skip_verify, management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), verbose=verbose, + last_response_store=self._last_response_store, ) self._mgmt_http = HTTPClientAsync( project_id=self._project_id, @@ -102,6 +108,7 @@ def __init__( secure=not skip_verify, management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), verbose=verbose, + last_response_store=self._last_response_store, ) self._auth = AuthAsync( self._project_id, @@ -319,7 +326,9 @@ async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict: return await self._auth.select_tenant(tenant_id, refresh_token) 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 + """Get the last HTTP response when verbose mode is enabled. + + Returns the most recent response across auth and mgmt operations, + whichever ran last. + """ + return self._last_response_store.get() diff --git a/descope/http_client.py b/descope/http_client.py index 598bc7adb..e857bbad6 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -1,6 +1,5 @@ from __future__ import annotations -import threading import time from typing import cast @@ -12,6 +11,7 @@ DEFAULT_TIMEOUT_SECONDS, DescopeResponse, HTTPClientBase, + ThreadLocalLastResponseStore, ) @@ -25,6 +25,7 @@ def __init__( secure: bool = True, management_key: str | None = None, verbose: bool = False, + last_response_store: ThreadLocalLastResponseStore | None = None, ) -> None: super().__init__( project_id, @@ -34,7 +35,9 @@ def __init__( management_key=management_key, verbose=verbose, ) - self._thread_local = threading.local() + # Shared by every client of one DescopeClient when passed in, so + # get_last_response() sees a single ordering across auth and mgmt. + self.last_response_store = last_response_store or ThreadLocalLastResponseStore() # ------------- public API ------------- def get( @@ -56,7 +59,7 @@ def get( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -81,7 +84,7 @@ def post( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -105,7 +108,7 @@ def put( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -129,7 +132,7 @@ def patch( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -151,7 +154,7 @@ def delete( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -165,6 +168,10 @@ def get_last_response(self) -> DescopeResponse | None: This method is thread-safe: each thread will receive its own last response when using a shared client instance. + When the store is shared with other clients — as ``DescopeClient`` does + for its auth and management clients — this reports the last response + across all of them, not just the ones this client issued. + Returns: DescopeResponse: The last response if verbose mode is enabled, None otherwise. @@ -177,7 +184,7 @@ def get_last_response(self) -> DescopeResponse | None: if resp: logger.error(f"cf-ray: {resp.headers.get('cf-ray')}") """ - return getattr(self._thread_local, "last_response", None) + return self.last_response_store.get() # ------------- helpers ------------- def _execute_with_retry(self, request_fn) -> httpx.Response: diff --git a/descope/http_client_async.py b/descope/http_client_async.py index 6f4e11189..ef47681d0 100644 --- a/descope/http_client_async.py +++ b/descope/http_client_async.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import contextvars from typing import Awaitable, Callable, cast import httpx @@ -10,6 +9,7 @@ _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES, DEFAULT_TIMEOUT_SECONDS, + ContextVarLastResponseStore, DescopeResponse, HTTPClientBase, ) @@ -25,6 +25,7 @@ def __init__( secure: bool = True, management_key: str | None = None, verbose: bool = False, + last_response_store: ContextVarLastResponseStore | None = None, ) -> None: super().__init__( project_id, @@ -38,9 +39,9 @@ def __init__( verify=self.client_verify, timeout=self.timeout_seconds, ) - self._last_response_var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( - "descope_async_last_response", default=None - ) + # Shared by every client of one DescopeClientAsync when passed in, so + # get_last_response() sees a single ordering across auth and mgmt. + self.last_response_store = last_response_store or ContextVarLastResponseStore() # Optional one-shot async hook invoked before the first request goes # out. Used by ``DescopeClientAsync`` to lazily run the license # handshake on ``_mgmt_http`` without blocking the event loop in @@ -65,7 +66,7 @@ async def get( ) ) if self.verbose: - self._last_response_var.set(DescopeResponse(response)) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -88,7 +89,7 @@ async def post( ) ) if self.verbose: - self._last_response_var.set(DescopeResponse(response)) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -110,7 +111,7 @@ async def put( ) ) if self.verbose: - self._last_response_var.set(DescopeResponse(response)) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -132,7 +133,7 @@ async def patch( ) ) if self.verbose: - self._last_response_var.set(DescopeResponse(response)) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -152,7 +153,7 @@ async def delete( ) ) if self.verbose: - self._last_response_var.set(DescopeResponse(response)) + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -162,8 +163,12 @@ def get_last_response(self) -> DescopeResponse | None: Uses a ContextVar (not threading.local) so each concurrent async task sees its own last response, even though all tasks share one event-loop thread. + + When the store is shared with other clients — as ``DescopeClientAsync`` does + for its auth and management clients — this reports the last response across + all of them, not just the ones this client issued. """ - return self._last_response_var.get() + return self.last_response_store.get() async def _async_execute_with_retry(self, request_fn) -> httpx.Response: if self._pre_request_hook is not None: diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index 3c87b5342..032df78ac 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -729,6 +729,8 @@ def __init__(self, http_client: HTTPClient): timeout_seconds=http_client.timeout_seconds, secure=http_client.secure, management_key=None, # Override the management key for this client + verbose=http_client.verbose, + last_response_store=http_client.last_response_store, ) super().__init__(no_key_client) diff --git a/descope/management/outbound_application_async.py b/descope/management/outbound_application_async.py index ff9d73838..b5f9b78e2 100644 --- a/descope/management/outbound_application_async.py +++ b/descope/management/outbound_application_async.py @@ -729,6 +729,8 @@ def __init__(self, http_client: HTTPClientAsync): timeout_seconds=http_client.timeout_seconds, secure=http_client.secure, management_key=None, # Override the management key for this client + verbose=http_client.verbose, + last_response_store=http_client.last_response_store, ) super().__init__(no_key_client) diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 8406b3497..95b25d802 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -840,6 +840,70 @@ async def test_verbose_mode_captures_mgmt_response(self, client_factory): assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" assert last_resp.status_code == 200 + async def test_verbose_mode_returns_most_recent_across_mgmt_then_auth(self, client_factory): + """A mgmt call followed by an auth call must return the auth response, not the mgmt one.""" + mgmt_response = mock.Mock() + mgmt_response.is_success = True + mgmt_response.json.return_value = {"user": {"id": "u1"}} + mgmt_response.headers = {"cf-ray": "mgmt-ray"} + mgmt_response.status_code = 200 + + auth_response = mock.Mock() + auth_response.is_success = True + auth_response.json.return_value = {"userId": "u1"} + auth_response.headers = {"cf-ray": "auth-ray"} + auth_response.status_code = 200 + + 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(mgmt_response): + await client.invoke(client.mgmt.user.create(login_id="test@example.com")) + assert client.get_last_response().headers.get("cf-ray") == "mgmt-ray" + + with client.mock_get(auth_response): + await client.invoke(client.me("dummy-refresh-token")) + + assert client.get_last_response().headers.get("cf-ray") == "auth-ray" + + async def test_verbose_mode_returns_most_recent_across_auth_then_mgmt(self, client_factory): + """And the other way round — the store has no built-in preference for either side.""" + auth_response = mock.Mock() + auth_response.is_success = True + auth_response.json.return_value = {"userId": "u1"} + auth_response.headers = {"cf-ray": "auth-ray"} + auth_response.status_code = 200 + + mgmt_response = mock.Mock() + mgmt_response.is_success = True + mgmt_response.json.return_value = {"user": {"id": "u1"}} + mgmt_response.headers = {"cf-ray": "mgmt-ray"} + mgmt_response.status_code = 200 + + 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_get(auth_response): + await client.invoke(client.me("dummy-refresh-token")) + assert client.get_last_response().headers.get("cf-ray") == "auth-ray" + + with client.mock_mgmt_post(mgmt_response): + await client.invoke(client.mgmt.user.create(login_id="test@example.com")) + + assert client.get_last_response().headers.get("cf-ray") == "mgmt-ray" + 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" diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 924d3c8f1..fd25049b2 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,8 +1,10 @@ import json import os +import threading import unittest from unittest.mock import Mock, patch +from descope._http_client_base import ThreadLocalLastResponseStore from descope.http_client import DescopeResponse, HTTPClient @@ -360,6 +362,51 @@ def test_verbose_mode_captures_delete_response(self, mock_delete): assert last_resp["deleted"] == "user1" assert last_resp.status_code == 204 + @patch("httpx.get") + @patch("httpx.post") + def test_clients_sharing_a_store_see_one_ordering(self, mock_post, mock_get): + """A shared store makes "last" mean last, regardless of which client wrote it.""" + mgmt_response = Mock() + mgmt_response.is_success = True + mgmt_response.json.return_value = {"src": "mgmt"} + mock_post.return_value = mgmt_response + + auth_response = Mock() + auth_response.is_success = True + auth_response.json.return_value = {"src": "auth"} + mock_get.return_value = auth_response + + store = ThreadLocalLastResponseStore() + mgmt = HTTPClient(project_id="test123", verbose=True, last_response_store=store) + auth = HTTPClient(project_id="test123", verbose=True, last_response_store=store) + + mgmt.post("/x", body={}) + assert store.get()["src"] == "mgmt" + + auth.get("/x") + assert store.get()["src"] == "auth" + + def test_shared_store_is_still_per_thread(self): + """Sharing one store must not leak a response between threads.""" + store = ThreadLocalLastResponseStore() + seen = {} + both_written = threading.Barrier(2) + + def worker(name): + response = Mock() + response.json.return_value = {"thread": name} + store.set(DescopeResponse(response)) + both_written.wait() # neither reads until both have written + seen[name] = store.get()["thread"] + + threads = [threading.Thread(target=worker, args=(name,)) for name in ("a", "b")] + for t in threads: + t.start() + for t in threads: + t.join() + + assert seen == {"a": "a", "b": "b"} + def test_raises_auth_exception_with_empty_project_id(self): """Test that HTTPClient raises AuthException when project_id is empty.""" from descope.exceptions import AuthException diff --git a/tests/test_http_client_async.py b/tests/test_http_client_async.py index 9125517a9..dbb7796d1 100644 --- a/tests/test_http_client_async.py +++ b/tests/test_http_client_async.py @@ -1,9 +1,11 @@ from __future__ import annotations +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest +from descope._http_client_base import ContextVarLastResponseStore, DescopeResponse from descope.exceptions import AuthException, RateLimitException from descope.http_client import _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES from descope.http_client_async import HTTPClientAsync @@ -12,7 +14,14 @@ _DEFAULT_BASE_URL = "https://api.descope.com" -def make_async_client(*, secure=True, verbose=False, project_id="test123", base_url=_DEFAULT_BASE_URL): +def make_async_client( + *, + secure=True, + verbose=False, + project_id="test123", + base_url=_DEFAULT_BASE_URL, + last_response_store=None, +): """Build an AsyncHTTPClient with a mocked _async_client (no real socket). base_url is passed explicitly so tests are never affected by the @@ -25,6 +34,7 @@ def make_async_client(*, secure=True, verbose=False, project_id="test123", base_ timeout_seconds=60, secure=secure, verbose=verbose, + last_response_store=last_response_store, ) @@ -351,6 +361,36 @@ async def test_delete_captures_response_when_verbose(self): assert last.status_code == 200 +class TestAsyncSharedLastResponseStore: + async def test_clients_sharing_a_store_see_one_ordering(self): + """A shared store makes "last" mean last, regardless of which client wrote it.""" + store = ContextVarLastResponseStore() + mgmt = make_async_client(verbose=True, last_response_store=store) + auth = make_async_client(verbose=True, last_response_store=store) + mgmt._async_client.post = AsyncMock(return_value=make_resp(json_data={"src": "mgmt"})) + auth._async_client.get = AsyncMock(return_value=make_resp(json_data={"src": "auth"})) + + await mgmt.post("/x", body={}) + assert store.get()["src"] == "mgmt" + + await auth.get("/x") + assert store.get()["src"] == "auth" + + async def test_shared_store_is_still_per_task(self): + """Sharing one store must not leak a response between concurrent tasks.""" + store = ContextVarLastResponseStore() + seen = {} + + async def worker(name): + store.set(DescopeResponse(make_resp(json_data={"task": name}))) + await asyncio.sleep(0) # let the sibling task write before reading + seen[name] = store.get()["task"] + + await asyncio.gather(worker("a"), worker("b")) + + assert seen == {"a": "a", "b": "b"} + + class TestAsyncErrors: async def test_raises_auth_exception_on_500(self): client = make_async_client() From 53382c3bd214fe46617b17b1deb1eeb871c8b4d8 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:50:23 +0300 Subject: [PATCH 3/8] refactor(http): keep cached_property only where it pays The seven HTTP metadata accessors gained nothing from caching: httpx already caches text/content/cookies internally, and status_code/headers/url are attribute reads. On Python 3.9-3.11 cached_property.__get__ takes a descriptor-wide lock on first access, so caching them cost more than it saved and made read-only properties assignable. Body parsing still caches, which is where the two real fixes are: a `null` body no longer re-parses, and is_json no longer re-attempts a failed parse on every call. --- descope/_http_client_base.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 8bd510d35..9fe93482f 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -63,12 +63,19 @@ class DescopeResponse: ``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. - The wrapped response is already complete, so the derived accessors are - ``cached_property``. ``functools.cache`` is not usable here: it keys on - ``self``, which is unhashable (``__eq__`` without ``__hash__``) and would - be pinned alive by the module-level cache. A failed parse is not cached — - ``cached_property`` stores nothing when the getter raises — so a non-JSON - body keeps raising from ``json()`` rather than caching a sentinel. + Body parsing is cached, since the wrapped response is already complete: + ``_json_data`` and ``is_json`` are ``cached_property``. A failed parse is not + cached — ``cached_property`` stores nothing when the getter raises — so a + non-JSON body keeps raising from ``json()`` rather than caching a sentinel. + ``functools.cache`` is not usable here: it keys on ``self``, which is + unhashable (``__eq__`` without ``__hash__``) and would be pinned alive by the + module-level cache. + + The HTTP metadata accessors below stay plain properties on purpose. httpx + already caches ``text``/``content``/``cookies`` internally, the rest are + attribute reads, and on Python 3.9-3.11 ``cached_property`` takes a + descriptor-wide lock on first access — so caching them would cost more than + it saves and would make them assignable. """ def __init__(self, response: httpx.Response): @@ -155,37 +162,37 @@ def __iter__(self): return iter(self.json()) # HTTP metadata properties - @cached_property + @property def headers(self): """Access response headers (e.g., response.headers.get('cf-ray')).""" return self.raw.headers - @cached_property + @property def status_code(self): """HTTP status code.""" return self.raw.status_code - @cached_property + @property def cookies(self): """Response cookies.""" return self.raw.cookies - @cached_property + @property def text(self): """Raw response text.""" return self.raw.text - @cached_property + @property def content(self): """Raw response content (bytes).""" return self.raw.content - @cached_property + @property def url(self): """Request URL.""" return self.raw.url - @cached_property + @property def ok(self): """True if status code indicates success (2xx).""" return self.raw.is_success From 2ef1e2b0f35d0e1c0add7a2e1895783c593a7d5a Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:00:44 +0300 Subject: [PATCH 4/8] docs(client): correct a comment left stale by the shared store get_last_response() no longer reads _auth_http_client/_mgmt_http_client, so "for verbose mode access" pointed at the wrong mechanism. --- descope/descope_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/descope/descope_client.py b/descope/descope_client.py index 978bbd1d5..06216eb5f 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -101,7 +101,8 @@ def __init__( fga_cache_url=fga_cache_url, ) - # Store references to HTTP clients for verbose mode access + # Direct handles on the underlying clients. Verbose mode no longer reads + # these — get_last_response() goes through the shared store above. self._auth_http_client = auth_http_client self._mgmt_http_client = mgmt_http_client From 9c6d62b131164b46a70b06e0e2202bf83e30da9c Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:08:44 +0300 Subject: [PATCH 5/8] refactor(http): drop explanatory comments the code already states --- descope/_http_client_base.py | 26 +++++--------------------- descope/descope_client.py | 5 ----- descope/descope_client_async.py | 3 --- descope/http_client.py | 7 ++----- descope/http_client_async.py | 7 ++----- 5 files changed, 9 insertions(+), 39 deletions(-) diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 9fe93482f..37aa28ab7 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -63,19 +63,9 @@ class DescopeResponse: ``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. - Body parsing is cached, since the wrapped response is already complete: - ``_json_data`` and ``is_json`` are ``cached_property``. A failed parse is not - cached — ``cached_property`` stores nothing when the getter raises — so a - non-JSON body keeps raising from ``json()`` rather than caching a sentinel. - ``functools.cache`` is not usable here: it keys on ``self``, which is - unhashable (``__eq__`` without ``__hash__``) and would be pinned alive by the - module-level cache. - - The HTTP metadata accessors below stay plain properties on purpose. httpx - already caches ``text``/``content``/``cookies`` internally, the rest are - attribute reads, and on Python 3.9-3.11 ``cached_property`` takes a - descriptor-wide lock on first access — so caching them would cost more than - it saves and would make them assignable. + Only body parsing is cached. The metadata accessors stay plain properties: + httpx already caches ``text``/``content``/``cookies``, and on Python 3.9-3.11 + ``cached_property`` takes a descriptor-wide lock on first access. """ def __init__(self, response: httpx.Response): @@ -199,11 +189,7 @@ def ok(self): class ThreadLocalLastResponseStore: - """One last-response slot, isolated per thread. - - Shared by every ``HTTPClient`` a ``DescopeClient`` owns, so "last" means the - most recent response across auth and management calls rather than per-client. - """ + """One last-response slot, isolated per thread.""" def __init__(self) -> None: self._local = threading.local() @@ -218,9 +204,7 @@ def get(self) -> DescopeResponse | None: class ContextVarLastResponseStore: """One last-response slot, isolated per async task. - ContextVar rather than threading.local: every asyncio task runs on the same - event-loop thread, so a thread-local slot would be a single slot shared by - all concurrent tasks. + ContextVar rather than threading.local: asyncio tasks share one thread. """ def __init__(self) -> None: diff --git a/descope/descope_client.py b/descope/descope_client.py index 06216eb5f..5c934b232 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -55,9 +55,6 @@ def __init__( base_url=base_url, verbose=verbose, ) - # One store shared by every HTTP client below, so get_last_response() - # returns the genuinely most recent response rather than picking between - # per-client slots that were overwritten independently. self._last_response_store = ThreadLocalLastResponseStore() auth_http_client = HTTPClient( project_id=self._project_id, @@ -101,8 +98,6 @@ def __init__( fga_cache_url=fga_cache_url, ) - # Direct handles on the underlying clients. Verbose mode no longer reads - # these — get_last_response() goes through the shared store above. self._auth_http_client = auth_http_client self._mgmt_http_client = mgmt_http_client diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 63e59a6c2..1763f61dc 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -88,9 +88,6 @@ def __init__( verbose=verbose, ) - # One store shared by every HTTP client below, so get_last_response() - # returns the genuinely most recent response rather than picking between - # per-client slots that were overwritten independently. self._last_response_store = ContextVarLastResponseStore() self._auth_http = HTTPClientAsync( project_id=self._project_id, diff --git a/descope/http_client.py b/descope/http_client.py index e857bbad6..59be97664 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -35,8 +35,6 @@ def __init__( management_key=management_key, verbose=verbose, ) - # Shared by every client of one DescopeClient when passed in, so - # get_last_response() sees a single ordering across auth and mgmt. self.last_response_store = last_response_store or ThreadLocalLastResponseStore() # ------------- public API ------------- @@ -168,9 +166,8 @@ def get_last_response(self) -> DescopeResponse | None: This method is thread-safe: each thread will receive its own last response when using a shared client instance. - When the store is shared with other clients — as ``DescopeClient`` does - for its auth and management clients — this reports the last response - across all of them, not just the ones this client issued. + With a shared store — as ``DescopeClient`` uses for its auth and + management clients — this reports the last response across all of them. Returns: DescopeResponse: The last response if verbose mode is enabled, None otherwise. diff --git a/descope/http_client_async.py b/descope/http_client_async.py index ef47681d0..37e4b7bc6 100644 --- a/descope/http_client_async.py +++ b/descope/http_client_async.py @@ -39,8 +39,6 @@ def __init__( verify=self.client_verify, timeout=self.timeout_seconds, ) - # Shared by every client of one DescopeClientAsync when passed in, so - # get_last_response() sees a single ordering across auth and mgmt. self.last_response_store = last_response_store or ContextVarLastResponseStore() # Optional one-shot async hook invoked before the first request goes # out. Used by ``DescopeClientAsync`` to lazily run the license @@ -164,9 +162,8 @@ def get_last_response(self) -> DescopeResponse | None: Uses a ContextVar (not threading.local) so each concurrent async task sees its own last response, even though all tasks share one event-loop thread. - When the store is shared with other clients — as ``DescopeClientAsync`` does - for its auth and management clients — this reports the last response across - all of them, not just the ones this client issued. + With a shared store — as ``DescopeClientAsync`` uses for its auth and + management clients — this reports the last response across all of them. """ return self.last_response_store.get() From 5b545172c72ddd04bd3c200fde4d906b0c59e1df Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:25:58 +0300 Subject: [PATCH 6/8] refactor(http): keep only the ContextVar rationale in comments --- descope/_http_client_base.py | 8 +++----- descope/http_client.py | 3 --- descope/http_client_async.py | 3 --- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 37aa28ab7..90a9f2b6e 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -62,10 +62,6 @@ class DescopeResponse: 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. - - Only body parsing is cached. The metadata accessors stay plain properties: - httpx already caches ``text``/``content``/``cookies``, and on Python 3.9-3.11 - ``cached_property`` takes a descriptor-wide lock on first access. """ def __init__(self, response: httpx.Response): @@ -204,7 +200,9 @@ def get(self) -> DescopeResponse | None: class ContextVarLastResponseStore: """One last-response slot, isolated per async task. - ContextVar rather than threading.local: asyncio tasks share one thread. + ContextVar rather than threading.local: every asyncio task runs on the same + event-loop thread, so a thread-local slot would be a single slot shared by + all concurrent tasks. """ def __init__(self) -> None: diff --git a/descope/http_client.py b/descope/http_client.py index 59be97664..1f4cdadf5 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -166,9 +166,6 @@ def get_last_response(self) -> DescopeResponse | None: This method is thread-safe: each thread will receive its own last response when using a shared client instance. - With a shared store — as ``DescopeClient`` uses for its auth and - management clients — this reports the last response across all of them. - Returns: DescopeResponse: The last response if verbose mode is enabled, None otherwise. diff --git a/descope/http_client_async.py b/descope/http_client_async.py index 37e4b7bc6..b915bc5e0 100644 --- a/descope/http_client_async.py +++ b/descope/http_client_async.py @@ -161,9 +161,6 @@ def get_last_response(self) -> DescopeResponse | None: Uses a ContextVar (not threading.local) so each concurrent async task sees its own last response, even though all tasks share one event-loop thread. - - With a shared store — as ``DescopeClientAsync`` uses for its auth and - management clients — this reports the last response across all of them. """ return self.last_response_store.get() From 5392fb3a1a727db812513ec97d0efcf3b8839bf8 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:06:06 +0300 Subject: [PATCH 7/8] test(http): pin the edges the caching and forwarding fixes actually changed test_json_caching uses a dict body, so it passes under the old `if self._json_data is None` sentinel. Add a `null`-body case and a non-JSON `is_json` case, which do not. Also cover OutboundApplicationByToken: it builds its own key-less client, and dropping the forwarded verbose/store arguments left the suite green. --- tests/management/test_outbound_application.py | 57 +++++++++++++++++++ tests/test_http_client.py | 33 +++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py index 64590ded3..76db78a86 100644 --- a/tests/management/test_outbound_application.py +++ b/tests/management/test_outbound_application.py @@ -958,3 +958,60 @@ async def test_fetch_tenant_token_failure(self, client_factory): "tenant789", ) ) + + +class TestOutboundApplicationByTokenVerbose: + async def test_by_token_response_reaches_client_get_last_response(self, client_factory): + """The by-token client builds its own key-less HTTPClient. + + It has to be handed the owning client's verbose flag and last-response store, + or its responses are invisible to DescopeClient.get_last_response(). + """ + client = client_factory.make( + PROJECT_ID, + PUBLIC_KEY_DICT, + False, + management_key="test-mgmt-key", + verbose=True, + ) + if client_factory.mode == "async": + client._raw._license_attempted = True + + response = make_response(TOKEN_RESPONSE) + response.headers = {"cf-ray": "by-token-ray"} + + with client.mock_mgmt_by_token_post(response): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + DUMMY_TOKEN, + "app123", + "user456", + ["read"], + ) + ) + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp.headers.get("cf-ray") == "by-token-ray" + + async def test_by_token_not_captured_when_verbose_disabled(self, client_factory): + client = client_factory.make( + PROJECT_ID, + PUBLIC_KEY_DICT, + False, + management_key="test-mgmt-key", + ) + if client_factory.mode == "async": + client._raw._license_attempted = True + + with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + DUMMY_TOKEN, + "app123", + "user456", + ["read"], + ) + ) + + assert client.get_last_response() is None diff --git a/tests/test_http_client.py b/tests/test_http_client.py index fd25049b2..22a87df77 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -64,6 +64,39 @@ def test_json_caching(self): # json() should only be called once on the underlying response assert mock_response.json.call_count == 1 + def test_null_json_body_parses_once(self): + """A `null` body is a real cached value, not a miss to retry. + + The old `if self._json_data is None` sentinel re-parsed on every access here, + which test_json_caching cannot catch because its body is a dict. + """ + mock_response = Mock() + mock_response.json.return_value = None + + resp = DescopeResponse(mock_response) + + assert resp.json() is None + assert resp.json() is None + assert mock_response.json.call_count == 1 + + def test_is_json_does_not_reparse_non_json_body(self): + """is_json probes by parsing, so an unparseable body must not re-probe.""" + body = "502" + mock_response = Mock() + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0) + mock_response.text = body + + resp = DescopeResponse(mock_response) + + assert resp.is_json is False + assert resp.is_json is False + assert resp.is_json is False + assert mock_response.json.call_count == 1 + + # A failed parse is not cached, so json() itself still raises every time. + with self.assertRaises(json.JSONDecodeError): + resp.json() + def test_dict_like_values_items(self): """Test that values() and items() work correctly.""" mock_response = Mock() From a7c7ef0d57254e12a062ec7216a707849036e932 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:26:12 +0300 Subject: [PATCH 8/8] test(client): pin that auth and mgmt share one last-response store The ordering tests assert behavior, which leaves the shape unpinned: they pass whether get_last_response() reads one store or picks between two that happen to hold the same object. Assert the identity directly, including the by-token client, so a re-split fails on the invariant rather than on a symptom. --- tests/test_descope_client.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 95b25d802..f5ee29150 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -840,6 +840,32 @@ async def test_verbose_mode_captures_mgmt_response(self, client_factory): assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" assert last_resp.status_code == 200 + async def test_auth_and_mgmt_share_one_last_response_store(self, client_factory): + """The auth and mgmt clients must hold the same store object, not two equal ones. + + This is the invariant that makes `get_last_response()` a single read. The + ordering tests cannot catch a re-split on their own: once both clients write + to one store, reading either of them returns the same response, so a stale + `mgmt_resp or auth_resp` still looks correct. Pin the identity instead. + """ + client = client_factory.make( + PROJECT_ID, + public_key=PUBLIC_KEY_DICT, + management_key="test-mgmt-key", + verbose=True, + ) + raw = client._raw + if client_factory.mode == "sync": + auth_store = raw._auth_http_client.last_response_store + mgmt_store = raw._mgmt_http_client.last_response_store + else: + auth_store = raw._auth_http.last_response_store + mgmt_store = raw._mgmt_http.last_response_store + + assert auth_store is mgmt_store + assert raw._last_response_store is auth_store + assert raw._mgmt._outbound_application_by_token._http.last_response_store is auth_store + async def test_verbose_mode_returns_most_recent_across_mgmt_then_auth(self, client_factory): """A mgmt call followed by an auth call must return the auth response, not the mgmt one.""" mgmt_response = mock.Mock()