Uh oh!
There was an error while loading. Please reload this page.
feat: Add AsyncLDClient with FDv1 data system and public API - #480
Open
jsonbailey wants to merge 17 commits into
Open
feat: Add AsyncLDClient with FDv1 data system and public API#480jsonbailey wants to merge 17 commits into
jsonbailey wants to merge 17 commits into
Conversation
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
jsonbailey
marked this pull request as draft
August 5, 2026 23:08
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.
jsonbaileyforce-pushed
the
jb/sdk-2867/async-client
branch
from
August 11, 2026 19:17
491115e to
bcd5bddCompareThere was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 4 potential issues.
❌ 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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- 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.
keelerm84
approved these changes
Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds the async LaunchDarkly client (
AsyncLDClient) built onasyncio/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), thenawait start()/await close()(orasync 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 sharedaiohttpsession (lazily, on first use inside the loop), starts the data source, starts the big-segment status poll, and sets up the event processor.flag_trackerand the status providers are available beforestart(), matching the other LaunchDarkly SDKs (they no longer raise). Flag-change listeners registered beforestart()fire once data begins to flow.What's included
ldclient/async_client.py—AsyncLDClientwith 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 theAsyncDataSystemprotocol.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 instart().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-loadsAsyncLDClientso plainimport ldclientdoesn't pull inaiohttp;get_plugin_hooksnow takesconfig.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()raisesNotImplementedError("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
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, andmypyclean on all changed filesNote
Overview
Adds an experimental
AsyncLDClientfor asyncio/aiohttp apps, wired to the FDv1 data system. Construction is loop-free;await start()/await close()(orasync with) own loop-bound work like the HTTP session, data source, event processor, and big-segment polling.Supports the usual evaluation and analytics surface (
variation,variation_detail,all_flags_state, migrations, hooks, trackers). Status providers andflag_trackerare available beforestart(). FDv2 raisesNotImplementedErrorfor now.Also adjusts shared plumbing so async import stays optional and components can be built without a running loop: lazy
AsyncLDClientvia__getattr__,get_plugin_hooksnow takes a plugins list, deferred big-segment poll / flag-tracker scheduler start, plus async null stubs and parity tests.Reviewed by Cursor Bugbot for commit 948902c. Bugbot is set up for automated code reviews on this repo. Configure here.