From d5ee2779d624f7efd5c7451c07cb875367cde82a Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Thu, 1 Oct 2026 15:24:15 -0400 Subject: [PATCH 1/3] fix(client): release the global OTel tracer provider on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shutdown()` dropped its provider handle but left OpenTelemetry's global tracer provider registered. That global is once-guarded — a second `set_tracer_provider` logs "Overriding of current TracerProvider is not allowed" and keeps the provider already in place — so an init/shutdown/init cycle left every later span routed to the provider that had just been shut down, and exported nothing. Release both the global slot and the `Once` that guards it on teardown; clearing the slot alone leaves the guard tripped and the next set a no-op. Only released when telemetry actually started, so a process where `_setup_telemetry` bailed keeps whatever another library registered. The global text map propagator needs no equivalent — `set_global_textmap` is a plain assignment, so the next setup overwrites it. `_reset_for_testing` does the same, so a suite that initializes more than once does not leave every later span on the first test's provider. Also covers the BYOC idempotency this SDK already had: `_resolve_client` checks the singleton ahead of the pre-initialized-client path, so a repeat `init_client(options, client)` neither re-runs telemetry setup nor swaps the stored client. The JS SDK had that check below the BYOC branch and was re-registering OTel on every call; these tests pin the ordering here. Co-Authored-By: Claude Opus 5 --- packages/client/agents.md | 2 +- .../src/launchdarkly_ai_server/lifecycle.py | 39 ++++++ packages/client/tests/test_lifecycle.py | 115 ++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/client/agents.md b/packages/client/agents.md index b3664663..36c46c74 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -900,7 +900,7 @@ When `enabled` is `False`, `config` is always `None`. When `enabled` is `True` b - **Explicit initialization — SDK path.** `await init_client(options?)` dynamically imports `launchdarkly-server-sdk` at runtime (optional peer dep). If the package is not installed it raises with a clear message. - **Explicit initialization — BYOC path.** `await init_client(client)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for custom or edge environments whose SDK has different init semantics. - `get_client()` raises `RuntimeError` if `init_client()` has not resolved. -- `await shutdown()` must be called before process exit. It flushes OTel spans, flushes LD events, and closes the LD client. +- `await shutdown()` must be called before process exit. It flushes OTel spans, flushes LD events, and closes the LD client. It also releases the process-global OTel tracer provider, so a later `init_client()` can install its own — `trace.set_tracer_provider` is once-guarded, and leaving it set would route every later span to the provider just torn down. Only released when telemetry actually started, so globals another library owns are left alone. --- diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index aeea9111..503a98e0 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -230,6 +230,35 @@ async def _resolve_client(opts: InitClientOptions, client: Any) -> Any: return _client +def _release_otel_globals() -> None: + """ + Releases the process-global tracer provider that ``_setup_telemetry`` + installed, so a later ``init_client`` can install its own. + + ``trace.set_tracer_provider`` is guarded by a ``Once``: a second call logs + "Overriding of current TracerProvider is not allowed" and keeps the provider + already in place. Without this, an init/shutdown/init cycle would leave every + span routed to the provider that was already shut down, and export nothing. + + opentelemetry-python exposes no public API to unset it, so this reaches for + the module globals — both the slot and the ``Once`` that guards it, since + clearing the slot alone leaves the guard tripped and the next set a no-op. + Only called when we actually installed a provider, so a process where + telemetry never started keeps whatever another library registered. + + The global text map propagator needs no equivalent: ``set_global_textmap`` + is a plain assignment with no ``Once``, so the next setup overwrites it. + """ + try: + from opentelemetry import trace + from opentelemetry.util._once import Once + + trace._TRACER_PROVIDER = None + trace._TRACER_PROVIDER_SET_ONCE = Once() + except Exception: # pragma: no cover - defensive, OTel absent or restructured + logger.debug("Could not release the global OTel tracer provider", exc_info=True) + + async def shutdown() -> None: """ Shuts down the singleton client. Idempotent — safe to call multiple times @@ -238,6 +267,10 @@ async def shutdown() -> None: Also clears the configured skill store (and telemetry emitter): after a shutdown, re-pass ``skillStore`` to the next ``init_client`` if the skill accessors should keep working. + + When telemetry was running, this also releases the process-global tracer + provider so a later ``init_client`` can install its own — see + ``_release_otel_globals``. """ global _client, _tracer_provider @@ -256,6 +289,7 @@ async def shutdown() -> None: local_provider.shutdown() except Exception: pass + _release_otel_globals() if local_client is not None: try: @@ -281,9 +315,14 @@ def _set_client_for_testing(c: Any) -> None: def _reset_for_testing() -> None: """Test helper — clear all singleton state.""" global _client, _tracer_provider + had_provider = _tracer_provider is not None _client = None _tracer_provider = None skills._clear_state() + # Mirrors shutdown(): without this a suite that inits more than once leaves + # every later span on the first test's provider. + if had_provider: + _release_otel_globals() async def inspect_config( diff --git a/packages/client/tests/test_lifecycle.py b/packages/client/tests/test_lifecycle.py index 60bf35f1..dda9a030 100644 --- a/packages/client/tests/test_lifecycle.py +++ b/packages/client/tests/test_lifecycle.py @@ -28,6 +28,34 @@ def reset_singleton() -> None: reset_ai_sdk_info(clear_known=True) +@pytest.fixture +def restore_otel_globals() -> Any: + """Snapshot and restore the real OTel trace globals around one test. + + The autouse reset only releases them when lifecycle actually installed a + provider, so tests that set them directly have to put them back themselves + or they leak into every later test in the session. + """ + from opentelemetry import trace as otel_trace + from opentelemetry.util._once import Once + + def _install(provider: Any) -> None: + # The Once is only meaningful alongside the slot it guards: tripped iff a + # provider is installed. Restoring the *same* Once object would hand back + # one this test already tripped, so build a fresh one each time. + otel_trace._TRACER_PROVIDER = provider + once = Once() + if provider is not None: + once.do_once(lambda: None) + otel_trace._TRACER_PROVIDER_SET_ONCE = once + + saved_provider = otel_trace._TRACER_PROVIDER + # Start from a clean slate — an earlier test may have left the guard tripped. + _install(None) + yield otel_trace + _install(saved_provider) + + def _make_stub_client() -> MagicMock: stub = MagicMock() stub.variation = AsyncMock(return_value=None) @@ -73,6 +101,28 @@ async def test_get_client_returns_passed_client(self) -> None: await init_client(client=stub) assert get_client() is stub + async def test_repeat_call_does_not_rerun_telemetry_setup(self) -> None: + # The idempotency check sits ahead of this path on purpose. OTel's global + # tracer provider is once-guarded, so a second _setup_telemetry would + # build a provider that receives no spans while taking over the handle + # shutdown() flushes — silently dropping the first provider's buffer. + stub = _make_stub_client() + with patch.object( + lifecycle_module, "_setup_telemetry", return_value=None + ) as setup: + await init_client({"serviceName": "first"}, stub) + await init_client({}, stub) + await init_client(None, _make_stub_client()) + assert setup.call_count == 1 + assert setup.call_args.args[1] == {"serviceName": "first"} + + async def test_repeat_call_does_not_swap_the_client(self) -> None: + first = _make_stub_client() + second = _make_stub_client() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + assert await init_client(client=first) is first + assert await init_client(client=second) is first + async def test_flushes_registered_ai_package_information(self) -> None: stub = _make_stub_client() register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") @@ -258,6 +308,71 @@ async def test_allows_reinitialization(self) -> None: await init_client(client=stub2) assert get_client() is stub2 + async def test_releases_the_global_tracer_provider( + self, restore_otel_globals: Any + ) -> None: + # Without this, the next set_tracer_provider is refused and every later + # span routes to the provider shutdown() just tore down. + otel_trace = restore_otel_globals + provider = MagicMock() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=provider): + await init_client(client=_make_stub_client()) + # _setup_telemetry is patched, so stand in for what it would have left. + lifecycle_module._tracer_provider = provider + otel_trace._TRACER_PROVIDER = provider + otel_trace._TRACER_PROVIDER_SET_ONCE.do_once(lambda: None) + + await shutdown() + + provider.shutdown.assert_called_once() + assert otel_trace._TRACER_PROVIDER is None + assert otel_trace._TRACER_PROVIDER_SET_ONCE._done is False + + async def test_a_full_init_shutdown_init_cycle_lands_on_a_live_provider( + self, restore_otel_globals: Any + ) -> None: + # End to end over the real _setup_telemetry: the second cycle's provider + # must be the one the global trace API hands out, not the dead first one. + # The OTLP exporter is stubbed so this never reaches the network. + otel_trace = restore_otel_globals + from opentelemetry.exporter.otlp.proto.http import trace_exporter + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + class _NoopExporter(SpanExporter): + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def export(self, spans: Any) -> Any: + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + seen = [] + with patch.object(trace_exporter, "OTLPSpanExporter", _NoopExporter): + for name in ("cycle1", "cycle2"): + await init_client( + {"sdkKey": "k", "serviceName": name}, _make_stub_client() + ) + resource = otel_trace.get_tracer_provider().resource + seen.append(resource.attributes.get("service.name")) + await shutdown() + assert seen == ["cycle1", "cycle2"] + + async def test_does_not_release_otel_globals_when_telemetry_never_started( + self, restore_otel_globals: Any + ) -> None: + # setup returned None (OTel absent), so those globals are not ours to + # clear — another library in the process may own them. + otel_trace = restore_otel_globals + sentinel = MagicMock() + otel_trace._TRACER_PROVIDER = sentinel + otel_trace._TRACER_PROVIDER_SET_ONCE.do_once(lambda: None) + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=_make_stub_client()) + await shutdown() + assert otel_trace._TRACER_PROVIDER is sentinel + async def test_reemits_registered_packages_after_shutdown(self) -> None: stub1 = _make_stub_client() stub2 = _make_stub_client() From 8f7d6609300dd89667f4aded58d82737c07182c2 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Thu, 1 Oct 2026 16:29:36 -0400 Subject: [PATCH 2/3] fix(client): only release the tracer provider this SDK actually registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Bugbot on #126. The previous commit gated the global teardown on `_tracer_provider` being set, which says we *built* a provider, not that we own the global. `_setup_telemetry` assigns the handle after `set_tracer_provider`, whose set is refused when another library got there first — so in a process with an existing provider (auto-instrumentation, an APM agent, or an app that configures its own), `shutdown()` cleared that provider and reset the `Once`, leaving the global a no-op proxy and silently killing the host application's tracing. Track whether our set actually took, via `trace.get_tracer_provider() is provider`, and gate the release on that. The provider is still shut down either way since we built it and it owns an exporter and a batch timer. Also warn when the set is refused: the caller's `otlpEndpoint`/`serviceName` cannot take effect, and the only existing signal is OTel's own terse warning. The same flaw was in the JS change this ports from; fixed there too. Co-Authored-By: Claude Opus 5 --- packages/client/agents.md | 2 +- .../src/launchdarkly_ai_server/lifecycle.py | 43 +++++++++++++++---- packages/client/tests/test_lifecycle.py | 28 +++++++++++- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/client/agents.md b/packages/client/agents.md index 36c46c74..999414f2 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -900,7 +900,7 @@ When `enabled` is `False`, `config` is always `None`. When `enabled` is `True` b - **Explicit initialization — SDK path.** `await init_client(options?)` dynamically imports `launchdarkly-server-sdk` at runtime (optional peer dep). If the package is not installed it raises with a clear message. - **Explicit initialization — BYOC path.** `await init_client(client)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for custom or edge environments whose SDK has different init semantics. - `get_client()` raises `RuntimeError` if `init_client()` has not resolved. -- `await shutdown()` must be called before process exit. It flushes OTel spans, flushes LD events, and closes the LD client. It also releases the process-global OTel tracer provider, so a later `init_client()` can install its own — `trace.set_tracer_provider` is once-guarded, and leaving it set would route every later span to the provider just torn down. Only released when telemetry actually started, so globals another library owns are left alone. +- `await shutdown()` must be called before process exit. It flushes OTel spans, flushes LD events, and closes the LD client. It also releases the process-global OTel tracer provider, so a later `init_client()` can install its own — `trace.set_tracer_provider` is once-guarded, and leaving it set would route every later span to the provider just torn down. Only released when this SDK's own `set_tracer_provider` actually took effect — the set is once-guarded, so when another library registered first ours is refused (a warning is emitted) and that provider is left alone rather than torn down. --- diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index 503a98e0..904a29fe 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -24,6 +24,11 @@ def _env(name: str) -> str | None: _client: Any = None _tracer_provider: Any = None +# True only when *this* SDK's call to trace.set_tracer_provider actually took +# effect. "We built a provider" is not the same as "we own the global": the set +# is once-guarded, so when another library registered first ours is refused and +# the global stays theirs. Only the owner may release it on shutdown. +_owns_otel_globals: bool = False def get_client() -> Any: @@ -49,7 +54,7 @@ def _setup_telemetry(sdk_key: str, options: InitClientOptions | None = None) -> - Registers W3C trace context and baggage propagators. - Configures GZIP compression on the OTLP exporter. """ - global _tracer_provider + global _tracer_provider, _owns_otel_globals opts = options or {} @@ -113,6 +118,18 @@ def _setup_telemetry(sdk_key: str, options: InitClientOptions | None = None) -> pass trace.set_tracer_provider(provider) + # The set is refused, with a warning from OTel, when another library got + # there first. Record whether it actually took: shutdown must not release + # a global it never owned, and the caller's telemetry options are moot if + # someone else's provider is the one handing out tracers. + _owns_otel_globals = trace.get_tracer_provider() is provider + if not _owns_otel_globals: + logger.warning( + "An OpenTelemetry tracer provider was already registered by " + "something else in this process, so LaunchDarkly's telemetry " + "configuration is not in effect; spans will go wherever that " + "provider sends them." + ) _tracer_provider = provider return provider @@ -243,8 +260,11 @@ def _release_otel_globals() -> None: opentelemetry-python exposes no public API to unset it, so this reaches for the module globals — both the slot and the ``Once`` that guards it, since clearing the slot alone leaves the guard tripped and the next set a no-op. - Only called when we actually installed a provider, so a process where - telemetry never started keeps whatever another library registered. + + Callers must gate this on ``_owns_otel_globals``. Having built a provider is + not enough: when another library registered first, our set was refused and + the global is still theirs, so releasing it here would tear down the host + application's tracing and leave the global a no-op proxy. The global text map propagator needs no equivalent: ``set_global_textmap`` is a plain assignment with no ``Once``, so the next setup overwrites it. @@ -272,24 +292,30 @@ async def shutdown() -> None: provider so a later ``init_client`` can install its own — see ``_release_otel_globals``. """ - global _client, _tracer_provider + global _client, _tracer_provider, _owns_otel_globals local_client = _client local_provider = _tracer_provider + owned_globals = _owns_otel_globals skills._clear_state() # Null the singleton before any awaits so a second call is a no-op _client = None _tracer_provider = None + _owns_otel_globals = False reset_ai_sdk_info() if local_provider is not None: + # Shut the provider down either way — we built it, and it owns an + # exporter and a batch timer — but only release the global registration + # when it was ours to take. try: local_provider.shutdown() except Exception: pass - _release_otel_globals() + if owned_globals: + _release_otel_globals() if local_client is not None: try: @@ -314,14 +340,15 @@ def _set_client_for_testing(c: Any) -> None: def _reset_for_testing() -> None: """Test helper — clear all singleton state.""" - global _client, _tracer_provider - had_provider = _tracer_provider is not None + global _client, _tracer_provider, _owns_otel_globals + owned_globals = _owns_otel_globals _client = None _tracer_provider = None + _owns_otel_globals = False skills._clear_state() # Mirrors shutdown(): without this a suite that inits more than once leaves # every later span on the first test's provider. - if had_provider: + if owned_globals: _release_otel_globals() diff --git a/packages/client/tests/test_lifecycle.py b/packages/client/tests/test_lifecycle.py index dda9a030..758d19af 100644 --- a/packages/client/tests/test_lifecycle.py +++ b/packages/client/tests/test_lifecycle.py @@ -317,8 +317,10 @@ async def test_releases_the_global_tracer_provider( provider = MagicMock() with patch.object(lifecycle_module, "_setup_telemetry", return_value=provider): await init_client(client=_make_stub_client()) - # _setup_telemetry is patched, so stand in for what it would have left. + # _setup_telemetry is patched, so stand in for what it would have left, + # ownership flag included — this is the case where our set did take. lifecycle_module._tracer_provider = provider + lifecycle_module._owns_otel_globals = True otel_trace._TRACER_PROVIDER = provider otel_trace._TRACER_PROVIDER_SET_ONCE.do_once(lambda: None) @@ -359,6 +361,29 @@ def shutdown(self) -> None: await shutdown() assert seen == ["cycle1", "cycle2"] + async def test_does_not_release_a_tracer_provider_another_library_registered( + self, restore_otel_globals: Any + ) -> None: + # Building a provider is not owning the global. When something else in + # the process registered first, our set_tracer_provider is refused and + # the global stays theirs — releasing it on shutdown would tear down the + # host application's tracing and leave a no-op proxy behind. + otel_trace = restore_otel_globals + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + + foreign = TracerProvider(resource=Resource.create({"service.name": "foreign"})) + otel_trace.set_tracer_provider(foreign) + + await init_client({"sdkKey": "k", "serviceName": "ld"}, _make_stub_client()) + assert otel_trace.get_tracer_provider() is foreign + assert lifecycle_module._owns_otel_globals is False + + await shutdown() + + assert otel_trace.get_tracer_provider() is foreign + assert otel_trace._TRACER_PROVIDER is foreign + async def test_does_not_release_otel_globals_when_telemetry_never_started( self, restore_otel_globals: Any ) -> None: @@ -370,6 +395,7 @@ async def test_does_not_release_otel_globals_when_telemetry_never_started( otel_trace._TRACER_PROVIDER_SET_ONCE.do_once(lambda: None) with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): await init_client(client=_make_stub_client()) + assert lifecycle_module._owns_otel_globals is False await shutdown() assert otel_trace._TRACER_PROVIDER is sentinel From d1e89e68347b6a7a851128a40161660f2b2244a3 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Thu, 1 Oct 2026 16:37:21 -0400 Subject: [PATCH 3/3] fix(client): only adopt a client once telemetry setup has succeeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_client is not None` is `_resolve_client`'s idempotency guard, but `_client` was assigned before `_setup_telemetry` ran. When setup raised — a malformed `OTEL_EXPORTER_OTLP_TIMEOUT` does, with a ValueError from the OTLP exporter — `_client` stayed set. The next `init_client()` returned that half-initialized client as a silent success with no telemetry, hiding the config error, and on the SDK-key path the LD client's connection was never closed. That contradicts `init_client`'s documented promise that a call which raises leaves no global state behind. Assign `_client` only after setup succeeds, on both paths. On the SDK-key path close the client we built when setup fails; on the BYOC path leave it open, since the caller owns it. Found in a pass over the lifecycle guards following the ownership fix; the JS SDK's counterpart was a cached failed-init promise, fixed in launchdarkly/js-ai-sdk#103. Co-Authored-By: Claude Opus 5.5 --- .../src/launchdarkly_ai_server/lifecycle.py | 21 +++++++++- packages/client/tests/test_lifecycle.py | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index 904a29fe..fb38f2e6 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -194,8 +194,13 @@ async def _resolve_client(opts: InitClientOptions, client: Any) -> Any: # BYOC path — pre-initialized client if client is not None: - _client = client + # Adopted only after telemetry setup succeeds. ``_client`` is the + # idempotency guard above, so assigning it first meant a setup that + # raised (a malformed OTEL_EXPORTER_OTLP_TIMEOUT does) left it set: the + # next call returned it as a silent success with no telemetry, hiding + # the config error. The caller owns this client, so it is not closed. _setup_telemetry(opts.get("sdkKey", "byoc"), opts) + _client = client flush_ai_sdk_info(_client) return _client @@ -241,8 +246,20 @@ async def _resolve_client(opts: InitClientOptions, client: Any) -> Any: # start_wait caps the blocking init time; matches the TS SDK's 10 s timeout. ld_client = client_cls(ld_config, start_wait=10) + # 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 + raise _client = ld_client - _setup_telemetry(sdk_key, opts) flush_ai_sdk_info(_client) return _client diff --git a/packages/client/tests/test_lifecycle.py b/packages/client/tests/test_lifecycle.py index 758d19af..589bb8cc 100644 --- a/packages/client/tests/test_lifecycle.py +++ b/packages/client/tests/test_lifecycle.py @@ -123,6 +123,19 @@ async def test_repeat_call_does_not_swap_the_client(self) -> None: assert await init_client(client=first) is first assert await init_client(client=second) is first + async def test_a_failed_telemetry_setup_does_not_adopt_the_client(self) -> None: + stub = _make_stub_client() + with patch.object( + lifecycle_module, "_setup_telemetry", side_effect=ValueError("bad config") + ): + with pytest.raises(ValueError): + await init_client(client=stub) + assert lifecycle_module._client is None + # The caller owns a BYOC client, so a failed init must not close it. + stub.close.assert_not_called() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + assert await init_client(client=stub) is stub + async def test_flushes_registered_ai_package_information(self) -> None: stub = _make_stub_client() register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") @@ -222,6 +235,32 @@ async def test_is_idempotent(self) -> None: # LDClient is only instantiated on first init; singleton is reused assert mock_ld.LDClient.call_count == 1 + async def test_a_failed_telemetry_setup_leaves_no_client_behind( + self, restore_otel_globals: Any + ) -> None: + # A malformed OTEL_EXPORTER_OTLP_TIMEOUT makes the real _setup_telemetry + # raise. _client used to be assigned first, so the next call returned the + # half-built client as a silent success with no telemetry, and its + # connection was never closed. + failed = _make_stub_client() + healthy = _make_stub_client() + mock_ld = MagicMock() + mock_ld.Config = MagicMock(return_value=MagicMock()) + mock_ld.LDClient = MagicMock(side_effect=[failed, healthy]) + with patch("importlib.import_module", return_value=mock_ld): + bad_env = {"LD_SDK_KEY": "k", "OTEL_EXPORTER_OTLP_TIMEOUT": "soon"} + with patch.dict(os.environ, bad_env): + with pytest.raises(ValueError): + await init_client() + assert lifecycle_module._client is None + failed.close.assert_awaited_once() + + with patch.dict(os.environ, {"LD_SDK_KEY": "k"}): + with patch.object( + lifecycle_module, "_setup_telemetry", return_value=None + ): + assert await init_client() is healthy + async def test_returns_initialized_client(self) -> None: stub = _make_stub_client() mock_ld = MagicMock()