diff --git a/README.md b/README.md index 6b651b4..799bcb3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,21 @@ confidence = root_confidence.with_context({"user_id": "some-user-id"}) default_value = False flag_details = confidence.resolve_boolean_details("flag-name.property-name", default_value) print(flag_details) +``` + +### Configuration options +The SDK can be configured with several options: + +```python +from confidence.confidence import Confidence, Region + +# Configure timeout for network requests +confidence = Confidence( + client_secret="CLIENT_TOKEN", + region=Region.EU, # Optional: defaults to GLOBAL + timeout_ms=5000 # Optional: specify timeout in milliseconds for network requests (default: 10000ms) +) ``` ### Tracking events diff --git a/confidence/confidence.py b/confidence/confidence.py index c694e90..20f9750 100644 --- a/confidence/confidence.py +++ b/confidence/confidence.py @@ -22,16 +22,21 @@ from confidence import __version__ from confidence.errors import ( FlagNotFoundError, + GeneralError, ParseError, TypeMismatchError, + TimeoutError, ) -from .flag_types import FlagResolutionDetails, Reason +from .flag_types import FlagResolutionDetails, Reason, ErrorCode from .names import FlagName, VariantName EU_RESOLVE_API_ENDPOINT = "https://resolver.eu.confidence.dev" US_RESOLVE_API_ENDPOINT = "https://resolver.us.confidence.dev" GLOBAL_RESOLVE_API_ENDPOINT = "https://resolver.confidence.dev" +# Default timeout in milliseconds (10 seconds) +DEFAULT_TIMEOUT_MS = 10000 + Primitive = Union[str, int, float, bool, None] FieldType = Union[Primitive, List[Primitive], List["Object"], "Object"] Object = Dict[str, FieldType] @@ -79,6 +84,8 @@ def with_context(self, context: Dict[str, FieldType]) -> "Confidence": self._region, self._apply_on_resolve, self._custom_resolve_base_url, + timeout_ms=self._timeout_ms, + logger=self.logger, async_client=self.async_client, ) new_confidence.context = {**self.context, **context} @@ -90,6 +97,7 @@ def __init__( region: Region = Region.GLOBAL, apply_on_resolve: bool = True, custom_resolve_base_url: Optional[str] = None, + timeout_ms: Optional[int] = DEFAULT_TIMEOUT_MS, logger: logging.Logger = logging.getLogger("confidence_logger"), async_client: httpx.AsyncClient = httpx.AsyncClient(), ): @@ -97,6 +105,7 @@ def __init__( self._region = region self._api_endpoint = region.endpoint() self._apply_on_resolve = apply_on_resolve + self._timeout_ms = timeout_ms self.logger = logger self.async_client = async_client self._setup_logger(logger) @@ -217,10 +226,47 @@ def _evaluate( else: flag_id = flag_key value_path = None - result = self._resolve(FlagName(flag_id), context) - return self._handle_evaluation_result( - result, flag_id, flag_key, value_type, default_value, value_path, context - ) + try: + result = self._resolve(FlagName(flag_id), context) + return self._handle_evaluation_result( + result, + flag_id, + flag_key, + value_type, + default_value, + value_path, + context, + ) + except FlagNotFoundError: + self.logger.info(f"Flag {flag_key} not found") + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag {flag_key} not found", + flag_metadata={"flag_key": flag_key}, + ) + except TimeoutError as e: + self.logger.warning( + f"Request timed out after {self._timeout_ms} ms" + f" when resolving flag {flag_key}" + ) + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.TIMEOUT, + error_message=str(e), + flag_metadata={"flag_key": flag_key}, + ) + except Exception as e: + self.logger.error(f"Error resolving flag {flag_key}: {str(e)}") + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message=str(e), + flag_metadata={"flag_key": flag_key}, + ) async def _evaluate_async( self, @@ -234,10 +280,47 @@ async def _evaluate_async( else: flag_id = flag_key value_path = None - result = await self._resolve_async(FlagName(flag_id), context) - return self._handle_evaluation_result( - result, flag_id, flag_key, value_type, default_value, value_path, context - ) + try: + result = await self._resolve_async(FlagName(flag_id), context) + return self._handle_evaluation_result( + result, + flag_id, + flag_key, + value_type, + default_value, + value_path, + context, + ) + except FlagNotFoundError: + self.logger.info(f"Flag {flag_key} not found") + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag {flag_key} not found", + flag_metadata={"flag_key": flag_key}, + ) + except TimeoutError as e: + self.logger.warning( + f"Request timed out after {self._timeout_ms} ms" + f" when resolving flag {flag_key}" + ) + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.TIMEOUT, + error_message=str(e), + flag_metadata={"flag_key": flag_key}, + ) + except Exception as e: + self.logger.error(f"Error resolving flag {flag_key}: {str(e)}") + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.GENERAL, + error_message=str(e), + flag_metadata={"flag_key": flag_key}, + ) # type-arg: ignore def track(self, event_name: str, data: Dict[str, FieldType]) -> None: @@ -266,20 +349,26 @@ def _send_event_internal(self, event_name: str, data: Dict[str, FieldType]) -> N event_url = "https://events.confidence.dev/v1/events:publish" headers = {"Content-Type": "application/json", "Accept": "application/json"} - response = requests.post(event_url, json=request_body, headers=headers) - if response.status_code == 200: - json = response.json() - - json_errors = json.get("errors") - if json_errors: - self.logger.warn("events emitted with errors:") - for error in json_errors: - self.logger.warn(error) - else: - self.logger.warn( - f"Track event {event_name} failed with status code" - + f" {response.status_code} and reason: {response.reason}" + timeout_sec = None if self._timeout_ms is None else self._timeout_ms / 1000.0 + try: + response = requests.post( + event_url, json=request_body, headers=headers, timeout=timeout_sec ) + if response.status_code == 200: + json = response.json() + + json_errors = json.get("errors") + if json_errors: + self.logger.warning("events emitted with errors:") + for error in json_errors: + self.logger.warning(error) + else: + self.logger.warning( + f"Track event {event_name} failed with status code" + + f" {response.status_code} and reason: {response.reason}" + ) + except requests.exceptions.RequestException as e: + self.logger.warning(f"Failed to track event {event_name}: {str(e)}") def _handle_resolve_response( self, response: requests.Response, flag_name: FlagName @@ -296,8 +385,7 @@ def _handle_resolve_response( token = response_body["resolveToken"] if len(resolved_flags) == 0: - self.logger.info(f"Flag {flag_name} not found") - return ResolveResult(None, None, token) + raise FlagNotFoundError() resolved_flag = resolved_flags[0] variant = resolved_flag.get("variant") @@ -320,8 +408,21 @@ def _resolve( base_url = self._custom_resolve_base_url resolve_url = f"{base_url}/v1/flags:resolve" - response = requests.post(resolve_url, json=request_body) - return self._handle_resolve_response(response, flag_name) + timeout_sec = None if self._timeout_ms is None else self._timeout_ms / 1000.0 + try: + response = requests.post( + resolve_url, json=request_body, timeout=timeout_sec + ) + return self._handle_resolve_response(response, flag_name) + except requests.exceptions.Timeout: + self.logger.warning( + f"Request timed out after {timeout_sec}s" + f" when resolving flag {flag_name}" + ) + raise TimeoutError() + except requests.exceptions.RequestException as e: + self.logger.warning(f"Error resolving flag {flag_name}: {str(e)}") + raise GeneralError(str(e)) async def _resolve_async( self, flag_name: FlagName, context: Dict[str, FieldType] @@ -338,8 +439,21 @@ async def _resolve_async( base_url = self._custom_resolve_base_url resolve_url = f"{base_url}/v1/flags:resolve" - response = await self.async_client.post(resolve_url, json=request_body) - return self._handle_resolve_response(response, flag_name) + timeout_sec = None if self._timeout_ms is None else self._timeout_ms / 1000.0 + try: + response = await self.async_client.post( + resolve_url, json=request_body, timeout=timeout_sec + ) + return self._handle_resolve_response(response, flag_name) + except httpx.TimeoutException: + self.logger.warning( + f"Request timed out after {timeout_sec}s" + f" when resolving flag {flag_name}" + ) + raise TimeoutError() + except httpx.HTTPError as e: + self.logger.warning(f"Error resolving flag {flag_name}: {str(e)}") + raise GeneralError(str(e)) @staticmethod def _select( diff --git a/confidence/errors.py b/confidence/errors.py index 2a5a7fd..ee4f98a 100644 --- a/confidence/errors.py +++ b/confidence/errors.py @@ -10,6 +10,7 @@ class ErrorCode(Enum): TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING" INVALID_CONTEXT = "INVALID_CONTEXT" GENERAL = "GENERAL" + TIMEOUT = "TIMEOUT" class ConfidenceError(Exception): @@ -31,9 +32,23 @@ def __init__( self.error_code = error_code +class TimeoutError(ConfidenceError): + """ + This exception should be raised when the request to the backend takes longer + than the timeout set in the client. + """ + + def __init__(self, error_message: typing.Optional[str] = None): + """ + Constructor for the TimeoutError. The error code for this type of exception + is ErrorCode.TIMEOUT. + """ + super().__init__(ErrorCode.TIMEOUT, error_message) + + class FlagNotFoundError(ConfidenceError): """ - This exception should be raised when the provider cannot find a flag with the + This exception should be raised when the SDK cannot find a flag with the key provided by the user. """ diff --git a/confidence/flag_types.py b/confidence/flag_types.py index a23058b..15a2046 100644 --- a/confidence/flag_types.py +++ b/confidence/flag_types.py @@ -37,6 +37,7 @@ class Reason(StrEnum): SPLIT = "SPLIT" TARGETING_MATCH = "TARGETING_MATCH" UNKNOWN = "UNKNOWN" + TIMEOUT = "TIMEOUT" FlagMetadata = typing.Mapping[str, typing.Any] diff --git a/confidence/openfeature_provider.py b/confidence/openfeature_provider.py index 73e0df7..6857ffb 100644 --- a/confidence/openfeature_provider.py +++ b/confidence/openfeature_provider.py @@ -85,6 +85,8 @@ def _to_openfeature_error_code( return openfeature.exception.ErrorCode.GENERAL if error_code is ErrorCode.PARSE_ERROR: return openfeature.exception.ErrorCode.PARSE_ERROR + if error_code is ErrorCode.TIMEOUT: + return openfeature.exception.ErrorCode.GENERAL if error_code is ErrorCode.NOT_READY: return openfeature.exception.ErrorCode.PROVIDER_NOT_READY diff --git a/demo.py b/demo.py index 729bc57..7eddc97 100644 --- a/demo.py +++ b/demo.py @@ -5,22 +5,25 @@ async def get_flag(): - root = Confidence("API_CLIENT") + root = Confidence("API CLIENT", timeout_ms=100) random_uuid = uuid.uuid4() uuid_string = str(random_uuid) confidence = root.with_context({"targeting_key": uuid_string}) - confidence.with_context({"app": "python"}).track("navigate", {}) - print("Tracked navigate event") + #confidence.with_context({"app": "python"}).track("navigate", {}) + #print("Tracked navigate event") - value = confidence.resolve_string_details("hawkflag.color", "False") - print(f"Flag value: {value}") + details = confidence.resolve_string_details("hawkflag.color", "default") + print(f"Flag value: {details.value}") + print(f"Flag reason: {details.reason}") + print(f"Flag error code: {details.error_code}") + print(f"Flag error message: {details.error_message}") # Another asynchronous function that calls the first one async def main(): await get_flag() print("Finished calling get_flag") - await asyncio.sleep(5) + await asyncio.sleep(1) print("Finished sleeping for 1 seconds") diff --git a/tests/test_confidence.py b/tests/test_confidence.py index 94764e0..83e60c6 100644 --- a/tests/test_confidence.py +++ b/tests/test_confidence.py @@ -1,13 +1,15 @@ import requests_mock import unittest -from unittest.mock import patch +from unittest.mock import patch, AsyncMock import json import httpx +from requests.exceptions import Timeout as RequestsTimeout -from openfeature.flag_evaluation import Reason import confidence.confidence -from confidence.confidence import Confidence +from confidence.confidence import Confidence, DEFAULT_TIMEOUT_MS +from confidence.errors import ErrorCode +from confidence.flag_types import Reason class TestConfidence(unittest.IsolatedAsyncioTestCase): @@ -49,6 +51,10 @@ def test_resolve_failed(self): ) self.assertEqual(result.value, "yellow") self.assertIsNone(result.variant) + self.assertEqual(result.error_code, ErrorCode.FLAG_NOT_FOUND) + self.assertEqual( + result.error_message, "Flag some-flag-that-doesnt-exist not found" + ) def test_resolve_string_with_dot_notation_request_payload(self): with requests_mock.Mocker() as mock: @@ -325,6 +331,151 @@ def test_no_segment_match(self): self.assertEqual(result.value, "brown") self.assertEqual(result.reason, Reason.DEFAULT) + def test_resolve_with_timeout(self): + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=SUCCESSFUL_FLAG_RESOLVE, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = SUCCESSFUL_FLAG_RESOLVE + + confidence_with_timeout = Confidence( + client_secret="test", timeout_ms=5500 + ) + confidence_with_timeout.resolve_string_details( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that timeout was passed to requests.post + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["timeout"], 5.5) + + async def test_resolve_with_timeout_async(self): + mock_response = httpx.Response( + status_code=200, + json=SUCCESSFUL_FLAG_RESOLVE, + request=httpx.Request( + "POST", "https://resolver.confidence.dev/v1/flags:resolve" + ), + ) + + # Create an AsyncMock that returns an awaitable that resolves to mock_response + mock_post = AsyncMock() + mock_post.return_value = mock_response + + with patch("httpx.AsyncClient.post", mock_post): + confidence_with_timeout = Confidence(client_secret="test", timeout_ms=3500) + await confidence_with_timeout.resolve_string_details_async( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that timeout was passed to httpx.AsyncClient.post + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["timeout"], 3.5) + + def test_resolve_with_default_timeout(self): + """Test that the default timeout is used when timeout_ms is not provided.""" + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=SUCCESSFUL_FLAG_RESOLVE, + ) + + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = SUCCESSFUL_FLAG_RESOLVE + + # Create client without specifying timeout_ms + confidence_default_timeout = Confidence(client_secret="test") + confidence_default_timeout.resolve_string_details( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that default timeout (10 seconds) was passed to requests.post + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["timeout"], DEFAULT_TIMEOUT_MS / 1000.0) + + async def test_resolve_with_default_timeout_async(self): + mock_response = httpx.Response( + status_code=200, + json=SUCCESSFUL_FLAG_RESOLVE, + request=httpx.Request( + "POST", "https://resolver.confidence.dev/v1/flags:resolve" + ), + ) + + # Create an AsyncMock that returns an awaitable that resolves to mock_response + mock_post = AsyncMock() + mock_post.return_value = mock_response + + with patch("httpx.AsyncClient.post", mock_post): + # Create client without specifying timeout_ms + confidence_default_timeout = Confidence(client_secret="test") + await confidence_default_timeout.resolve_string_details_async( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that default timeout (10 seconds) was passed to + # httpx.AsyncClient.post + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["timeout"], DEFAULT_TIMEOUT_MS / 1000.0) + + def test_handle_actual_timeout(self): + with patch("requests.post") as mock_post: + # Simulate a timeout by raising the Timeout exception + mock_post.side_effect = RequestsTimeout("Connection timed out") + + confidence_with_timeout = Confidence(client_secret="test", timeout_ms=100) + + # The operation should NOT raise an exception, but return default value + result = confidence_with_timeout.resolve_string_details( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that default value was returned + self.assertEqual(result.value, "yellow") + self.assertEqual(result.reason, Reason.DEFAULT) + self.assertEqual( + result.flag_metadata["flag_key"], "python-flag-1.string-key" + ) + self.assertIsNone(result.variant) + self.assertEqual(result.error_code, ErrorCode.TIMEOUT) + + async def test_handle_actual_timeout_async(self): + # Create an AsyncMock that raises a timeout exception + mock_post = AsyncMock() + mock_post.side_effect = httpx.TimeoutException("Connection timed out") + + with patch("httpx.AsyncClient.post", mock_post): + confidence_with_timeout = Confidence(client_secret="test", timeout_ms=100) + + # The operation should NOT raise an exception, but return default value + result = await confidence_with_timeout.resolve_string_details_async( + flag_key="python-flag-1.string-key", + default_value="yellow", + ) + + # Verify that default value was returned + self.assertEqual(result.value, "yellow") + self.assertEqual(result.reason, Reason.DEFAULT) + self.assertEqual( + result.flag_metadata["flag_key"], "python-flag-1.string-key" + ) + self.assertIsNone(result.variant) + self.assertEqual(result.error_code, ErrorCode.TIMEOUT) + if __name__ == "__main__": unittest.main()