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/3] 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/3] 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/3] 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