Skip to content

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

Merged
jsonbailey merged 19 commits into
mainfrom
jb/sdk-2867/async-client
Aug 12, 2026
Merged

feat: Add AsyncLDClient with FDv1 data system and public API#480
jsonbailey merged 19 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).

The client uses an explicit lifecycle: construct with AsyncLDClient(config), then await start() / await close() (or async with). Construction is loop-free, so the client can be built before an event loop exists and started per worker (e.g. one loop per ASGI worker).

Lifecycle shape

  • __init__ builds the object graph, loop-free — data system, status providers, big-segment manager, evaluator, and flag tracker are all constructed here; no event loop is required.
  • start() does the loop-bound work — creates the shared aiohttp session (lazily, on first use inside the loop), starts the data source, starts the big-segment status poll, and sets up the event processor.
  • flag_tracker and the status providers are available before start(), matching the other LaunchDarkly SDKs (they no longer raise). Flag-change listeners registered before start() fire once data begins to flow.
  • The shared HTTP session is created only when a network component first needs it, so offline/LDD mode creates none.

What's included

  • ldclient/async_client.pyAsyncLDClient with the FDv1 data system path.
  • ldclient/impl/datasystem/async_fdv1.py — async FDv1 data system (resolves the HTTP session from a lazy provider at start).
  • ldclient/impl/datasystem/__init__.py — adds the AsyncDataSystem protocol.
  • ldclient/impl/async_flag_tracker.py — the flag tracker's callback scheduler is created lazily on first use, so the tracker can be built without a running loop.
  • ldclient/impl/async_big_segments.py — the status poll task is created in __init__ and started in start().
  • 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.__getattr__ lazy-loads AsyncLDClient so plain import ldclient doesn't pull in aiohttp; get_plugin_hooks now takes config.plugins; async null event/update processor stubs).
  • ldclient/testing/mock_async_components.py, ldclient/testing/stub_util.py — async test doubles.
  • ldclient/testing/test_async_client.py, ldclient/testing/impl/test_async_big_segments.py, ldclient/testing/test_sync_async_parity.py — coverage for the async client, the big-segment lifecycle, and sync/async public-API parity.

Held back for a later PR (FDv2)

This slice is FDv1-only. The FDv2 branch in _make_data_system() raises NotImplementedError("FDv2 is not yet supported in the async client"); the FDv2 modules (async_fdv2, datasourcev2.async_*) and their session wiring land in PR 11.

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 --ignore=ldclient/testing/integrations -q → 1245 passed, 2 skipped (integration suites require Redis/DynamoDB/Consul services)
  • pycodestyle, isort --check --atomic, and mypy clean on all changed files

Note

Overview
Adds experimental AsyncLDClient for asyncio/aiohttp apps, mirroring the sync client’s flag evaluation, events, hooks, migrations, and status providers behind await start() / await close() (or async with).

Lifecycle: __init__ builds the object graph without a running event loop; start() wires the shared HTTP session, FDv1 update processor (streaming or polling), big-segment status poll, and event processor. Failed or cancelled start() tears down partial startup and treats the instance as single-shot. flag_tracker and status providers work before start(); listeners registered early stay on the same data system.

Data path: New AsyncDataSystem protocol and AsyncFDv1 implementation; FDv2 config raises NotImplementedError. Async null event/update processor stubs and deferred AsyncBigSegmentStoreManager.start() / lazy flag-tracker scheduler align with loop-free construction.

Shared plumbing: get_plugin_hooks now takes plugins directly (sync LDClient updated). ldclient.__getattr__ lazy-loads AsyncLDClient so plain import ldclient does not require aiohttp.

Tests cover client lifecycle, hooks, all_flags_state degradation, big-segment start timing, and a sync/async public API parity guard.

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

Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
@jsonbailey
jsonbailey marked this pull request as draft August 5, 2026 23:08
@jsonbailey
jsonbailey marked this pull request as ready for review August 11, 2026 19:02
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.
The async client is single-event-loop, so a plain list copy of the hooks is
atomic and add_hook is a same-loop sync append. This replaces the interim
snapshot-under-lock fix and removes the threading ReadWriteLock entirely, so no
lock is held across an await.
…nent isolation)

