1+ import asyncio
12import re
23from collections .abc import Mapping , Sequence
34from datetime import datetime , timedelta , timezone
45from email .utils import parsedate_to_datetime
6+ from json import JSONDecodeError
57from typing import Any , Callable , NoReturn , Optional , Union
68from urllib .parse import urljoin
79
8- import requests
9- from requests .exceptions import JSONDecodeError
10+ import httpx
1011
1112from openfeature .evaluation_context import EvaluationContext
1213from 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
209336def _build_request_data (
210337 evaluation_context : Optional [EvaluationContext ],
0 commit comments