Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ 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

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
Expand All @@ -231,8 +233,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"}`. |
Expand Down
47 changes: 34 additions & 13 deletions packages/client/src/launchdarkly_ai_server/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -319,35 +322,53 @@ 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)
flush_sdk_usage(_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:
Expand Down
52 changes: 52 additions & 0 deletions packages/client/tests/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
Loading