From 535fd27bad7da3313b41ed535fdf5f449dc56b31 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Tue, 7 May 2024 16:00:51 +1000 Subject: [PATCH] refactor: reduce _resolve function complexity Signed-off-by: Federico Bond --- .../contrib/provider/ofrep/__init__.py | 81 +++++++++++++------ .../tests/test_provider.py | 18 ++++- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py index 58c9e7e8..8c01d6a3 100644 --- a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py +++ b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py @@ -1,3 +1,6 @@ +import re +from datetime import datetime, timedelta, timezone +from email.utils import parsedate_to_datetime from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin @@ -32,7 +35,9 @@ def __init__( self.base_url = base_url self.headers = headers self.timeout = timeout + self.retry_after: Optional[datetime] = None self.session = requests.Session() + self.session.headers["User-Agent"] = "OpenFeature/1.0.0" if headers: self.session.headers.update(headers) @@ -88,6 +93,14 @@ def _resolve( default_value: Union[bool, str, int, float, dict, list], evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[Any]: + now = datetime.now(timezone.utc) + if self.retry_after and now <= self.retry_after: + raise GeneralError( + f"OFREP evaluation paused due to TooManyRequests until {self.retry_after}" + ) + elif self.retry_after: + self.retry_after = None + try: response = self.session.post( urljoin(self.base_url, f"/ofrep/v1/evaluate/flags/{flag_key}"), @@ -97,30 +110,7 @@ def _resolve( response.raise_for_status() except requests.RequestException as e: - if e.response is None: - raise GeneralError(str(e)) from e - - try: - data = e.response.json() - except JSONDecodeError: - raise ParseError(str(e)) from e - - if e.response.status_code == 404: - raise FlagNotFoundError(data["errorDetails"]) from e - - error_code = ErrorCode(data["errorCode"]) - error_details = data["errorDetails"] - - if error_code == ErrorCode.PARSE_ERROR: - raise ParseError(error_details) from e - if error_code == ErrorCode.TARGETING_KEY_MISSING: - raise TargetingKeyMissingError(error_details) from e - if error_code == ErrorCode.INVALID_CONTEXT: - raise InvalidContextError(error_details) from e - if error_code == ErrorCode.GENERAL: - raise GeneralError(error_details) from e - - raise OpenFeatureError(error_code, error_details) from e + self._handle_error(e) try: data = response.json() @@ -134,6 +124,40 @@ def _resolve( flag_metadata=data["metadata"], ) + def _handle_error(self, exception: requests.RequestException) -> None: + response = exception.response + if response is None: + raise GeneralError(str(exception)) from exception + + if response.status_code == 429: + retry_after = response.headers.get("Retry-After") + self.retry_after = _parse_retry_after(retry_after) + raise GeneralError( + f"Rate limited, retry after: {retry_after}" + ) from exception + + try: + data = response.json() + except JSONDecodeError: + raise ParseError(str(exception)) from exception + + error_code = ErrorCode(data["errorCode"]) + error_details = data["errorDetails"] + + if response.status_code == 404: + raise FlagNotFoundError(error_details) from exception + + if error_code == ErrorCode.PARSE_ERROR: + raise ParseError(error_details) from exception + if error_code == ErrorCode.TARGETING_KEY_MISSING: + raise TargetingKeyMissingError(error_details) from exception + if error_code == ErrorCode.INVALID_CONTEXT: + raise InvalidContextError(error_details) from exception + if error_code == ErrorCode.GENERAL: + raise GeneralError(error_details) from exception + + raise OpenFeatureError(error_code, error_details) from exception + def _build_request_data( evaluation_context: Optional[EvaluationContext], @@ -145,3 +169,12 @@ def _build_request_data( data["context"]["targetingKey"] = evaluation_context.targeting_key data["context"].update(evaluation_context.attributes) return data + + +def _parse_retry_after(retry_after: Optional[str]) -> Optional[datetime]: + if retry_after is None: + return None + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = int(retry_after) + return datetime.now(timezone.utc) + timedelta(seconds=seconds) + return parsedate_to_datetime(retry_after) diff --git a/providers/openfeature-provider-ofrep/tests/test_provider.py b/providers/openfeature-provider-ofrep/tests/test_provider.py index 9c2cccae..241d3ca7 100644 --- a/providers/openfeature-provider-ofrep/tests/test_provider.py +++ b/providers/openfeature-provider-ofrep/tests/test_provider.py @@ -4,6 +4,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( FlagNotFoundError, + GeneralError, InvalidContextError, ParseError, ) @@ -88,7 +89,7 @@ def match_request_json(request): "metadata": {}, "value": True, }, - additional_matcher=match_request_json + additional_matcher=match_request_json, ) context = EvaluationContext("1", {"foo": "bar"}) @@ -101,3 +102,18 @@ def match_request_json(request): reason=Reason.TARGETING_MATCH, variant="true", ) + + +def test_provider_retry_after_shortcircuit_resolution(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=429, + headers={"Retry-After": "1"}, + ) + + with pytest.raises(GeneralError, match="Rate limited, retry after: 1"): + ofrep_provider.resolve_boolean_details("flag_key", False) + with pytest.raises( + GeneralError, match="OFREP evaluation paused due to TooManyRequests" + ): + ofrep_provider.resolve_boolean_details("flag_key", False)