"""SplitIOClient provides methods to interact with the Split.io Admin API.""" import time from typing import Any, Dict, List from config import ( logger, SPLITIO_BASE_URL, ) import requests from requests.adapters import HTTPAdapter from src.utils.custom_exceptions import ( HTTPRequestError, InvalidContentTypeError, InvalidResponseError, JSONParsingError, RateLimitExceededError ) from urllib3.util.retry import Retry class SplitIOClient: """ Client for interacting with the Split.io Admin API. Supports user/owner lookup, traffic type retrieval, and feature flag operations. """ def __init__( self, api_key: str, workspace_id: str ) -> None: """ Initialize the SplitIOClient. Args: api_key (str): API key for Split.io. workspace_id (str): The workspace ID for Split.io. """ if not api_key: raise ValueError('api_key must be set. It cannot be empty or None.') if not workspace_id: raise ValueError('workspace_id must be set. It cannot be empty or None.') headers = { 'Authorization': f'Bearer {api_key}', 'Accept': 'application/json', 'Content-Type': 'application/json', } self.workspace_id = workspace_id self.session = self._init_session() self.session.headers.update(headers) logger.debug(f'Initialized SplitIOClient with workspace_id={workspace_id}') def _init_session(self) -> requests.Session: """ Initialize a requests session with retry strategy. Returns: requests.Session: Configured session. """ retry_strategy = Retry( total=3, status_forcelist=[500, 502, 503, 504], allowed_methods=['GET'], backoff_factor=1 ) adapter = HTTPAdapter(max_retries=retry_strategy) session = requests.Session() session.mount('https://', adapter) logger.debug('Session initialized with retry strategy') return session def _request_with_custom_retry( self, method: str, url: str, retries: int = 3, min_retry_wait: int = 5, **kwargs ) -> Dict[str, Any] | List[Dict[str, Any]]: """ Make a request with custom retry handling for rate limiting. Args: method (str): HTTP method. url (str): Request URL. retries (int): Max retry attempts for 429 responses. min_retry_wait (int): Minimum wait time between retries. **kwargs: Additional request parameters. Returns: Dict: Parsed JSON response. Raises: Exception: For HTTP errors or unexpected content. """ logger.debug(f'Request [{method}] {url} with kwargs:{kwargs}') for attempt in range(retries + 1): resp = self.session.request(method, url, **kwargs) logger.debug(f'Response code: {resp.status_code}') if resp.status_code == 429: org = int(resp.headers.get('X-RateLimit-Reset-Seconds-Org', 1)) ip = int(resp.headers.get('X-RateLimit-Reset-Seconds-IP', 1)) wait_time = max(min_retry_wait, max(org, ip)) logger.warning(f'Response headers: {resp.headers}') logger.warning(f'Rate limited. Retrying after {wait_time}s (attempt {attempt + 1}/{retries})') time.sleep(wait_time) continue if resp.status_code >= 400: logger.debug(f'HTTP error: {resp.status_code} - {resp.text}') raise HTTPRequestError(f'HTTP {resp.status_code}: {resp.text}') if 'application/json' not in resp.headers.get('Content-Type', ''): raise InvalidContentTypeError(f"Unexpected content type: {resp.headers.get('Content-Type')}") try: data = resp.json() except ValueError: raise JSONParsingError('Failed to parse JSON response.') logger.debug(f'Response JSON: {str(data)[:500]}') if not isinstance(data, (dict, list)): raise InvalidResponseError('Unexpected JSON format: expected dict or list of dicts') return data raise RateLimitExceededError(f'Exceeded retry attempts for {method} {url}') def get_environments(self) -> List[Dict[str, Any]]: """ Retrieve all environments for the workspace. Returns: List[Dict[str, Any]]: Environment data. Raises: InvalidResponseError: If response is not a list. """ logger.debug(f'Fetching environments for workspace: {self.workspace_id}') url = f'{SPLITIO_BASE_URL}/environments/ws/{self.workspace_id}' response = self._request_with_custom_retry('GET', url) if not isinstance(response, list): raise InvalidResponseError('Expected a list of environments.') return response def get_feature_flag_metadata(self, flag_name: str) -> Dict[str, Any] | None: """ Retrieve metadata for a feature flag. Args: flag_name (str): Name of the feature flag. Returns: Dict[str, Any] | None: Metadata including description and owners or None if not found. Raises: InvalidResponseError: If response is not a dict. """ logger.debug(f'Fetching metadata for feature flag: {flag_name}') url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}/{flag_name}' try: response = self._request_with_custom_retry('GET', url) except HTTPRequestError as e: if 'HTTP 404' in str(e): return None raise if not isinstance(response, dict): raise InvalidResponseError('Expected a dict response for feature flag metadata.') return response def get_feature_flag_definition(self, env_id: str, flag_name: str) -> Dict[str, Any] | None: """ Retrieve the definition of a feature flag in a specific environment. Args: env_id (str): Environment ID. flag_name (str): Name of the feature flag. Returns: Dict[str, Any] | None: Feature flag definition or None if not found. Raises: HTTPRequestError: If the request fails with 404. InvalidResponseError: If response is not a dict. """ logger.debug(f'Fetching definition for feature flag: {flag_name} in environment: {env_id}') url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}/{flag_name}/environments/{env_id}' try: response = self._request_with_custom_retry('GET', url) except HTTPRequestError as e: if 'HTTP 404' in str(e): return None raise if not isinstance(response, dict): raise InvalidResponseError('Expected a dict response for feature flag definition.') return response def get_all_FF_names(self) -> List[str]: """ Fetch all feature flag names for the workspace. Returns: List[str]: List of feature flag names. Raises: InvalidResponseError: If response is not a list. """ logger.debug('Fetching all feature flag names') ff_names: List[str] = [] offset: int = 0 limit: int = 50 while True: url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}' paged_url = f'{url}?offset={offset}&limit={limit}' data = self._request_with_custom_retry('GET', paged_url) if not isinstance(data, dict): raise InvalidResponseError('Expected a dict of feature flags.') batch = data.get('objects', []) ff_names.extend([ff['name'] for ff in batch if 'name' in ff]) if len(batch) < limit: break offset += limit logger.debug(f'Found {len(ff_names)} feature flags') return ff_names