Skip to content

feat: add async HTTP transport foundation (Stage 0)#1523

Open
LioriE wants to merge 6 commits into
mainfrom
feat/async-http-transport
Open

feat: add async HTTP transport foundation (Stage 0)#1523
LioriE wants to merge 6 commits into
mainfrom
feat/async-http-transport

Conversation

@LioriE
Copy link
Copy Markdown
Contributor

@LioriE LioriE commented May 10, 2026

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) — applies fn immediately on a sync result, or returns an awaitable that applies it after the coroutine resolves
  • wrap(result, as_awaitable) — wraps a sync value in a coroutine when needed
  • resolve(obj) — awaits if async, returns as-is if sync

HTTPClient — async execution path alongside the existing sync path:

  • Persistent httpx.AsyncClient instance created when async_mode_experimental=True
  • _async_execute_with_retry mirrors the sync retry loop using asyncio.sleep
  • All five public methods (get/post/put/patch/delete) accept async_mode: bool = False; passing True delegates to the async counterpart and returns a coroutine

DescopeClient.__init__ — accepts async_mode_experimental via **kwargs and forwards it to both HTTP client instances, ready for the eventual global-rollout stage

Sync callers are completely unaffected — all existing tests pass unchanged.

Related

descope/etc#5922

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.
Copilot AI review requested due to automatic review settings May 10, 2026 11:43
@shuni-bot-dev
Copy link
Copy Markdown

shuni-bot-dev Bot commented May 10, 2026

🐕 Review complete — View session on Shuni Portal 🐾

Comment thread tests/test_future_utils.py Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (persistent httpx.AsyncClient, async retry loop, and async_mode switch on HTTP verbs).
  • Introduce descope.future_utils helpers (then, wrap, resolve) plus unit tests.
  • Thread async_mode_experimental through DescopeClient.__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.

Comment thread descope/http_client.py
Comment on lines +208 to 212
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(
Comment thread descope/http_client.py
Comment on lines +193 to +199
self._async_client: httpx.AsyncClient | None = None
if async_mode_experimental:
self._async_client = httpx.AsyncClient(
verify=self.client_verify,
timeout=self.timeout_seconds,
)

Comment thread descope/future_utils.py Outdated
Comment on lines +9 to +20
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]
Comment thread descope/descope_client.py
Comment on lines 72 to 107
# 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,
)
Copy link
Copy Markdown

@shuni-bot-dev shuni-bot-dev Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐕 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 AttributeError when async_mode=True is used without enabling the experimental client
  • 2 🟡 MEDIUM: threading.local() not async-safe for last_response; no public aclose() path on DescopeClient for the new async clients

See inline comments for details. Woof!

Comment thread descope/http_client.py
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread descope/http_client.py Outdated
)
)
if self.verbose:
self._thread_local.last_response = DescopeResponse(response)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread descope/descope_client.py
secure=auth_http_client.secure,
management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"),
verbose=verbose,
async_mode_experimental=async_mode_experimental,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

LioriE added 2 commits May 10, 2026 14:58
- 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
Copilot AI review requested due to automatic review settings May 10, 2026 12:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 14 comments.

Comment thread descope/http_client.py
Comment on lines +491 to +495
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,
Comment thread descope/http_client.py
Comment on lines +513 to +520
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,
)
Comment thread descope/http_client.py
Comment on lines +535 to +542
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,
)
Comment thread descope/http_client.py
Comment on lines +555 to +562
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,
)
Comment thread descope/http_client.py
Comment on lines +575 to +582
) -> 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,
)
Comment thread descope/http_client.py
Comment on lines 205 to +214
@@ -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:
Comment thread descope/http_client.py
Comment on lines 237 to +247
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:
Comment thread descope/http_client.py
Comment on lines 271 to +280
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:
Comment thread descope/http_client.py
Comment on lines 302 to +311
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:
Comment thread descope/http_client.py
Comment on lines 335 to +343
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:
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
Comment thread descope/http_client.py Fixed
@LioriE LioriE changed the title [Feat] - Async HTTP transport foundation (Stage 0) feat: add async HTTP transport foundation (Stage 0) May 10, 2026
LioriE added 2 commits May 10, 2026 16:30
- 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)
Copilot AI review requested due to automatic review settings May 10, 2026 13:36

async def run_test():
with self.assertRaises(ValueError):
await result
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 5 comments.

Comment thread descope/http_client.py
Comment on lines +197 to +203
self._async_client: httpx.AsyncClient | None = None
if async_mode_experimental:
self._async_client = httpx.AsyncClient(
verify=self.client_verify,
timeout=self.timeout_seconds,
)

Comment thread descope/http_client.py
Comment on lines +510 to 513
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)
Comment thread descope/http_client.py
Comment on lines +595 to +599
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
Comment thread big-plan.md
Comment on lines +3 to +6
## 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
Comment thread descope/future_utils.py
Comment on lines +9 to +18
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]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants