Skip to content

Commit aa6274b

Browse files
committed
fix: Mark the EXCEPTION reason of a failed evaluation of an override flag
An evaluation that fails after reading a definition from the override layer is still marked (OVERRIDE 1.5.4). The single-flag and all-flags error paths of both clients now set overrideAffected on the EXCEPTION reason from the flag's own marker, read through is_override_definition so that the error path cannot fail on an unexpected store item.
1 parent 5aeb04f commit aa6274b

6 files changed

Lines changed: 84 additions & 4 deletions

File tree

‎ldclient/async_client.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
)
2929
from ldclient.impl.client_common import secure_mode_hash as _secure_mode_hash
3030
from ldclient.impl.datasystem import AsyncDataSystem, DataAvailability
31-
from ldclient.impl.evaluator_common import error_reason
31+
from ldclient.impl.evaluator_common import error_reason, is_override_definition
3232
from ldclient.impl.events.async_event_processor import (
3333
DefaultAsyncEventProcessor
3434
)
@@ -553,6 +553,10 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
553553
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
554554
log.debug(traceback.format_exc())
555555
reason = error_reason('EXCEPTION')
556+
# The evaluation read the flag definition before it failed, so a definition from
557+
# the override layer marks the result.
558+
if is_override_definition(flag):
559+
reason['overrideAffected'] = True
556560
self._send_event(event_factory.new_default_event(flag, context, default, reason))
557561
return EvaluationDetail(default, None, reason), flag
558562

@@ -646,7 +650,11 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
646650
except Exception as e:
647651
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
648652
log.debug(traceback.format_exc())
649-
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
653+
reason = error_reason('EXCEPTION')
654+
# The evaluation read the flag definition before it failed, so a definition from
655+
# the override layer marks the result.
656+
if is_override_definition(flag):
657+
reason['overrideAffected'] = True
650658
detail = EvaluationDetail(None, None, reason)
651659
prerequisites = []
652660
requires_experiment_data = EventFactory.is_experiment(flag, detail.reason)

