Repository navigation
Conversation
…t initialize The LaunchDarkly SDK's constructor returns normally when start_wait runs out and when the service rejects the SDK key; it only logs. init_client adopted that client anyway, so a bad key or an unreachable LaunchDarkly served every AI Config disabled with no error at startup. init_client now checks is_initialized() on the client it built. When it is false the client is closed and RuntimeError is raised; nothing is cached, so a later call retries. This matches the JavaScript and Go SDKs, which already fail here. A client passed as client= is not checked: the caller owns its readiness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no shared in-flight attempt, every concurrent lazy call during an outage built its own LaunchDarkly client in asyncio.to_thread and held a default-executor thread for the whole 10 s start wait, which could starve other to_thread users. Calls made while an attempt is in flight now wait for it. The attempt runs on a thread of its own, and its result is a concurrent.futures.Future, so callers on any event loop or thread can join it. The attempt thread adopts the client itself under a lock. Each caller waits on a future of its own loop, so cancelling one leaves the attempt running for the rest. Every caller of a failed attempt gets the failure, and the attempt is dropped before they see it, so a later call retries. The client is closed instead of adopted when shutdown() or a passed client replaced the attempt, or when every caller waiting on it was cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
The shared-attempt design is sound. I checked cancellation (per-loop futures, waiters only under the lock), retry after failure (attempt cleared before the future settles), and the all-cancelled path; they behave as described. Three timed-out wait_for calls during an outage build one client.
Requesting changes for the two spec mismatches in the inline comments (passed client when the built client is uninitialized, and shutdown followed by re-init). Both are small. Please add tests for those two orders and for "all waiters cancelled, a new caller joins before the attempt finishes".
Nit: start_wait=10 and the "10 seconds" in the message and docstring could be one constant.
…nt's state Review of the shared init attempt found two orders where a waiting call got the wrong answer: - A client passed while an attempt was in flight, during an outage: the attempt's own client was uninitialized, and that check ran first, so its callers raised "failed to initialize" although the passed client was now the singleton. The replaced check now comes first. - shutdown(), then a new call's attempt completing before the old one: the old attempt returned _client, which was the new attempt's client. A passed client now records itself on the attempt it replaces, and only that is returned; an attempt shutdown() replaced always raises. Adds tests for both orders and for a call made after every waiter was cancelled joining the attempt, and puts the 10 s start wait in one constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nit-attempt # Conflicts: # packages/client/README.md # packages/client/src/launchdarkly_ai_server/lifecycle.py
…nit-attempt # Conflicts: # packages/client/src/launchdarkly_ai_server/lifecycle.py
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 6452178. Configure here.
…tempt A passed client clears _attempt when it replaces one in flight, so a shutdown() that followed could no longer reach that attempt. When the attempt finished it returned its successor, the passed client that shutdown() had just closed. shutdown() now bumps a generation that each attempt records when it starts, and an attempt from an earlier generation fails as abandoned whatever replaced it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Stacked on #254. Retarget to
mainonce #254 merges.Problem
Since #254,
init_clientraises when the client it built has not initialized withinstart_wait=10, and caches nothing so a later call retries. While LaunchDarkly is unreachable, every lazy call (config().invoke(),graph(),inspect_config) builds its own client inasyncio.to_threadand blocks for 10 s. N concurrent callers each hold a default-executor thread for that long, which can starve otherto_threadusers.Change
Concurrent first calls now share one in-flight attempt, as the TypeScript SDK's
initClientdoes.concurrent.futures.Future, so callers on any event loop or thread can join it. The skills watcher, for example, runsasyncio.runon its own thread._state_lock. Each caller waits on a future of its own loop, which is woken throughcall_soon_threadsafe, so cancelling one caller leaves the attempt running for the rest. (asyncio.wrap_futurewould cancel the shared future.)shutdown()ran, when a client was passed in, or when every caller waiting on it was cancelled. A lone cancelled call still leaves nothing behind. Before this change, an attempt still in flight whenshutdown()ran adopted its client afterwards.Tests
New tests in
packages/client/tests/test_lifecycle.pyuse a client constructor that blocks until the test releases it:asyncio.runon another thread joins the attempt.shutdown()abandons an attempt in flight.The old two-calls-build-two-clients test was replaced, since it tested the old behavior.
make lint format-check typecheck api-report-check testpasses: 3805 passed, 18 skipped.🤖 Generated with Claude Code
Note
Overview
Concurrent lazy
init_clientcalls now share a single in-flight connection attempt instead of each starting its own 10-second LD client build on the default executor (which could starve otherasyncio.to_threadwork during outages).The lifecycle module introduces
_InitAttemptguarded by_state_lock: one daemon thread builds the client and settles a sharedconcurrent.futures.Future, while each async caller waits on a loop-local future wired viacall_soon_threadsafeso cancelling one waiter does not tear down the attempt for others. Failed attempts are still not cached;shutdown(), a BYOC client passed mid-flight, or no remaining waiters abandons the attempt (closes the built client, raises abandoned where appropriate, and uses a successor when BYOC replaced the attempt). README text is updated to match.Lifecycle tests replace the old “two concurrent calls → two clients” expectation with coverage for shared success/failure, cross-loop join, shutdown/BYOC races, and cancellation edge cases.
Reviewed by Cursor Bugbot for commit cecb9b0. Bugbot is set up for automated code reviews on this repo. Configure here.