Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions big-plan.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +3 to +6

---

## 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
22 changes: 22 additions & 0 deletions descope/descope_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand All @@ -51,6 +52,15 @@ def __init__(
),
)

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)}")

# Warn about TLS verification bypass
if skip_verify:
warnings.warn(
Expand All @@ -70,6 +80,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,
Expand All @@ -95,6 +106,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,
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.

)
self._mgmt = MGMT(
http_client=mgmt_http_client,
Expand All @@ -106,6 +118,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
Expand Down
35 changes: 35 additions & 0 deletions descope/future_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

import inspect
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 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]
Comment on lines +9 to +18


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 inspect.isawaitable(obj):
return await obj
return obj
Loading
Loading