The three component stop() methods (event processor, data system, big segment
store manager) do not raise a regular exception in practice — the event
processor guards its own shutdown, and the data-source/store closes bottom out
in aiohttp/redis close() calls that do not raise. The only escaping exception is
CancelledError, which the per-component 'except Exception' would not catch. So
the isolation guarded a non-occurring path and diverged from the sync client;
remove it for parity.
Set prerequisites next to detail in the success and error branches instead of a post-hoc result-is-None guard, so it's clear an errored flag yields no prerequisites.
… objects

The decode was copied from the sync client, where it exists for pre-8.0.0 custom stores that predate the model-object migration. The async SDK is post-8.0.0 with no such legacy: the only async feature store (AsyncInMemoryFeatureStore) decodes on init and returns model objects, and all_flags_state already reads the store without decoding. Evaluate off store.get() directly.
The _get_store_item shim used to decode raw dicts on read. With it gone,
MockAsyncFeatureStore.force_set must decode on write, as the real store
does on init/upsert, so variation tests get model objects.
…sed client

Every other LaunchDarkly server SDK (sync Python, Go, .NET, Node, Ruby)
degrades lifecycle misuse to a logged no-op rather than throwing. Match
that: start() on a closed AsyncLDClient logs a warning and returns.
Build the flag tracker in __init__ instead of start(), matching every other
LaunchDarkly SDK, so flag_tracker no longer raises before start(). The client
owns the flag-change Listeners and injects it into the data system, so
listeners registered before start() fire once data flows. The callback
scheduler is created lazily on the first add_flag_value_change_listener call,
which always runs on the loop thread.
…lazily

Construct the data system, status providers, big-segment manager, evaluator,
and flag tracker in __init__ (loop-free); start() only does the loop-bound work.
The shared aiohttp session is created lazily on first use inside the loop (via a
provider the data source resolves), so offline/LDD mode creates none. The
big-segment manager's poll task is created in __init__ and started in start().
This matches the loop-free-init / loop-bound-start shape used across the async
ecosystem, and makes the status providers available before start() too.
On main, error_reason is no longer re-exported from async_evaluator; import it
from its canonical home, matching how the sync client sources it via evaluator.
@jsonbailey
jsonbailey force-pushed the jb/sdk-2867/async-client branch from 491115e to bcd5bdd Compare August 11, 2026 19:17

@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 4 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 bcd5bdd. Configure here.

Comment thread ldclient/async_client.py
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/testing/mock_async_components.py
- Make a failed start() single-shot: mark the client closed and clear the
  session, so a retry is a no-op and a fresh client is required.
- Consolidate the HTTP session onto make_client_session so SSL/cert setup and
  proxy handling match the async transport (trust_env=False; proxies resolved
  per request), dropping a duplicate factory that diverged on env proxies.
- Reparent MockAsyncUpdateProcessor onto AsyncUpdateProcessor with an async
  stop(), so close() no longer hits a swallowed TypeError that skipped teardown.
start()'s except caught Exception, which misses asyncio.CancelledError, so a
cancelled start (wait_for timeout, aborted async with) left the event processor,
data system, and big-segment poll running while _started stayed False -- close()
then skipped _close_components and leaked them. Catch BaseException so cleanup
runs on cancellation too, then re-raise to propagate it.
Comment thread ldclient/impl/datasystem/__init__.py
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py Outdated
Comment thread ldclient/async_client.py
…tion

Set _started before __start_up so a failed or in-progress start still
tears down through close(). Append plugin hooks to those already
registered (config plus any added via add_hook before start) rather
than overwriting. Stop the event processor last on close because it may
still be sending events the other components generated.
Remove the close() timeout: no other LaunchDarkly SDK bounds close, and
without a timeout the whole teardown always runs, so nothing leaks. Merge
the failed-start cleanup into _close_components so both close() and a
failed start() share one idempotent teardown, and mark a failed start
closed so a later close() is a no-op.
@jsonbailey
jsonbailey merged commit fd041a5 into main Aug 12, 2026
21 of 25 checks passed
@jsonbailey
jsonbailey deleted the jb/sdk-2867/async-client branch August 12, 2026 18:08
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.

3 participants