|
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 |
2 | 9 |
|
3 | 10 | 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 |
5 | 22 | from openfeature.hook import Hook
|
6 | 23 | from openfeature.provider import AbstractProvider, Metadata
|
7 | 24 |
|
| 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 | + |
8 | 39 |
|
9 | 40 | 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 | + |
10 | 54 | def get_metadata(self) -> Metadata:
|
11 | 55 | return Metadata(name="OpenFeature Remote Evaluation Protocol Provider")
|
12 | 56 |
|
13 | 57 | def get_provider_hooks(self) -> List[Hook]:
|
14 | 58 | return []
|
15 | 59 |
|
16 |
| - def resolve_boolean_details( # type: ignore[empty-body] |
| 60 | + def resolve_boolean_details( |
17 | 61 | self,
|
18 | 62 | flag_key: str,
|
19 | 63 | default_value: bool,
|
20 | 64 | 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 | + ) |
22 | 69 |
|
23 |
| - def resolve_string_details( # type: ignore[empty-body] |
| 70 | + def resolve_string_details( |
24 | 71 | self,
|
25 | 72 | flag_key: str,
|
26 | 73 | default_value: str,
|
27 | 74 | 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 | + ) |
29 | 79 |
|
30 |
| - def resolve_integer_details( # type: ignore[empty-body] |
| 80 | + def resolve_integer_details( |
31 | 81 | self,
|
32 | 82 | flag_key: str,
|
33 | 83 | default_value: int,
|
34 | 84 | 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 | + ) |
36 | 89 |
|
37 |
| - def resolve_float_details( # type: ignore[empty-body] |
| 90 | + def resolve_float_details( |
38 | 91 | self,
|
39 | 92 | flag_key: str,
|
40 | 93 | default_value: float,
|
41 | 94 | 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 | + ) |
43 | 99 |
|
44 |
| - def resolve_object_details( # type: ignore[empty-body] |
| 100 | + def resolve_object_details( |
45 | 101 | self,
|
46 | 102 | flag_key: str,
|
47 | 103 | default_value: Union[dict, list],
|
48 | 104 | 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)}") |
0 commit comments