Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
170 changes: 142 additions & 28 deletions confidence/confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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}
Expand All @@ -90,13 +97,15 @@ 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(),
):
self._client_secret = client_secret
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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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]
Expand All @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion confidence/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class ErrorCode(Enum):
TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
INVALID_CONTEXT = "INVALID_CONTEXT"
GENERAL = "GENERAL"
TIMEOUT = "TIMEOUT"


class ConfidenceError(Exception):
Expand All @@ -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.
"""

Expand Down
1 change: 1 addition & 0 deletions confidence/flag_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Reason(StrEnum):
SPLIT = "SPLIT"
TARGETING_MATCH = "TARGETING_MATCH"
UNKNOWN = "UNKNOWN"
TIMEOUT = "TIMEOUT"


FlagMetadata = typing.Mapping[str, typing.Any]
Expand Down
2 changes: 2 additions & 0 deletions confidence/openfeature_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Loading
Loading