Skip to content
Open
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
4 changes: 2 additions & 2 deletions clients/python/provider-sdk-tests/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,8 @@ async def setup_with_sdk(client, org_id: str, workspace_id: str):

async def run_demo(org_id: str, workspace_id: str):
refresh_strategy = PollingStrategy(
interval=5,
timeout=3,
interval_milliseconds=5_000,
timeout_milliseconds=3_000,
)
http_options = SuperpositionOptions(
endpoint="http://localhost:8080",
Expand Down
11 changes: 5 additions & 6 deletions clients/python/provider/superposition_provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,17 @@
from .local_provider import LocalResolutionProvider, RefreshStrategy
from .remote_provider import SuperpositionAPIProvider

from .errors import SuperpositionError, ErrorCode

from .types import (
SuperpositionOptions,
EvaluationCacheOptions,
PollingStrategy,
OnDemandStrategy,
WatchStrategy,
ManualStrategy,
RefreshStrategy as RefreshStrategyType,
ConfigurationOptions,
ExperimentationOptions,
LocalProviderOptions,
RemoteProviderOptions,
SuperpositionProviderOptions,
)

Expand All @@ -53,17 +52,17 @@
"LocalResolutionProvider",
"SuperpositionAPIProvider",
"RefreshStrategy",
# Errors
"SuperpositionError",
"ErrorCode",
# Types
"SuperpositionOptions",
"EvaluationCacheOptions",
"PollingStrategy",
"OnDemandStrategy",
"WatchStrategy",
"ManualStrategy",
"RefreshStrategyType",
"ConfigurationOptions",
"ExperimentationOptions",
"LocalProviderOptions",
"RemoteProviderOptions",
"SuperpositionProviderOptions",
]
15 changes: 10 additions & 5 deletions clients/python/provider/superposition_provider/cac_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def poll_config(interval: int, timeout: int) -> None:
finally:
del self_ref

await asyncio.sleep(interval)
await asyncio.sleep(interval / 1000) # interval is in milliseconds

latest_config = await self._get_config(self.superposition_options)
if latest_config is None and self.cached_config is None:
Expand All @@ -140,13 +140,18 @@ async def poll_config(interval: int, timeout: int) -> None:
self.on_config_change()

match self.options.refresh_strategy:
case PollingStrategy(interval=interval, timeout=timeout):
logger.info(f"Using PollingStrategy: interval={interval}, timeout={timeout}")
case PollingStrategy():
interval = self.options.refresh_strategy.interval_ms()
timeout = self.options.refresh_strategy.timeout_ms()
logger.info(f"Using PollingStrategy: interval={interval}ms, timeout={timeout}ms")
if self._polling_task is None:
self._polling_task = asyncio.create_task(poll_config(interval, timeout))

case OnDemandStrategy(ttl=ttl, use_stale_on_error=use_stale, timeout=timeout):
logger.info(f"Using OnDemandStrategy: ttl={ttl}, use_stale_on_error={use_stale}, timeout={timeout}")
case OnDemandStrategy():
ttl = self.options.refresh_strategy.ttl_ms()
use_stale = self.options.refresh_strategy.use_stale_on_error
timeout = self.options.refresh_strategy.timeout_ms()
logger.info(f"Using OnDemandStrategy: ttl={ttl}ms, use_stale_on_error={use_stale}, timeout={timeout}ms")
Comment thread
coderabbitai[bot] marked this conversation as resolved.



Expand Down
30 changes: 23 additions & 7 deletions clients/python/provider/superposition_provider/data_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,45 @@
T = TypeVar('T')

class FetchResponse(Generic[T]):
"""Represents a fetch response with optional data, supporting 304 Not Modified."""
"""Either fetched data or a 304 Not Modified marker.

def __init__(self, data: Optional[T] = None):
A true sum type: `NotModified` is a distinct variant rather than "data is None", so a data
source that legitimately returns nothing cannot be mistaken for an unchanged one.
"""

__slots__ = ("_data", "_not_modified")

def __init__(self, data: Optional[T] = None, not_modified: bool = False):
self._data = data
self._not_modified = not_modified

@staticmethod
def not_modified():
def not_modified() -> "FetchResponse[T]":
"""Create a 304 Not Modified response."""
return FetchResponse(data=None)
return FetchResponse(data=None, not_modified=True)

