Skip to content

Commit 15014c1

Browse files
fix: Do not report a permanent data source failure as fatal (#52)
A permanent data source failure no longer puts the provider into the OpenFeature `FATAL` state, so the OpenFeature client keeps evaluating against the flag data the LaunchDarkly client already has. - `DataSourceState.OFF` now emits `ErrorCode.GENERAL` instead of `ErrorCode.PROVIDER_FATAL` - Matches the Java provider, which reports this as `ERROR` - Relates to #49 **Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions <details> <summary>Implementation details</summary> **Related issues** #49 — during an outage, a `401` on an already-established stream took the data source to `OFF` and OpenFeature evaluations started returning call-site defaults even though the LaunchDarkly client still had valid flag data. **Describe the solution you've provided** The OpenFeature Python SDK short-circuits evaluation when the provider status is `FATAL`: ```python if status == ProviderStatus.FATAL: return ProviderFatalError() ``` That means the provider is never asked to evaluate and the call-site default is returned. `PROVIDER_FATAL` is what moves the provider into that status, so reporting a permanent data source failure that way discards flag data the LaunchDarkly client can still serve. `ErrorCode.GENERAL` keeps the provider in `ERROR`, which still surfaces the failure through provider events and `get_provider_status()` while leaving evaluation intact. **Describe alternatives you've considered** Leaving the state fatal only when the client never initialized was considered. It adds state tracking to the provider for little benefit: a client that never initialized has no flag data, so evaluations already fall back to defaults with a `PROVIDER_NOT_READY`/`ERROR` reason, and the `ERROR` status carries the same signal to anything watching provider events. **Testing** Added `test_evaluations_continue_after_the_data_source_permanently_fails`, backed by a new `InitializedThenFailingDataSource` fixture that initializes with flag data, goes `VALID`, then transitions to `OFF` with a `401`. The test asserts the provider status is `ERROR` and that the cached flag still evaluates to its real value rather than the call-site default. </details> Link to Devin session: https://app.devin.ai/sessions/0c452d209ec54b068ba120b4c92b8f6c Requested by: @kinyoklion <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Stops treating a permanent data source failure (`DataSourceState.OFF`) as OpenFeature `PROVIDER_FATAL`, so evaluations keep using cached LaunchDarkly flag data instead of being short-circuited to call-site defaults. > > `__handle_data_source_status` now emits `ErrorCode.GENERAL`, leaving the provider in `ERROR` while still surfacing the failure. A new test initializes flags, then fails the data source with a 401, and asserts status is `ERROR` and the cached flag still evaluates correctly. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e0fea34. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 82de51d commit 15014c1

3 files changed

Lines changed: 62 additions & 2 deletions

File tree

ld_openfeature/provider.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def __handle_data_source_status(self, status: DataSourceStatus):
4141
elif state == DataSourceState.OFF:
4242
error_message = self.__get_message(status,
4343
"the provider has encountered a permanent error or has been shutdown")
44-
self.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.PROVIDER_FATAL,
44+
# This is not reported as a fatal error. A fatal provider prevents the OpenFeature client
45+
# from evaluating flags at all, but the LaunchDarkly client can keep evaluating the flag
46+
# data it already has.
47+
self.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.GENERAL,
4548
message=error_message))
4649
elif state == DataSourceState.INTERRUPTED:
4750
error_message = self.__get_message(status, "encountered an unknown error")

tests/test_data_sources.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ def initialized(self):
7474
return False
7575

7676

77+
class InitializedThenFailingDataSource(UpdateProcessor):
78+
def __init__(self, config: Config, store, ready: threading.Event):
79+
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink
80+
self._ready = ready
81+
82+
def start(self):
83+
self._ready.set()
84+
self._data_source_update_sink.init(
85+
{FEATURES: {"cached-boolean": TestData().data_source().flag("cached-boolean").on(True)._build(1)}})
86+
self._data_source_update_sink.update_status(DataSourceState.VALID, None)
87+
88+
def data_source_failure():
89+
self._data_source_update_sink.update_status(
90+
DataSourceState.OFF,
91+
DataSourceErrorInfo(
92+
DataSourceErrorKind.ERROR_RESPONSE,
93+
401,
94+
time.time(),
95+
str("Bad things")
96+
)
97+
)
98+
99+
threading.Timer(0.1, data_source_failure).start()
100+
101+
def stop(self):
102+
pass
103+
104+
def is_alive(self):
105+
return False
106+
107+
def initialized(self):
108+
return True
109+
110+
77111
class StaleDataSource(UpdateProcessor):
78112
def __init__(self, config: Config, store, ready: threading.Event):
79113
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink

tests/test_provider.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
from openfeature.event import ProviderEvent, EventDetails
1212
from openfeature.exception import ErrorCode
1313
from openfeature.flag_evaluation import Reason
14+
from openfeature.provider import ProviderStatus
1415
from openfeature import api
1516

1617
from ld_openfeature import LaunchDarklyProvider, Config
17-
from tests.test_data_sources import FailingDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource
18+
from tests.test_data_sources import FailingDataSource, InitializedThenFailingDataSource, StaleDataSource, UpdatingDataSource, DelayedFailingDataSource
1819

1920

2021
@pytest.fixture
@@ -232,6 +233,28 @@ def handle_status(details: EventDetails):
232233
api.shutdown()
233234

234235

236+
def test_evaluations_continue_after_the_data_source_permanently_fails():
237+
thread_event = threading.Event()
238+
239+
def handle_status(details: EventDetails):
240+
if details.provider_name == 'launchdarkly-openfeature-server':
241+
thread_event.set()
242+
243+
api.add_handler(ProviderEvent.PROVIDER_ERROR, handle_status)
244+
245+
provider = LaunchDarklyProvider(
246+
Config("", update_processor_class=InitializedThenFailingDataSource, send_events=False))
247+
api.set_provider(provider)
248+
client = api.get_client()
249+
250+
assert thread_event.wait(timeout=5)
251+
252+
assert client.get_provider_status() == ProviderStatus.ERROR
253+
assert client.get_boolean_value("cached-boolean", False, EvaluationContext('user-key')) is True
254+
255+
api.shutdown()
256+
257+
235258
def test_provider_emits_stale_event():
236259
thread_event = threading.Event()
237260

0 commit comments

Comments
 (0)