Skip to content

feat: Add AsyncLDClient with FDv1 data system and public API - #480

Draft
jsonbailey wants to merge 4 commits into
mainfrom
jb/sdk-2867/async-client
Draft

feat: Add AsyncLDClient with FDv1 data system and public API#480
jsonbailey wants to merge 4 commits into
mainfrom
jb/sdk-2867/async-client

Conversation

@jsonbailey

@jsonbailey jsonbailey commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the async LaunchDarkly client (AsyncLDClient) built on asyncio/aiohttp, wired to the FDv1 data system, plus the public API surface for constructing it. This is the first extraction slice from the async SDK implementation branch (epic SDK-60).

What's included

  • ldclient/async_client.pyAsyncLDClient with the FDv1 data system path.
  • ldclient/impl/datasystem/async_fdv1.py — async FDv1 data system.
  • ldclient/impl/datasystem/__init__.py — adds the AsyncDataSystem protocol.
  • ldclient/__init__.py, ldclient/client.py, ldclient/impl/client_common.py, ldclient/impl/stubs.py — public API / shared plumbing needed by the async client.
  • ldclient/testing/mock_async_components.py, ldclient/testing/stub_util.py — async test doubles.
  • ldclient/testing/test_async_client.py, ldclient/testing/test_sync_async_parity.py — coverage for the async client and sync/async API parity.

Held back for a later PR (FDv2)

This slice is FDv1-only. The FDv2 data-system branch in _make_data_system() (lazy imports of async_fdv2 / datasourcev2.async_polling / async_streaming) is not included — those modules land in PR 11. For now the FDv2 path raises NotImplementedError("FDv2 is not yet supported in the async client"). The _wire_data_source_sessions helper (FDv2-only) was likewise omitted.

Follow-ups

  • The async contract-test service follows as a stacked PR (SDK-2868) on top of this branch.
  • FDv2 support for the async client is deferred to PR 11.

Verification

  • uv run pytest ldclient/testing/test_async_client.py ldclient/testing/test_sync_async_parity.py -q → 21 passed
  • pycodestyle, isort --check --atomic, and mypy clean on all changed files
  • Confirmed no remaining import of async_fdv2 or datasourcev2.async_* in the tree

Note

Medium Risk
Large new experimental client surface touching flag evaluation, networking, and lifecycle; FDv2 is blocked but misconfiguration could surprise adopters until follow-up PRs land.

Overview
Introduces an experimental asyncio-based AsyncLDClient with explicit await start() / close() (or async context manager), mirroring the sync client’s evaluation, events, hooks, and status APIs on AsyncConfig and aiohttp.

Wires the client to a new AsyncFDv1 implementation and an AsyncDataSystem protocol (streaming/polling via existing async datasource types). FDv2 is intentionally unsupported for now (NotImplementedError when datasystem_config is set).

Public plumbing: ldclient exposes AsyncLDClient via lazy __getattr__; get_plugin_hooks now takes a plugin sequence (sync LDClient updated accordingly). Adds AsyncNullEventProcessor / AsyncNullUpdateProcessor for offline and no-op paths.

Tests: test_async_client.py (lifecycle, variation, hooks, events) and test_sync_async_parity.py to guard sync/async API surface drift.

Reviewed by Cursor Bugbot for commit 3cef7b5. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3cef7b5. Configure here.

Comment thread ldclient/async_client.py Outdated
hooks = [] # type: List[AsyncHook]
with self.__hooks_lock.read():
if len(self.__hooks) == 0:
return await block()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hooks lock held across await

High Severity

When no hooks are registered, __evaluate_with_hooks awaits evaluation while still holding a threading ReadWriteLock read lock. A concurrent add_hook then blocks the event-loop thread on the write lock, so the reader never resumes and the process deadlocks. This is the common default path with an empty hook list.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cef7b5. Configure here.

Comment thread ldclient/async_client.py Outdated
'variation': detail.variation_index,
'reason': detail.reason,
'version': flag['version'],
'prerequisites': result.prerequisites,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbound result after flag error

Medium Severity

In all_flags_state, if evaluating a single flag raises, the except path sets detail but still reads result.prerequisites afterward. That raises UnboundLocalError and aborts the whole bootstrap payload instead of continuing with the other flags.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cef7b5. Configure here.

@jsonbailey
jsonbailey marked this pull request as draft August 5, 2026 23:08
Address review findings in the async client evaluation and shutdown paths:

- __evaluate_with_hooks held the hooks read lock across `await block()` on
  the empty-hooks fast path. A concurrent sync add_hook() takes the write
  lock with a blocking wait, freezing the event-loop thread. Snapshot the
  hooks under the lock and release it before awaiting, mirroring the sync
  client's no-await safety.
- all_flags_state referenced `result.prerequisites` unconditionally even
  when a per-flag evaluation raised, causing UnboundLocalError on the first
  failure or reuse of a neighbor's prerequisites on a later one. Bind the
  prerequisites safely in both branches so an error degrades only that flag.
- __try_execute_stage swallowed asyncio.CancelledError via `except
  BaseException`, defeating cancellation and shutdown. Re-raise it.
- _close_components stopped components in sequence with no isolation, so one
  failing stop() skipped the rest. Stop each component in its own try/except.
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.

1 participant