diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py index 432181a08..90a9f2b6e 100644 --- a/descope/_http_client_base.py +++ b/descope/_http_client_base.py @@ -1,9 +1,12 @@ # 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 @@ -63,15 +66,16 @@ class DescopeResponse: 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: @@ -180,6 +184,39 @@ def ok(self): return self.raw.is_success +class ThreadLocalLastResponseStore: + """One last-response slot, isolated per thread.""" + + 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..5c934b232 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,7 @@ def __init__( base_url=base_url, verbose=verbose, ) + self._last_response_store = ThreadLocalLastResponseStore() auth_http_client = HTTPClient( project_id=self._project_id, base_url=base_url, @@ -61,6 +63,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 +90,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, @@ -94,7 +98,6 @@ def __init__( fga_cache_url=fga_cache_url, ) - # Store references to HTTP clients for verbose mode access self._auth_http_client = auth_http_client self._mgmt_http_client = mgmt_http_client @@ -378,7 +381,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 +396,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..1763f61dc 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,7 @@ def __init__( verbose=verbose, ) + self._last_response_store = ContextVarLastResponseStore() self._auth_http = HTTPClientAsync( project_id=self._project_id, base_url=base_url, @@ -94,6 +96,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 +105,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 +323,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 badcf0c2a..1f4cdadf5 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,7 @@ def __init__( management_key=management_key, verbose=verbose, ) - self._thread_local = threading.local() + self.last_response_store = last_response_store or ThreadLocalLastResponseStore() # ------------- public API ------------- def get( @@ -56,7 +57,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 +82,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 @@ -104,6 +105,8 @@ def put( timeout=self.timeout_seconds, ) ) + if self.verbose: + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -127,7 +130,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 @@ -149,7 +152,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 @@ -175,7 +178,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 205b5d225..b915bc5e0 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,7 @@ 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 - ) + 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 +64,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 +87,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 @@ -109,6 +108,8 @@ async def put( params=params, ) ) + if self.verbose: + self.last_response_store.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -130,7 +131,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 @@ -150,7 +151,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 @@ -161,7 +162,7 @@ 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. """ - 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 = "