@staticmethod
def data(data: T):
def data(data: T) -> "FetchResponse[T]":
"""Create a successful response with data."""
return FetchResponse(data=data)
return FetchResponse(data=data, not_modified=False)

def is_not_modified(self) -> bool:
"""Check if this is a 304 Not Modified response."""
return self._data is None
return self._not_modified

def get_data(self) -> Optional[T]:
"""Get the response data, or None if not modified."""
return self._data

def map_data(self, mapper):
"""Transform the data if present, preserving a NotModified response."""
if self._not_modified:
return FetchResponse.not_modified()
return FetchResponse.data(mapper(self._data))

def __repr__(self) -> str:
return "FetchResponse.NotModified" if self._not_modified else f"FetchResponse.Data({self._data})"


@dataclass
class ConfigData:
Expand Down
61 changes: 61 additions & 0 deletions clients/python/provider/superposition_provider/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Typed errors raised by the Superposition provider.

Mirrors the Rust `SuperpositionError` enum and the Java `SuperpositionError` exception, so a
failure means the same thing in every client.
"""

from enum import Enum
from typing import Optional


class ErrorCode(Enum):
"""The kind of failure a SuperpositionError represents."""

CONFIG_ERROR = "CONFIG_ERROR"
NETWORK_ERROR = "NETWORK_ERROR"
SERIALIZATION_ERROR = "SERIALIZATION_ERROR"
PROVIDER_ERROR = "PROVIDER_ERROR"
DATA_SOURCE_ERROR = "DATA_SOURCE_ERROR"
REFRESH_ERROR = "REFRESH_ERROR"


class SuperpositionError(Exception):
"""Raised when a provider or data source operation fails."""

def __init__(self, code: ErrorCode, message: str, cause: Optional[BaseException] = None):
super().__init__(message)
self.code = code
self.message = message
self.cause = cause
if cause is not None:
self.__cause__ = cause

def __str__(self) -> str:
return f"{self.code.value}: {self.message}"

# --- Factories, one per variant ---

@staticmethod
def config_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.CONFIG_ERROR, message, cause)

@staticmethod
def network_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.NETWORK_ERROR, message, cause)

@staticmethod
def serialization_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.SERIALIZATION_ERROR, message, cause)

@staticmethod
def provider_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.PROVIDER_ERROR, message, cause)

@staticmethod
def data_source_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.DATA_SOURCE_ERROR, message, cause)

@staticmethod
def refresh_error(message: str, cause: Optional[BaseException] = None) -> "SuperpositionError":
return SuperpositionError(ErrorCode.REFRESH_ERROR, message, cause)
15 changes: 10 additions & 5 deletions clients/python/provider/superposition_provider/exp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async def poll_config(interval: int, timeout: int) -> None:
finally:
del self_ref

await asyncio.sleep(interval)
await asyncio.sleep(interval / 1000) # interval is in milliseconds

latest_exp_list = await self._get_experiments(self.superposition_options)
latest_exp_grp_list = await self._get_experiment_groups(self.superposition_options)
Expand All @@ -92,13 +92,18 @@ async def poll_config(interval: int, timeout: int) -> None:
self.on_config_change()

match self.options.refresh_strategy:
case PollingStrategy(interval=interval, timeout=timeout):
logger.info(f"Using PollingStrategy: interval={interval}, timeout={timeout}")
case PollingStrategy():
interval = self.options.refresh_strategy.interval_ms()
timeout = self.options.refresh_strategy.timeout_ms()
logger.info(f"Using PollingStrategy: interval={interval}ms, timeout={timeout}ms")
if self._polling_task is None:
self._polling_task = asyncio.create_task(poll_config(interval, timeout))

case OnDemandStrategy(ttl=ttl, use_stale_on_error=use_stale, timeout=timeout):
logger.info(f"Using OnDemandStrategy: ttl={ttl}, use_stale_on_error={use_stale}, timeout={timeout}")
case OnDemandStrategy():
ttl = self.options.refresh_strategy.ttl_ms()
use_stale = self.options.refresh_strategy.use_stale_on_error
timeout = self.options.refresh_strategy.timeout_ms()
logger.info(f"Using OnDemandStrategy: ttl={ttl}ms, use_stale_on_error={use_stale}, timeout={timeout}ms")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
async def _get_experiments(superposition_options: SuperpositionOptions) -> Optional[list[FfiExperiment]]:
Expand Down
Loading
Loading