Skip to content

Commit 789e6e0

Browse files
authored
feat: implement provider status (#288)
* feat: implement provider status Signed-off-by: Federico Bond <[email protected]> * feat: set provider status to fatal if initialize raises PROVIDER_FATAL error Signed-off-by: Federico Bond <[email protected]> * feat: add a provider status accessor to clients Signed-off-by: Federico Bond <[email protected]> * feat: short circuit flag resolution when provider is not ready Signed-off-by: Federico Bond <[email protected]> --------- Signed-off-by: Federico Bond <[email protected]>
1 parent 7ba7d61 commit 789e6e0

File tree

5 files changed

+172
-9
lines changed

5 files changed

+172
-9
lines changed

openfeature/client.py

+38-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
ErrorCode,
99
GeneralError,
1010
OpenFeatureError,
11+
ProviderFatalError,
12+
ProviderNotReadyError,
1113
TypeMismatchError,
1214
)
1315
from openfeature.flag_evaluation import (
@@ -24,7 +26,7 @@
2426
before_hooks,
2527
error_hooks,
2628
)
27-
from openfeature.provider import FeatureProvider
29+
from openfeature.provider import FeatureProvider, ProviderStatus
2830

2931
logger = logging.getLogger("openfeature")
3032

@@ -81,6 +83,10 @@ def __init__(
8183
def provider(self) -> FeatureProvider:
8284
return api._provider_registry.get_provider(self.domain)
8385

86+
def get_provider_status(self) -> ProviderStatus:
87+
provider = api._provider_registry.get_provider(self.domain)
88+
return api._provider_registry.get_provider_status(provider)
89+
8490
def get_metadata(self) -> ClientMetadata:
8591
return ClientMetadata(domain=self.domain)
8692

@@ -232,7 +238,7 @@ def get_object_details(
232238
flag_evaluation_options,
233239
)
234240

235-
def evaluate_flag_details(
241+
def evaluate_flag_details( # noqa: PLR0915
236242
self,
237243
flag_type: FlagType,
238244
flag_key: str,
@@ -282,6 +288,36 @@ def evaluate_flag_details(
282288
reversed_merged_hooks = merged_hooks[:]
283289
reversed_merged_hooks.reverse()
284290

291+
status = self.get_provider_status()
292+
if status == ProviderStatus.NOT_READY:
293+
error_hooks(
294+
flag_type,
295+
hook_context,
296+
ProviderNotReadyError(),
297+
reversed_merged_hooks,
298+
hook_hints,
299+
)
300+
return FlagEvaluationDetails(
301+
flag_key=flag_key,
302+
value=default_value,
303+
reason=Reason.ERROR,
304+
error_code=ErrorCode.PROVIDER_NOT_READY,
305+
)
306+
if status == ProviderStatus.FATAL:
307+
error_hooks(
308+
flag_type,
309+
hook_context,
310+
ProviderFatalError(),
311+
reversed_merged_hooks,
312+
hook_hints,
313+
)
314+
return FlagEvaluationDetails(
315+
flag_key=flag_key,
316+
value=default_value,
317+
reason=Reason.ERROR,
318+
error_code=ErrorCode.PROVIDER_FATAL,
319+
)
320+
285321
try:
286322
# https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md
287323
# Any resulting evaluation context from a before hook will overwrite

openfeature/exception.py

+31
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
class ErrorCode(Enum):
66
PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
7+
PROVIDER_FATAL = "PROVIDER_FATAL"
78
FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
89
PARSE_ERROR = "PARSE_ERROR"
910
TYPE_MISMATCH = "TYPE_MISMATCH"
@@ -31,6 +32,36 @@ def __init__(
3132
self.error_code = error_code
3233

3334

35+
class ProviderNotReadyError(OpenFeatureError):
36+
"""
37+
This exception should be raised when the provider is not ready to be used.
38+
"""
39+
40+
def __init__(self, error_message: typing.Optional[str] = None):
41+
"""
42+
Constructor for the ProviderNotReadyError. The error code for this type of
43+
exception is ErrorCode.PROVIDER_NOT_READY.
44+
@param error_message: a string message representing why the error has been
45+
raised
46+
"""
47+
super().__init__(ErrorCode.PROVIDER_NOT_READY, error_message)
48+
49+
50+
class ProviderFatalError(OpenFeatureError):
51+
"""
52+
This exception should be raised when the provider encounters a fatal error.
53+
"""
54+
55+
def __init__(self, error_message: typing.Optional[str] = None):
56+
"""
57+
Constructor for the ProviderFatalError. The error code for this type of
58+
exception is ErrorCode.PROVIDER_FATAL.
59+
@param error_message: a string message representing why the error has been
60+
raised
61+
"""
62+
super().__init__(ErrorCode.PROVIDER_FATAL, error_message)
63+
64+
3465
class FlagNotFoundError(OpenFeatureError):
3566
"""
3667
This exception should be raised when the provider cannot find a flag with the

openfeature/provider/__init__.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing
2+
from enum import Enum
23

34
from openfeature.evaluation_context import EvaluationContext
45
from openfeature.flag_evaluation import FlagResolutionDetails
@@ -7,6 +8,14 @@
78
from .metadata import Metadata
89

910

11+
class ProviderStatus(Enum):
12+
NOT_READY = "NOT_READY"
13+
READY = "READY"
14+
ERROR = "ERROR"
15+
STALE = "STALE"
16+
FATAL = "FATAL"
17+
18+
1019
class FeatureProvider(typing.Protocol): # pragma: no cover
1120
def initialize(self, evaluation_context: EvaluationContext) -> None:
1221
...

openfeature/provider/registry.py

+40-7
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import typing
22

33
from openfeature.evaluation_context import EvaluationContext
4-
from openfeature.exception import GeneralError
5-
from openfeature.provider import FeatureProvider
4+
from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError
5+
from openfeature.provider import FeatureProvider, ProviderStatus
66
from openfeature.provider.no_op_provider import NoOpProvider
77

88

99
class ProviderRegistry:
1010
_default_provider: FeatureProvider
1111
_providers: typing.Dict[str, FeatureProvider]
12+
_provider_status: typing.Dict[FeatureProvider, ProviderStatus]
1213

1314
def __init__(self) -> None:
1415
self._default_provider = NoOpProvider()
1516
self._providers = {}
17+
self._provider_status = {}
18+
self._set_provider_status(self._default_provider, ProviderStatus.NOT_READY)
1619

1720
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
1821
if provider is None:
@@ -22,9 +25,9 @@ def set_provider(self, domain: str, provider: FeatureProvider) -> None:
2225
old_provider = providers[domain]
2326
del providers[domain]
2427
if old_provider not in providers.values():
25-
old_provider.shutdown()
28+
self._shutdown_provider(old_provider)
2629
if provider not in providers.values():
27-
provider.initialize(self._get_evaluation_context())
30+
self._initialize_provider(provider)
2831
providers[domain] = provider
2932

3033
def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider:
@@ -36,9 +39,9 @@ def set_default_provider(self, provider: FeatureProvider) -> None:
3639
if provider is None:
3740
raise GeneralError(error_message="No provider")
3841
if self._default_provider:
39-
self._default_provider.shutdown()
42+
self._shutdown_provider(self._default_provider)
4043
self._default_provider = provider
41-
provider.initialize(self._get_evaluation_context())
44+
self._initialize_provider(provider)
4245

4346
def get_default_provider(self) -> FeatureProvider:
4447
return self._default_provider
@@ -50,10 +53,40 @@ def clear_providers(self) -> None:
5053

5154
def shutdown(self) -> None:
5255
for provider in {self._default_provider, *self._providers.values()}:
53-
provider.shutdown()
56+
self._shutdown_provider(provider)
5457

5558
def _get_evaluation_context(self) -> EvaluationContext:
5659
# imported here to avoid circular imports
5760
from openfeature.api import get_evaluation_context
5861

5962
return get_evaluation_context()
63+
64+
def _initialize_provider(self, provider: FeatureProvider) -> None:
65+
try:
66+
if hasattr(provider, "initialize"):
67+
provider.initialize(self._get_evaluation_context())
68+
self._set_provider_status(provider, ProviderStatus.READY)
69+
except Exception as err:
70+
if (
71+
isinstance(err, OpenFeatureError)
72+
and err.error_code == ErrorCode.PROVIDER_FATAL
73+
):
74+
self._set_provider_status(provider, ProviderStatus.FATAL)
75+
else:
76+
self._set_provider_status(provider, ProviderStatus.ERROR)
77+
78+
def _shutdown_provider(self, provider: FeatureProvider) -> None:
79+
try:
80+
if hasattr(provider, "shutdown"):
81+
provider.shutdown()
82+
self._set_provider_status(provider, ProviderStatus.NOT_READY)
83+
except Exception:
84+
self._set_provider_status(provider, ProviderStatus.FATAL)
85+
86+
def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus:
87+
return self._provider_status.get(provider, ProviderStatus.NOT_READY)
88+
89+
def _set_provider_status(
90+
self, provider: FeatureProvider, status: ProviderStatus
91+
) -> None:
92+
self._provider_status[provider] = status

tests/test_client.py

+54
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from openfeature.exception import ErrorCode, OpenFeatureError
88
from openfeature.flag_evaluation import Reason
99
from openfeature.hook import Hook
10+
from openfeature.provider import ProviderStatus
1011
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
1112
from openfeature.provider.no_op_provider import NoOpProvider
1213

@@ -182,3 +183,56 @@ def test_should_call_api_level_hooks(no_op_provider_client):
182183
# Then
183184
api_hook.before.assert_called_once()
184185
api_hook.after.assert_called_once()
186+
187+
188+
# Requirement 1.7.5
189+
def test_should_define_a_provider_status_accessor(no_op_provider_client):
190+
# When
191+
status = no_op_provider_client.get_provider_status()
192+
# Then
193+
assert status is not None
194+
assert status == ProviderStatus.READY
195+
196+
197+
# Requirement 1.7.6
198+
def test_should_shortcircuit_if_provider_is_not_ready(
199+
no_op_provider_client, monkeypatch
200+
):
201+
# Given
202+
monkeypatch.setattr(
203+
no_op_provider_client, "get_provider_status", lambda: ProviderStatus.NOT_READY
204+
)
205+
spy_hook = MagicMock(spec=Hook)
206+
no_op_provider_client.add_hooks([spy_hook])
207+
# When
208+
flag_details = no_op_provider_client.get_boolean_details(
209+
flag_key="Key", default_value=True
210+
)
211+
# Then
212+
assert flag_details is not None
213+
assert flag_details.value
214+
assert flag_details.reason == Reason.ERROR
215+
assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY
216+
spy_hook.error.assert_called_once()
217+
218+
219+
# Requirement 1.7.7
220+
def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state(
221+
no_op_provider_client, monkeypatch
222+
):
223+
# Given
224+
monkeypatch.setattr(
225+
no_op_provider_client, "get_provider_status", lambda: ProviderStatus.FATAL
226+
)
227+
spy_hook = MagicMock(spec=Hook)
228+
no_op_provider_client.add_hooks([spy_hook])
229+
# When
230+
flag_details = no_op_provider_client.get_boolean_details(
231+
flag_key="Key", default_value=True
232+
)
233+
# Then
234+
assert flag_details is not None
235+
assert flag_details.value
236+
assert flag_details.reason == Reason.ERROR
237+
assert flag_details.error_code == ErrorCode.PROVIDER_FATAL
238+
spy_hook.error.assert_called_once()

0 commit comments

Comments
 (0)