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 = "502 Bad Gateway" diff --git a/tests/test_http_client.py b/tests/test_http_client.py index ab48302d5..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 @@ -311,6 +313,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.""" @@ -329,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 25f100dcb..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, ) @@ -317,6 +327,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( @@ -330,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()