From 3b51b47990a855d800a27effbdcaff5688f1aa09 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 30 Oct 2026 13:00:14 -0400 Subject: [PATCH 1/2] fix(AIC-3600): init_client raises when the LaunchDarkly client did not 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 --- packages/client/README.md | 6 +-- .../src/launchdarkly_ai_server/lifecycle.py | 47 ++++++++++++----- packages/client/tests/test_lifecycle.py | 52 +++++++++++++++++++ 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index b547d19c..bab643b1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -206,7 +206,7 @@ The client uses **lazy initialization**: importing the package does not connect Call `init_client()` explicitly when you want to: - Pass SDK or telemetry options programmatically (overriding env vars) - Initialize at startup before the first AI call (e.g. to avoid latency on the first request) -- Fail fast at boot if `LD_SDK_KEY` is missing +- Fail fast at boot if `LD_SDK_KEY` is missing, rejected, or LaunchDarkly is unreachable ```python import asyncio @@ -231,8 +231,8 @@ asyncio.run(main()) | Export | Description | |---|---| | `configure(options=...)` | Set process-wide behavior defaults. Keyword-only. Separate from `init_client`. | -| `init_client(options?)` | Auto-discover and initialize `launchdarkly-server-sdk`. Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Returns `Awaitable[LDClientInterface]`. | -| `init_client(client=...)` | **BYOC overload** — accept a pre-initialized `LDClientInterface`. Skips SDK auto-discovery. | +| `init_client(options?)` | Auto-discover and initialize `launchdarkly-server-sdk`. Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Returns `Awaitable[LDClientInterface]`. Raises `RuntimeError` when the client has not initialized within 10 seconds or its SDK key was rejected; a later call retries. | +| `init_client(client=...)` | **BYOC overload** — accept a pre-initialized `LDClientInterface`. Skips SDK auto-discovery and does not check whether that client has initialized. | | `get_client()` | Return the initialized `LDClientInterface`. Raises if `init_client` has not completed. | | `shutdown()` | Flush all events and telemetry, then close the client. Await before process exit. Restores the built-in SDK defaults. | | `inspect_config(key, context)` | Read an AI Config variation without invoking the model. Never raises. Returns `{"enabled", "config", "meta"}`. | diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index b53153f5..60fd595c 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -233,7 +233,10 @@ async def init_client( Idempotent: later calls return the existing client and ignore every option. An option not listed above is logged as a warning and ignored. - Returns the initialized ``LDClientInterface`` instance. + Returns the initialized ``LDClientInterface`` instance. Raises + ``RuntimeError`` when the LaunchDarkly client it builds has not initialized + within 10 seconds or its SDK key was rejected; a later call retries. A + client passed as *client* is not checked. """ global _client @@ -319,28 +322,32 @@ async def init_client( # A concurrent first call may have won while this one was connecting: keep # its client and close ours, so only one streaming connection survives. if _client is not None: - try: - close_result = ld_client.close() - if inspect.isawaitable(close_result): - await close_result - except Exception: - pass + await _close_quietly(ld_client) flush_ai_sdk_info(_client) flush_sdk_usage(_client) return _client + # The constructor returns normally both when start_wait runs out and when + # the service rejects the SDK key; it only logs. Adopting that client would + # serve every AI Config disabled with no error, so fail here. Close it too: + # a failed call is not cached, and each retry would otherwise leave a + # streaming connection open. + if not ld_client.is_initialized(): + await _close_quietly(ld_client) + raise RuntimeError( + "LaunchDarkly client failed to initialize: it did not connect within " + "10 seconds, or the SDK key was rejected. Check LD_SDK_KEY (or the " + "sdkKey option) and that LaunchDarkly is reachable; the LaunchDarkly " + "SDK's log has the cause." + ) + # As on the BYOC path: only a fully set-up client becomes the singleton. We # built this one, so close it on failure — it holds a streaming connection # that would otherwise outlive the attempt. try: _setup_telemetry(sdk_key, opts) except Exception: - try: - close_result = ld_client.close() - if inspect.isawaitable(close_result): - await close_result - except Exception: - pass + await _close_quietly(ld_client) raise _client = ld_client flush_ai_sdk_info(_client) @@ -348,6 +355,20 @@ async def init_client( return _client +async def _close_quietly(ld_client: Any) -> None: + """Closes a client ``init_client`` built and will not adopt. + + An error from ``close`` is swallowed so it cannot replace the reason the + client is being discarded. + """ + try: + close_result = ld_client.close() + if inspect.isawaitable(close_result): + await close_result + except Exception: + pass + + def _close_abandoned_client(build: asyncio.Future[Any]) -> None: """Closes a client whose ``init_client`` call was cancelled while it was being built.""" if build.cancelled() or build.exception() is not None: diff --git a/packages/client/tests/test_lifecycle.py b/packages/client/tests/test_lifecycle.py index c3f12a43..e25ed930 100644 --- a/packages/client/tests/test_lifecycle.py +++ b/packages/client/tests/test_lifecycle.py @@ -70,6 +70,7 @@ def _make_stub_client() -> MagicMock: stub.flush = AsyncMock() stub.close = AsyncMock() stub.wait_for_initialization = AsyncMock() + stub.is_initialized = MagicMock(return_value=True) return stub @@ -209,6 +210,16 @@ async def test_flushes_package_registered_after_initialization(self) -> None: stub.track.call_args.args[2]["aiSdkName"] == "launchdarkly-ai-openai-agents" ) + async def test_does_not_check_whether_a_passed_client_initialized( + self, + ) -> None: + # The caller built a passed client and owns its readiness. + stub = _make_stub_client() + stub.is_initialized = MagicMock(return_value=False) + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + assert await init_client(client=stub) is stub + stub.is_initialized.assert_not_called() + async def test_does_not_call_node_sdk(self) -> None: stub = _make_stub_client() mock_import = MagicMock() @@ -330,6 +341,47 @@ async def test_a_failed_telemetry_setup_leaves_no_client_behind( ): assert await init_client() is healthy + async def test_an_sdk_client_that_did_not_initialize_fails_the_call( + self, + ) -> None: + # The SDK constructor returns normally when start_wait runs out or the + # key is rejected. Adopting that client served every AI Config + # disabled, with no error at startup. + failed = _make_stub_client() + failed.is_initialized = MagicMock(return_value=False) + healthy = _make_stub_client() + mock_ld = MagicMock() + mock_ld.Config = MagicMock(return_value=MagicMock()) + mock_ld.LDClient = MagicMock(side_effect=[failed, healthy]) + with patch.dict(os.environ, {"LD_SDK_KEY": "test-key"}): + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + with patch("importlib.import_module", return_value=mock_ld): + with pytest.raises(RuntimeError, match="failed to initialize"): + await init_client() + failed.close.assert_awaited_once() + with pytest.raises(RuntimeError, match="init_client"): + get_client() + + assert await init_client() is healthy + assert mock_ld.LDClient.call_count == 2 + assert get_client() is healthy + + async def test_a_close_error_does_not_replace_the_initialization_error( + self, + ) -> None: + failed = _make_stub_client() + failed.is_initialized = MagicMock(return_value=False) + failed.close = AsyncMock(side_effect=OSError("close failed")) + mock_ld = MagicMock() + mock_ld.Config = MagicMock(return_value=MagicMock()) + mock_ld.LDClient = MagicMock(return_value=failed) + with patch.dict(os.environ, {"LD_SDK_KEY": "test-key"}): + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + with patch("importlib.import_module", return_value=mock_ld): + with pytest.raises(RuntimeError, match="failed to initialize"): + await init_client() + assert lifecycle_module._client is None + async def test_retries_after_a_failed_init_instead_of_replaying_it(self) -> None: # A failure must not be cached: an app whose first call ran before its # key was available would otherwise fail for the life of the process. From d42eee515bd241ebdc2b0759e95d8741cdd0ae74 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 30 Oct 2026 13:51:37 -0400 Subject: [PATCH 2/2] docs(AIC-3600): note that every lazy call waits during an outage Co-Authored-By: Claude Opus 5.5 --- packages/client/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/README.md b/packages/client/README.md index bab643b1..2b22427d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -208,6 +208,8 @@ Call `init_client()` explicitly when you want to: - Initialize at startup before the first AI call (e.g. to avoid latency on the first request) - Fail fast at boot if `LD_SDK_KEY` is missing, rejected, or LaunchDarkly is unreachable +A failed initialization is not cached, so the process recovers without a restart once LaunchDarkly is reachable again. The cost is that while it is not, every call that initializes lazily, `inspect_config()` included, waits up to 10 seconds for a new LaunchDarkly client before it fails, and concurrent calls each build their own in a worker thread. To keep an outage from adding that wait to every request, call `init_client()` at startup and handle its error there. + ```python import asyncio from launchdarkly_ai_server import init_client, shutdown