feat: add async HTTP transport foundation (Stage 0)#1523
Conversation
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.
|
🐕 Review complete — View session on Shuni Portal 🐾 |
There was a problem hiding this comment.
Pull request overview
Lays initial groundwork for async support in the SDK by introducing async-aware helper utilities and an optional async execution path in HTTPClient, while adding regression tests to ensure existing sync APIs remain synchronous even when the experimental async flag is enabled.
Changes:
- Add an experimental async path to
HTTPClient(persistenthttpx.AsyncClient, async retry loop, andasync_modeswitch on HTTP verbs). - Introduce
descope.future_utilshelpers (then,wrap,resolve) plus unit tests. - Thread
async_mode_experimentalthroughDescopeClient.__init__and add broad “sync behavior remains sync” tests across auth/mgmt modules.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| descope/http_client.py | Adds async_mode_experimental, persistent AsyncClient, async retry loop, and async_mode switch on HTTP methods. |
| descope/future_utils.py | New helper utilities for working uniformly with sync values vs awaitables. |
| descope/descope_client.py | Accepts/validates async_mode_experimental via **kwargs and forwards it to underlying HTTP clients. |
| tests/test_future_utils.py | New unit tests covering then, wrap, and resolve. |
| tests/test_http_client.py | Verifies async_mode_experimental alone does not change sync behavior of HTTPClient.get(). |
| tests/test_descope_client.py | Ensures unknown kwargs raise TypeError and experimental async flag doesn’t force coroutines from auth APIs. |
| tests/test_auth.py | Ensures _fetch_public_keys stays synchronous with experimental async flag. |
| tests/test_otp.py | Ensures OTP sync methods remain synchronous with experimental async flag. |
| tests/test_magiclink.py | Ensures MagicLink sync methods remain synchronous with experimental async flag. |
| tests/test_enchantedlink.py | Ensures EnchantedLink sync methods remain synchronous with experimental async flag. |
| tests/test_password.py | Ensures Password sync methods remain synchronous with experimental async flag. |
| tests/test_totp.py | Ensures TOTP sync methods remain synchronous with experimental async flag. |
| tests/test_webauthn.py | Ensures WebAuthn sync methods remain synchronous with experimental async flag. |
| tests/test_oauth.py | Ensures OAuth sync methods remain synchronous with experimental async flag. |
| tests/test_sso.py | Ensures SSO sync methods remain synchronous with experimental async flag. |
| tests/test_saml.py | Ensures SAML sync methods remain synchronous with experimental async flag. |
| tests/management/test_user.py | Ensures mgmt user APIs remain synchronous with experimental async flag. |
| tests/management/test_tenant.py | Ensures mgmt tenant APIs remain synchronous with experimental async flag. |
| tests/management/test_sso_settings.py | Ensures mgmt SSO settings APIs remain synchronous with experimental async flag. |
| tests/management/test_sso_application.py | Ensures mgmt SSO application APIs remain synchronous with experimental async flag. |
| tests/management/test_role.py | Ensures mgmt role APIs remain synchronous with experimental async flag. |
| tests/management/test_project.py | Ensures mgmt project APIs remain synchronous with experimental async flag. |
| tests/management/test_permission.py | Ensures mgmt permission APIs remain synchronous with experimental async flag. |
| tests/management/test_outbound_application.py | Ensures mgmt outbound application APIs remain synchronous with experimental async flag. |
| tests/management/test_mgmtkey.py | Ensures mgmt management key APIs remain synchronous with experimental async flag. |
| tests/management/test_jwt.py | Ensures mgmt JWT APIs remain synchronous with experimental async flag. |
| tests/management/test_group.py | Ensures mgmt group APIs remain synchronous with experimental async flag. |
| tests/management/test_flow.py | Ensures mgmt flow APIs remain synchronous with experimental async flag. |
| tests/management/test_fga.py | Ensures mgmt FGA APIs remain synchronous with experimental async flag. |
| tests/management/test_descoper.py | Ensures mgmt descoper APIs remain synchronous with experimental async flag. |
| tests/management/test_authz.py | Ensures mgmt authz APIs remain synchronous with experimental async flag. |
| tests/management/test_audit.py | Ensures mgmt audit APIs remain synchronous with experimental async flag. |
| tests/management/test_access_key.py | Ensures mgmt access key APIs remain synchronous with experimental async flag. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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( |
| self._async_client: httpx.AsyncClient | None = None | ||
| if async_mode_experimental: | ||
| self._async_client = httpx.AsyncClient( | ||
| verify=self.client_verify, | ||
| timeout=self.timeout_seconds, | ||
| ) | ||
|
|
| 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] |
| # Auth Initialization | ||
| auth_http_client = HTTPClient( | ||
| project_id=project_id, | ||
| base_url=base_url, | ||
| timeout_seconds=timeout_seconds, | ||
| 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, | ||
| public_key, | ||
| jwt_validation_leeway, | ||
| http_client=auth_http_client, | ||
| ) | ||
| self._magiclink = MagicLink(self._auth) | ||
| self._enchantedlink = EnchantedLink(self._auth) | ||
| self._oauth = OAuth(self._auth) | ||
| self._saml = SAML(self._auth) # deprecated | ||
| self._sso = SSO(self._auth) | ||
| self._otp = OTP(self._auth) | ||
| self._totp = TOTP(self._auth) | ||
| self._webauthn = WebAuthn(self._auth) | ||
| self._password = Password(self._auth) | ||
|
|
||
| # Management Initialization | ||
| mgmt_http_client = HTTPClient( | ||
| project_id=project_id, | ||
| base_url=auth_http_client.base_url, | ||
| timeout_seconds=auth_http_client.timeout_seconds, | ||
| secure=auth_http_client.secure, | ||
| management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), | ||
| verbose=verbose, | ||
| async_mode_experimental=async_mode_experimental, | ||
| ) |
There was a problem hiding this comment.
🐕 Shuni's Review
Lays the async transport groundwork — adds httpx.AsyncClient-backed paths, future_utils helpers, and a **kwargs-gated async_mode_experimental flag. Good bones overall, but a few rough edges to sniff out before this becomes load-bearing.
Sniffed out 3 issues:
- 1 🟠 HIGH: misleading
AttributeErrorwhenasync_mode=Trueis used without enabling the experimental client - 2 🟡 MEDIUM:
threading.local()not async-safe forlast_response; no publicaclose()path onDescopeClientfor the new async clients
See inline comments for details. Woof!
| 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) |
There was a problem hiding this comment.
🟠 HIGH: When a caller passes async_mode=True but the client was constructed with async_mode_experimental=False, self._async_client is None. The returned coroutine eventually calls self._async_client.get(...) and fails with a confusing AttributeError: 'NoneType' object has no attribute 'get' — and only when awaited, far from the call site.
Guard at the public-method boundary so the misuse surfaces synchronously with a meaningful error. Same pattern applies to post/put/patch/delete.
| ) | ||
| ) | ||
| if self.verbose: | ||
| self._thread_local.last_response = DescopeResponse(response) |
There was a problem hiding this comment.
🟡 MEDIUM: self._thread_local is a threading.local(), but in async code many coroutines share the same OS thread. Concurrent async calls in a single event loop will overwrite each other's last_response, so get_last_response() returns whichever coroutine wrote last — not the caller's response.
For the async path, prefer a contextvars.ContextVar (which propagates per-task) over threading.local, or document that verbose mode is unsupported under async until this is addressed.
| secure=auth_http_client.secure, | ||
| management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), | ||
| verbose=verbose, | ||
| async_mode_experimental=async_mode_experimental, |
There was a problem hiding this comment.
🟡 MEDIUM: When async_mode_experimental=True, two httpx.AsyncClient instances are created (auth + mgmt) but DescopeClient exposes no public aclose() to shut them down. Users would need to reach into _auth_http_client / _mgmt_http_client (private) and call aclose() on each, otherwise the clients leak open connections and can emit unclosed-resource / Event loop is closed warnings at GC.
Consider adding async def aclose(self) on DescopeClient that awaits both http clients (and optionally __aenter__/__aexit__) before this stage progresses.
- 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
| 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, |
| 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, | ||
| ) |
| 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, | ||
| ) |
| 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, | ||
| ) |
| ) -> 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, | ||
| ) |
| @@ -194,7 +209,16 @@ | |||
| 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: | |||
| def 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: | ||
| async_mode: bool = False, | ||
| ) -> httpx.Response | Awaitable[httpx.Response]: | ||
| if async_mode: |
| def put( | ||
| self, | ||
| uri: str, | ||
| *, | ||
| 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: |
| def patch( | ||
| self, | ||
| uri: str, | ||
| *, | ||
| 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: |
| def delete( | ||
| self, | ||
| uri: str, | ||
| *, | ||
| params=None, | ||
| pswd: str | None = None, | ||
| ) -> httpx.Response: | ||
| async_mode: bool = False, | ||
| ) -> httpx.Response | Awaitable[httpx.Response]: | ||
| if async_mode: |
- 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)
|
|
||
| async def run_test(): | ||
| with self.assertRaises(ValueError): | ||
| await result |
| self._async_client: httpx.AsyncClient | None = None | ||
| if async_mode_experimental: | ||
| self._async_client = httpx.AsyncClient( | ||
| verify=self.client_verify, | ||
| timeout=self.timeout_seconds, | ||
| ) | ||
|
|
| 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) |
| 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 |
| ## 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 |
| 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(): | ||
| result = await result_or_coro | ||
| return modifier(result) | ||
|
|
||
| return process_async() | ||
|
|
||
| return modifier(result_or_coro) # type: ignore[arg-type] |
What
Lays the foundation for async support in the Python SDK as the first stage of the async rollout plan.
future_utils.py— three transparent helpers:then(result_or_coro, fn)— appliesfnimmediately on a sync result, or returns an awaitable that applies it after the coroutine resolveswrap(result, as_awaitable)— wraps a sync value in a coroutine when neededresolve(obj)— awaits if async, returns as-is if syncHTTPClient— async execution path alongside the existing sync path:httpx.AsyncClientinstance created whenasync_mode_experimental=True_async_execute_with_retrymirrors the sync retry loop usingasyncio.sleepget/post/put/patch/delete) acceptasync_mode: bool = False; passingTruedelegates to the async counterpart and returns a coroutineDescopeClient.__init__— acceptsasync_mode_experimentalvia**kwargsand forwards it to both HTTP client instances, ready for the eventual global-rollout stageSync callers are completely unaffected — all existing tests pass unchanged.
Related
descope/etc#5922