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()