From 71b2faed18d73cec38c1dfb5e10b21d3c6310647 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Thu, 6 Aug 2026 16:24:31 -0600 Subject: [PATCH 1/2] fix: Degrade only the failing flag in all_flags_state on evaluation error all_flags_state read result.prerequisites unconditionally even when a per-flag evaluation raised. The except branch set only detail, leaving result unbound (or holding a previous flag's value), so the first flag raising caused an UnboundLocalError that aborted the whole payload, and a later flag raising made that flag inherit the previous flag's prerequisites. Evaluator.evaluate() only catches its own EvaluationException, so any other exception from malformed flag data reaches this path. Bind prerequisites in both eval branches (success -> result.prerequisites, error -> []) so an error degrades only that flag. Mirrors the same fix in the new async client. --- ldclient/client.py | 5 ++- ldclient/testing/test_ldclient_evaluation.py | 40 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/ldclient/client.py b/ldclient/client.py index 28de0eaf..6864596c 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -640,11 +640,14 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: try: result = self._evaluator.evaluate(flag, context, self._event_factory_default) detail = result.detail + prerequisites = result.prerequisites except Exception as e: log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e))) log.debug(traceback.format_exc()) reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'} detail = EvaluationDetail(None, None, reason) + # A per-flag error degrades only that flag: no value, no prerequisites. + prerequisites = [] requires_experiment_data = EventFactory.is_experiment(flag, detail.reason) flag_state = { @@ -653,7 +656,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: 'variation': detail.variation_index, 'reason': detail.reason, 'version': flag['version'], - 'prerequisites': result.prerequisites, + 'prerequisites': prerequisites, 'trackEvents': flag.get('trackEvents', False) or requires_experiment_data, 'trackReason': requires_experiment_data, 'debugEventsUntilDate': flag.get('debugEventsUntilDate', None), diff --git a/ldclient/testing/test_ldclient_evaluation.py b/ldclient/testing/test_ldclient_evaluation.py index 39a0eb21..f403e953 100644 --- a/ldclient/testing/test_ldclient_evaluation.py +++ b/ldclient/testing/test_ldclient_evaluation.py @@ -342,3 +342,43 @@ def test_all_flags_returns_empty_state_if_feature_store_throws_error(caplog): assert state.valid is False errlog = get_log_lines(caplog, 'ERROR') assert errlog == ['Unable to read flags for all_flag_state: NotImplementedError()'] + + +def test_all_flags_state_degrades_per_flag_on_evaluator_error(): + # A flag whose evaluation raises degrades only that flag: the loop must not + # raise UnboundLocalError when the first flag raises, and a failed flag must + # not inherit a previous good flag's prerequisites. + from unittest.mock import MagicMock + + store = InMemoryFeatureStore() + # Ordering matters: 'bad-first' raises on the first iteration (would + # UnboundLocalError if result were read unconditionally); 'bad-last' raises + # after a good flag set result (would reuse the good result's prerequisites). + store.init({FEATURES: { + 'bad-first': {'key': 'bad-first', 'version': 1, 'on': True, 'fallthrough': {'variation': 0}, 'variations': ['x']}, + 'good': {'key': 'good', 'version': 1, 'on': True, 'fallthrough': {'variation': 0}, 'variations': ['y']}, + 'bad-last': {'key': 'bad-last', 'version': 1, 'on': True, 'fallthrough': {'variation': 0}, 'variations': ['z']}, + }}) + client = make_client(store) + + good_result = MagicMock() + good_result.detail = EvaluationDetail('y', 0, {'kind': 'FALLTHROUGH'}) + good_result.prerequisites = ['prereq-of-good'] + + def fake_evaluate(flag, context, event_factory): + if flag['key'] == 'good': + return good_result + raise RuntimeError("boom") + + client._evaluator.evaluate = MagicMock(side_effect=fake_evaluate) + + # This must not raise UnboundLocalError. + state = client.all_flags_state(user) + assert state.valid + + metadata = state.to_json_dict()['$flagsState'] + # The good flag carries its own prerequisites; the failed flags carry none + # (not a neighbor's). + assert metadata['good'].get('prerequisites') == ['prereq-of-good'] + assert 'prerequisites' not in metadata['bad-first'] + assert 'prerequisites' not in metadata['bad-last'] From 4f100554e37d28ac02da955160a11a1a9c996981 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Thu, 6 Aug 2026 16:50:00 -0600 Subject: [PATCH 2/2] docs: Drop a redundant comment in all_flags_state --- ldclient/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ldclient/client.py b/ldclient/client.py index 6864596c..07fec4f2 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -646,7 +646,6 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: log.debug(traceback.format_exc()) reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'} detail = EvaluationDetail(None, None, reason) - # A per-flag error degrades only that flag: no value, no prerequisites. prerequisites = [] requires_experiment_data = EventFactory.is_experiment(flag, detail.reason)