Skip to content

Commit 9fdf3e1

Browse files
committed
Update ofrep provider to use httpx and support async
1 parent b45e3c8 commit 9fdf3e1

File tree

1 file changed

+135
-8
lines changed
  • providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep

1 file changed

+135
-8
lines changed

providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import asyncio
12
import re
23
from collections.abc import Mapping, Sequence
34
from datetime import datetime, timedelta, timezone
45
from email.utils import parsedate_to_datetime
6+
from json import JSONDecodeError
57
from typing import Any, Callable, NoReturn, Optional, Union
68
from urllib.parse import urljoin
79

8-
import requests
9-
from requests.exceptions import JSONDecodeError
10+
import httpx
1011

1112
from openfeature.evaluation_context import EvaluationContext
1213
from openfeature.exception import (
@@ -55,7 +56,23 @@ def __init__(
5556
self.headers_factory = headers_factory
5657
self.timeout = timeout
5758
self.retry_after: Optional[datetime] = None
58-
self.session = requests.Session()
59+
60+
self.client = httpx.Client()
61+
self.client_async = httpx.AsyncClient()
62+
self._client_async_is_entered = False
63+
64+
def initialize(self, evaluation_context: EvaluationContext) -> None:
65+
self.client.__enter__()
66+
67+
def shutdown(self) -> None:
68+
self.client.__exit__(None, None, None)
69+
70+
try:
71+
# TODO(someday): support non asyncio runtimes here
72+
asyncio.get_running_loop().create_task(self.client_async.__aexit__(None, None, None))
73+
self._client_async_is_entered = False
74+
except Exception:
75+
pass
5976

6077
def get_metadata(self) -> Metadata:
6178
return Metadata(name="OpenFeature Remote Evaluation Protocol Provider")
@@ -73,6 +90,16 @@ def resolve_boolean_details(
7390
FlagType.BOOLEAN, flag_key, default_value, evaluation_context
7491
)
7592

93+
async def resolve_boolean_details_async(
94+
self,
95+
flag_key: str,
96+
default_value: bool,
97+
evaluation_context: Optional[EvaluationContext] = None,
98+
) -> FlagResolutionDetails[bool]:
99+
return await self._resolve_async(
100+
FlagType.BOOLEAN, flag_key, default_value, evaluation_context
101+
)
102+
76103
def resolve_string_details(
77104
self,
78105
flag_key: str,
@@ -83,6 +110,16 @@ def resolve_string_details(
83110
FlagType.STRING, flag_key, default_value, evaluation_context
84111
)
85112

113+
async def resolve_string_details_async(
114+
self,
115+
flag_key: str,
116+
default_value: str,
117+
evaluation_context: Optional[EvaluationContext] = None,
118+
) -> FlagResolutionDetails[str]:
119+
return await self._resolve_async(
120+
FlagType.STRING, flag_key, default_value, evaluation_context
121+
)
122+
86123
def resolve_integer_details(
87124
self,
88125
flag_key: str,
@@ -93,6 +130,16 @@ def resolve_integer_details(
93130
FlagType.INTEGER, flag_key, default_value, evaluation_context
94131
)
95132

133+
async def resolve_integer_details_async(
134+
self,
135+
flag_key: str,
136+
default_value: int,
137+
evaluation_context: Optional[EvaluationContext] = None,
138+
) -> FlagResolutionDetails[int]:
139+
return await self._resolve_async(
140+
FlagType.INTEGER, flag_key, default_value, evaluation_context
141+
)
142+
96143
def resolve_float_details(
97144
self,
98145
flag_key: str,
@@ -103,6 +150,17 @@ def resolve_float_details(
103150
FlagType.FLOAT, flag_key, default_value, evaluation_context
104151
)
105152

153+
154+
async def resolve_float_details_async(
155+
self,
156+
flag_key: str,
157+
default_value: float,
158+
evaluation_context: Optional[EvaluationContext] = None,
159+
) -> FlagResolutionDetails[float]:
160+
return await self._resolve_async(
161+
FlagType.FLOAT, flag_key, default_value, evaluation_context
162+
)
163+
106164
def resolve_object_details(
107165
self,
108166
flag_key: str,
@@ -115,6 +173,16 @@ def resolve_object_details(
115173
FlagType.OBJECT, flag_key, default_value, evaluation_context
116174
)
117175

176+
async def resolve_object_details_async(
177+
self,
178+
flag_key: str,
179+
default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]],
180+
evaluation_context: Optional[EvaluationContext] = None,
181+
) -> FlagResolutionDetails[Sequence[FlagValueType] | Mapping[str, FlagValueType]]:
182+
return await self._resolve_async(
183+
FlagType.OBJECT, flag_key, default_value, evaluation_context
184+
)
185+
118186
def _get_ofrep_api_url(self, api_version: str = "v1") -> str:
119187
ofrep_base_url = (
120188
self.base_url if self.base_url.endswith("/") else f"{self.base_url}/"
@@ -146,15 +214,15 @@ def _resolve(
146214
self.retry_after = None
147215

148216
try:
149-
response = self.session.post(
217+
response = self.client.post(
150218
urljoin(self._get_ofrep_api_url(), f"evaluate/flags/{flag_key}"),
151219
json=_build_request_data(evaluation_context),
152220
timeout=self.timeout,
153221
headers=self.headers_factory() if self.headers_factory else None,
154222
)
155223
response.raise_for_status()
156224

157-
except requests.RequestException as e:
225+
except httpx.HTTPError as e:
158226
self._handle_error(e)
159227

160228
try:
@@ -171,11 +239,66 @@ def _resolve(
171239
flag_metadata=data.get("metadata", {}),
172240
)
173241

174-
def _handle_error(self, exception: requests.RequestException) -> NoReturn:
175-
response = exception.response
176-
if response is None:
242+
async def _resolve_async(
243+
self,
244+
flag_type: FlagType,
245+
flag_key: str,
246+
default_value: Union[
247+
bool,
248+
str,
249+
int,
250+
float,
251+
dict,
252+
list,
253+
Sequence[FlagValueType],
254+
Mapping[str, FlagValueType],
255+
],
256+
evaluation_context: Optional[EvaluationContext] = None,
257+
) -> FlagResolutionDetails[Any]:
258+
if not self._client_async_is_entered:
259+
await self.client_async.__aenter__()
260+
self._client_async_is_entered = True
261+
262+
now = datetime.now(timezone.utc)
263+
if self.retry_after and now <= self.retry_after:
264+
raise GeneralError(
265+
f"OFREP evaluation paused due to TooManyRequests until {self.retry_after}"
266+
)
267+
elif self.retry_after:
268+
self.retry_after = None
269+
270+
try:
271+
response = await self.client_async.post(
272+
urljoin(self._get_ofrep_api_url(), f"evaluate/flags/{flag_key}"),
273+
json=_build_request_data(evaluation_context),
274+
timeout=self.timeout,
275+
headers=self.headers_factory() if self.headers_factory else None,
276+
)
277+
response.raise_for_status()
278+
279+
except httpx.HTTPError as e:
280+
self._handle_error(e)
281+
282+
try:
283+
data = response.json()
284+
except JSONDecodeError as e:
285+
raise ParseError(str(e)) from e
286+
287+
_typecheck_flag_value(data["value"], flag_type)
288+
289+
return FlagResolutionDetails(
290+
value=data["value"],
291+
reason=Reason[data["reason"]],
292+
variant=data["variant"],
293+
flag_metadata=data.get("metadata", {}),
294+
)
295+
296+
def _handle_error(self, exception: httpx.HTTPError) -> NoReturn:
297+
if not isinstance(exception, httpx.HTTPStatusError):
177298
raise GeneralError(str(exception)) from exception
178299

300+
response = exception.response
301+
179302
if response.status_code == 429:
180303
retry_after = response.headers.get("Retry-After")
181304
self.retry_after = _parse_retry_after(retry_after)
@@ -205,6 +328,10 @@ def _handle_error(self, exception: requests.RequestException) -> NoReturn:
205328

206329
raise OpenFeatureError(error_code, error_details) from exception
207330

331+
def __del__(self):
332+
# Ensure clients get cleaned up
333+
self.shutdown()
334+
208335

209336
def _build_request_data(
210337
evaluation_context: Optional[EvaluationContext],

0 commit comments

Comments
 (0)