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.
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!
- 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
- 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)
- Run ruff format on descope_client.py (was flagged by CI lint job) - Add # pragma: no cover to overload stub pass bodies (never executed at runtime; they were new uncovered lines after the ... -> pass change) - Add tests for async_mode=True guard checks (all 5 verbs) and actual async method calls via asyncio.run() to cover _async_* paths - Add verbose-mode test for put() sync and async paths - Add test for HTTPClient.aclose() with and without async client - Add test for TypeError when async_mode_experimental is non-bool - Overall coverage: 98% (meets fail_under threshold)
Coverage reportThe coverage rate went from
Diff Coverage details (click to unfold)descope/future_utils.py
descope/http_client.py
descope/descope_client.py
|
- Unify last_response storage onto ContextVar so sync/async recency is correct - Add R TypeVar to future_utils.then for proper type propagation - Fix no-effect await in test_future_utils (use _ = await) - Add async retry tests (503 retries, non-retryable skips retry) - Update big-plan.md Current state to reflect Stage 0 is complete
| self._async_last_response: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( | ||
| "last_response", default=None | ||
| ) |
| ) -> 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) |
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