From d751b99b66fc64cfb8e75d84dd2e05f352df1b4f Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 10 May 2026 14:42:40 +0300 Subject: [PATCH 1/5] feat: add async HTTP transport foundation (Stage 0) Introduces `future_utils` with `then`/`wrap`/`resolve` helpers that are transparent to sync callers, adds an `httpx.AsyncClient`-backed async execution path to `HTTPClient` (get/post/put/patch/delete each accept `async_mode=True`), and threads `async_mode_experimental` through `DescopeClient.__init__` so the flag is available for future global rollout. Sync behaviour is completely unchanged. --- descope/descope_client.py | 9 + descope/future_utils.py | 37 ++++ descope/http_client.py | 159 +++++++++++++++++- tests/management/test_access_key.py | 20 +++ tests/management/test_audit.py | 30 ++++ tests/management/test_authz.py | 17 ++ tests/management/test_descoper.py | 24 +++ tests/management/test_fga.py | 17 ++ tests/management/test_flow.py | 18 ++ tests/management/test_group.py | 18 ++ tests/management/test_jwt.py | 20 +++ tests/management/test_mgmtkey.py | 26 +++ tests/management/test_outbound_application.py | 28 +++ tests/management/test_permission.py | 17 ++ tests/management/test_project.py | 17 ++ tests/management/test_role.py | 17 ++ tests/management/test_sso_application.py | 23 +++ tests/management/test_sso_settings.py | 19 +++ tests/management/test_tenant.py | 20 +++ tests/management/test_user.py | 20 +++ tests/test_auth.py | 23 +++ tests/test_descope_client.py | 31 ++++ tests/test_enchantedlink.py | 26 +++ tests/test_future_utils.py | 140 +++++++++++++++ tests/test_http_client.py | 20 +++ tests/test_magiclink.py | 26 +++ tests/test_oauth.py | 24 +++ tests/test_otp.py | 19 +++ tests/test_password.py | 29 ++++ tests/test_saml.py | 24 +++ tests/test_sso.py | 24 +++ tests/test_totp.py | 24 +++ tests/test_webauthn.py | 27 +++ 33 files changed, 987 insertions(+), 6 deletions(-) create mode 100644 descope/future_utils.py create mode 100644 tests/test_future_utils.py diff --git a/descope/descope_client.py b/descope/descope_client.py index ace79755c..5eb9a7744 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -38,6 +38,7 @@ def __init__( *, base_url: str | None = None, verbose: bool = False, + **kwargs, ): # validate project id project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") @@ -51,6 +52,12 @@ def __init__( ), ) + async_mode_experimental = bool(kwargs.pop("async_mode_experimental", False)) + if kwargs: + raise TypeError( + f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}" + ) + # Warn about TLS verification bypass if skip_verify: warnings.warn( @@ -70,6 +77,7 @@ def __init__( secure=not skip_verify, management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), verbose=verbose, + async_mode_experimental=async_mode_experimental, ) self._auth = Auth( project_id, @@ -95,6 +103,7 @@ def __init__( secure=auth_http_client.secure, management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), verbose=verbose, + async_mode_experimental=async_mode_experimental, ) self._mgmt = MGMT( http_client=mgmt_http_client, diff --git a/descope/future_utils.py b/descope/future_utils.py new file mode 100644 index 000000000..f4a69a8b4 --- /dev/null +++ b/descope/future_utils.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, TypeVar, Union + +T = TypeVar("T") + + +def then( + result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any] +) -> Union[Any, Awaitable[Any]]: + if asyncio.iscoroutine(result_or_coro) or asyncio.isfuture(result_or_coro): + + async def process_async(): + result = await result_or_coro + return modifier(result) + + return process_async() + + return modifier(result_or_coro) # type: ignore[arg-type] + + +def wrap(result: T, as_awaitable: bool) -> Union[Any, Awaitable[Any]]: + if as_awaitable: + + async def awaitable_wrapper(): + return result + + return awaitable_wrapper() + + return result + + +async def resolve(obj: Union[Any, Awaitable[Any]]) -> Any: + if asyncio.iscoroutine(obj) or asyncio.isfuture(obj): + return await obj + return obj diff --git a/descope/http_client.py b/descope/http_client.py index b9665a810..0289a3236 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import os import platform import ssl @@ -7,7 +8,7 @@ import time from http import HTTPStatus from importlib.metadata import version -from typing import cast +from typing import Awaitable, cast import certifi import httpx @@ -153,6 +154,7 @@ def __init__( secure: bool = True, management_key: str | None = None, verbose: bool = False, + async_mode_experimental: bool = False, ) -> None: if not project_id: raise AuthException( @@ -173,6 +175,8 @@ def __init__( self.secure = secure self.management_key = management_key self.verbose = verbose + # Reserved for the future global async rollout (see big-plan.md "Final stage") + self.async_mode_experimental = async_mode_experimental self._thread_local = threading.local() # Setup SSL verification for httpx (backwards compatibility with requests) @@ -186,6 +190,13 @@ def __init__( ssl_ctx.load_verify_locations(cafile=os.environ.get("REQUESTS_CA_BUNDLE")) self.client_verify = ssl_ctx + self._async_client: httpx.AsyncClient | None = None + if async_mode_experimental: + self._async_client = httpx.AsyncClient( + verify=self.client_verify, + timeout=self.timeout_seconds, + ) + # ------------- public API ------------- def get( self, @@ -194,7 +205,10 @@ def get( params=None, allow_redirects: bool | None = True, pswd: str | None = None, - ) -> httpx.Response: + async_mode: bool = False, + ) -> httpx.Response | Awaitable[httpx.Response]: + if async_mode: + return self._async_get(uri, params=params, allow_redirects=allow_redirects, pswd=pswd) response = self._execute_with_retry( lambda: httpx.get( f"{self.base_url}{uri}", @@ -218,7 +232,10 @@ def post( params=None, pswd: str | None = None, base_url: str | None = None, - ) -> httpx.Response: + async_mode: bool = False, + ) -> httpx.Response | Awaitable[httpx.Response]: + if async_mode: + return self._async_post(uri, body=body, params=params, pswd=pswd, base_url=base_url) response = self._execute_with_retry( lambda: httpx.post( f"{base_url or self.base_url}{uri}", @@ -242,7 +259,10 @@ def put( body: dict | list[dict] | list[str] | None = None, params=None, pswd: str | None = None, - ) -> httpx.Response: + async_mode: bool = False, + ) -> httpx.Response | Awaitable[httpx.Response]: + if async_mode: + return self._async_put(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.put( f"{self.base_url}{uri}", @@ -264,7 +284,10 @@ def patch( body: dict | list[dict] | list[str] | None, params=None, pswd: str | None = None, - ) -> httpx.Response: + async_mode: bool = False, + ) -> httpx.Response | Awaitable[httpx.Response]: + if async_mode: + return self._async_patch(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.patch( f"{self.base_url}{uri}", @@ -287,7 +310,10 @@ def delete( *, params=None, pswd: str | None = None, - ) -> httpx.Response: + async_mode: bool = False, + ) -> httpx.Response | Awaitable[httpx.Response]: + if async_mode: + return self._async_delete(uri, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.delete( f"{self.base_url}{uri}", @@ -330,6 +356,10 @@ def get_last_response(self) -> DescopeResponse | None: def get_default_headers(self, pswd: str | None = None) -> dict: return self._get_default_headers(pswd) + async def aclose(self) -> None: + if self._async_client is not None: + await self._async_client.aclose() + # ------------- helpers ------------- def _execute_with_retry(self, request_fn) -> httpx.Response: """Execute request_fn and retry on retryable status codes. @@ -401,3 +431,120 @@ def _get_default_headers(self, pswd: str | None = None): bearer = f"{bearer}:{self.management_key}" headers["Authorization"] = f"Bearer {bearer}" return headers + + # ------------- async helpers ------------- + async def _async_execute_with_retry(self, request_fn) -> httpx.Response: + response = await request_fn() + for delay in _RETRY_DELAYS_SECONDS: + if response.status_code not in _RETRY_STATUS_CODES: + break + await response.aclose() + await asyncio.sleep(delay) + response = await request_fn() + return response + + async def _async_get( + self, + uri: str, + *, + params=None, + allow_redirects: bool | None = True, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.get( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + params=params, + follow_redirects=cast(bool, allow_redirects), + ) + ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) + self._raise_from_response(response) + return response + + async def _async_post( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = None, + params=None, + pswd: str | None = None, + base_url: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.post( + f"{base_url or self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) + self._raise_from_response(response) + return response + + async def _async_put( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = None, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.put( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + self._raise_from_response(response) + return response + + async def _async_patch( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.patch( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) + self._raise_from_response(response) + return response + + async def _async_delete( + self, + uri: str, + *, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.delete( + f"{self.base_url}{uri}", + params=params, + headers=self._get_default_headers(pswd), + follow_redirects=False, + ) + ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) + self._raise_from_response(response) + return response diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py index de10d636d..b316c3856 100644 --- a/tests/management/test_access_key.py +++ b/tests/management/test_access_key.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -340,3 +341,22 @@ def test_delete(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = json.loads("""{"key": {"id": "ak1"}, "cleartext": "abc"}""") + mock_post.return_value = network_resp + result = client.mgmt.access_key.create(name="key-name") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["key"]["id"], "ak1") diff --git a/tests/management/test_audit.py b/tests/management/test_audit.py index bf5e6b753..1e20c8b42 100644 --- a/tests/management/test_audit.py +++ b/tests/management/test_audit.py @@ -1,3 +1,4 @@ +import asyncio from datetime import datetime from unittest import mock from unittest.mock import patch @@ -130,3 +131,32 @@ def test_create_event(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = { + "audits": [ + { + "projectId": "p", + "userId": "u1", + "action": "a1", + "externalIds": ["e1"], + "occurred": str(datetime.now().timestamp() * 1000), + } + ] + } + mock_post.return_value = network_resp + result = client.mgmt.audit.search() + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(len(result["audits"]), 1) diff --git a/tests/management/test_authz.py b/tests/management/test_authz.py index 75dd42c12..0ab824edd 100644 --- a/tests/management/test_authz.py +++ b/tests/management/test_authz.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import patch from descope import AuthException, DescopeClient @@ -686,3 +687,19 @@ def test_authz_cache_url_what_can_target_access(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) self.assertEqual(result, [{"resource": "r1"}]) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + result = client.mgmt.authz.save_schema({"name": "kuku"}, True) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py index 88f6e2611..02d4c6a30 100644 --- a/tests/management/test_descoper.py +++ b/tests/management/test_descoper.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -513,3 +514,26 @@ def test_list(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.put") as mock_put: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = json.loads( + '{"descopers": [{"id": "U2111111111111111111111111", "status": "invited"}], "total": 1}' + ) + mock_put.return_value = network_resp + result = client.mgmt.descoper.create( + descopers=[DescoperCreate(login_id="user1@example.com")] + ) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["total"], 1) diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py index 967fd1adc..a31287f73 100644 --- a/tests/management/test_fga.py +++ b/tests/management/test_fga.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import patch from descope import AuthException, DescopeClient @@ -486,3 +487,19 @@ def test_fga_without_cache_url_uses_default_base_url(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + result = client.mgmt.fga.save_schema("model AuthZ 1.0") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_flow.py b/tests/management/test_flow.py index 34e265d5e..fae411c23 100644 --- a/tests/management/test_flow.py +++ b/tests/management/test_flow.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import patch from descope import AuthException, DescopeClient @@ -459,3 +460,20 @@ def test_get_flow_async_result(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = {"flows": [], "total": 0} + result = client.mgmt.flow.list_flows() + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) diff --git a/tests/management/test_group.py b/tests/management/test_group.py index 1d06b4a85..396dea82b 100644 --- a/tests/management/test_group.py +++ b/tests/management/test_group.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import patch from descope import AuthException, DescopeClient @@ -139,3 +140,20 @@ def test_load_all_group_members(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = [{"id": "g1"}] + result = client.mgmt.group.load_all_groups("someTenantId") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index 1535910a7..ce317778c 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -394,3 +395,22 @@ def test_anonymous(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = json.loads("""{"jwt": "response"}""") + mock_post.return_value = network_resp + result = client.mgmt.jwt.update_jwt("test", {"k1": "v1"}, 0) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result, "response") diff --git a/tests/management/test_mgmtkey.py b/tests/management/test_mgmtkey.py index fd76c6f5f..35848d14c 100644 --- a/tests/management/test_mgmtkey.py +++ b/tests/management/test_mgmtkey.py @@ -1,3 +1,4 @@ +import asyncio from unittest import mock from unittest.mock import patch @@ -478,3 +479,28 @@ def test_search(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.put") as mock_put: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = { + "cleartext": "cleartext-secret", + "key": {"id": "mk1", "name": "test-key"}, + } + mock_put.return_value = network_resp + result = client.mgmt.management_key.create( + name="test-key", + rebac=MgmtKeyReBac(company_roles=["role1"]), + ) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["cleartext"], "cleartext-secret") diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py index 7daa507c0..a0e6a5de2 100644 --- a/tests/management/test_outbound_application.py +++ b/tests/management/test_outbound_application.py @@ -1,3 +1,4 @@ +import asyncio from unittest import mock from unittest.mock import patch @@ -896,6 +897,33 @@ def test_prompt_type_enum_values(self): assert PromptType.CONSENT.value == "consent" assert PromptType.SELECT_ACCOUNT.value == "select_account" + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } + } + mock_post.return_value = network_resp + result = client.mgmt.outbound_application.create_application( + "Test App", description="Test Description", client_secret="secret" + ) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["app"]["id"], "app123") + class TestOutboundApplicationByToken(common.DescopeTest): def setUp(self) -> None: diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index 035d916a4..6460e437d 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -451,3 +452,19 @@ def test_load_all(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + result = client.mgmt.permission.create("P1", "Something") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_project.py b/tests/management/test_project.py index 88fca6ef3..23a7726a5 100644 --- a/tests/management/test_project.py +++ b/tests/management/test_project.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -296,3 +297,19 @@ def test_import_project(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + result = client.mgmt.project.update_name("new-name") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 39c679478..3de743b59 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -729,3 +730,19 @@ def test_create_without_private_parameter(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + result = client.mgmt.role.create("R1", "Something", ["P1"]) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_sso_application.py b/tests/management/test_sso_application.py index d9972872a..8b441a1e1 100644 --- a/tests/management/test_sso_application.py +++ b/tests/management/test_sso_application.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -518,3 +519,25 @@ def test_load_all(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = {"id": "app1"} + mock_post.return_value = network_resp + result = client.mgmt.sso_application.create_oidc_application( + name="name", + login_page_url="http://dummy.com", + ) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["id"], "app1") diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 0b0cfb6ef..828cc15c2 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -841,3 +842,21 @@ def test_recalculate_sso_mappings(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.delete") as mock_delete: + network_resp = mock.Mock() + network_resp.is_success = True + mock_delete.return_value = network_resp + result = client.mgmt.sso.delete_settings("tenant-id") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNone(result) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 4678a9c9f..09253debf 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -610,3 +611,22 @@ def test_update_default_roles(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = {"id": "t1"} + mock_post.return_value = network_resp + result = client.mgmt.tenant.create("name", "t1", ["domain.com"]) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["id"], "t1") diff --git a/tests/management/test_user.py b/tests/management/test_user.py index d835830ec..fc5fd5685 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -2827,3 +2828,22 @@ def test_patch_test_user(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + def test_sync_behavior_with_async_mode_experimental(self, _mock_async): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + async_mode_experimental=True, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = {"user": {"id": "u1"}} + mock_post.return_value = network_resp + result = client.mgmt.user.create(login_id="name@mail.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result["user"]["id"], "u1") diff --git a/tests/test_auth.py b/tests/test_auth.py index 82dc5b0d4..ce49ac734 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1260,6 +1260,29 @@ def test_validate_token_success(self): out = auth._validate_token("tok") self.assertEqual(out["jwt"], "tok") + @patch("httpx.AsyncClient") + @patch("httpx.get") + def test_sync_behavior_with_async_mode_experimental(self, mock_get, _mock_async): + """With async_mode_experimental=True, _fetch_public_keys still returns synchronously.""" + import asyncio + + from descope.http_client import HTTPClient + + mock_get.return_value.is_success = True + mock_get.return_value.text = '{"keys": []}' + + http_client = HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ) + auth = Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=http_client, + ) + result = auth._fetch_public_keys() + self.assertFalse(asyncio.iscoroutine(result)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 9f77b0947..bd29affd8 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -1052,6 +1052,37 @@ def test_verbose_mode_captures_mgmt_response(self, mock_post): assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" assert last_resp.status_code == 200 + def test_unknown_kwargs_raise_type_error(self): + with self.assertRaises(TypeError): + DescopeClient( + project_id=self.dummy_project_id, + public_key=self.public_key_dict, + bogus_kwarg=1, + ) + + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_async_mode_experimental_flag_does_not_return_coroutine( + self, mock_post, mock_async_client + ): + """DescopeClient with async_mode_experimental=True still returns sync results from auth methods.""" + import asyncio + + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.status_code = 200 + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + client = DescopeClient( + project_id=self.dummy_project_id, + public_key=self.public_key_dict, + async_mode_experimental=True, + ) + result = client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index eefcc9787..a0df9f7bb 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -562,6 +563,31 @@ def test_update_user_email(self): params=None, ) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, enchantedlink.sign_in still returns synchronously.""" + from descope.http_client import HTTPClient + + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"pendingRef": "aaaa", "linkId": "24"} + mock_post.return_value = my_mock_response + + enchantedlink = EnchantedLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = enchantedlink.sign_in("dummy@dummy.com", "http://test.me") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_future_utils.py b/tests/test_future_utils.py new file mode 100644 index 000000000..f42ce0d39 --- /dev/null +++ b/tests/test_future_utils.py @@ -0,0 +1,140 @@ +import asyncio +import unittest + +from descope.future_utils import resolve, then, wrap + + +class TestFutureUtils(unittest.TestCase): + def test_then_with_sync_result(self): + result = then("test_result", lambda x: f"modified_{x}") + self.assertEqual(result, "modified_test_result") + + def test_then_with_coroutine(self): + async def async_func(): + return "async_result" + + result = then(async_func(), lambda x: f"modified_{x}") + self.assertTrue(asyncio.iscoroutine(result)) + + async def run_test(): + actual_result = await result + self.assertEqual(actual_result, "modified_async_result") + + asyncio.run(run_test()) + + def test_then_with_future(self): + async def run_test(): + future = asyncio.Future() + future.set_result("future_result") + + result = then(future, lambda x: f"modified_{x}") + self.assertTrue(asyncio.iscoroutine(result)) + + actual_result = await result + self.assertEqual(actual_result, "modified_future_result") + + asyncio.run(run_test()) + + def test_wrap_with_false(self): + wrapped = wrap("test_result", False) + self.assertEqual(wrapped, "test_result") + self.assertFalse(asyncio.iscoroutine(wrapped)) + + def test_wrap_with_true(self): + wrapped = wrap("test_result", True) + self.assertTrue(asyncio.iscoroutine(wrapped)) + + async def run_test(): + actual_result = await wrapped + self.assertEqual(actual_result, "test_result") + + asyncio.run(run_test()) + + def test_resolve_with_sync_object(self): + async def run_test(): + result = await resolve("sync_object") + self.assertEqual(result, "sync_object") + + asyncio.run(run_test()) + + def test_resolve_with_coroutine(self): + async def async_func(): + return "coroutine_result" + + async def run_test(): + result = await resolve(async_func()) + self.assertEqual(result, "coroutine_result") + + asyncio.run(run_test()) + + def test_resolve_with_future(self): + async def run_test(): + future = asyncio.Future() + future.set_result("future_result") + + result = await resolve(future) + self.assertEqual(result, "future_result") + + asyncio.run(run_test()) + + def test_then_with_complex_modifier(self): + result = then({"key": "value"}, lambda x: {**x, "modified": True}) + self.assertEqual(result, {"key": "value", "modified": True}) + + def test_wrap_with_none(self): + wrapped = wrap(None, True) + self.assertTrue(asyncio.iscoroutine(wrapped)) + + async def run_test(): + actual_result = await wrapped + self.assertIsNone(actual_result) + + asyncio.run(run_test()) + + def test_resolve_with_none(self): + async def run_test(): + result = await resolve(None) + self.assertIsNone(result) + + asyncio.run(run_test()) + + def test_then_with_exception_in_modifier(self): + def modifier(x): + raise ValueError("Test exception") + + with self.assertRaises(ValueError): + then("test_result", modifier) + + def test_then_with_exception_in_async_modifier(self): + async def async_func(): + return "async_result" + + def modifier(x): + raise ValueError("Test exception") + + result = then(async_func(), modifier) + + async def run_test(): + with self.assertRaises(ValueError): + await result + + asyncio.run(run_test()) + + def test_resolve_with_exception_in_coroutine(self): + async def async_func(): + raise ValueError("Test exception") + + async def run_test(): + with self.assertRaises(ValueError): + await resolve(async_func()) + + asyncio.run(run_test()) + + def test_wrap_with_false_and_none(self): + wrapped = wrap(None, False) + self.assertIsNone(wrapped) + self.assertFalse(asyncio.iscoroutine(wrapped)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http_client.py b/tests/test_http_client.py index f9f90851a..359f48514 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -987,5 +987,25 @@ def test_ssl_matcher_equality(self): assert not (SSLMatcher(insecure=True) == real_ctx) +class TestAsyncModeExperimental(unittest.TestCase): + @patch("httpx.AsyncClient") + @patch("httpx.get") + def test_class_flag_does_not_trigger_async(self, mock_get, mock_async_client): + """Class-level async_mode_experimental flag is inert; calling get() returns sync response.""" + import asyncio + + mock_response = Mock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + mock_get.return_value = mock_response + + client = HTTPClient(project_id="Ptest1234567890123456789", async_mode_experimental=True) + result = client.get("/test") + + self.assertFalse(asyncio.iscoroutine(result)) + assert isinstance(result, type(mock_response)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py index 771025d17..677554e32 100644 --- a/tests/test_magiclink.py +++ b/tests/test_magiclink.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -709,6 +710,31 @@ def test_update_user_phone(self): params=None, ) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, magiclink.sign_in still returns synchronously.""" + from descope.http_client import HTTPClient + + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + magiclink = MagicLink( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_oauth.py b/tests/test_oauth.py index ac944e810..539942164 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -175,6 +176,29 @@ def test_exchange_token(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, oauth.start still returns synchronously.""" + from descope.http_client import HTTPClient + + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = {} + + oauth = OAuth( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = oauth.start("google") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_otp.py b/tests/test_otp.py index 0c6816742..692896033 100644 --- a/tests/test_otp.py +++ b/tests/test_otp.py @@ -1,3 +1,4 @@ +import asyncio from enum import Enum from unittest import mock from unittest.mock import patch @@ -791,3 +792,21 @@ def test_update_user_phone(self): timeout=DEFAULT_TIMEOUT_SECONDS, params=None, ) + + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, otp.sign_in still returns synchronously.""" + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + async_mode_experimental=True, + ) + result = client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) diff --git a/tests/test_password.py b/tests/test_password.py index cf5897220..fbf04058c 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -524,3 +525,31 @@ def test_policy(self): verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) + + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, password.sign_up still returns synchronously.""" + from descope.http_client import HTTPClient + + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.cookies = {} + my_mock_response.json.return_value = json.loads( + '{"jwts": [], "user": {"loginIds": ["dummy@dummy.com"]}, "firstSeen": false}' + ) + mock_post.return_value = my_mock_response + + password = Password( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = password.sign_up("dummy@dummy.com", "123456", {"email": "dummy@dummy.com"}) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) diff --git a/tests/test_saml.py b/tests/test_saml.py index a333308c6..c843f5a41 100644 --- a/tests/test_saml.py +++ b/tests/test_saml.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -169,6 +170,29 @@ def test_exchange_token(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, saml.start still returns synchronously.""" + from descope.http_client import HTTPClient + + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = {} + + saml = SAML( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = saml.start("tenant1", "http://dummy.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sso.py b/tests/test_sso.py index 5cabc30e2..fd9676ba7 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -306,6 +307,29 @@ def test_exchange_token(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, sso.start still returns synchronously.""" + from descope.http_client import HTTPClient + + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = {} + + sso = SSO( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = sso.start("tenant1", "http://dummy.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_totp.py b/tests/test_totp.py index 9299b100d..6e38706d7 100644 --- a/tests/test_totp.py +++ b/tests/test_totp.py @@ -1,3 +1,4 @@ +import asyncio import json from unittest import mock from unittest.mock import patch @@ -195,3 +196,26 @@ def test_update_user(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) self.assertEqual(res, valid_response) + + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, totp.sign_up still returns synchronously.""" + from descope.http_client import HTTPClient + + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = {"provisioningUrl": "otpauth://totp/test"} + + totp = TOTP( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = totp.sign_up("dummy@dummy.com", {"email": "dummy@dummy.com"}) + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 444de1ab5..5d9879adc 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -1,3 +1,4 @@ +import asyncio import json import unittest from unittest import mock @@ -513,6 +514,32 @@ def test_update_finish(self): ) self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) + @patch("httpx.AsyncClient") + @patch("httpx.post") + def test_sync_behavior_with_async_mode_experimental(self, mock_post, _mock_async): + """With async_mode_experimental=True, webauthn.sign_up_start still returns synchronously.""" + from descope.http_client import HTTPClient + + mock_post.return_value.is_success = True + mock_post.return_value.json.return_value = { + "transactionId": "txn1", + "options": "{}", + } + + webauthn = WebAuthn( + Auth( + self.dummy_project_id, + self.public_key_dict, + http_client=HTTPClient( + project_id=self.dummy_project_id, + async_mode_experimental=True, + ), + ) + ) + result = webauthn.sign_up_start("dummy@dummy.com", "https://example.com") + self.assertFalse(asyncio.iscoroutine(result)) + self.assertIsNotNone(result) + if __name__ == "__main__": unittest.main() From 83afa6a5b0b8330bed07dad445ceafa2f13ec318 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 10 May 2026 14:58:09 +0300 Subject: [PATCH 2/5] fix: address PR review comments on async transport foundation - Use inspect.isawaitable() in future_utils for correct awaitable detection - Guard async_mode=True with a clear AuthException when async client is not initialized - Use contextvars.ContextVar for last_response in async methods (thread-safe per task) - Add aclose() and __aenter__/__aexit__ to DescopeClient for async resource cleanup --- descope/descope_client.py | 10 ++++++++++ descope/future_utils.py | 6 +++--- descope/http_client.py | 25 +++++++++++++++++++++---- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/descope/descope_client.py b/descope/descope_client.py index 5eb9a7744..d2d2d6c1d 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -115,6 +115,16 @@ def __init__( self._auth_http_client = auth_http_client self._mgmt_http_client = mgmt_http_client + async def aclose(self) -> None: + await self._auth_http_client.aclose() + await self._mgmt_http_client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + await self.aclose() + @property def mgmt(self): return self._mgmt diff --git a/descope/future_utils.py b/descope/future_utils.py index f4a69a8b4..9f4753e7f 100644 --- a/descope/future_utils.py +++ b/descope/future_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -import asyncio +import inspect from typing import Any, Awaitable, Callable, TypeVar, Union T = TypeVar("T") @@ -9,7 +9,7 @@ def then( result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any] ) -> Union[Any, Awaitable[Any]]: - if asyncio.iscoroutine(result_or_coro) or asyncio.isfuture(result_or_coro): + if inspect.isawaitable(result_or_coro): async def process_async(): result = await result_or_coro @@ -32,6 +32,6 @@ async def awaitable_wrapper(): async def resolve(obj: Union[Any, Awaitable[Any]]) -> Any: - if asyncio.iscoroutine(obj) or asyncio.isfuture(obj): + if inspect.isawaitable(obj): return await obj return obj diff --git a/descope/http_client.py b/descope/http_client.py index 0289a3236..fda537f9a 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextvars import os import platform import ssl @@ -178,6 +179,9 @@ def __init__( # Reserved for the future global async rollout (see big-plan.md "Final stage") self.async_mode_experimental = async_mode_experimental self._thread_local = threading.local() + self._async_last_response: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( + "last_response", default=None + ) # Setup SSL verification for httpx (backwards compatibility with requests) self.client_verify: bool | ssl.SSLContext = False @@ -208,6 +212,8 @@ def get( async_mode: bool = False, ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: + if self._async_client is None: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") return self._async_get(uri, params=params, allow_redirects=allow_redirects, pswd=pswd) response = self._execute_with_retry( lambda: httpx.get( @@ -235,6 +241,8 @@ def post( async_mode: bool = False, ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: + if self._async_client is None: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") return self._async_post(uri, body=body, params=params, pswd=pswd, base_url=base_url) response = self._execute_with_retry( lambda: httpx.post( @@ -262,6 +270,8 @@ def put( async_mode: bool = False, ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: + if self._async_client is None: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") return self._async_put(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.put( @@ -287,6 +297,8 @@ def patch( async_mode: bool = False, ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: + if self._async_client is None: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") return self._async_patch(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.patch( @@ -313,6 +325,8 @@ def delete( async_mode: bool = False, ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: + if self._async_client is None: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") return self._async_delete(uri, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.delete( @@ -351,6 +365,9 @@ def get_last_response(self) -> DescopeResponse | None: if resp: logger.error(f"cf-ray: {resp.headers.get('cf-ray')}") """ + async_resp = self._async_last_response.get(None) + if async_resp is not None: + return async_resp return getattr(self._thread_local, "last_response", None) def get_default_headers(self, pswd: str | None = None) -> dict: @@ -460,7 +477,7 @@ async def _async_get( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self._async_last_response.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -483,7 +500,7 @@ async def _async_post( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self._async_last_response.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -525,7 +542,7 @@ async def _async_patch( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self._async_last_response.set(DescopeResponse(response)) self._raise_from_response(response) return response @@ -545,6 +562,6 @@ async def _async_delete( ) ) if self.verbose: - self._thread_local.last_response = DescopeResponse(response) + self._async_last_response.set(DescopeResponse(response)) self._raise_from_response(response) return response From 3186ea0ed18fec427dd0870c72c9ee2fa93f5337 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 10 May 2026 15:41:51 +0300 Subject: [PATCH 3/5] style: apply ruff format --- big-plan.md | 86 +++++++++++++++++++++++++++++++ descope/descope_client.py | 4 +- descope/future_utils.py | 4 +- descope/http_client.py | 30 +++++++++-- tests/management/test_descoper.py | 4 +- tests/test_descope_client.py | 4 +- 6 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 big-plan.md diff --git a/big-plan.md b/big-plan.md new file mode 100644 index 000000000..1b1d5776e --- /dev/null +++ b/big-plan.md @@ -0,0 +1,86 @@ +# Async Rollout Plan — python-sdk + +## Current state +- `future_utils.py` exists with `then`, `wrap`, `resolve` helpers +- `HTTPClient` stores `async_mode_experimental` but doesn't act on it yet +- `DescopeClient.__init__` accepts and forwards the flag + +--- + +## Stage 0 — Foundation: async HTTP transport (1 PR + 1 test PR) + +**PR 0a — Implementation:** +- Add `httpx.AsyncClient` (persistent, per-instance) alongside the existing synchronous path +- Add `async def _async_execute_with_retry(request_fn)` mirroring the sync retry loop +- Each public method accepts an explicit `async_mode: bool = False` parameter; passing `True` delegates to the async path and returns a coroutine; the class-level `async_mode_experimental` flag is stored but inert until the final global-rollout stage +- No callers change yet — this PR is purely internal to `HTTPClient` + +**PR 0b — Tests:** +- Unit tests asserting async mode methods return coroutines (`asyncio.iscoroutine`) +- Verify sync mode is completely unaffected (all existing tests continue to pass unchanged) +- Test async retry logic (mock 503s, assert delays and retry count) + +--- + +## Stage 1–9 — Auth methods (one file per PR pair) + +**Pattern for every auth method file:** + +```python +# Before +response = self._http.post(uri, body=body) +return Auth.extract_masked_address(response.json(), method) + +# After (using then from future_utils) +from descope.future_utils import then +response = self._http.post(uri, body=body) +return then(response, lambda r: Auth.extract_masked_address(r.json(), method)) +``` + +When the HTTP client returns a plain `httpx.Response` (sync mode), `then` applies the lambda immediately and returns the final value — zero behaviour change. When it returns a coroutine (async mode), `then` returns a new coroutine that awaits it and applies the lambda. + +Rollout order (each is one implementation PR + one test PR): + +| Stage | File | Methods | +|-------|------|---------| +| 1 | `authmethod/otp.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify\_code, update\_user\_email, update\_user\_phone | +| 2 | `authmethod/magiclink.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify, update\_user\_email, update\_user\_phone | +| 3 | `authmethod/enchantedlink.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify, get\_session, update\_user\_email, update\_user\_phone | +| 4 | `authmethod/oauth.py` | start, exchange\_token, update\_user | +| 5 | `authmethod/password.py` | sign\_in, sign\_up, send\_reset, update, replace, get\_policy | +| 6 | `authmethod/totp.py` | sign\_in, sign\_up, sign\_up\_or\_in, update\_user, verify | +| 7 | `authmethod/webauthn.py` | sign\_in\_start/finish, sign\_up\_start/finish, update\_user\_start/finish | +| 8 | `authmethod/saml.py` + `sso.py` | start methods | +| 9 | `auth.py` | validate\_session, refresh\_session, exchange\_access\_key (I/O-bound JWKS fetch) | + +--- + +## Stage 10–N — Management files (one file per PR pair) + +Same `then()` wrapping pattern. Suggested order by impact: + +| Stage | File | +|-------|------| +| 10 | `management/user.py` | +| 11 | `management/access_key.py` | +| 12 | `management/tenant.py` | +| 13 | `management/role.py` + `permission.py` | +| 14 | `management/audit.py` | +| 15 | `management/authz.py` + `management/fga.py` | +| 16 | `management/sso_settings.py` + `management/sso_application.py` | +| 17 | `management/flow.py` + `management/jwt.py` | +| 18 | `management/group.py` + `management/project.py` + remaining files | + +--- + +## Final stage — Global setting (future, after all stages done) + +Once every file is converted, add a class-level `async_mode` property to `DescopeClient` that applies to all methods at once, and graduate the feature out of experimental. The per-file opt-in PRs make this final step trivial since all callers already use `then()`. + +--- + +## Invariants throughout +- Sync callers are **never broken** at any stage — `then(sync_result, fn)` is identical to `fn(sync_result)` +- No new public API surface until the global-setting stage +- Each implementation PR is independently reviewable and rollback-safe +- Test PRs always cover both sync (regression) and async (new) paths for the converted file diff --git a/descope/descope_client.py b/descope/descope_client.py index d2d2d6c1d..3e3ff391b 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -54,9 +54,7 @@ def __init__( async_mode_experimental = bool(kwargs.pop("async_mode_experimental", False)) if kwargs: - raise TypeError( - f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}" - ) + raise TypeError(f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}") # Warn about TLS verification bypass if skip_verify: diff --git a/descope/future_utils.py b/descope/future_utils.py index 9f4753e7f..04501be1a 100644 --- a/descope/future_utils.py +++ b/descope/future_utils.py @@ -6,9 +6,7 @@ T = TypeVar("T") -def then( - result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any] -) -> Union[Any, Awaitable[Any]]: +def then(result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any]) -> Union[Any, Awaitable[Any]]: if inspect.isawaitable(result_or_coro): async def process_async(): diff --git a/descope/http_client.py b/descope/http_client.py index fda537f9a..c28c74a64 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -213,7 +213,11 @@ def get( ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: if self._async_client is None: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "async_mode requires async_mode_experimental=True at client construction", + ) return self._async_get(uri, params=params, allow_redirects=allow_redirects, pswd=pswd) response = self._execute_with_retry( lambda: httpx.get( @@ -242,7 +246,11 @@ def post( ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: if self._async_client is None: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "async_mode requires async_mode_experimental=True at client construction", + ) return self._async_post(uri, body=body, params=params, pswd=pswd, base_url=base_url) response = self._execute_with_retry( lambda: httpx.post( @@ -271,7 +279,11 @@ def put( ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: if self._async_client is None: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "async_mode requires async_mode_experimental=True at client construction", + ) return self._async_put(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.put( @@ -298,7 +310,11 @@ def patch( ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: if self._async_client is None: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "async_mode requires async_mode_experimental=True at client construction", + ) return self._async_patch(uri, body=body, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.patch( @@ -326,7 +342,11 @@ def delete( ) -> httpx.Response | Awaitable[httpx.Response]: if async_mode: if self._async_client is None: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction") + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "async_mode requires async_mode_experimental=True at client construction", + ) return self._async_delete(uri, params=params, pswd=pswd) response = self._execute_with_retry( lambda: httpx.delete( diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py index 02d4c6a30..12a25b36c 100644 --- a/tests/management/test_descoper.py +++ b/tests/management/test_descoper.py @@ -532,8 +532,6 @@ def test_sync_behavior_with_async_mode_experimental(self, _mock_async): '{"descopers": [{"id": "U2111111111111111111111111", "status": "invited"}], "total": 1}' ) mock_put.return_value = network_resp - result = client.mgmt.descoper.create( - descopers=[DescoperCreate(login_id="user1@example.com")] - ) + result = client.mgmt.descoper.create(descopers=[DescoperCreate(login_id="user1@example.com")]) self.assertFalse(asyncio.iscoroutine(result)) self.assertEqual(result["total"], 1) diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index bd29affd8..c59e1653c 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -1062,9 +1062,7 @@ def test_unknown_kwargs_raise_type_error(self): @patch("httpx.AsyncClient") @patch("httpx.post") - def test_async_mode_experimental_flag_does_not_return_coroutine( - self, mock_post, mock_async_client - ): + def test_async_mode_experimental_flag_does_not_return_coroutine(self, mock_post, mock_async_client): """DescopeClient with async_mode_experimental=True still returns sync results from auth methods.""" import asyncio From adf4672cebb5f3eec7ea45eb0533151d60eac9f2 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 10 May 2026 15:50:07 +0300 Subject: [PATCH 4/5] fix(types): add overloads and asserts to fix mypy errors in async HTTP client --- descope/http_client.py | 117 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/descope/http_client.py b/descope/http_client.py index c28c74a64..30a813315 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -9,7 +9,7 @@ import time from http import HTTPStatus from importlib.metadata import version -from typing import Awaitable, cast +from typing import Awaitable, Literal, cast, overload import certifi import httpx @@ -202,6 +202,28 @@ def __init__( ) # ------------- public API ------------- + @overload + def get( + self, + uri: str, + *, + params=None, + allow_redirects: bool | None = ..., + pswd: str | None = ..., + async_mode: Literal[False] = ..., + ) -> httpx.Response: ... + + @overload + def get( + self, + uri: str, + *, + params=None, + allow_redirects: bool | None = ..., + pswd: str | None = ..., + async_mode: Literal[True], + ) -> Awaitable[httpx.Response]: ... + def get( self, uri: str, @@ -234,6 +256,30 @@ def get( self._raise_from_response(response) return response + @overload + def post( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = ..., + params=..., + pswd: str | None = ..., + base_url: str | None = ..., + async_mode: Literal[False] = ..., + ) -> httpx.Response: ... + + @overload + def post( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = ..., + params=..., + pswd: str | None = ..., + base_url: str | None = ..., + async_mode: Literal[True], + ) -> Awaitable[httpx.Response]: ... + def post( self, uri: str, @@ -268,6 +314,28 @@ def post( self._raise_from_response(response) return response + @overload + def put( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = ..., + params=..., + pswd: str | None = ..., + async_mode: Literal[False] = ..., + ) -> httpx.Response: ... + + @overload + def put( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = ..., + params=..., + pswd: str | None = ..., + async_mode: Literal[True], + ) -> Awaitable[httpx.Response]: ... + def put( self, uri: str, @@ -299,6 +367,28 @@ def put( self._raise_from_response(response) return response + @overload + def patch( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None, + params=..., + pswd: str | None = ..., + async_mode: Literal[False] = ..., + ) -> httpx.Response: ... + + @overload + def patch( + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None, + params=..., + pswd: str | None = ..., + async_mode: Literal[True], + ) -> Awaitable[httpx.Response]: ... + def patch( self, uri: str, @@ -332,6 +422,26 @@ def patch( self._raise_from_response(response) return response + @overload + def delete( + self, + uri: str, + *, + params=..., + pswd: str | None = ..., + async_mode: Literal[False] = ..., + ) -> httpx.Response: ... + + @overload + def delete( + self, + uri: str, + *, + params=..., + pswd: str | None = ..., + async_mode: Literal[True], + ) -> Awaitable[httpx.Response]: ... + def delete( self, uri: str, @@ -488,6 +598,7 @@ async def _async_get( allow_redirects: bool | None = True, pswd: str | None = None, ) -> httpx.Response: + assert self._async_client is not None response = await self._async_execute_with_retry( lambda: self._async_client.get( f"{self.base_url}{uri}", @@ -510,6 +621,7 @@ async def _async_post( pswd: str | None = None, base_url: str | None = None, ) -> httpx.Response: + assert self._async_client is not None response = await self._async_execute_with_retry( lambda: self._async_client.post( f"{base_url or self.base_url}{uri}", @@ -532,6 +644,7 @@ async def _async_put( params=None, pswd: str | None = None, ) -> httpx.Response: + assert self._async_client is not None response = await self._async_execute_with_retry( lambda: self._async_client.put( f"{self.base_url}{uri}", @@ -552,6 +665,7 @@ async def _async_patch( params=None, pswd: str | None = None, ) -> httpx.Response: + assert self._async_client is not None response = await self._async_execute_with_retry( lambda: self._async_client.patch( f"{self.base_url}{uri}", @@ -573,6 +687,7 @@ async def _async_delete( params=None, pswd: str | None = None, ) -> httpx.Response: + assert self._async_client is not None response = await self._async_execute_with_retry( lambda: self._async_client.delete( f"{self.base_url}{uri}", From 9539d67936d1babbf04c7e76d378d0bddc9ecf86 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 10 May 2026 16:36:46 +0300 Subject: [PATCH 5/5] fix: address review comments on async HTTP transport - Add missing verbose last_response capture in put() and _async_put() (all other verbs already captured it; put was the only one missing) - Change overload stub bodies from ... to pass to silence CodeQL "statement has no effect" warnings - Change async_mode: Literal[False] = ... defaults to = False in overload signatures for clarity - Validate async_mode_experimental is a bool; raise TypeError for non-bool inputs instead of silently coercing (e.g. bool("False") == True) --- descope/descope_client.py | 7 ++++++- descope/http_client.py | 44 ++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/descope/descope_client.py b/descope/descope_client.py index 3e3ff391b..4bc2c5ff9 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -52,7 +52,12 @@ def __init__( ), ) - async_mode_experimental = bool(kwargs.pop("async_mode_experimental", False)) + raw_async_mode = kwargs.pop("async_mode_experimental", False) + if not isinstance(raw_async_mode, bool): + raise TypeError( + f"async_mode_experimental must be a bool, got {type(raw_async_mode).__name__!r}" + ) + async_mode_experimental: bool = raw_async_mode if kwargs: raise TypeError(f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}") diff --git a/descope/http_client.py b/descope/http_client.py index 30a813315..1eaa50b5c 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -210,8 +210,9 @@ def get( params=None, allow_redirects: bool | None = ..., pswd: str | None = ..., - async_mode: Literal[False] = ..., - ) -> httpx.Response: ... + async_mode: Literal[False] = False, + ) -> httpx.Response: + pass @overload def get( @@ -222,7 +223,8 @@ def get( allow_redirects: bool | None = ..., pswd: str | None = ..., async_mode: Literal[True], - ) -> Awaitable[httpx.Response]: ... + ) -> Awaitable[httpx.Response]: + pass def get( self, @@ -265,8 +267,9 @@ def post( params=..., pswd: str | None = ..., base_url: str | None = ..., - async_mode: Literal[False] = ..., - ) -> httpx.Response: ... + async_mode: Literal[False] = False, + ) -> httpx.Response: + pass @overload def post( @@ -278,7 +281,8 @@ def post( pswd: str | None = ..., base_url: str | None = ..., async_mode: Literal[True], - ) -> Awaitable[httpx.Response]: ... + ) -> Awaitable[httpx.Response]: + pass def post( self, @@ -322,8 +326,9 @@ def put( body: dict | list[dict] | list[str] | None = ..., params=..., pswd: str | None = ..., - async_mode: Literal[False] = ..., - ) -> httpx.Response: ... + async_mode: Literal[False] = False, + ) -> httpx.Response: + pass @overload def put( @@ -334,7 +339,8 @@ def put( params=..., pswd: str | None = ..., async_mode: Literal[True], - ) -> Awaitable[httpx.Response]: ... + ) -> Awaitable[httpx.Response]: + pass def put( self, @@ -364,6 +370,8 @@ def put( timeout=self.timeout_seconds, ) ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) self._raise_from_response(response) return response @@ -375,8 +383,9 @@ def patch( body: dict | list[dict] | list[str] | None, params=..., pswd: str | None = ..., - async_mode: Literal[False] = ..., - ) -> httpx.Response: ... + async_mode: Literal[False] = False, + ) -> httpx.Response: + pass @overload def patch( @@ -387,7 +396,8 @@ def patch( params=..., pswd: str | None = ..., async_mode: Literal[True], - ) -> Awaitable[httpx.Response]: ... + ) -> Awaitable[httpx.Response]: + pass def patch( self, @@ -429,8 +439,9 @@ def delete( *, params=..., pswd: str | None = ..., - async_mode: Literal[False] = ..., - ) -> httpx.Response: ... + async_mode: Literal[False] = False, + ) -> httpx.Response: + pass @overload def delete( @@ -440,7 +451,8 @@ def delete( params=..., pswd: str | None = ..., async_mode: Literal[True], - ) -> Awaitable[httpx.Response]: ... + ) -> Awaitable[httpx.Response]: + pass def delete( self, @@ -654,6 +666,8 @@ async def _async_put( params=params, ) ) + if self.verbose: + self._async_last_response.set(DescopeResponse(response)) self._raise_from_response(response) return response