‎ldclient/client.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ldclient.impl.datasystem import DataAvailability, DataSystem
2929
from ldclient.impl.datasystem.fdv2 import FDv2
3030
from ldclient.impl.evaluator import Evaluator, error_reason
31+
from ldclient.impl.evaluator_common import is_override_definition
3132
from ldclient.impl.events.diagnostics import (
3233
_DiagnosticAccumulator,
3334
create_diagnostic_id
@@ -539,6 +540,10 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
539540
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
540541
log.debug(traceback.format_exc())
541542
reason = error_reason('EXCEPTION')
543+
# The evaluation read the flag definition before it failed, so a definition from
544+
# the override layer marks the result.
545+
if is_override_definition(flag):
546+
reason['overrideAffected'] = True
542547
self._send_event(event_factory.new_default_event(flag, context, default, reason))
543548
return EvaluationDetail(default, None, reason), flag
544549

@@ -638,7 +643,11 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
638643
except Exception as e:
639644
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
640645
log.debug(traceback.format_exc())
641-
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
646+
reason = error_reason('EXCEPTION')
647+
# The evaluation read the flag definition before it failed, so a definition from
648+
# the override layer marks the result.
649+
if is_override_definition(flag):
650+
reason['overrideAffected'] = True
642651
detail = EvaluationDetail(None, None, reason)
643652
prerequisites = []
644653

‎ldclient/impl/evaluator_common.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,16 @@ def error_reason(error_kind: str) -> dict:
262262
return {'kind': 'ERROR', 'errorKind': error_kind}
263263

264264

265+
def is_override_definition(item: Any) -> bool:
266+
"""
267+
Reports whether a flag or segment read from the store came from the override layer. The
268+
data systems decode the dicts a store holds into model objects before the client reads
269+
them, but an error path reads the marker this way so that an unexpected item cannot make
270+
it fail too.
271+
"""
272+
return getattr(item, 'is_override', False) is True
273+
274+
265275
def mark_override_affected(detail: EvaluationDetail) -> None:
266276
"""
267277
Sets the override-affected indicator on the evaluation reason. The indicator is present

‎ldclient/testing/impl/test_evaluator_overrides.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010

1111
from ldclient import Context
12-
from ldclient.impl.evaluator_common import EvalResult
12+
from ldclient.impl.evaluator_common import EvalResult, is_override_definition
1313
from ldclient.impl.events.types import EventFactory, EventInputEvaluation
1414
from ldclient.testing.builders import *
1515
from ldclient.testing.impl.evaluator_util import EvaluatorBuilder
@@ -314,3 +314,12 @@ def test_reason_indicator_is_absent_when_events_do_not_carry_reasons():
314314
flag = make_override_test_flag('feature').with_override_marker()
315315
result = EvaluatorBuilder().build().evaluate(flag, override_test_context, EventFactory(False, lambda: 0))
316316
assert_override_affected(True, result)
317+
318+
319+
def test_is_override_definition_reads_the_marker_and_never_fails():
320+
flag = FlagBuilder('feature').on(False).off_variation(0).variations('off', 'on').build()
321+
assert is_override_definition(flag.with_override_marker()) is True
322+
assert is_override_definition(flag) is False
323+
# An item without the marker attribute, such as a raw dict, reads as unmarked.
324+
assert is_override_definition(flag.to_json_dict()) is False
325+
assert is_override_definition(None) is False

‎ldclient/testing/test_async_client_overrides.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,27 @@ async def test_flag_value_change_listener_sees_override_value_changes():
415415
assert change.new_value == 'override-value'
416416
finally:
417417
await client.close()
418+
419+
420+
async def raise_evaluation_failure(*args):
421+
raise RuntimeError("evaluation failure")
422+
423+
424+
@pytest.mark.asyncio
425+
async def test_failed_evaluation_reason_is_marked_only_when_the_flag_came_from_the_override_layer(monkeypatch):
426+
# The evaluation read the flag definition before it failed, so the failure of the override
427+
# flag is marked and the failure of the ordinary flag is not, in a single evaluation and in
428+
# the all-flags state.
429+
source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)})
430+
client = await make_initialized_client({'plain-flag': single_value_flag('plain-flag', True)}, source)
431+
try:
432+
monkeypatch.setattr(client._evaluator, 'evaluate', raise_evaluation_failure)
433+
434+
assert await client.variation_detail('overridden-flag', user, 'default') == EvaluationDetail('default', None, {'kind': 'ERROR', 'errorKind': 'EXCEPTION', 'overrideAffected': True})
435+
assert await client.variation_detail('plain-flag', user, 'default') == EvaluationDetail('default', None, {'kind': 'ERROR', 'errorKind': 'EXCEPTION'})
436+
437+
state = await client.all_flags_state(user, with_reasons=True)
438+
assert state.get_flag_reason('overridden-flag') == {'kind': 'ERROR', 'errorKind': 'EXCEPTION', 'overrideAffected': True}
439+
assert state.get_flag_reason('plain-flag') == {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
440+
finally:
441+
await client.close()

‎ldclient/testing/test_ldclient_overrides.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,23 @@ def test_data_source_status_is_unaffected_by_overrides():
462462
with make_uninitialized_client(source) as client:
463463
assert client.is_initialized() is False
464464
assert client.data_source_status_provider.status.state == DataSourceState.INITIALIZING
465+
466+
467+
def raise_evaluation_failure(*args):
468+
raise RuntimeError("evaluation failure")
469+
470+
471+
def test_failed_evaluation_reason_is_marked_only_when_the_flag_came_from_the_override_layer(monkeypatch):
472+
# The evaluation read the flag definition before it failed, so the failure of the override
473+
# flag is marked and the failure of the ordinary flag is not, in a single evaluation and in
474+
# the all-flags state.
475+
source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)})
476+
with make_initialized_client({'plain-flag': single_value_flag('plain-flag', True)}, source) as client:
477+
monkeypatch.setattr(client._evaluator, 'evaluate', raise_evaluation_failure)
478+
479+
assert client.variation_detail('overridden-flag', user, 'default') == EvaluationDetail('default', None, {'kind': 'ERROR', 'errorKind': 'EXCEPTION', 'overrideAffected': True})
480+
assert client.variation_detail('plain-flag', user, 'default') == EvaluationDetail('default', None, {'kind': 'ERROR', 'errorKind': 'EXCEPTION'})
481+
482+
state = client.all_flags_state(user, with_reasons=True)
483+
assert state.get_flag_reason('overridden-flag') == {'kind': 'ERROR', 'errorKind': 'EXCEPTION', 'overrideAffected': True}
484+
assert state.get_flag_reason('plain-flag') == {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}

0 commit comments

Comments
 (0)