Skip to content

Commit cb42cc0

Browse files
authored
feat: implement basic functionality of OFREP provider (#88)
Signed-off-by: Federico Bond <[email protected]>
1 parent 3327c8e commit cb42cc0

File tree

5 files changed

+360
-12
lines changed

5 files changed

+360
-12
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ repos:
2525
- opentelemetry-api
2626
- types-protobuf
2727
- types-PyYAML
28+
- types-requests
2829
- mmh3
2930
- semver
3031
- panzi-json-logic

providers/openfeature-provider-ofrep/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ classifiers = [
1818
keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.7.0",
21+
"requests"
2122
]
2223
requires-python = ">=3.8"
2324

@@ -30,6 +31,8 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib"
3031
dependencies = [
3132
"coverage[toml]>=6.5",
3233
"pytest",
34+
"requests-mock",
35+
"types-requests",
3336
]
3437

3538
[tool.hatch.envs.default.scripts]
Lines changed: 182 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,219 @@
1-
from typing import List, Optional, Union
1+
import re
2+
from datetime import datetime, timedelta, timezone
3+
from email.utils import parsedate_to_datetime
4+
from typing import Any, Callable, Dict, List, NoReturn, Optional, Tuple, Type, Union
5+
from urllib.parse import urljoin
6+
7+
import requests
8+
from requests.exceptions import JSONDecodeError
29

310
from openfeature.evaluation_context import EvaluationContext
4-
from openfeature.flag_evaluation import FlagResolutionDetails
11+
from openfeature.exception import (
12+
ErrorCode,
13+
FlagNotFoundError,
14+
GeneralError,
15+
InvalidContextError,
16+
OpenFeatureError,
17+
ParseError,
18+
TargetingKeyMissingError,
19+
TypeMismatchError,
20+
)
21+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason
522
from openfeature.hook import Hook
623
from openfeature.provider import AbstractProvider, Metadata
724

25+
__all__ = ["OFREPProvider"]
26+
27+
28+
TypeMap = Dict[
29+
FlagType,
30+
Union[
31+
Type[bool],
32+
Type[int],
33+
Type[float],
34+
Type[str],
35+
Tuple[Type[dict], Type[list]],
36+
],
37+
]
38+
839

940
class OFREPProvider(AbstractProvider):
41+
def __init__(
42+
self,
43+
base_url: str,
44+
*,
45+
headers_factory: Optional[Callable[[], Dict[str, str]]] = None,
46+
timeout: float = 5.0,
47+
):
48+
self.base_url = base_url
49+
self.headers_factory = headers_factory
50+
self.timeout = timeout
51+
self.retry_after: Optional[datetime] = None
52+
self.session = requests.Session()
53+
1054
def get_metadata(self) -> Metadata:
1155
return Metadata(name="OpenFeature Remote Evaluation Protocol Provider")
1256

1357
def get_provider_hooks(self) -> List[Hook]:
1458
return []
1559

16-
def resolve_boolean_details( # type: ignore[empty-body]
60+
def resolve_boolean_details(
1761
self,
1862
flag_key: str,
1963
default_value: bool,
2064
evaluation_context: Optional[EvaluationContext] = None,
21-
) -> FlagResolutionDetails[bool]: ...
65+
) -> FlagResolutionDetails[bool]:
66+
return self._resolve(
67+
FlagType.BOOLEAN, flag_key, default_value, evaluation_context
68+
)
2269

23-
def resolve_string_details( # type: ignore[empty-body]
70+
def resolve_string_details(
2471
self,
2572
flag_key: str,
2673
default_value: str,
2774
evaluation_context: Optional[EvaluationContext] = None,
28-
) -> FlagResolutionDetails[str]: ...
75+
) -> FlagResolutionDetails[str]:
76+
return self._resolve(
77+
FlagType.STRING, flag_key, default_value, evaluation_context
78+
)
2979

30-
def resolve_integer_details( # type: ignore[empty-body]
80+
def resolve_integer_details(
3181
self,
3282
flag_key: str,
3383
default_value: int,
3484
evaluation_context: Optional[EvaluationContext] = None,
35-
) -> FlagResolutionDetails[int]: ...
85+
) -> FlagResolutionDetails[int]:
86+
return self._resolve(
87+
FlagType.INTEGER, flag_key, default_value, evaluation_context
88+
)
3689

37-
def resolve_float_details( # type: ignore[empty-body]
90+
def resolve_float_details(
3891
self,
3992
flag_key: str,
4093
default_value: float,
4194
evaluation_context: Optional[EvaluationContext] = None,
42-
) -> FlagResolutionDetails[float]: ...
95+
) -> FlagResolutionDetails[float]:
96+
return self._resolve(
97+
FlagType.FLOAT, flag_key, default_value, evaluation_context
98+
)
4399

44-
def resolve_object_details( # type: ignore[empty-body]
100+
def resolve_object_details(
45101
self,
46102
flag_key: str,
47103
default_value: Union[dict, list],
48104
evaluation_context: Optional[EvaluationContext] = None,
49-
) -> FlagResolutionDetails[Union[dict, list]]: ...
105+
) -> FlagResolutionDetails[Union[dict, list]]:
106+
return self._resolve(
107+
FlagType.OBJECT, flag_key, default_value, evaluation_context
108+
)
109+
110+
def _resolve(
111+
self,
112+
flag_type: FlagType,
113+
flag_key: str,
114+
default_value: Union[bool, str, int, float, dict, list],
115+
evaluation_context: Optional[EvaluationContext] = None,
116+
) -> FlagResolutionDetails[Any]:
117+
now = datetime.now(timezone.utc)
118+
if self.retry_after and now <= self.retry_after:
119+
raise GeneralError(
120+
f"OFREP evaluation paused due to TooManyRequests until {self.retry_after}"
121+
)
122+
elif self.retry_after:
123+
self.retry_after = None
124+
125+
try:
126+
response = self.session.post(
127+
urljoin(self.base_url, f"/ofrep/v1/evaluate/flags/{flag_key}"),
128+
json=_build_request_data(evaluation_context),
129+
timeout=self.timeout,
130+
headers=self.headers_factory() if self.headers_factory else None,
131+
)
132+
response.raise_for_status()
133+
134+
except requests.RequestException as e:
135+
self._handle_error(e)
136+
137+
try:
138+
data = response.json()
139+
except JSONDecodeError as e:
140+
raise ParseError(str(e)) from e
141+
142+
_typecheck_flag_value(data["value"], flag_type)
143+
144+
return FlagResolutionDetails(
145+
value=data["value"],
146+
reason=Reason[data["reason"]],
147+
variant=data["variant"],
148+
flag_metadata=data["metadata"],
149+
)
150+
151+
def _handle_error(self, exception: requests.RequestException) -> NoReturn:
152+
response = exception.response
153+
if response is None:
154+
raise GeneralError(str(exception)) from exception
155+
156+
if response.status_code == 429:
157+
retry_after = response.headers.get("Retry-After")
158+
self.retry_after = _parse_retry_after(retry_after)
159+
raise GeneralError(
160+
f"Rate limited, retry after: {retry_after}"
161+
) from exception
162+
163+
try:
164+
data = response.json()
165+
except JSONDecodeError:
166+
raise ParseError(str(exception)) from exception
167+
168+
error_code = ErrorCode(data["errorCode"])
169+
error_details = data["errorDetails"]
170+
171+
if response.status_code == 404:
172+
raise FlagNotFoundError(error_details) from exception
173+
174+
if error_code == ErrorCode.PARSE_ERROR:
175+
raise ParseError(error_details) from exception
176+
if error_code == ErrorCode.TARGETING_KEY_MISSING:
177+
raise TargetingKeyMissingError(error_details) from exception
178+
if error_code == ErrorCode.INVALID_CONTEXT:
179+
raise InvalidContextError(error_details) from exception
180+
if error_code == ErrorCode.GENERAL:
181+
raise GeneralError(error_details) from exception
182+
183+
raise OpenFeatureError(error_code, error_details) from exception
184+
185+
186+
def _build_request_data(
187+
evaluation_context: Optional[EvaluationContext],
188+
) -> Dict[str, Any]:
189+
data: Dict[str, Any] = {}
190+
if evaluation_context:
191+
data["context"] = {}
192+
if evaluation_context.targeting_key:
193+
data["context"]["targetingKey"] = evaluation_context.targeting_key
194+
data["context"].update(evaluation_context.attributes)
195+
return data
196+
197+
198+
def _parse_retry_after(retry_after: Optional[str]) -> Optional[datetime]:
199+
if retry_after is None:
200+
return None
201+
if re.match(r"^\s*[0-9]+\s*$", retry_after):
202+
seconds = int(retry_after)
203+
return datetime.now(timezone.utc) + timedelta(seconds=seconds)
204+
return parsedate_to_datetime(retry_after)
205+
206+
207+
def _typecheck_flag_value(value: Any, flag_type: FlagType) -> None:
208+
type_map: TypeMap = {
209+
FlagType.BOOLEAN: bool,
210+
FlagType.STRING: str,
211+
FlagType.OBJECT: (dict, list),
212+
FlagType.FLOAT: float,
213+
FlagType.INTEGER: int,
214+
}
215+
_type = type_map.get(flag_type)
216+
if not _type:
217+
raise GeneralError(error_message="Unknown flag type")
218+
if not isinstance(value, _type):
219+
raise TypeMismatchError(f"Expected type {_type} but got {type(value)}")
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
from openfeature.contrib.provider.ofrep import OFREPProvider
4+
5+
6+
@pytest.fixture
7+
def ofrep_provider():
8+
return OFREPProvider("http://localhost:8080")

0 commit comments

Comments
 (0)